feat(growth): add deterministic forecasting engine
This commit is contained in:
101
internal/growth/opportunities.go
Normal file
101
internal/growth/opportunities.go
Normal file
@@ -0,0 +1,101 @@
|
||||
package growth
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func historicalWins(history []DailyPoint, catalog []Event, before time.Time) []EventEffect {
|
||||
wins := make([]EventEffect, 0)
|
||||
for _, event := range catalog {
|
||||
if !event.EndDate.Before(before) {
|
||||
continue
|
||||
}
|
||||
effect := AnalyzeEventEffect(history, event, catalog)
|
||||
if effect.Eligible && effect.MaterialWin {
|
||||
wins = append(wins, effect)
|
||||
}
|
||||
}
|
||||
slices.SortFunc(wins, func(a, b EventEffect) int {
|
||||
aScore := maxInt64(a.PaidSessionUpliftBPS, a.AmountUpliftBPS)
|
||||
bScore := maxInt64(b.PaidSessionUpliftBPS, b.AmountUpliftBPS)
|
||||
if aScore != bScore {
|
||||
if aScore > bScore {
|
||||
return -1
|
||||
}
|
||||
return 1
|
||||
}
|
||||
return strings.Compare(a.EventID, b.EventID)
|
||||
})
|
||||
return wins
|
||||
}
|
||||
|
||||
func buildOpportunities(wins []EventEffect, catalog []Event, forecast Forecast, start time.Time, limit int) []Opportunity {
|
||||
if forecast.Confidence == ConfidenceInsufficientData || limit <= 0 {
|
||||
return nil
|
||||
}
|
||||
eventByID := make(map[string]Event, len(catalog))
|
||||
for _, event := range catalog {
|
||||
eventByID[event.ID] = event
|
||||
}
|
||||
forecastByDate := make(map[string]ForecastPoint, len(forecast.Points))
|
||||
for _, point := range forecast.Points {
|
||||
forecastByDate[dayKey(point.Date)] = point
|
||||
}
|
||||
best := make(map[string]Opportunity)
|
||||
for _, win := range wins {
|
||||
historical := eventByID[win.EventID]
|
||||
upcoming := make([]Event, 0)
|
||||
for _, event := range catalog {
|
||||
if !event.EndDate.Before(start) {
|
||||
upcoming = append(upcoming, event)
|
||||
}
|
||||
}
|
||||
for _, match := range MatchUpcomingEvents(historical, upcoming) {
|
||||
point, ok := forecastByDate[dayKey(match.Event.StartDate)]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
candidate := Opportunity{
|
||||
EvidenceID: EvidenceID("opportunity", match.Event.ID+":"+win.EventID), EventID: match.Event.ID,
|
||||
EventTitleFA: match.Event.TitleFA, Date: normalizeDate(match.Event.StartDate), SimilarityBPS: match.ScoreBPS,
|
||||
ExpectedSessions: point.PaidSessions, ExpectedAmountRial: point.PaidAmountRial,
|
||||
HistoricalEvidenceID: win.EvidenceID,
|
||||
}
|
||||
if current, exists := best[match.Event.ID]; !exists || candidate.SimilarityBPS > current.SimilarityBPS {
|
||||
best[match.Event.ID] = candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
result := make([]Opportunity, 0, len(best))
|
||||
for _, opportunity := range best {
|
||||
result = append(result, opportunity)
|
||||
}
|
||||
slices.SortFunc(result, func(a, b Opportunity) int {
|
||||
if a.SimilarityBPS != b.SimilarityBPS {
|
||||
if a.SimilarityBPS > b.SimilarityBPS {
|
||||
return -1
|
||||
}
|
||||
return 1
|
||||
}
|
||||
if comparison := a.Date.Compare(b.Date); comparison != 0 {
|
||||
return comparison
|
||||
}
|
||||
return strings.Compare(a.EventID, b.EventID)
|
||||
})
|
||||
if len(result) > limit {
|
||||
result = result[:limit]
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func filterCatalog(catalog []Event, category string) []Event {
|
||||
filtered := make([]Event, 0, len(catalog))
|
||||
for _, event := range catalog {
|
||||
if category == "" || len(event.Categories) == 0 || slices.Contains(event.Categories, category) {
|
||||
filtered = append(filtered, event)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
Reference in New Issue
Block a user