Files
zarinpal-analytics-backend/internal/growth/forecast.go

333 lines
11 KiB
Go

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)
}