fix: keep growth analysis available per terminal

This commit is contained in:
2026-08-21 02:57:46 +03:30
parent 7951664d08
commit e6afaa7a10
11 changed files with 180 additions and 45 deletions

View File

@@ -63,6 +63,26 @@ func TestBuildForecastReturnsOrderedNinetyDayIntegerRanges(t *testing.T) {
}
}
func TestBuildForecastUsesLimitedHistoryWithLowConfidence(t *testing.T) {
start := date("2026-07-01")
history := make([]DailyPoint, 0, 14)
for daysAgo := 14; daysAgo > 0; daysAgo-- {
day := start.AddDate(0, 0, -daysAgo)
value := int64(80 + int(day.Weekday())*5)
history = append(history, DailyPoint{Date: day, SessionCount: value, PaidSessionCount: value, PaidAmountRial: value * 100_000})
}
forecast := BuildForecast(history, nil, start, 90)
if forecast.Confidence != ConfidenceLow || len(forecast.Points) != 90 {
t.Fatalf("limited history should produce a low-confidence 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 {
t.Fatalf("limited-history range is unordered: %+v", point.PaidSessions)
}
}
}
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}},

View File

@@ -80,7 +80,7 @@ func TestAnalyzeEventEffectUsesCompleteComparableWindows(t *testing.T) {
}
}
func TestForecastSuppressesPoorBacktest(t *testing.T) {
func TestForecastKeepsPoorBacktestVisibleWithLowConfidence(t *testing.T) {
start := date("2026-07-01")
history := make([]DailyPoint, 0, 140)
for i := 140; i > 0; i-- {
@@ -91,8 +91,8 @@ func TestForecastSuppressesPoorBacktest(t *testing.T) {
history = append(history, DailyPoint{Date: start.AddDate(0, 0, -i), SessionCount: value, PaidSessionCount: value, PaidAmountRial: value * 1_000})
}
forecast := BuildForecast(history, nil, start, 90)
if forecast.Backtest.Origins < 4 || forecast.Confidence != ConfidenceInsufficientData || len(forecast.Points) != 0 {
t.Fatalf("poor model should be suppressed: %+v", forecast)
if forecast.Backtest.Origins < 4 || forecast.Confidence != ConfidenceLow || len(forecast.Points) != 90 {
t.Fatalf("poor model should remain visible with low confidence: %+v", forecast)
}
}

View File

@@ -17,20 +17,17 @@ func BuildForecast(history []DailyPoint, catalog []Event, start time.Time, horiz
}
result := Forecast{StartDate: normalizeDate(start), HorizonDays: horizonDays, Confidence: ConfidenceInsufficientData, SuppressionReason: SuppressionInsufficientHistory, Points: []ForecastPoint{}}
ordered := normalizedHistory(history, start)
if len(ordered) < minimumHistoryDays {
if len(ordered) == 0 {
return result
}
result.Backtest = backtest(ordered, catalog)
result.Confidence = classifyConfidence(result.Backtest)
if result.Confidence == ConfidenceInsufficientData {
return result
}
result.SuppressionReason = ""
result.Points = forecastPoints(ordered, catalog, result.StartDate, horizonDays)
result.Points = forecastPoints(ordered, catalog, result.StartDate, horizonDays, result.Confidence)
return result
}
func forecastPoints(history []DailyPoint, catalog []Event, start time.Time, horizon int) []ForecastPoint {
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++ {
@@ -38,8 +35,8 @@ func forecastPoints(history []DailyPoint, catalog []Event, start time.Time, hori
baselineSessions, baselineAmount, predictedSessions, predictedAmount, eventIDs := predictCenter(history, catalog, day)
points = append(points, ForecastPoint{
Date: day, BaselineSessions: baselineSessions, BaselineAmountRial: baselineAmount,
PaidSessions: rangeFromResiduals(predictedSessions, sessionResiduals),
PaidAmountRial: rangeFromResiduals(predictedAmount, amountResiduals), EventIDs: eventIDs,
PaidSessions: forecastRange(predictedSessions, sessionResiduals, confidence),
PaidAmountRial: forecastRange(predictedAmount, amountResiduals, confidence), EventIDs: eventIDs,
})
}
return points
@@ -64,6 +61,11 @@ func weekdayMedian(history []DailyPoint, weekday time.Weekday, value func(DailyP
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)
}
@@ -100,6 +102,13 @@ func rangeFromResiduals(center int64, residualValues []int64) Range {
}
}
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
@@ -186,7 +195,7 @@ func predictCenter(history []DailyPoint, catalog []Event, day time.Time) (int64,
func classifyConfidence(backtest BacktestResult) Confidence {
if backtest.Origins < 4 {
return ConfidenceInsufficientData
return ConfidenceLow
}
worstMAPE := maxInt64(backtest.SessionMAPEBPS, backtest.AmountMAPEBPS)
if worstMAPE <= 2_000 && backtest.IntervalCoverageBPS >= 7_000 {
@@ -195,7 +204,7 @@ func classifyConfidence(backtest BacktestResult) Confidence {
if worstMAPE <= 4_000 && backtest.IntervalCoverageBPS >= 5_000 {
return ConfidenceMedium
}
return ConfidenceInsufficientData
return ConfidenceLow
}
type familyUplift struct{ sessions, amount int64 }

View File

@@ -4,7 +4,7 @@ import "time"
const (
CatalogVersion = "1405.2"
AnalyticsVersion = "1.1.0"
AnalyticsVersion = "1.2.0"
DefaultModelConfigVersion = "avalai-responses-v1-tools4-turns2-output1600"
MinBaselineObservations = 4
@@ -18,6 +18,7 @@ type Confidence string
const (
ConfidenceHigh Confidence = "HIGH"
ConfidenceMedium Confidence = "MEDIUM"
ConfidenceLow Confidence = "LOW"
ConfidenceInsufficientData Confidence = "INSUFFICIENT_DATA"
)