diff --git a/internal/growth/aggregate.go b/internal/growth/aggregate.go new file mode 100644 index 0000000..31bec78 --- /dev/null +++ b/internal/growth/aggregate.go @@ -0,0 +1,43 @@ +package growth + +import ( + "slices" + "time" +) + +type Session struct { + CreatedAt time.Time `json:"createdAt"` + Status string `json:"status"` + AmountRial int64 `json:"amountRial"` +} + +// AggregateDaily groups already-authorized, scope-filtered sessions by Tehran +// local day. Paid and Verified are the only successful bank-payment statuses. +func AggregateDaily(sessions []Session, catalog []Event) []DailyPoint { + byDate := make(map[string]DailyPoint) + for _, session := range sessions { + local := session.CreatedAt.In(tehranLocation()) + date := time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, tehranLocation()) + key := dayKey(date) + point := byDate[key] + point.Date = date + point.SessionCount++ + if session.Status == "Paid" || session.Status == "Verified" { + point.PaidSessionCount++ + point.PaidAmountRial += session.AmountRial + } + byDate[key] = point + } + points := make([]DailyPoint, 0, len(byDate)) + for _, point := range byDate { + for _, event := range catalog { + if !dateBefore(point.Date, event.StartDate) && !dateAfter(point.Date, event.EndDate) { + point.EventIDs = append(point.EventIDs, event.ID) + } + } + slices.Sort(point.EventIDs) + points = append(points, point) + } + slices.SortFunc(points, func(a, b DailyPoint) int { return a.Date.Compare(b.Date) }) + return points +} diff --git a/internal/growth/aggregate_test.go b/internal/growth/aggregate_test.go new file mode 100644 index 0000000..b7d9672 --- /dev/null +++ b/internal/growth/aggregate_test.go @@ -0,0 +1,29 @@ +package growth + +import ( + "testing" + "time" +) + +func TestAggregateDailyUsesTehranDayAndSessionGrain(t *testing.T) { + sessions := []Session{ + {CreatedAt: time.Date(2026, 1, 1, 20, 29, 0, 0, time.UTC), Status: "Paid", AmountRial: 100}, + {CreatedAt: time.Date(2026, 1, 1, 20, 31, 0, 0, time.UTC), Status: "Verified", AmountRial: 200}, + {CreatedAt: time.Date(2026, 1, 1, 21, 0, 0, 0, time.UTC), Status: "Failed", AmountRial: 900}, + } + points := AggregateDaily(sessions, nil) + if len(points) != 2 || dayKey(points[0].Date) != "2026-01-01" || points[0].PaidAmountRial != 100 { + t.Fatalf("unexpected first day: %+v", points) + } + if dayKey(points[1].Date) != "2026-01-02" || points[1].SessionCount != 2 || points[1].PaidSessionCount != 1 || points[1].PaidAmountRial != 200 { + t.Fatalf("unexpected second day: %+v", points[1]) + } +} + +func TestAggregateDailyAnnotatesCatalogEvents(t *testing.T) { + events := []Event{{ID: "b", StartDate: date("2026-05-02"), EndDate: date("2026-05-02")}, {ID: "a", StartDate: date("2026-05-02"), EndDate: date("2026-05-02")}} + points := AggregateDaily([]Session{{CreatedAt: time.Date(2026, 5, 2, 8, 0, 0, 0, tehranLocation()), Status: "Paid", AmountRial: 10}}, events) + if len(points) != 1 || len(points[0].EventIDs) != 2 || points[0].EventIDs[0] != "a" || points[0].EventIDs[1] != "b" { + t.Fatalf("unexpected event annotation: %+v", points) + } +} diff --git a/internal/growth/analytics_test.go b/internal/growth/analytics_test.go index d32b4f6..f566c8a 100644 --- a/internal/growth/analytics_test.go +++ b/internal/growth/analytics_test.go @@ -74,13 +74,25 @@ func TestDetectRisksRequiresTwentyPercentDropAndBaselineSize(t *testing.T) { } } +func TestDetectRisksCoalescesAdjacentDaysAndRanksDeepestFirst(t *testing.T) { + forecast := Forecast{Confidence: ConfidenceHigh, Points: []ForecastPoint{ + {Date: date("2026-07-01"), BaselineSessions: 200, BaselineAmountRial: 1_000, PaidSessions: Range{P50: 150}, PaidAmountRial: Range{P50: 750}}, + {Date: date("2026-07-02"), BaselineSessions: 200, BaselineAmountRial: 1_000, PaidSessions: Range{P50: 100}, PaidAmountRial: Range{P50: 500}}, + {Date: date("2026-07-04"), BaselineSessions: 200, BaselineAmountRial: 1_000, PaidSessions: Range{P50: 120}, PaidAmountRial: Range{P50: 600}}, + }} + risks := DetectRisks(forecast, 3) + if len(risks) != 2 || risks[0].Date != date("2026-07-01") || risks[0].EndDate != date("2026-07-02") || risks[0].SessionDeclineBPS != 5_000 { + t.Fatalf("unexpected ranked/coalesced 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 { + if result.State != PeerReady || result.MedianPaidSessions != 5 || result.PercentileBPS != 7_500 { t.Fatalf("unexpected peer result: %+v", result) } suppressed := EvaluatePeer(PeerInput{TerminalScoped: true, Peers: peers}) @@ -90,7 +102,7 @@ func TestEvaluatePeerAppliesPrivacyThresholdAndEqualMerchantWeight(t *testing.T) } 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"} + input := FingerprintInput{MerchantID: 7, DatasetWatermark: "run-1", ForecastStart: date("2026-07-01"), HorizonDays: 90, CatalogVersion: "1", AnalyticsVersion: "1", PromptVersion: "1", SchemaVersion: "1", Model: "mini", ModelConfigVersion: "cfg-1"} a := Fingerprint(input) b := Fingerprint(input) input.Model = "other" @@ -99,6 +111,26 @@ func TestFingerprintIsStableAndVersionSensitive(t *testing.T) { } } +func TestPeerPercentileUsesMidRankForTies(t *testing.T) { + peers := make([]PeerMerchantValue, 10) + for index := range peers { + peers[index] = PeerMerchantValue{PaidSessions: 50, SessionCount: 100} + } + result := EvaluatePeer(PeerInput{MerchantPaidSessions: 50, Peers: peers}) + if result.PercentileBPS != 5_000 { + t.Fatalf("all-tie percentile = %d, want 5000", result.PercentileBPS) + } +} + +func TestFingerprintIncludesModelConfigurationPolicy(t *testing.T) { + input := FingerprintInput{MerchantID: 7, ForecastStart: date("2026-07-01"), HorizonDays: 90, Model: "mini", ModelConfigVersion: "cfg-1"} + first := Fingerprint(input) + input.ModelConfigVersion = "cfg-2" + if first == Fingerprint(input) { + t.Fatal("model configuration policy must invalidate fingerprint") + } +} + 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) diff --git a/internal/growth/catalog.go b/internal/growth/catalog.go index a6b8bd6..872b18b 100644 --- a/internal/growth/catalog.go +++ b/internal/growth/catalog.go @@ -9,6 +9,7 @@ import ( const officialCalendarSource = "https://calendar.ut.ac.ir/" const curatedCommercialSource = "CURATED_COMMERCIAL_1405_V1" +const officialCalendarVersion = "IR_OFFICIAL_1404_1405_REVIEWED_2026-08-21" var importedMerchantCategories = []string{ "ارائه دهنده خدمات اینترنت", @@ -23,12 +24,29 @@ var importedMerchantCategories = []string{ // 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("doctors-day-1405", "روز پزشک", "2026-08-23", "2026-08-23", "appreciation", EventOfficial, []string{"هدیه", "قدردانی", "حرفه‌ای"}), - catalogEvent("back-to-school-1405", "آمادگی بازگشایی مدارس", "2026-09-10", "2026-09-22", "education-season", EventCommercial, []string{"آموزش", "خرید", "مدرسه"}), + officialEvent("fathers-day-1404", "روز پدر", "2026-01-03", "2026-01-03", "appreciation", true, []string{"هدیه", "قدردانی"}), + officialEvent("mabath-1404", "مبعث پیامبر اکرم", "2026-01-17", "2026-01-17", "religious-holiday", true, []string{"تعطیل", "مناسبت-مذهبی"}), + officialEvent("imam-mahdi-birthday-1404", "نیمه شعبان", "2026-02-04", "2026-02-04", "religious-holiday", true, []string{"تعطیل", "مناسبت-مذهبی"}), + officialEvent("revolution-day-1404", "پیروزی انقلاب اسلامی", "2026-02-11", "2026-02-11", "national-holiday", true, []string{"تعطیل", "مناسبت-ملی"}), + officialEvent("imam-ali-martyrdom-1404", "شهادت امام علی", "2026-03-11", "2026-03-11", "religious-holiday", true, []string{"تعطیل", "مناسبت-مذهبی"}), + officialEvent("oil-nationalization-1404", "ملی شدن صنعت نفت", "2026-03-19", "2026-03-19", "national-holiday", true, []string{"تعطیل", "مناسبت-ملی"}), + officialEvent("nowruz-1405", "نوروز", "2026-03-21", "2026-03-24", "new-year", true, []string{"هدیه", "خرید", "سال-نو", "تعطیل"}), + officialEvent("islamic-republic-day-1405", "روز جمهوری اسلامی", "2026-04-01", "2026-04-01", "national-holiday", true, []string{"تعطیل", "مناسبت-ملی"}), + officialEvent("nature-day-1405", "روز طبیعت", "2026-04-02", "2026-04-02", "leisure", true, []string{"تفریح", "خرید", "تعطیل"}), + officialEvent("imam-sadegh-martyrdom-1405", "شهادت امام جعفر صادق", "2026-04-14", "2026-04-14", "religious-holiday", true, []string{"تعطیل", "مناسبت-مذهبی"}), + officialEvent("teachers-day-1405", "روز معلم", "2026-05-02", "2026-05-02", "appreciation", false, []string{"هدیه", "آموزش", "قدردانی"}), + officialEvent("eid-al-adha-1405", "عید قربان", "2026-05-27", "2026-05-27", "religious-holiday", true, []string{"تعطیل", "مناسبت-مذهبی"}), + officialEvent("khomeini-ghadir-1405", "رحلت امام خمینی و عید غدیر", "2026-06-04", "2026-06-04", "official-holiday", true, []string{"تعطیل", "مناسبت-ملی", "مناسبت-مذهبی"}), + officialEvent("khordad-15-1405", "قیام پانزده خرداد", "2026-06-05", "2026-06-05", "national-holiday", true, []string{"تعطیل", "مناسبت-ملی"}), + officialEvent("tasua-1405", "تاسوعای حسینی", "2026-06-24", "2026-06-24", "religious-holiday", true, []string{"تعطیل", "مناسبت-مذهبی"}), + officialEvent("ashura-1405", "عاشورای حسینی", "2026-06-25", "2026-06-25", "religious-holiday", true, []string{"تعطیل", "مناسبت-مذهبی"}), + commercialEvent("summer-midseason-1405", "میانه فصل تابستان", "2026-07-15", "2026-07-17", "seasonal-shopping", []string{"خرید", "تابستان", "پیشنهاد-فصلی"}, importedMerchantCategories), + officialEvent("arbaeen-1405", "اربعین حسینی", "2026-08-04", "2026-08-04", "religious-holiday", true, []string{"تعطیل", "مناسبت-مذهبی"}), + officialEvent("prophet-demise-1405", "رحلت پیامبر اکرم و شهادت امام حسن", "2026-08-12", "2026-08-12", "religious-holiday", true, []string{"تعطیل", "مناسبت-مذهبی"}), + officialEvent("doctors-day-1405", "روز پزشک", "2026-08-23", "2026-08-23", "appreciation", false, []string{"هدیه", "قدردانی", "حرفه‌ای"}), + officialEvent("prophet-birthday-1405", "میلاد پیامبر اکرم و امام جعفر صادق", "2026-08-30", "2026-08-30", "religious-holiday", true, []string{"تعطیل", "مناسبت-مذهبی"}), + commercialEvent("back-to-school-1405", "آمادگی بازگشایی مدارس", "2026-09-10", "2026-09-22", "education-season", []string{"آموزش", "خرید", "مدرسه"}, []string{"مراکز آموزشی مجازی", "کیف و کفش فروشی"}), + commercialEvent("autumn-opening-1405", "آغاز فصل پاییز", "2026-09-23", "2026-09-28", "seasonal-shopping", []string{"خرید", "پاییز", "پیشنهاد-فصلی"}, importedMerchantCategories), } result := make([]Event, len(events)) for index, event := range events { @@ -39,18 +57,18 @@ func Catalog1405() []Event { return result } -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: slices.Clone(tags), Source: source, Categories: slices.Clone(importedMerchantCategories), Kind: kind} +func officialEvent(id, title, start, end, family string, holiday bool, tags []string) Event { + return Event{ID: id, TitleFA: title, StartDate: mustCatalogDate(start), EndDate: mustCatalogDate(end), Family: family, Tags: slices.Clone(tags), Source: officialCalendarSource, SourceVersion: officialCalendarVersion, Kind: EventOfficial, OfficialHoliday: holiday, Universal: true} +} + +func commercialEvent(id, title, start, end, family string, tags, categories []string) Event { + return Event{ID: id, TitleFA: title, StartDate: mustCatalogDate(start), EndDate: mustCatalogDate(end), Family: family, Tags: slices.Clone(tags), Source: curatedCommercialSource, SourceVersion: CatalogVersion, Categories: slices.Clone(categories), Kind: EventCommercial, Universal: false} } func ValidateCatalog(events []Event) error { seen := make(map[string]struct{}, len(events)) for index, event := range events { - if strings.TrimSpace(event.ID) == "" || strings.TrimSpace(event.TitleFA) == "" || strings.TrimSpace(event.Family) == "" || strings.TrimSpace(event.Source) == "" { + if strings.TrimSpace(event.ID) == "" || strings.TrimSpace(event.TitleFA) == "" || strings.TrimSpace(event.Family) == "" || strings.TrimSpace(event.Source) == "" || strings.TrimSpace(event.SourceVersion) == "" { return fmt.Errorf("growth catalog event %d is incomplete", index) } if _, exists := seen[event.ID]; exists { @@ -63,8 +81,14 @@ func ValidateCatalog(events []Event) error { if event.Kind != EventOfficial && event.Kind != EventCommercial { return fmt.Errorf("growth catalog event %q has invalid kind", event.ID) } - if len(normalizedSet(event.Tags)) == 0 || len(event.Categories) == 0 { - return fmt.Errorf("growth catalog event %q lacks tags or supported categories", event.ID) + if len(normalizedSet(event.Tags)) == 0 { + return fmt.Errorf("growth catalog event %q lacks tags", event.ID) + } + if event.Universal && len(event.Categories) != 0 { + return fmt.Errorf("growth catalog universal event %q must not contain categories", event.ID) + } + if !event.Universal && len(event.Categories) == 0 { + return fmt.Errorf("growth catalog scoped event %q lacks categories", event.ID) } } return nil diff --git a/internal/growth/catalog_test.go b/internal/growth/catalog_test.go index 3ffafbd..fc4e57b 100644 --- a/internal/growth/catalog_test.go +++ b/internal/growth/catalog_test.go @@ -15,7 +15,7 @@ func TestCatalog1405IsValidAndDefensivelyCopied(t *testing.T) { } func TestValidateCatalogRejectsDuplicateAndIncompleteEvents(t *testing.T) { - event := catalogEvent("duplicate", "رویداد", "2026-01-01", "2026-01-01", "family", EventOfficial, []string{"tag"}) + event := officialEvent("duplicate", "رویداد", "2026-01-01", "2026-01-01", "family", false, []string{"tag"}) if err := ValidateCatalog([]Event{event, event}); err == nil { t.Fatal("expected duplicate id error") } @@ -25,6 +25,22 @@ func TestValidateCatalogRejectsDuplicateAndIncompleteEvents(t *testing.T) { } } +func TestCatalogCoversImportedHistoryAndForecastHorizon(t *testing.T) { + catalog := Catalog1405() + if catalog[0].StartDate.After(date("2026-01-03")) || catalog[len(catalog)-1].EndDate.Before(date("2026-09-28")) { + t.Fatalf("catalog coverage is incomplete: %s..%s", catalog[0].StartDate, catalog[len(catalog)-1].EndDate) + } + var scoped bool + for _, event := range catalog { + if event.ID == "back-to-school-1405" { + scoped = !event.Universal && len(event.Categories) == 2 + } + } + if !scoped { + t.Fatal("back-to-school event must preserve explicit category scope") + } +} + func TestAnalyzeEventEffectExcludesCatalogEventsFromBaseline(t *testing.T) { target := Event{ID: "target", StartDate: date("2026-05-02"), EndDate: date("2026-05-02")} excluded := Event{ID: "other", StartDate: target.StartDate.AddDate(0, 0, -7), EndDate: target.StartDate.AddDate(0, 0, -7)} @@ -43,10 +59,31 @@ func TestAnalyzeEventEffectExcludesCatalogEventsFromBaseline(t *testing.T) { } } +func TestAnalyzeEventEffectUsesCompleteComparableWindows(t *testing.T) { + target := Event{ID: "two-day", StartDate: date("2026-05-02"), EndDate: date("2026-05-03")} + history := []DailyPoint{ + {Date: date("2026-04-25"), SessionCount: 60, PaidSessionCount: 50, PaidAmountRial: 5_000}, + {Date: date("2026-04-26"), SessionCount: 60, PaidSessionCount: 50, PaidAmountRial: 5_000}, + {Date: date("2026-04-18"), SessionCount: 1_000, PaidSessionCount: 1_000, PaidAmountRial: 100_000}, // fragmented week, next day absent + {Date: date("2026-04-11"), SessionCount: 60, PaidSessionCount: 50, PaidAmountRial: 5_000}, + {Date: date("2026-04-12"), SessionCount: 60, PaidSessionCount: 50, PaidAmountRial: 5_000}, + {Date: date("2026-04-04"), SessionCount: 60, PaidSessionCount: 50, PaidAmountRial: 5_000}, + {Date: date("2026-04-05"), SessionCount: 60, PaidSessionCount: 50, PaidAmountRial: 5_000}, + {Date: date("2026-03-28"), SessionCount: 60, PaidSessionCount: 50, PaidAmountRial: 5_000}, + {Date: date("2026-03-29"), SessionCount: 60, PaidSessionCount: 50, PaidAmountRial: 5_000}, + {Date: target.StartDate, SessionCount: 80, PaidSessionCount: 75, PaidAmountRial: 7_500}, + {Date: target.EndDate, SessionCount: 80, PaidSessionCount: 75, PaidAmountRial: 7_500}, + } + effect := AnalyzeEventEffect(history, target, nil) + if effect.ObservationCount != 4 || effect.BaselineSessionCount != 120 || effect.ActualSessionCount != 160 || effect.PaidSessionUpliftBPS != 5_000 { + t.Fatalf("unexpected complete-window effect: %+v", effect) + } +} + func TestForecastSuppressesPoorBacktest(t *testing.T) { start := date("2026-07-01") - history := make([]DailyPoint, 0, 90) - for i := 90; i > 0; i-- { + history := make([]DailyPoint, 0, 140) + for i := 140; i > 0; i-- { value := int64(100) if i%2 == 0 { value = 1_000 @@ -54,7 +91,21 @@ func TestForecastSuppressesPoorBacktest(t *testing.T) { history = append(history, DailyPoint{Date: start.AddDate(0, 0, -i), SessionCount: value, PaidSessionCount: value, PaidAmountRial: value * 1_000}) } forecast := BuildForecast(history, nil, start, 90) - if forecast.Confidence != ConfidenceInsufficientData || len(forecast.Points) != 0 { + if forecast.Backtest.Origins < 4 || forecast.Confidence != ConfidenceInsufficientData || len(forecast.Points) != 0 { t.Fatalf("poor model should be suppressed: %+v", forecast) } } + +func TestBacktestUsesProductionTrendPredictorAcrossFourteenDayHorizons(t *testing.T) { + start := date("2026-07-01") + history := make([]DailyPoint, 0, 140) + for index := 0; index < 140; index++ { + block := index / 28 + value := int64(100 + block*20) + history = append(history, DailyPoint{Date: start.AddDate(0, 0, index-140), SessionCount: value, PaidSessionCount: value, PaidAmountRial: value * 1_000}) + } + forecast := BuildForecast(history, nil, start, 90) + if forecast.Backtest.Origins < 4 || forecast.Confidence == ConfidenceInsufficientData { + t.Fatalf("trend-aware rolling backtest should be usable: %+v", forecast.Backtest) + } +} diff --git a/internal/growth/events.go b/internal/growth/events.go index e4e10d2..6e8fb1f 100644 --- a/internal/growth/events.go +++ b/internal/growth/events.go @@ -12,26 +12,46 @@ func AnalyzeEventEffect(history []DailyPoint, event Event, catalog []Event) Even byDate := pointsByDate(history) eventDays := daysInclusive(event.StartDate, event.EndDate) var baselineSessions, baselinePaid, baselineAmount int64 + actualComplete := true for _, eventDay := range eventDays { - if point, ok := byDate[dayKey(eventDay)]; ok { - effect.ActualSessionCount += point.SessionCount - effect.ActualPaidSessions += point.PaidSessionCount - effect.ActualAmountRial += point.PaidAmountRial + point, ok := byDate[dayKey(eventDay)] + if !ok { + actualComplete = false + break } - for week := 1; week <= 8; week++ { + effect.ActualSessionCount += point.SessionCount + effect.ActualPaidSessions += point.PaidSessionCount + effect.ActualAmountRial += point.PaidAmountRial + } + if !actualComplete { + effect.SuppressionReason = SuppressionIncompleteEventWindow + return effect + } + for week := 1; week <= 8; week++ { + var windowSessions, windowPaid, windowAmount int64 + complete := true + for _, eventDay := range eventDays { candidate := eventDay.AddDate(0, 0, -7*week) if eventDates[dayKey(candidate)] { - continue + complete = false + break } point, ok := byDate[dayKey(candidate)] if !ok { - continue + complete = false + break } - effect.ObservationCount++ - baselineSessions += point.SessionCount - baselinePaid += point.PaidSessionCount - baselineAmount += point.PaidAmountRial + windowSessions += point.SessionCount + windowPaid += point.PaidSessionCount + windowAmount += point.PaidAmountRial } + if !complete { + continue + } + effect.ObservationCount++ + baselineSessions += windowSessions + baselinePaid += windowPaid + baselineAmount += windowAmount } if effect.ObservationCount < MinBaselineObservations { effect.SuppressionReason = SuppressionInsufficientObservations @@ -45,9 +65,9 @@ func AnalyzeEventEffect(history []DailyPoint, event Event, catalog []Event) Even 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.SessionUpliftBPS = changeBPS(effect.ActualSessionCount, effect.BaselineSessionCount) + effect.PaidSessionUpliftBPS = changeBPS(effect.ActualPaidSessions, effect.BaselinePaidSessions) + effect.AmountUpliftBPS = changeBPS(effect.ActualAmountRial, effect.BaselineAmountRial) effect.MaterialWin = effect.PaidSessionUpliftBPS >= MaterialChangeBPS || effect.AmountUpliftBPS >= MaterialChangeBPS effect.MaterialDecline = effect.PaidSessionUpliftBPS <= -MaterialChangeBPS || effect.AmountUpliftBPS <= -MaterialChangeBPS return effect @@ -140,6 +160,9 @@ func normalizeDate(value time.Time) time.Time { func dayKey(value time.Time) string { return normalizeDate(value).Format(time.DateOnly) } +func dateBefore(a, b time.Time) bool { return dayKey(a) < dayKey(b) } +func dateAfter(a, b time.Time) bool { return dayKey(a) > dayKey(b) } + func changeBPS(actual, baseline int64) int64 { if baseline <= 0 { return 0 diff --git a/internal/growth/forecast.go b/internal/growth/forecast.go index 121e181..bce5747 100644 --- a/internal/growth/forecast.go +++ b/internal/growth/forecast.go @@ -20,7 +20,7 @@ func BuildForecast(history []DailyPoint, catalog []Event, start time.Time, horiz if len(ordered) < minimumHistoryDays { return result } - result.Backtest = backtest(ordered) + result.Backtest = backtest(ordered, catalog) result.Confidence = classifyConfidence(result.Backtest) if result.Confidence == ConfidenceInsufficientData { return result @@ -31,28 +31,11 @@ func BuildForecast(history []DailyPoint, catalog []Event, start time.Time, horiz } 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) + sessionResiduals, amountResiduals := modelResiduals(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) - } - } + baselineSessions, baselineAmount, predictedSessions, predictedAmount, eventIDs := predictCenter(history, catalog, day) points = append(points, ForecastPoint{ Date: day, BaselineSessions: baselineSessions, BaselineAmountRial: baselineAmount, PaidSessions: rangeFromResiduals(predictedSessions, sessionResiduals), @@ -65,7 +48,7 @@ func forecastPoints(history []DailyPoint, catalog []Event, start time.Time, hori func normalizedHistory(history []DailyPoint, before time.Time) []DailyPoint { ordered := make([]DailyPoint, 0, len(history)) for _, point := range history { - if point.Date.Before(before) { + if dateBefore(point.Date, before) { point.Date = normalizeDate(point.Date) ordered = append(ordered, point) } @@ -97,13 +80,14 @@ func median(values []int64) int64 { 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++ { +func modelResiduals(history []DailyPoint, catalog []Event) ([]int64, []int64) { + sessions := make([]int64, 0, maxInt(0, len(history)-minimumHistoryDays)) + amounts := make([]int64, 0, maxInt(0, len(history)-minimumHistoryDays)) + for index := minimumHistoryDays; 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 })) + _, _, predictedSessions, predictedAmount, _ := predictCenter(prior, catalog, history[index].Date) + sessions = append(sessions, history[index].PaidSessionCount-predictedSessions) + amounts = append(amounts, history[index].PaidAmountRial-predictedAmount) } return sessions, amounts } @@ -147,22 +131,23 @@ func trendBPS(history []DailyPoint, value func(DailyPoint) int64) int64 { return trend } -func backtest(history []DailyPoint) BacktestResult { +func backtest(history []DailyPoint, catalog []Event) BacktestResult { result := BacktestResult{} var sessionError, amountError, covered int64 var evaluated int64 - first := len(history) - 28 - for origin := first; origin < len(history); origin += 7 { + first := len(history) - 56 + for origin := first; origin < len(history); origin += 14 { if origin < minimumHistoryDays { continue } result.Origins++ training := history[:origin] - sessionResiduals, amountResiduals := residuals(training) - for index := origin; index < minInt(origin+7, len(history)); index++ { + sessionResiduals, amountResiduals := modelResiduals(training, catalog) + for index := origin; index < minInt(origin+14, 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) + _, _, sessionCenter, amountCenter, _ := predictCenter(training, catalog, actual.Date) + sessionRange := rangeFromResiduals(sessionCenter, sessionResiduals) + amountRange := rangeFromResiduals(amountCenter, 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 { @@ -179,6 +164,26 @@ func backtest(history []DailyPoint) BacktestResult { return result } +func predictCenter(history []DailyPoint, catalog []Event, day time.Time) (int64, int64, int64, int64, []string) { + 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, trendBPS(history, func(p DailyPoint) int64 { return p.PaidSessionCount })) + predictedAmount := applyBPS(baselineAmount, trendBPS(history, func(p DailyPoint) int64 { return p.PaidAmountRial })) + eventIDs := make([]string, 0, 1) + familyEffects := eventFamilyEffects(history, catalog) + for _, event := range catalog { + if dateBefore(day, event.StartDate) || dateAfter(day, 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) + } + } + return baselineSessions, baselineAmount, predictedSessions, predictedAmount, eventIDs +} + func classifyConfidence(backtest BacktestResult) Confidence { if backtest.Origins < 4 { return ConfidenceInsufficientData @@ -199,7 +204,7 @@ func eventFamilyEffects(history []DailyPoint, catalog []Event) map[string]family 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) { + if event.Family == "" || dateAfter(event.StartDate, history[len(history)-1].Date) { continue } effect := AnalyzeEventEffect(history, event, catalog) @@ -249,11 +254,18 @@ func minInt(a, b int) int { return b } +func maxInt(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) + candidates := make([]Risk, 0, len(forecast.Points)) for _, point := range forecast.Points { if point.BaselineSessions < MinBaselineSessions { continue @@ -263,10 +275,49 @@ func DetectRisks(forecast Forecast, limit int) []Risk { 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 + candidates = append(candidates, Risk{Date: point.Date, EndDate: point.Date, SessionDeclineBPS: maxInt64(0, sessionDecline), AmountDeclineBPS: maxInt64(0, amountDecline), ExpectedSessions: point.PaidSessions, ExpectedAmountRial: point.PaidAmountRial}) + } + coalesced := coalesceRisks(candidates) + slices.SortStableFunc(coalesced, func(a, b Risk) int { + aSeverity := maxInt64(a.SessionDeclineBPS, a.AmountDeclineBPS) + bSeverity := maxInt64(b.SessionDeclineBPS, b.AmountDeclineBPS) + if aSeverity > bSeverity { + return -1 + } + if aSeverity < bSeverity { + return 1 + } + return a.Date.Compare(b.Date) + }) + if len(coalesced) > limit { + coalesced = coalesced[:limit] + } + return coalesced +} + +func coalesceRisks(candidates []Risk) []Risk { + if len(candidates) == 0 { + return nil + } + result := make([]Risk, 0, len(candidates)) + current := candidates[0] + for _, candidate := range candidates[1:] { + if dayKey(candidate.Date) != dayKey(current.EndDate.AddDate(0, 0, 1)) { + current.EvidenceID = EvidenceID("risk", dayKey(current.Date)+":"+dayKey(current.EndDate)) + result = append(result, current) + current = candidate + continue + } + current.EndDate = candidate.EndDate + if candidate.SessionDeclineBPS > current.SessionDeclineBPS { + current.SessionDeclineBPS = candidate.SessionDeclineBPS + current.ExpectedSessions = candidate.ExpectedSessions + } + if candidate.AmountDeclineBPS > current.AmountDeclineBPS { + current.AmountDeclineBPS = candidate.AmountDeclineBPS + current.ExpectedAmountRial = candidate.ExpectedAmountRial } } - return risks + current.EvidenceID = EvidenceID("risk", dayKey(current.Date)+":"+dayKey(current.EndDate)) + return append(result, current) } diff --git a/internal/growth/opportunities.go b/internal/growth/opportunities.go index 84173f0..82010bf 100644 --- a/internal/growth/opportunities.go +++ b/internal/growth/opportunities.go @@ -9,7 +9,7 @@ import ( func historicalWins(history []DailyPoint, catalog []Event, before time.Time) []EventEffect { wins := make([]EventEffect, 0) for _, event := range catalog { - if !event.EndDate.Before(before) { + if !dateBefore(event.EndDate, before) { continue } effect := AnalyzeEventEffect(history, event, catalog) @@ -48,7 +48,7 @@ func buildOpportunities(wins []EventEffect, catalog []Event, forecast Forecast, historical := eventByID[win.EventID] upcoming := make([]Event, 0) for _, event := range catalog { - if !event.EndDate.Before(start) { + if !dateBefore(event.EndDate, start) { upcoming = append(upcoming, event) } } @@ -93,7 +93,7 @@ func buildOpportunities(wins []EventEffect, catalog []Event, forecast Forecast, 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) { + if category == "" || event.Universal || slices.Contains(event.Categories, category) { filtered = append(filtered, event) } } diff --git a/internal/growth/peer.go b/internal/growth/peer.go index 40997b0..bd32bcc 100644 --- a/internal/growth/peer.go +++ b/internal/growth/peer.go @@ -47,12 +47,14 @@ func EvaluatePeer(input PeerInput) PeerResult { slices.Sort(values) result.State = PeerReady result.MedianPaidSessions = median(values) - var atOrBelow int64 + var below, equal int64 for _, value := range values { - if input.MerchantPaidSessions >= value { - atOrBelow++ + if input.MerchantPaidSessions > value { + below++ + } else if input.MerchantPaidSessions == value { + equal++ } } - result.PercentileBPS = atOrBelow * 10_000 / int64(len(values)) + result.PercentileBPS = (2*below + equal) * 5_000 / int64(len(values)) return result } diff --git a/internal/growth/report.go b/internal/growth/report.go index 0f3338f..793db53 100644 --- a/internal/growth/report.go +++ b/internal/growth/report.go @@ -9,16 +9,17 @@ import ( ) 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"` + 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"` + ModelConfigVersion string `json:"modelConfigVersion"` } func Fingerprint(input FingerprintInput) string { @@ -30,6 +31,7 @@ func Fingerprint(input FingerprintInput) string { DatasetWatermark: input.DatasetWatermark, HorizonDays: input.HorizonDays, CatalogVersion: input.CatalogVersion, AnalyticsVersion: input.AnalyticsVersion, PromptVersion: input.PromptVersion, SchemaVersion: input.SchemaVersion, Model: input.Model, + ModelConfigVersion: input.ModelConfigVersion, }, ForecastDate: input.ForecastStart.In(tehranLocation()).Format(time.DateOnly)}) if err != nil { return "" @@ -95,6 +97,7 @@ func BuildFallback(bundle EvidenceBundle) []CampaignBrief { HeadlineFA: "یک فرصت مناسب در راه است: " + opportunity.EventTitleFA, GuidanceFA: "داده‌های قبلی شما نشان می‌دهد این مناسبت می‌تواند برای برنامه‌ریزی فروش ارزشمند باشد.", OfferFA: "پیشنهاد خود را متناسب با نیاز مشتریان این مناسبت آماده کنید.", + MessageFA: "برای این مناسبت یک پیشنهاد ویژه برای شما آماده کرده‌ایم.", PreparationFA: "موجودی، پیام کمپین و مسیر پرداخت را پیش از شروع مناسبت بررسی کنید.", LimitationFA: "این پیشنهاد بر پایه الگوهای گذشته است و نتیجه قطعی نیست.", EvidenceIDs: []string{opportunity.EvidenceID}, @@ -105,6 +108,7 @@ func BuildFallback(bundle EvidenceBundle) []CampaignBrief { HeadlineFA: "برای یک بازه آرام‌تر آماده باشید", GuidanceFA: "الگوی گذشته احتمال کاهش فعالیت را در این بازه نشان می‌دهد.", OfferFA: "یک پیشنهاد سبک برای حفظ ارتباط با مشتریان در نظر بگیرید.", + MessageFA: "این روزها هم کنار شما هستیم؛ پیشنهادهای تازه را ببینید.", PreparationFA: "پیام یادآوری و تجربه پرداخت را پیش از این بازه بازبینی کنید.", LimitationFA: "این هشدار بر پایه الگوهای گذشته است و نتیجه قطعی نیست.", EvidenceIDs: []string{risk.EvidenceID}, diff --git a/internal/growth/types.go b/internal/growth/types.go index e86c4bf..761b66f 100644 --- a/internal/growth/types.go +++ b/internal/growth/types.go @@ -3,8 +3,9 @@ package growth import "time" const ( - CatalogVersion = "1405.1" - AnalyticsVersion = "1.0.0" + CatalogVersion = "1405.2" + AnalyticsVersion = "1.1.0" + DefaultModelConfigVersion = "avalai-responses-v1-tools4-turns2-output1600" MinBaselineObservations = 4 MinBaselineSessions = 100 @@ -26,6 +27,7 @@ const ( SuppressionInsufficientObservations SuppressionReason = "INSUFFICIENT_BASELINE_OBSERVATIONS" SuppressionInsufficientBaselineSessions SuppressionReason = "INSUFFICIENT_BASELINE_SESSIONS" SuppressionInsufficientHistory SuppressionReason = "INSUFFICIENT_HISTORY" + SuppressionIncompleteEventWindow SuppressionReason = "INCOMPLETE_EVENT_WINDOW" SuppressionInsufficientCohort SuppressionReason = "INSUFFICIENT_COHORT" SuppressionTerminalScope SuppressionReason = "TERMINAL_SCOPE_UNSUPPORTED" ) @@ -46,15 +48,18 @@ const ( ) 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"` + 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"` + SourceVersion string `json:"sourceVersion"` + Categories []string `json:"categories"` + Kind EventKind `json:"kind"` + OfficialHoliday bool `json:"officialHoliday"` + Universal bool `json:"universal"` } type EventEffect struct { @@ -127,6 +132,7 @@ type Opportunity struct { type Risk struct { EvidenceID string `json:"evidenceId"` Date time.Time `json:"date"` + EndDate time.Time `json:"endDate"` SessionDeclineBPS int64 `json:"sessionDeclineBps"` AmountDeclineBPS int64 `json:"amountDeclineBps"` ExpectedSessions Range `json:"expectedSessions"` @@ -142,6 +148,7 @@ type CampaignBrief struct { HeadlineFA string `json:"headlineFa"` GuidanceFA string `json:"guidanceFa"` OfferFA string `json:"offerFa"` + MessageFA string `json:"messageFa"` PreparationFA string `json:"preparationFa"` LimitationFA string `json:"limitationFa"` EvidenceIDs []string `json:"evidenceIds"`