273 lines
8.9 KiB
Go
273 lines
8.9 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) < 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
|
|
}
|