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) == 0 { return result } result.Backtest = backtest(ordered, catalog) result.Confidence = classifyConfidence(result.Backtest) result.SuppressionReason = "" result.Points = forecastPoints(ordered, catalog, result.StartDate, horizonDays, result.Confidence) return result } func forecastPoints(history []DailyPoint, catalog []Event, start time.Time, horizon int, confidence Confidence) []ForecastPoint { sessionResiduals, amountResiduals := modelResiduals(history, catalog) points := make([]ForecastPoint, 0, horizon) for index := 0; index < horizon; index++ { day := start.AddDate(0, 0, index) baselineSessions, baselineAmount, predictedSessions, predictedAmount, eventIDs := predictCenter(history, catalog, day) points = append(points, ForecastPoint{ Date: day, BaselineSessions: baselineSessions, BaselineAmountRial: baselineAmount, PaidSessions: forecastRange(predictedSessions, sessionResiduals, confidence), PaidAmountRial: forecastRange(predictedAmount, amountResiduals, confidence), EventIDs: eventIDs, }) } return points } func normalizedHistory(history []DailyPoint, before time.Time) []DailyPoint { ordered := make([]DailyPoint, 0, len(history)) for _, point := range history { if dateBefore(point.Date, 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])) } } if len(values) == 0 { for index := len(history) - 1; index >= 0 && len(values) < 8; index-- { 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 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] _, _, 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 } 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 forecastRange(center int64, residualValues []int64, confidence Confidence) Range { if confidence == ConfidenceLow && len(residualValues) == 0 { return Range{P10: center / 2, P50: center, P90: center + center/2} } return rangeFromResiduals(center, residualValues) } 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, catalog []Event) BacktestResult { result := BacktestResult{} var sessionError, amountError, covered int64 var evaluated int64 first := len(history) - 56 for origin := first; origin < len(history); origin += 14 { if origin < minimumHistoryDays { continue } result.Origins++ training := history[:origin] sessionResiduals, amountResiduals := modelResiduals(training, catalog) for index := origin; index < minInt(origin+14, len(history)); index++ { actual := history[index] _, _, 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 { covered++ } evaluated++ } } if evaluated > 0 { result.SessionMAPEBPS = sessionError / evaluated result.AmountMAPEBPS = amountError / evaluated result.IntervalCoverageBPS = covered * 10_000 / evaluated } 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 ConfidenceLow } 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 ConfidenceLow } 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 == "" || dateAfter(event.StartDate, 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 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 } candidates := make([]Risk, 0, len(forecast.Points)) 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 } 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 } } current.EvidenceID = EvidenceID("risk", dayKey(current.Date)+":"+dayKey(current.EndDate)) return append(result, current) }