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 }