diff --git a/internal/growth/analytics_test.go b/internal/growth/analytics_test.go new file mode 100644 index 0000000..d32b4f6 --- /dev/null +++ b/internal/growth/analytics_test.go @@ -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 +} diff --git a/internal/growth/catalog.go b/internal/growth/catalog.go new file mode 100644 index 0000000..78f6bd7 --- /dev/null +++ b/internal/growth/catalog.go @@ -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 +} diff --git a/internal/growth/events.go b/internal/growth/events.go new file mode 100644 index 0000000..e4e10d2 --- /dev/null +++ b/internal/growth/events.go @@ -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 +} diff --git a/internal/growth/forecast.go b/internal/growth/forecast.go new file mode 100644 index 0000000..121e181 --- /dev/null +++ b/internal/growth/forecast.go @@ -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 +} diff --git a/internal/growth/opportunities.go b/internal/growth/opportunities.go new file mode 100644 index 0000000..84173f0 --- /dev/null +++ b/internal/growth/opportunities.go @@ -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 +} diff --git a/internal/growth/peer.go b/internal/growth/peer.go new file mode 100644 index 0000000..40997b0 --- /dev/null +++ b/internal/growth/peer.go @@ -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 +} diff --git a/internal/growth/report.go b/internal/growth/report.go new file mode 100644 index 0000000..0f3338f --- /dev/null +++ b/internal/growth/report.go @@ -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 +} diff --git a/internal/growth/types.go b/internal/growth/types.go new file mode 100644 index 0000000..e86c4bf --- /dev/null +++ b/internal/growth/types.go @@ -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"` +}