feat(growth): add deterministic forecasting engine

This commit is contained in:
2026-08-21 00:15:47 +03:30
parent 37416a6f20
commit df140b4c25
8 changed files with 1057 additions and 0 deletions

View File

@@ -0,0 +1,166 @@
package growth
import (
"testing"
"time"
)
func TestAnalyzeEventEffectUsesMatchingWeekdaysAndThresholds(t *testing.T) {
event := Event{ID: "teachers-day", TitleFA: "روز معلم", StartDate: date("2026-05-02"), EndDate: date("2026-05-02")}
history := make([]DailyPoint, 0, 9)
for weeks := 8; weeks >= 1; weeks-- {
history = append(history, DailyPoint{Date: event.StartDate.AddDate(0, 0, -7*weeks), SessionCount: 25, PaidSessionCount: 20, PaidAmountRial: 2_000_000})
}
history = append(history, DailyPoint{Date: event.StartDate, SessionCount: 40, PaidSessionCount: 30, PaidAmountRial: 3_000_000})
effect := AnalyzeEventEffect(history, event, nil)
if !effect.Eligible || !effect.MaterialWin {
t.Fatalf("expected eligible material win, got %+v", effect)
}
if effect.BaselineSessionCount != 25 || effect.SessionUpliftBPS != 6_000 || effect.AmountUpliftBPS != 5_000 {
t.Fatalf("unexpected effect: %+v", effect)
}
}
func TestAnalyzeEventEffectSuppressesSmallBaseline(t *testing.T) {
event := Event{ID: "small", StartDate: date("2026-05-02"), EndDate: date("2026-05-02")}
history := []DailyPoint{{Date: event.StartDate.AddDate(0, 0, -7), SessionCount: 20}, {Date: event.StartDate.AddDate(0, 0, -14), SessionCount: 20}, {Date: event.StartDate.AddDate(0, 0, -21), SessionCount: 20}, {Date: event.StartDate.AddDate(0, 0, -28), SessionCount: 20}, {Date: event.StartDate, SessionCount: 50}}
effect := AnalyzeEventEffect(history, event, nil)
if effect.Eligible || effect.SuppressionReason != SuppressionInsufficientBaselineSessions {
t.Fatalf("expected baseline suppression, got %+v", effect)
}
}
func TestMatchUpcomingEventsPrefersFamilyThenTags(t *testing.T) {
historical := Event{ID: "teacher", Family: "appreciation", Tags: []string{"gift", "education"}}
upcoming := []Event{
{ID: "exact", Family: "appreciation", Tags: []string{"unrelated"}},
{ID: "similar", Family: "other", Tags: []string{"gift", "education", "campaign"}},
{ID: "weak", Family: "other", Tags: []string{"internet"}},
}
matches := MatchUpcomingEvents(historical, upcoming)
if len(matches) != 2 || matches[0].Event.ID != "exact" || matches[0].ScoreBPS != 10_000 || matches[1].Event.ID != "similar" {
t.Fatalf("unexpected matches: %+v", matches)
}
}
func TestBuildForecastReturnsOrderedNinetyDayIntegerRanges(t *testing.T) {
start := date("2026-07-01")
history := make([]DailyPoint, 0, 112)
for i := 112; i > 0; i-- {
day := start.AddDate(0, 0, -i)
base := int64(100 + int(day.Weekday())*5)
history = append(history, DailyPoint{Date: day, SessionCount: base + 20, PaidSessionCount: base, PaidAmountRial: base * 100_000})
}
forecast := BuildForecast(history, nil, start, 90)
if len(forecast.Points) != 90 || forecast.Confidence == ConfidenceInsufficientData {
t.Fatalf("unexpected forecast: confidence=%s points=%d", forecast.Confidence, len(forecast.Points))
}
for _, point := range forecast.Points {
if point.PaidSessions.P10 > point.PaidSessions.P50 || point.PaidSessions.P50 > point.PaidSessions.P90 || point.PaidAmountRial.P10 > point.PaidAmountRial.P50 || point.PaidAmountRial.P50 > point.PaidAmountRial.P90 {
t.Fatalf("unordered range: %+v", point)
}
}
}
func TestDetectRisksRequiresTwentyPercentDropAndBaselineSize(t *testing.T) {
forecast := Forecast{Confidence: ConfidenceMedium, Points: []ForecastPoint{
{Date: date("2026-07-01"), BaselineSessions: 200, PaidSessions: Range{P50: 150}, BaselineAmountRial: 10_000_000, PaidAmountRial: Range{P50: 7_000_000}},
{Date: date("2026-07-02"), BaselineSessions: 99, PaidSessions: Range{P50: 10}, BaselineAmountRial: 10_000_000, PaidAmountRial: Range{P50: 1}},
}}
risks := DetectRisks(forecast, 3)
if len(risks) != 1 || risks[0].Date != date("2026-07-01") {
t.Fatalf("unexpected risks: %+v", risks)
}
}
func TestEvaluatePeerAppliesPrivacyThresholdAndEqualMerchantWeight(t *testing.T) {
peers := make([]PeerMerchantValue, 10)
for i := range peers {
peers[i] = PeerMerchantValue{PaidSessions: int64(i + 1), SessionCount: 100}
}
result := EvaluatePeer(PeerInput{MerchantPaidSessions: 8, Peers: peers})
if result.State != PeerReady || result.MedianPaidSessions != 5 || result.PercentileBPS != 8_000 {
t.Fatalf("unexpected peer result: %+v", result)
}
suppressed := EvaluatePeer(PeerInput{TerminalScoped: true, Peers: peers})
if suppressed.State != PeerSuppressed || suppressed.Reason != SuppressionTerminalScope {
t.Fatalf("unexpected terminal result: %+v", suppressed)
}
}
func TestFingerprintIsStableAndVersionSensitive(t *testing.T) {
input := FingerprintInput{MerchantID: 7, DatasetWatermark: "run-1", ForecastStart: date("2026-07-01"), HorizonDays: 90, CatalogVersion: "1", AnalyticsVersion: "1", PromptVersion: "1", SchemaVersion: "1", Model: "mini"}
a := Fingerprint(input)
b := Fingerprint(input)
input.Model = "other"
if a == "" || a != b || a == Fingerprint(input) {
t.Fatalf("fingerprint stability/versioning failed")
}
}
func TestTehranRefreshBoundary(t *testing.T) {
last := time.Date(2026, 8, 20, 20, 31, 0, 0, time.UTC) // 00:01 Tehran on Aug 21.
next := NextRefreshAt(last)
want := time.Date(2026, 8, 21, 20, 30, 0, 0, time.UTC)
if !next.Equal(want) || CanManualRefresh(last, want.Add(-time.Second)) || !CanManualRefresh(last, want) {
t.Fatalf("unexpected boundary next=%s", next)
}
}
func TestBuildFallbackUsesOnlyEvidenceAndPersianCopy(t *testing.T) {
bundle := EvidenceBundle{Opportunities: []Opportunity{{EvidenceID: "opp-1", EventTitleFA: "روز معلم"}}, Risks: []Risk{{EvidenceID: "risk-1"}}}
briefs := BuildFallback(bundle)
if len(briefs) != 2 || briefs[0].EvidenceIDs[0] != "opp-1" || briefs[0].HeadlineFA == "" {
t.Fatalf("unexpected briefs: %+v", briefs)
}
}
func TestBuildDeterministicReportLimitsAndOrdersOpportunities(t *testing.T) {
start := date("2026-07-01")
history := make([]DailyPoint, 0, 120)
for i := 120; i > 0; i-- {
day := start.AddDate(0, 0, -i)
sessions := int64(120)
amount := int64(12_000_000)
if day.Equal(date("2026-05-02")) {
sessions = 180
amount = 18_000_000
}
history = append(history, DailyPoint{Date: day, SessionCount: sessions, PaidSessionCount: sessions, PaidAmountRial: amount})
}
catalog := []Event{
{ID: "past", TitleFA: "روز معلم", StartDate: date("2026-05-02"), EndDate: date("2026-05-02"), Family: "appreciation", Tags: []string{"gift"}},
{ID: "future", TitleFA: "روز پدر", StartDate: date("2026-07-10"), EndDate: date("2026-07-10"), Family: "appreciation", Tags: []string{"gift"}},
}
report := BuildDeterministicReport(ReportInput{History: history, Catalog: catalog, ForecastStart: start, HorizonDays: 90, Peer: &PeerInput{TerminalScoped: true}})
if len(report.HistoricalWins) != 1 || len(report.Opportunities) != 1 || report.Opportunities[0].EventID != "future" {
t.Fatalf("unexpected report evidence: %+v", report)
}
if report.Peer == nil || report.Peer.Reason != SuppressionTerminalScope {
t.Fatalf("expected terminal peer suppression: %+v", report.Peer)
}
}
func TestFingerprintTreatsEquivalentForecastDatesEqually(t *testing.T) {
tehran, err := time.LoadLocation("Asia/Tehran")
if err != nil {
t.Fatal(err)
}
local := time.Date(2026, 7, 1, 0, 0, 0, 0, tehran)
utcInstant := local.UTC()
base := FingerprintInput{MerchantID: 1, ForecastStart: local, HorizonDays: 90}
a := Fingerprint(base)
base.ForecastStart = utcInstant
if a != Fingerprint(base) {
t.Fatal("equivalent forecast instants must fingerprint equally")
}
}
func date(value string) time.Time {
parsed, err := time.Parse(time.DateOnly, value)
if err != nil {
panic(err)
}
return parsed
}

View File

@@ -0,0 +1,37 @@
package growth
import "time"
const officialCalendarSource = "https://calendar.ut.ac.ir/"
const curatedCommercialSource = "CURATED_COMMERCIAL_1405_V1"
// Catalog1405 returns a defensive copy of the reviewed initial catalog. Dates are
// Gregorian Tehran-local calendar dates and cover the imported 1405 history plus
// the first 90 forecast days after 2026-06-30.
func Catalog1405() []Event {
events := []Event{
catalogEvent("nowruz-1405", "نوروز", "2026-03-21", "2026-03-24", "new-year", EventOfficial, []string{"هدیه", "خرید", "سال-نو"}),
catalogEvent("nature-day-1405", "روز طبیعت", "2026-04-02", "2026-04-02", "leisure", EventOfficial, []string{"تفریح", "خرید"}),
catalogEvent("teachers-day-1405", "روز معلم", "2026-05-02", "2026-05-02", "appreciation", EventOfficial, []string{"هدیه", "آموزش", "قدردانی"}),
catalogEvent("khordad-15-1405", "قیام پانزده خرداد", "2026-06-05", "2026-06-05", "official-holiday", EventOfficial, []string{"تعطیل"}),
catalogEvent("fathers-day-commercial-1405", "روز پدر", "2026-08-25", "2026-08-25", "appreciation", EventCommercial, []string{"هدیه", "خرید", "قدردانی"}),
catalogEvent("back-to-school-1405", "آمادگی بازگشایی مدارس", "2026-09-10", "2026-09-22", "education-season", EventCommercial, []string{"آموزش", "خرید", "مدرسه"}),
}
return append([]Event(nil), events...)
}
func catalogEvent(id, title, start, end, family string, kind EventKind, tags []string) Event {
source := officialCalendarSource
if kind == EventCommercial {
source = curatedCommercialSource
}
return Event{ID: id, TitleFA: title, StartDate: mustCatalogDate(start), EndDate: mustCatalogDate(end), Family: family, Tags: append([]string(nil), tags...), Source: source, Kind: kind}
}
func mustCatalogDate(value string) time.Time {
parsed, err := time.Parse(time.DateOnly, value)
if err != nil {
panic("invalid reviewed event catalog date: " + value)
}
return parsed
}

148
internal/growth/events.go Normal file
View File

@@ -0,0 +1,148 @@
package growth
import (
"slices"
"strings"
"time"
)
func AnalyzeEventEffect(history []DailyPoint, event Event, catalog []Event) EventEffect {
effect := EventEffect{EvidenceID: "event:" + event.ID, EventID: event.ID}
eventDates := catalogDates(append(slices.Clone(catalog), event))
byDate := pointsByDate(history)
eventDays := daysInclusive(event.StartDate, event.EndDate)
var baselineSessions, baselinePaid, baselineAmount int64
for _, eventDay := range eventDays {
if point, ok := byDate[dayKey(eventDay)]; ok {
effect.ActualSessionCount += point.SessionCount
effect.ActualPaidSessions += point.PaidSessionCount
effect.ActualAmountRial += point.PaidAmountRial
}
for week := 1; week <= 8; week++ {
candidate := eventDay.AddDate(0, 0, -7*week)
if eventDates[dayKey(candidate)] {
continue
}
point, ok := byDate[dayKey(candidate)]
if !ok {
continue
}
effect.ObservationCount++
baselineSessions += point.SessionCount
baselinePaid += point.PaidSessionCount
baselineAmount += point.PaidAmountRial
}
}
if effect.ObservationCount < MinBaselineObservations {
effect.SuppressionReason = SuppressionInsufficientObservations
return effect
}
effect.BaselineSessionCount = baselineSessions / int64(effect.ObservationCount)
effect.BaselinePaidSessions = baselinePaid / int64(effect.ObservationCount)
effect.BaselineAmountRial = baselineAmount / int64(effect.ObservationCount)
if baselineSessions < MinBaselineSessions {
effect.SuppressionReason = SuppressionInsufficientBaselineSessions
return effect
}
effect.Eligible = true
effect.SessionUpliftBPS = changeBPS(effect.ActualSessionCount/int64(len(eventDays)), effect.BaselineSessionCount)
effect.PaidSessionUpliftBPS = changeBPS(effect.ActualPaidSessions/int64(len(eventDays)), effect.BaselinePaidSessions)
effect.AmountUpliftBPS = changeBPS(effect.ActualAmountRial/int64(len(eventDays)), effect.BaselineAmountRial)
effect.MaterialWin = effect.PaidSessionUpliftBPS >= MaterialChangeBPS || effect.AmountUpliftBPS >= MaterialChangeBPS
effect.MaterialDecline = effect.PaidSessionUpliftBPS <= -MaterialChangeBPS || effect.AmountUpliftBPS <= -MaterialChangeBPS
return effect
}
func MatchScoreBPS(historical, upcoming Event) (int64, string) {
if historical.Family != "" && strings.EqualFold(strings.TrimSpace(historical.Family), strings.TrimSpace(upcoming.Family)) {
return 10_000, "FAMILY"
}
a := normalizedSet(historical.Tags)
b := normalizedSet(upcoming.Tags)
if len(a) == 0 || len(b) == 0 {
return 0, "TAGS"
}
intersection := 0
union := make(map[string]struct{}, len(a)+len(b))
for tag := range a {
union[tag] = struct{}{}
if _, ok := b[tag]; ok {
intersection++
}
}
for tag := range b {
union[tag] = struct{}{}
}
return int64(intersection * 10_000 / len(union)), "TAGS"
}
func MatchUpcomingEvents(historical Event, upcoming []Event) []EventMatch {
matches := make([]EventMatch, 0, len(upcoming))
for _, candidate := range upcoming {
score, basis := MatchScoreBPS(historical, candidate)
if score >= MinSimilarityBPS {
matches = append(matches, EventMatch{Event: candidate, ScoreBPS: score, MatchBasis: basis})
}
}
slices.SortFunc(matches, func(a, b EventMatch) int {
if a.ScoreBPS != b.ScoreBPS {
return int(b.ScoreBPS - a.ScoreBPS)
}
return strings.Compare(a.Event.ID, b.Event.ID)
})
return matches
}
func pointsByDate(points []DailyPoint) map[string]DailyPoint {
result := make(map[string]DailyPoint, len(points))
for _, point := range points {
result[dayKey(point.Date)] = point
}
return result
}
func catalogDates(events []Event) map[string]bool {
result := make(map[string]bool)
for _, event := range events {
for _, day := range daysInclusive(event.StartDate, event.EndDate) {
result[dayKey(day)] = true
}
}
return result
}
func daysInclusive(start, end time.Time) []time.Time {
if end.Before(start) {
return nil
}
days := make([]time.Time, 0, int(end.Sub(start).Hours()/24)+1)
for day := normalizeDate(start); !day.After(normalizeDate(end)); day = day.AddDate(0, 0, 1) {
days = append(days, day)
}
return days
}
func normalizedSet(values []string) map[string]struct{} {
result := make(map[string]struct{}, len(values))
for _, value := range values {
value = strings.ToLower(strings.TrimSpace(value))
if value != "" {
result[value] = struct{}{}
}
}
return result
}
func normalizeDate(value time.Time) time.Time {
year, month, day := value.Date()
return time.Date(year, month, day, 0, 0, 0, 0, value.Location())
}
func dayKey(value time.Time) string { return normalizeDate(value).Format(time.DateOnly) }
func changeBPS(actual, baseline int64) int64 {
if baseline <= 0 {
return 0
}
return (actual - baseline) * 10_000 / baseline
}

272
internal/growth/forecast.go Normal file
View File

@@ -0,0 +1,272 @@
package growth
import (
"slices"
"time"
)
const (
defaultHorizonDays = 90
minimumHistoryDays = 56
maxTrendBPS = 2_000
)
func BuildForecast(history []DailyPoint, catalog []Event, start time.Time, horizonDays int) Forecast {
if horizonDays <= 0 {
horizonDays = defaultHorizonDays
}
result := Forecast{StartDate: normalizeDate(start), HorizonDays: horizonDays, Confidence: ConfidenceInsufficientData, SuppressionReason: SuppressionInsufficientHistory, Points: []ForecastPoint{}}
ordered := normalizedHistory(history, start)
if len(ordered) < minimumHistoryDays {
return result
}
result.Backtest = backtest(ordered)
result.Confidence = classifyConfidence(result.Backtest)
if result.Confidence == ConfidenceInsufficientData {
return result
}
result.SuppressionReason = ""
result.Points = forecastPoints(ordered, catalog, result.StartDate, horizonDays)
return result
}
func forecastPoints(history []DailyPoint, catalog []Event, start time.Time, horizon int) []ForecastPoint {
sessionResiduals, amountResiduals := residuals(history)
trendSessions := trendBPS(history, func(p DailyPoint) int64 { return p.PaidSessionCount })
trendAmount := trendBPS(history, func(p DailyPoint) int64 { return p.PaidAmountRial })
familyEffects := eventFamilyEffects(history, catalog)
points := make([]ForecastPoint, 0, horizon)
for index := 0; index < horizon; index++ {
day := start.AddDate(0, 0, index)
baselineSessions := weekdayMedian(history, day.Weekday(), func(p DailyPoint) int64 { return p.PaidSessionCount })
baselineAmount := weekdayMedian(history, day.Weekday(), func(p DailyPoint) int64 { return p.PaidAmountRial })
predictedSessions := applyBPS(baselineSessions, trendSessions)
predictedAmount := applyBPS(baselineAmount, trendAmount)
eventIDs := make([]string, 0, 1)
for _, event := range catalog {
if day.Before(normalizeDate(event.StartDate)) || day.After(normalizeDate(event.EndDate)) {
continue
}
eventIDs = append(eventIDs, event.ID)
if uplift, ok := familyEffects[event.Family]; ok {
predictedSessions = applyBPS(predictedSessions, uplift.sessions)
predictedAmount = applyBPS(predictedAmount, uplift.amount)
}
}
points = append(points, ForecastPoint{
Date: day, BaselineSessions: baselineSessions, BaselineAmountRial: baselineAmount,
PaidSessions: rangeFromResiduals(predictedSessions, sessionResiduals),
PaidAmountRial: rangeFromResiduals(predictedAmount, amountResiduals), EventIDs: eventIDs,
})
}
return points
}
func normalizedHistory(history []DailyPoint, before time.Time) []DailyPoint {
ordered := make([]DailyPoint, 0, len(history))
for _, point := range history {
if point.Date.Before(before) {
point.Date = normalizeDate(point.Date)
ordered = append(ordered, point)
}
}
slices.SortFunc(ordered, func(a, b DailyPoint) int { return a.Date.Compare(b.Date) })
return ordered
}
func weekdayMedian(history []DailyPoint, weekday time.Weekday, value func(DailyPoint) int64) int64 {
values := make([]int64, 0, 8)
for index := len(history) - 1; index >= 0 && len(values) < 8; index-- {
if history[index].Date.Weekday() == weekday {
values = append(values, value(history[index]))
}
}
return median(values)
}
func median(values []int64) int64 {
if len(values) == 0 {
return 0
}
copyOfValues := slices.Clone(values)
slices.Sort(copyOfValues)
middle := len(copyOfValues) / 2
if len(copyOfValues)%2 == 1 {
return copyOfValues[middle]
}
return (copyOfValues[middle-1] + copyOfValues[middle]) / 2
}
func residuals(history []DailyPoint) ([]int64, []int64) {
sessions := make([]int64, 0, len(history)-28)
amounts := make([]int64, 0, len(history)-28)
for index := 28; index < len(history); index++ {
prior := history[:index]
sessions = append(sessions, history[index].PaidSessionCount-weekdayMedian(prior, history[index].Date.Weekday(), func(p DailyPoint) int64 { return p.PaidSessionCount }))
amounts = append(amounts, history[index].PaidAmountRial-weekdayMedian(prior, history[index].Date.Weekday(), func(p DailyPoint) int64 { return p.PaidAmountRial }))
}
return sessions, amounts
}
func rangeFromResiduals(center int64, residualValues []int64) Range {
return Range{
P10: maxInt64(0, center+quantile(residualValues, 10)),
P50: maxInt64(0, center+quantile(residualValues, 50)),
P90: maxInt64(0, center+quantile(residualValues, 90)),
}
}
func quantile(values []int64, percentile int) int64 {
if len(values) == 0 {
return 0
}
ordered := slices.Clone(values)
slices.Sort(ordered)
index := (len(ordered) - 1) * percentile / 100
return ordered[index]
}
func trendBPS(history []DailyPoint, value func(DailyPoint) int64) int64 {
if len(history) < 56 {
return 0
}
var recent, previous int64
for _, point := range history[len(history)-28:] {
recent += value(point)
}
for _, point := range history[len(history)-56 : len(history)-28] {
previous += value(point)
}
trend := changeBPS(recent, previous)
if trend > maxTrendBPS {
return maxTrendBPS
}
if trend < -maxTrendBPS {
return -maxTrendBPS
}
return trend
}
func backtest(history []DailyPoint) BacktestResult {
result := BacktestResult{}
var sessionError, amountError, covered int64
var evaluated int64
first := len(history) - 28
for origin := first; origin < len(history); origin += 7 {
if origin < minimumHistoryDays {
continue
}
result.Origins++
training := history[:origin]
sessionResiduals, amountResiduals := residuals(training)
for index := origin; index < minInt(origin+7, len(history)); index++ {
actual := history[index]
sessionRange := rangeFromResiduals(weekdayMedian(training, actual.Date.Weekday(), func(p DailyPoint) int64 { return p.PaidSessionCount }), sessionResiduals)
amountRange := rangeFromResiduals(weekdayMedian(training, actual.Date.Weekday(), func(p DailyPoint) int64 { return p.PaidAmountRial }), amountResiduals)
sessionError += absolutePercentageErrorBPS(actual.PaidSessionCount, sessionRange.P50)
amountError += absolutePercentageErrorBPS(actual.PaidAmountRial, amountRange.P50)
if actual.PaidSessionCount >= sessionRange.P10 && actual.PaidSessionCount <= sessionRange.P90 && actual.PaidAmountRial >= amountRange.P10 && actual.PaidAmountRial <= amountRange.P90 {
covered++
}
evaluated++
}
}
if evaluated > 0 {
result.SessionMAPEBPS = sessionError / evaluated
result.AmountMAPEBPS = amountError / evaluated
result.IntervalCoverageBPS = covered * 10_000 / evaluated
}
return result
}
func classifyConfidence(backtest BacktestResult) Confidence {
if backtest.Origins < 4 {
return ConfidenceInsufficientData
}
worstMAPE := maxInt64(backtest.SessionMAPEBPS, backtest.AmountMAPEBPS)
if worstMAPE <= 2_000 && backtest.IntervalCoverageBPS >= 7_000 {
return ConfidenceHigh
}
if worstMAPE <= 4_000 && backtest.IntervalCoverageBPS >= 5_000 {
return ConfidenceMedium
}
return ConfidenceInsufficientData
}
type familyUplift struct{ sessions, amount int64 }
func eventFamilyEffects(history []DailyPoint, catalog []Event) map[string]familyUplift {
result := make(map[string]familyUplift)
counts := make(map[string]int64)
for _, event := range catalog {
if event.Family == "" || event.StartDate.After(history[len(history)-1].Date) {
continue
}
effect := AnalyzeEventEffect(history, event, catalog)
if !effect.Eligible {
continue
}
value := result[event.Family]
value.sessions += effect.PaidSessionUpliftBPS
value.amount += effect.AmountUpliftBPS
result[event.Family] = value
counts[event.Family]++
}
for family, value := range result {
value.sessions /= counts[family]
value.amount /= counts[family]
result[family] = value
}
return result
}
func applyBPS(value, adjustment int64) int64 { return maxInt64(0, value*(10_000+adjustment)/10_000) }
func absolutePercentageErrorBPS(actual, predicted int64) int64 {
if actual <= 0 {
if predicted == 0 {
return 0
}
return 10_000
}
difference := actual - predicted
if difference < 0 {
difference = -difference
}
return difference * 10_000 / actual
}
func maxInt64(a, b int64) int64 {
if a > b {
return a
}
return b
}
func minInt(a, b int) int {
if a < b {
return a
}
return b
}
func DetectRisks(forecast Forecast, limit int) []Risk {
if forecast.Confidence == ConfidenceInsufficientData || limit <= 0 {
return nil
}
risks := make([]Risk, 0, limit)
for _, point := range forecast.Points {
if point.BaselineSessions < MinBaselineSessions {
continue
}
sessionDecline := -changeBPS(point.PaidSessions.P50, point.BaselineSessions)
amountDecline := -changeBPS(point.PaidAmountRial.P50, point.BaselineAmountRial)
if sessionDecline < MaterialChangeBPS && amountDecline < MaterialChangeBPS {
continue
}
risks = append(risks, Risk{EvidenceID: "risk:" + dayKey(point.Date), Date: point.Date, SessionDeclineBPS: maxInt64(0, sessionDecline), AmountDeclineBPS: maxInt64(0, amountDecline), ExpectedSessions: point.PaidSessions, ExpectedAmountRial: point.PaidAmountRial})
if len(risks) == limit {
break
}
}
return risks
}

View File

@@ -0,0 +1,101 @@
package growth
import (
"slices"
"strings"
"time"
)
func historicalWins(history []DailyPoint, catalog []Event, before time.Time) []EventEffect {
wins := make([]EventEffect, 0)
for _, event := range catalog {
if !event.EndDate.Before(before) {
continue
}
effect := AnalyzeEventEffect(history, event, catalog)
if effect.Eligible && effect.MaterialWin {
wins = append(wins, effect)
}
}
slices.SortFunc(wins, func(a, b EventEffect) int {
aScore := maxInt64(a.PaidSessionUpliftBPS, a.AmountUpliftBPS)
bScore := maxInt64(b.PaidSessionUpliftBPS, b.AmountUpliftBPS)
if aScore != bScore {
if aScore > bScore {
return -1
}
return 1
}
return strings.Compare(a.EventID, b.EventID)
})
return wins
}
func buildOpportunities(wins []EventEffect, catalog []Event, forecast Forecast, start time.Time, limit int) []Opportunity {
if forecast.Confidence == ConfidenceInsufficientData || limit <= 0 {
return nil
}
eventByID := make(map[string]Event, len(catalog))
for _, event := range catalog {
eventByID[event.ID] = event
}
forecastByDate := make(map[string]ForecastPoint, len(forecast.Points))
for _, point := range forecast.Points {
forecastByDate[dayKey(point.Date)] = point
}
best := make(map[string]Opportunity)
for _, win := range wins {
historical := eventByID[win.EventID]
upcoming := make([]Event, 0)
for _, event := range catalog {
if !event.EndDate.Before(start) {
upcoming = append(upcoming, event)
}
}
for _, match := range MatchUpcomingEvents(historical, upcoming) {
point, ok := forecastByDate[dayKey(match.Event.StartDate)]
if !ok {
continue
}
candidate := Opportunity{
EvidenceID: EvidenceID("opportunity", match.Event.ID+":"+win.EventID), EventID: match.Event.ID,
EventTitleFA: match.Event.TitleFA, Date: normalizeDate(match.Event.StartDate), SimilarityBPS: match.ScoreBPS,
ExpectedSessions: point.PaidSessions, ExpectedAmountRial: point.PaidAmountRial,
HistoricalEvidenceID: win.EvidenceID,
}
if current, exists := best[match.Event.ID]; !exists || candidate.SimilarityBPS > current.SimilarityBPS {
best[match.Event.ID] = candidate
}
}
}
result := make([]Opportunity, 0, len(best))
for _, opportunity := range best {
result = append(result, opportunity)
}
slices.SortFunc(result, func(a, b Opportunity) int {
if a.SimilarityBPS != b.SimilarityBPS {
if a.SimilarityBPS > b.SimilarityBPS {
return -1
}
return 1
}
if comparison := a.Date.Compare(b.Date); comparison != 0 {
return comparison
}
return strings.Compare(a.EventID, b.EventID)
})
if len(result) > limit {
result = result[:limit]
}
return result
}
func filterCatalog(catalog []Event, category string) []Event {
filtered := make([]Event, 0, len(catalog))
for _, event := range catalog {
if category == "" || len(event.Categories) == 0 || slices.Contains(event.Categories, category) {
filtered = append(filtered, event)
}
}
return filtered
}

58
internal/growth/peer.go Normal file
View File

@@ -0,0 +1,58 @@
package growth
import "slices"
type PeerState string
const (
PeerReady PeerState = "READY"
PeerSuppressed PeerState = "SUPPRESSED"
)
type PeerMerchantValue struct {
PaidSessions int64 `json:"paidSessions"`
SessionCount int64 `json:"sessionCount"`
}
type PeerInput struct {
TerminalScoped bool `json:"terminalScoped"`
MerchantPaidSessions int64 `json:"merchantPaidSessions"`
Peers []PeerMerchantValue `json:"peers"`
}
type PeerResult struct {
State PeerState `json:"state"`
Reason SuppressionReason `json:"reason,omitempty"`
EligiblePeerCount int `json:"eligiblePeerCount"`
CombinedPeerSessions int64 `json:"combinedPeerSessions"`
MedianPaidSessions int64 `json:"medianPaidSessions"`
PercentileBPS int64 `json:"percentileBps"`
}
func EvaluatePeer(input PeerInput) PeerResult {
result := PeerResult{State: PeerSuppressed, EligiblePeerCount: len(input.Peers)}
if input.TerminalScoped {
result.Reason = SuppressionTerminalScope
return result
}
values := make([]int64, 0, len(input.Peers))
for _, peer := range input.Peers {
result.CombinedPeerSessions += peer.SessionCount
values = append(values, peer.PaidSessions)
}
if len(values) < 10 || result.CombinedPeerSessions < 1_000 {
result.Reason = SuppressionInsufficientCohort
return result
}
slices.Sort(values)
result.State = PeerReady
result.MedianPaidSessions = median(values)
var atOrBelow int64
for _, value := range values {
if input.MerchantPaidSessions >= value {
atOrBelow++
}
}
result.PercentileBPS = atOrBelow * 10_000 / int64(len(values))
return result
}

127
internal/growth/report.go Normal file
View File

@@ -0,0 +1,127 @@
package growth
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"time"
)
type FingerprintInput struct {
MerchantID int64 `json:"merchantId"`
TerminalID string `json:"terminalId,omitempty"`
DatasetWatermark string `json:"datasetWatermark"`
ForecastStart time.Time `json:"forecastStart"`
HorizonDays int `json:"horizonDays"`
CatalogVersion string `json:"catalogVersion"`
AnalyticsVersion string `json:"analyticsVersion"`
PromptVersion string `json:"promptVersion"`
SchemaVersion string `json:"schemaVersion"`
Model string `json:"model"`
}
func Fingerprint(input FingerprintInput) string {
payload, err := json.Marshal(struct {
FingerprintInput
ForecastDate string `json:"forecastDate"`
}{FingerprintInput: FingerprintInput{
MerchantID: input.MerchantID, TerminalID: input.TerminalID,
DatasetWatermark: input.DatasetWatermark, HorizonDays: input.HorizonDays,
CatalogVersion: input.CatalogVersion, AnalyticsVersion: input.AnalyticsVersion,
PromptVersion: input.PromptVersion, SchemaVersion: input.SchemaVersion, Model: input.Model,
}, ForecastDate: input.ForecastStart.In(tehranLocation()).Format(time.DateOnly)})
if err != nil {
return ""
}
digest := sha256.Sum256(payload)
return hex.EncodeToString(digest[:])
}
type ReportInput struct {
History []DailyPoint `json:"history"`
Catalog []Event `json:"catalog"`
ForecastStart time.Time `json:"forecastStart"`
HorizonDays int `json:"horizonDays"`
Category string `json:"category,omitempty"`
Peer *PeerInput `json:"peer,omitempty"`
}
type DeterministicReport struct {
Forecast Forecast `json:"forecast"`
HistoricalWins []EventEffect `json:"historicalWins"`
Opportunities []Opportunity `json:"opportunities"`
Risks []Risk `json:"risks"`
Peer *PeerResult `json:"peer,omitempty"`
FallbackBriefs []CampaignBrief `json:"fallbackBriefs"`
}
func BuildDeterministicReport(input ReportInput) DeterministicReport {
catalog := filterCatalog(input.Catalog, input.Category)
forecast := BuildForecast(input.History, catalog, input.ForecastStart, input.HorizonDays)
wins := historicalWins(input.History, catalog, input.ForecastStart)
opportunities := buildOpportunities(wins, catalog, forecast, input.ForecastStart, 5)
risks := DetectRisks(forecast, 3)
bundle := EvidenceBundle{Opportunities: opportunities, Risks: risks}
report := DeterministicReport{Forecast: forecast, HistoricalWins: wins, Opportunities: opportunities, Risks: risks, FallbackBriefs: BuildFallback(bundle)}
if input.Peer != nil {
peer := EvaluatePeer(*input.Peer)
report.Peer = &peer
}
return report
}
func TehranDay(value time.Time) string {
return value.In(tehranLocation()).Format(time.DateOnly)
}
func NextRefreshAt(lastRefresh time.Time) time.Time {
local := lastRefresh.In(tehranLocation())
nextLocal := time.Date(local.Year(), local.Month(), local.Day()+1, 0, 0, 0, 0, tehranLocation())
return nextLocal.UTC()
}
func CanManualRefresh(lastRefresh, now time.Time) bool {
if lastRefresh.IsZero() {
return true
}
return !now.Before(NextRefreshAt(lastRefresh))
}
func BuildFallback(bundle EvidenceBundle) []CampaignBrief {
briefs := make([]CampaignBrief, 0, len(bundle.Opportunities)+len(bundle.Risks))
for _, opportunity := range bundle.Opportunities {
briefs = append(briefs, CampaignBrief{
HeadlineFA: "یک فرصت مناسب در راه است: " + opportunity.EventTitleFA,
GuidanceFA: "داده‌های قبلی شما نشان می‌دهد این مناسبت می‌تواند برای برنامه‌ریزی فروش ارزشمند باشد.",
OfferFA: "پیشنهاد خود را متناسب با نیاز مشتریان این مناسبت آماده کنید.",
PreparationFA: "موجودی، پیام کمپین و مسیر پرداخت را پیش از شروع مناسبت بررسی کنید.",
LimitationFA: "این پیشنهاد بر پایه الگوهای گذشته است و نتیجه قطعی نیست.",
EvidenceIDs: []string{opportunity.EvidenceID},
})
}
for _, risk := range bundle.Risks {
briefs = append(briefs, CampaignBrief{
HeadlineFA: "برای یک بازه آرام‌تر آماده باشید",
GuidanceFA: "الگوی گذشته احتمال کاهش فعالیت را در این بازه نشان می‌دهد.",
OfferFA: "یک پیشنهاد سبک برای حفظ ارتباط با مشتریان در نظر بگیرید.",
PreparationFA: "پیام یادآوری و تجربه پرداخت را پیش از این بازه بازبینی کنید.",
LimitationFA: "این هشدار بر پایه الگوهای گذشته است و نتیجه قطعی نیست.",
EvidenceIDs: []string{risk.EvidenceID},
})
}
return briefs
}
func EvidenceID(kind, stableKey string) string {
digest := sha256.Sum256([]byte(kind + "\x00" + stableKey))
return fmt.Sprintf("%s_%s", kind, hex.EncodeToString(digest[:8]))
}
func tehranLocation() *time.Location {
location, err := time.LoadLocation("Asia/Tehran")
if err != nil {
return time.FixedZone("Asia/Tehran", 3*60*60+30*60)
}
return location
}

148
internal/growth/types.go Normal file
View File

@@ -0,0 +1,148 @@
package growth
import "time"
const (
CatalogVersion = "1405.1"
AnalyticsVersion = "1.0.0"
MinBaselineObservations = 4
MinBaselineSessions = 100
MaterialChangeBPS = 2_000
MinSimilarityBPS = 6_000
)
type Confidence string
const (
ConfidenceHigh Confidence = "HIGH"
ConfidenceMedium Confidence = "MEDIUM"
ConfidenceInsufficientData Confidence = "INSUFFICIENT_DATA"
)
type SuppressionReason string
const (
SuppressionInsufficientObservations SuppressionReason = "INSUFFICIENT_BASELINE_OBSERVATIONS"
SuppressionInsufficientBaselineSessions SuppressionReason = "INSUFFICIENT_BASELINE_SESSIONS"
SuppressionInsufficientHistory SuppressionReason = "INSUFFICIENT_HISTORY"
SuppressionInsufficientCohort SuppressionReason = "INSUFFICIENT_COHORT"
SuppressionTerminalScope SuppressionReason = "TERMINAL_SCOPE_UNSUPPORTED"
)
type DailyPoint struct {
Date time.Time `json:"date"`
SessionCount int64 `json:"sessionCount"`
PaidSessionCount int64 `json:"paidSessionCount"`
PaidAmountRial int64 `json:"paidAmountRial"`
EventIDs []string `json:"eventIds,omitempty"`
}
type EventKind string
const (
EventOfficial EventKind = "OFFICIAL"
EventCommercial EventKind = "COMMERCIAL"
)
type Event struct {
ID string `json:"id"`
TitleFA string `json:"titleFa"`
StartDate time.Time `json:"startDate"`
EndDate time.Time `json:"endDate"`
Family string `json:"family"`
Tags []string `json:"tags"`
Source string `json:"source"`
Categories []string `json:"categories"`
Kind EventKind `json:"kind"`
}
type EventEffect struct {
EvidenceID string `json:"evidenceId"`
EventID string `json:"eventId"`
Eligible bool `json:"eligible"`
SuppressionReason SuppressionReason `json:"suppressionReason,omitempty"`
ObservationCount int `json:"observationCount"`
BaselineSessionCount int64 `json:"baselineSessionCount"`
BaselinePaidSessions int64 `json:"baselinePaidSessions"`
BaselineAmountRial int64 `json:"baselineAmountRial"`
ActualSessionCount int64 `json:"actualSessionCount"`
ActualPaidSessions int64 `json:"actualPaidSessions"`
ActualAmountRial int64 `json:"actualAmountRial"`
SessionUpliftBPS int64 `json:"sessionUpliftBps"`
PaidSessionUpliftBPS int64 `json:"paidSessionUpliftBps"`
AmountUpliftBPS int64 `json:"amountUpliftBps"`
MaterialWin bool `json:"materialWin"`
MaterialDecline bool `json:"materialDecline"`
}
type EventMatch struct {
Event Event `json:"event"`
ScoreBPS int64 `json:"scoreBps"`
MatchBasis string `json:"matchBasis"`
}
type Range struct {
P10 int64 `json:"p10"`
P50 int64 `json:"p50"`
P90 int64 `json:"p90"`
}
type ForecastPoint struct {
Date time.Time `json:"date"`
BaselineSessions int64 `json:"baselineSessions"`
BaselineAmountRial int64 `json:"baselineAmountRial"`
PaidSessions Range `json:"paidSessions"`
PaidAmountRial Range `json:"paidAmountRial"`
EventIDs []string `json:"eventIds,omitempty"`
}
type BacktestResult struct {
Origins int `json:"origins"`
SessionMAPEBPS int64 `json:"sessionMapeBps"`
AmountMAPEBPS int64 `json:"amountMapeBps"`
IntervalCoverageBPS int64 `json:"intervalCoverageBps"`
}
type Forecast struct {
StartDate time.Time `json:"startDate"`
HorizonDays int `json:"horizonDays"`
Confidence Confidence `json:"confidence"`
SuppressionReason SuppressionReason `json:"suppressionReason,omitempty"`
Points []ForecastPoint `json:"points"`
Backtest BacktestResult `json:"backtest"`
}
type Opportunity struct {
EvidenceID string `json:"evidenceId"`
EventID string `json:"eventId"`
EventTitleFA string `json:"eventTitleFa"`
Date time.Time `json:"date"`
SimilarityBPS int64 `json:"similarityBps"`
ExpectedSessions Range `json:"expectedSessions"`
ExpectedAmountRial Range `json:"expectedAmountRial"`
HistoricalEvidenceID string `json:"historicalEvidenceId"`
}
type Risk struct {
EvidenceID string `json:"evidenceId"`
Date time.Time `json:"date"`
SessionDeclineBPS int64 `json:"sessionDeclineBps"`
AmountDeclineBPS int64 `json:"amountDeclineBps"`
ExpectedSessions Range `json:"expectedSessions"`
ExpectedAmountRial Range `json:"expectedAmountRial"`
}
type EvidenceBundle struct {
Opportunities []Opportunity `json:"opportunities"`
Risks []Risk `json:"risks"`
}
type CampaignBrief struct {
HeadlineFA string `json:"headlineFa"`
GuidanceFA string `json:"guidanceFa"`
OfferFA string `json:"offerFa"`
PreparationFA string `json:"preparationFa"`
LimitationFA string `json:"limitationFa"`
EvidenceIDs []string `json:"evidenceIds"`
}