feat(growth): add deterministic forecasting engine
This commit is contained in:
166
internal/growth/analytics_test.go
Normal file
166
internal/growth/analytics_test.go
Normal file
@@ -0,0 +1,166 @@
|
||||
package growth
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestAnalyzeEventEffectUsesMatchingWeekdaysAndThresholds(t *testing.T) {
|
||||
event := Event{ID: "teachers-day", TitleFA: "روز معلم", StartDate: date("2026-05-02"), EndDate: date("2026-05-02")}
|
||||
history := make([]DailyPoint, 0, 9)
|
||||
for weeks := 8; weeks >= 1; weeks-- {
|
||||
history = append(history, DailyPoint{Date: event.StartDate.AddDate(0, 0, -7*weeks), SessionCount: 25, PaidSessionCount: 20, PaidAmountRial: 2_000_000})
|
||||
}
|
||||
history = append(history, DailyPoint{Date: event.StartDate, SessionCount: 40, PaidSessionCount: 30, PaidAmountRial: 3_000_000})
|
||||
|
||||
effect := AnalyzeEventEffect(history, event, nil)
|
||||
if !effect.Eligible || !effect.MaterialWin {
|
||||
t.Fatalf("expected eligible material win, got %+v", effect)
|
||||
}
|
||||
if effect.BaselineSessionCount != 25 || effect.SessionUpliftBPS != 6_000 || effect.AmountUpliftBPS != 5_000 {
|
||||
t.Fatalf("unexpected effect: %+v", effect)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyzeEventEffectSuppressesSmallBaseline(t *testing.T) {
|
||||
event := Event{ID: "small", StartDate: date("2026-05-02"), EndDate: date("2026-05-02")}
|
||||
history := []DailyPoint{{Date: event.StartDate.AddDate(0, 0, -7), SessionCount: 20}, {Date: event.StartDate.AddDate(0, 0, -14), SessionCount: 20}, {Date: event.StartDate.AddDate(0, 0, -21), SessionCount: 20}, {Date: event.StartDate.AddDate(0, 0, -28), SessionCount: 20}, {Date: event.StartDate, SessionCount: 50}}
|
||||
effect := AnalyzeEventEffect(history, event, nil)
|
||||
if effect.Eligible || effect.SuppressionReason != SuppressionInsufficientBaselineSessions {
|
||||
t.Fatalf("expected baseline suppression, got %+v", effect)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchUpcomingEventsPrefersFamilyThenTags(t *testing.T) {
|
||||
historical := Event{ID: "teacher", Family: "appreciation", Tags: []string{"gift", "education"}}
|
||||
upcoming := []Event{
|
||||
{ID: "exact", Family: "appreciation", Tags: []string{"unrelated"}},
|
||||
{ID: "similar", Family: "other", Tags: []string{"gift", "education", "campaign"}},
|
||||
{ID: "weak", Family: "other", Tags: []string{"internet"}},
|
||||
}
|
||||
matches := MatchUpcomingEvents(historical, upcoming)
|
||||
if len(matches) != 2 || matches[0].Event.ID != "exact" || matches[0].ScoreBPS != 10_000 || matches[1].Event.ID != "similar" {
|
||||
t.Fatalf("unexpected matches: %+v", matches)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildForecastReturnsOrderedNinetyDayIntegerRanges(t *testing.T) {
|
||||
start := date("2026-07-01")
|
||||
history := make([]DailyPoint, 0, 112)
|
||||
for i := 112; i > 0; i-- {
|
||||
day := start.AddDate(0, 0, -i)
|
||||
base := int64(100 + int(day.Weekday())*5)
|
||||
history = append(history, DailyPoint{Date: day, SessionCount: base + 20, PaidSessionCount: base, PaidAmountRial: base * 100_000})
|
||||
}
|
||||
forecast := BuildForecast(history, nil, start, 90)
|
||||
if len(forecast.Points) != 90 || forecast.Confidence == ConfidenceInsufficientData {
|
||||
t.Fatalf("unexpected 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 || point.PaidAmountRial.P10 > point.PaidAmountRial.P50 || point.PaidAmountRial.P50 > point.PaidAmountRial.P90 {
|
||||
t.Fatalf("unordered range: %+v", point)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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}},
|
||||
{Date: date("2026-07-02"), BaselineSessions: 99, PaidSessions: Range{P50: 10}, BaselineAmountRial: 10_000_000, PaidAmountRial: Range{P50: 1}},
|
||||
}}
|
||||
risks := DetectRisks(forecast, 3)
|
||||
if len(risks) != 1 || risks[0].Date != date("2026-07-01") {
|
||||
t.Fatalf("unexpected risks: %+v", risks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluatePeerAppliesPrivacyThresholdAndEqualMerchantWeight(t *testing.T) {
|
||||
peers := make([]PeerMerchantValue, 10)
|
||||
for i := range peers {
|
||||
peers[i] = PeerMerchantValue{PaidSessions: int64(i + 1), SessionCount: 100}
|
||||
}
|
||||
result := EvaluatePeer(PeerInput{MerchantPaidSessions: 8, Peers: peers})
|
||||
if result.State != PeerReady || result.MedianPaidSessions != 5 || result.PercentileBPS != 8_000 {
|
||||
t.Fatalf("unexpected peer result: %+v", result)
|
||||
}
|
||||
suppressed := EvaluatePeer(PeerInput{TerminalScoped: true, Peers: peers})
|
||||
if suppressed.State != PeerSuppressed || suppressed.Reason != SuppressionTerminalScope {
|
||||
t.Fatalf("unexpected terminal result: %+v", suppressed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFingerprintIsStableAndVersionSensitive(t *testing.T) {
|
||||
input := FingerprintInput{MerchantID: 7, DatasetWatermark: "run-1", ForecastStart: date("2026-07-01"), HorizonDays: 90, CatalogVersion: "1", AnalyticsVersion: "1", PromptVersion: "1", SchemaVersion: "1", Model: "mini"}
|
||||
a := Fingerprint(input)
|
||||
b := Fingerprint(input)
|
||||
input.Model = "other"
|
||||
if a == "" || a != b || a == Fingerprint(input) {
|
||||
t.Fatalf("fingerprint stability/versioning failed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTehranRefreshBoundary(t *testing.T) {
|
||||
last := time.Date(2026, 8, 20, 20, 31, 0, 0, time.UTC) // 00:01 Tehran on Aug 21.
|
||||
next := NextRefreshAt(last)
|
||||
want := time.Date(2026, 8, 21, 20, 30, 0, 0, time.UTC)
|
||||
if !next.Equal(want) || CanManualRefresh(last, want.Add(-time.Second)) || !CanManualRefresh(last, want) {
|
||||
t.Fatalf("unexpected boundary next=%s", next)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFallbackUsesOnlyEvidenceAndPersianCopy(t *testing.T) {
|
||||
bundle := EvidenceBundle{Opportunities: []Opportunity{{EvidenceID: "opp-1", EventTitleFA: "روز معلم"}}, Risks: []Risk{{EvidenceID: "risk-1"}}}
|
||||
briefs := BuildFallback(bundle)
|
||||
if len(briefs) != 2 || briefs[0].EvidenceIDs[0] != "opp-1" || briefs[0].HeadlineFA == "" {
|
||||
t.Fatalf("unexpected briefs: %+v", briefs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDeterministicReportLimitsAndOrdersOpportunities(t *testing.T) {
|
||||
start := date("2026-07-01")
|
||||
history := make([]DailyPoint, 0, 120)
|
||||
for i := 120; i > 0; i-- {
|
||||
day := start.AddDate(0, 0, -i)
|
||||
sessions := int64(120)
|
||||
amount := int64(12_000_000)
|
||||
if day.Equal(date("2026-05-02")) {
|
||||
sessions = 180
|
||||
amount = 18_000_000
|
||||
}
|
||||
history = append(history, DailyPoint{Date: day, SessionCount: sessions, PaidSessionCount: sessions, PaidAmountRial: amount})
|
||||
}
|
||||
catalog := []Event{
|
||||
{ID: "past", TitleFA: "روز معلم", StartDate: date("2026-05-02"), EndDate: date("2026-05-02"), Family: "appreciation", Tags: []string{"gift"}},
|
||||
{ID: "future", TitleFA: "روز پدر", StartDate: date("2026-07-10"), EndDate: date("2026-07-10"), Family: "appreciation", Tags: []string{"gift"}},
|
||||
}
|
||||
report := BuildDeterministicReport(ReportInput{History: history, Catalog: catalog, ForecastStart: start, HorizonDays: 90, Peer: &PeerInput{TerminalScoped: true}})
|
||||
if len(report.HistoricalWins) != 1 || len(report.Opportunities) != 1 || report.Opportunities[0].EventID != "future" {
|
||||
t.Fatalf("unexpected report evidence: %+v", report)
|
||||
}
|
||||
if report.Peer == nil || report.Peer.Reason != SuppressionTerminalScope {
|
||||
t.Fatalf("expected terminal peer suppression: %+v", report.Peer)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFingerprintTreatsEquivalentForecastDatesEqually(t *testing.T) {
|
||||
tehran, err := time.LoadLocation("Asia/Tehran")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
local := time.Date(2026, 7, 1, 0, 0, 0, 0, tehran)
|
||||
utcInstant := local.UTC()
|
||||
base := FingerprintInput{MerchantID: 1, ForecastStart: local, HorizonDays: 90}
|
||||
a := Fingerprint(base)
|
||||
base.ForecastStart = utcInstant
|
||||
if a != Fingerprint(base) {
|
||||
t.Fatal("equivalent forecast instants must fingerprint equally")
|
||||
}
|
||||
}
|
||||
|
||||
func date(value string) time.Time {
|
||||
parsed, err := time.Parse(time.DateOnly, value)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
37
internal/growth/catalog.go
Normal file
37
internal/growth/catalog.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package growth
|
||||
|
||||
import "time"
|
||||
|
||||
const officialCalendarSource = "https://calendar.ut.ac.ir/"
|
||||
const curatedCommercialSource = "CURATED_COMMERCIAL_1405_V1"
|
||||
|
||||
// Catalog1405 returns a defensive copy of the reviewed initial catalog. Dates are
|
||||
// Gregorian Tehran-local calendar dates and cover the imported 1405 history plus
|
||||
// the first 90 forecast days after 2026-06-30.
|
||||
func Catalog1405() []Event {
|
||||
events := []Event{
|
||||
catalogEvent("nowruz-1405", "نوروز", "2026-03-21", "2026-03-24", "new-year", EventOfficial, []string{"هدیه", "خرید", "سال-نو"}),
|
||||
catalogEvent("nature-day-1405", "روز طبیعت", "2026-04-02", "2026-04-02", "leisure", EventOfficial, []string{"تفریح", "خرید"}),
|
||||
catalogEvent("teachers-day-1405", "روز معلم", "2026-05-02", "2026-05-02", "appreciation", EventOfficial, []string{"هدیه", "آموزش", "قدردانی"}),
|
||||
catalogEvent("khordad-15-1405", "قیام پانزده خرداد", "2026-06-05", "2026-06-05", "official-holiday", EventOfficial, []string{"تعطیل"}),
|
||||
catalogEvent("fathers-day-commercial-1405", "روز پدر", "2026-08-25", "2026-08-25", "appreciation", EventCommercial, []string{"هدیه", "خرید", "قدردانی"}),
|
||||
catalogEvent("back-to-school-1405", "آمادگی بازگشایی مدارس", "2026-09-10", "2026-09-22", "education-season", EventCommercial, []string{"آموزش", "خرید", "مدرسه"}),
|
||||
}
|
||||
return append([]Event(nil), events...)
|
||||
}
|
||||
|
||||
func catalogEvent(id, title, start, end, family string, kind EventKind, tags []string) Event {
|
||||
source := officialCalendarSource
|
||||
if kind == EventCommercial {
|
||||
source = curatedCommercialSource
|
||||
}
|
||||
return Event{ID: id, TitleFA: title, StartDate: mustCatalogDate(start), EndDate: mustCatalogDate(end), Family: family, Tags: append([]string(nil), tags...), Source: source, Kind: kind}
|
||||
}
|
||||
|
||||
func mustCatalogDate(value string) time.Time {
|
||||
parsed, err := time.Parse(time.DateOnly, value)
|
||||
if err != nil {
|
||||
panic("invalid reviewed event catalog date: " + value)
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
148
internal/growth/events.go
Normal file
148
internal/growth/events.go
Normal file
@@ -0,0 +1,148 @@
|
||||
package growth
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func AnalyzeEventEffect(history []DailyPoint, event Event, catalog []Event) EventEffect {
|
||||
effect := EventEffect{EvidenceID: "event:" + event.ID, EventID: event.ID}
|
||||
eventDates := catalogDates(append(slices.Clone(catalog), event))
|
||||
byDate := pointsByDate(history)
|
||||
eventDays := daysInclusive(event.StartDate, event.EndDate)
|
||||
var baselineSessions, baselinePaid, baselineAmount int64
|
||||
for _, eventDay := range eventDays {
|
||||
if point, ok := byDate[dayKey(eventDay)]; ok {
|
||||
effect.ActualSessionCount += point.SessionCount
|
||||
effect.ActualPaidSessions += point.PaidSessionCount
|
||||
effect.ActualAmountRial += point.PaidAmountRial
|
||||
}
|
||||
for week := 1; week <= 8; week++ {
|
||||
candidate := eventDay.AddDate(0, 0, -7*week)
|
||||
if eventDates[dayKey(candidate)] {
|
||||
continue
|
||||
}
|
||||
point, ok := byDate[dayKey(candidate)]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
effect.ObservationCount++
|
||||
baselineSessions += point.SessionCount
|
||||
baselinePaid += point.PaidSessionCount
|
||||
baselineAmount += point.PaidAmountRial
|
||||
}
|
||||
}
|
||||
if effect.ObservationCount < MinBaselineObservations {
|
||||
effect.SuppressionReason = SuppressionInsufficientObservations
|
||||
return effect
|
||||
}
|
||||
effect.BaselineSessionCount = baselineSessions / int64(effect.ObservationCount)
|
||||
effect.BaselinePaidSessions = baselinePaid / int64(effect.ObservationCount)
|
||||
effect.BaselineAmountRial = baselineAmount / int64(effect.ObservationCount)
|
||||
if baselineSessions < MinBaselineSessions {
|
||||
effect.SuppressionReason = SuppressionInsufficientBaselineSessions
|
||||
return effect
|
||||
}
|
||||
effect.Eligible = true
|
||||
effect.SessionUpliftBPS = changeBPS(effect.ActualSessionCount/int64(len(eventDays)), effect.BaselineSessionCount)
|
||||
effect.PaidSessionUpliftBPS = changeBPS(effect.ActualPaidSessions/int64(len(eventDays)), effect.BaselinePaidSessions)
|
||||
effect.AmountUpliftBPS = changeBPS(effect.ActualAmountRial/int64(len(eventDays)), effect.BaselineAmountRial)
|
||||
effect.MaterialWin = effect.PaidSessionUpliftBPS >= MaterialChangeBPS || effect.AmountUpliftBPS >= MaterialChangeBPS
|
||||
effect.MaterialDecline = effect.PaidSessionUpliftBPS <= -MaterialChangeBPS || effect.AmountUpliftBPS <= -MaterialChangeBPS
|
||||
return effect
|
||||
}
|
||||
|
||||
func MatchScoreBPS(historical, upcoming Event) (int64, string) {
|
||||
if historical.Family != "" && strings.EqualFold(strings.TrimSpace(historical.Family), strings.TrimSpace(upcoming.Family)) {
|
||||
return 10_000, "FAMILY"
|
||||
}
|
||||
a := normalizedSet(historical.Tags)
|
||||
b := normalizedSet(upcoming.Tags)
|
||||
if len(a) == 0 || len(b) == 0 {
|
||||
return 0, "TAGS"
|
||||
}
|
||||
intersection := 0
|
||||
union := make(map[string]struct{}, len(a)+len(b))
|
||||
for tag := range a {
|
||||
union[tag] = struct{}{}
|
||||
if _, ok := b[tag]; ok {
|
||||
intersection++
|
||||
}
|
||||
}
|
||||
for tag := range b {
|
||||
union[tag] = struct{}{}
|
||||
}
|
||||
return int64(intersection * 10_000 / len(union)), "TAGS"
|
||||
}
|
||||
|
||||
func MatchUpcomingEvents(historical Event, upcoming []Event) []EventMatch {
|
||||
matches := make([]EventMatch, 0, len(upcoming))
|
||||
for _, candidate := range upcoming {
|
||||
score, basis := MatchScoreBPS(historical, candidate)
|
||||
if score >= MinSimilarityBPS {
|
||||
matches = append(matches, EventMatch{Event: candidate, ScoreBPS: score, MatchBasis: basis})
|
||||
}
|
||||
}
|
||||
slices.SortFunc(matches, func(a, b EventMatch) int {
|
||||
if a.ScoreBPS != b.ScoreBPS {
|
||||
return int(b.ScoreBPS - a.ScoreBPS)
|
||||
}
|
||||
return strings.Compare(a.Event.ID, b.Event.ID)
|
||||
})
|
||||
return matches
|
||||
}
|
||||
|
||||
func pointsByDate(points []DailyPoint) map[string]DailyPoint {
|
||||
result := make(map[string]DailyPoint, len(points))
|
||||
for _, point := range points {
|
||||
result[dayKey(point.Date)] = point
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func catalogDates(events []Event) map[string]bool {
|
||||
result := make(map[string]bool)
|
||||
for _, event := range events {
|
||||
for _, day := range daysInclusive(event.StartDate, event.EndDate) {
|
||||
result[dayKey(day)] = true
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func daysInclusive(start, end time.Time) []time.Time {
|
||||
if end.Before(start) {
|
||||
return nil
|
||||
}
|
||||
days := make([]time.Time, 0, int(end.Sub(start).Hours()/24)+1)
|
||||
for day := normalizeDate(start); !day.After(normalizeDate(end)); day = day.AddDate(0, 0, 1) {
|
||||
days = append(days, day)
|
||||
}
|
||||
return days
|
||||
}
|
||||
|
||||
func normalizedSet(values []string) map[string]struct{} {
|
||||
result := make(map[string]struct{}, len(values))
|
||||
for _, value := range values {
|
||||
value = strings.ToLower(strings.TrimSpace(value))
|
||||
if value != "" {
|
||||
result[value] = struct{}{}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func normalizeDate(value time.Time) time.Time {
|
||||
year, month, day := value.Date()
|
||||
return time.Date(year, month, day, 0, 0, 0, 0, value.Location())
|
||||
}
|
||||
|
||||
func dayKey(value time.Time) string { return normalizeDate(value).Format(time.DateOnly) }
|
||||
|
||||
func changeBPS(actual, baseline int64) int64 {
|
||||
if baseline <= 0 {
|
||||
return 0
|
||||
}
|
||||
return (actual - baseline) * 10_000 / baseline
|
||||
}
|
||||
272
internal/growth/forecast.go
Normal file
272
internal/growth/forecast.go
Normal file
@@ -0,0 +1,272 @@
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
58
internal/growth/peer.go
Normal file
58
internal/growth/peer.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package growth
|
||||
|
||||
import "slices"
|
||||
|
||||
type PeerState string
|
||||
|
||||
const (
|
||||
PeerReady PeerState = "READY"
|
||||
PeerSuppressed PeerState = "SUPPRESSED"
|
||||
)
|
||||
|
||||
type PeerMerchantValue struct {
|
||||
PaidSessions int64 `json:"paidSessions"`
|
||||
SessionCount int64 `json:"sessionCount"`
|
||||
}
|
||||
|
||||
type PeerInput struct {
|
||||
TerminalScoped bool `json:"terminalScoped"`
|
||||
MerchantPaidSessions int64 `json:"merchantPaidSessions"`
|
||||
Peers []PeerMerchantValue `json:"peers"`
|
||||
}
|
||||
|
||||
type PeerResult struct {
|
||||
State PeerState `json:"state"`
|
||||
Reason SuppressionReason `json:"reason,omitempty"`
|
||||
EligiblePeerCount int `json:"eligiblePeerCount"`
|
||||
CombinedPeerSessions int64 `json:"combinedPeerSessions"`
|
||||
MedianPaidSessions int64 `json:"medianPaidSessions"`
|
||||
PercentileBPS int64 `json:"percentileBps"`
|
||||
}
|
||||
|
||||
func EvaluatePeer(input PeerInput) PeerResult {
|
||||
result := PeerResult{State: PeerSuppressed, EligiblePeerCount: len(input.Peers)}
|
||||
if input.TerminalScoped {
|
||||
result.Reason = SuppressionTerminalScope
|
||||
return result
|
||||
}
|
||||
values := make([]int64, 0, len(input.Peers))
|
||||
for _, peer := range input.Peers {
|
||||
result.CombinedPeerSessions += peer.SessionCount
|
||||
values = append(values, peer.PaidSessions)
|
||||
}
|
||||
if len(values) < 10 || result.CombinedPeerSessions < 1_000 {
|
||||
result.Reason = SuppressionInsufficientCohort
|
||||
return result
|
||||
}
|
||||
slices.Sort(values)
|
||||
result.State = PeerReady
|
||||
result.MedianPaidSessions = median(values)
|
||||
var atOrBelow int64
|
||||
for _, value := range values {
|
||||
if input.MerchantPaidSessions >= value {
|
||||
atOrBelow++
|
||||
}
|
||||
}
|
||||
result.PercentileBPS = atOrBelow * 10_000 / int64(len(values))
|
||||
return result
|
||||
}
|
||||
127
internal/growth/report.go
Normal file
127
internal/growth/report.go
Normal file
@@ -0,0 +1,127 @@
|
||||
package growth
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
type FingerprintInput struct {
|
||||
MerchantID int64 `json:"merchantId"`
|
||||
TerminalID string `json:"terminalId,omitempty"`
|
||||
DatasetWatermark string `json:"datasetWatermark"`
|
||||
ForecastStart time.Time `json:"forecastStart"`
|
||||
HorizonDays int `json:"horizonDays"`
|
||||
CatalogVersion string `json:"catalogVersion"`
|
||||
AnalyticsVersion string `json:"analyticsVersion"`
|
||||
PromptVersion string `json:"promptVersion"`
|
||||
SchemaVersion string `json:"schemaVersion"`
|
||||
Model string `json:"model"`
|
||||
}
|
||||
|
||||
func Fingerprint(input FingerprintInput) string {
|
||||
payload, err := json.Marshal(struct {
|
||||
FingerprintInput
|
||||
ForecastDate string `json:"forecastDate"`
|
||||
}{FingerprintInput: FingerprintInput{
|
||||
MerchantID: input.MerchantID, TerminalID: input.TerminalID,
|
||||
DatasetWatermark: input.DatasetWatermark, HorizonDays: input.HorizonDays,
|
||||
CatalogVersion: input.CatalogVersion, AnalyticsVersion: input.AnalyticsVersion,
|
||||
PromptVersion: input.PromptVersion, SchemaVersion: input.SchemaVersion, Model: input.Model,
|
||||
}, ForecastDate: input.ForecastStart.In(tehranLocation()).Format(time.DateOnly)})
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
digest := sha256.Sum256(payload)
|
||||
return hex.EncodeToString(digest[:])
|
||||
}
|
||||
|
||||
type ReportInput struct {
|
||||
History []DailyPoint `json:"history"`
|
||||
Catalog []Event `json:"catalog"`
|
||||
ForecastStart time.Time `json:"forecastStart"`
|
||||
HorizonDays int `json:"horizonDays"`
|
||||
Category string `json:"category,omitempty"`
|
||||
Peer *PeerInput `json:"peer,omitempty"`
|
||||
}
|
||||
|
||||
type DeterministicReport struct {
|
||||
Forecast Forecast `json:"forecast"`
|
||||
HistoricalWins []EventEffect `json:"historicalWins"`
|
||||
Opportunities []Opportunity `json:"opportunities"`
|
||||
Risks []Risk `json:"risks"`
|
||||
Peer *PeerResult `json:"peer,omitempty"`
|
||||
FallbackBriefs []CampaignBrief `json:"fallbackBriefs"`
|
||||
}
|
||||
|
||||
func BuildDeterministicReport(input ReportInput) DeterministicReport {
|
||||
catalog := filterCatalog(input.Catalog, input.Category)
|
||||
forecast := BuildForecast(input.History, catalog, input.ForecastStart, input.HorizonDays)
|
||||
wins := historicalWins(input.History, catalog, input.ForecastStart)
|
||||
opportunities := buildOpportunities(wins, catalog, forecast, input.ForecastStart, 5)
|
||||
risks := DetectRisks(forecast, 3)
|
||||
bundle := EvidenceBundle{Opportunities: opportunities, Risks: risks}
|
||||
report := DeterministicReport{Forecast: forecast, HistoricalWins: wins, Opportunities: opportunities, Risks: risks, FallbackBriefs: BuildFallback(bundle)}
|
||||
if input.Peer != nil {
|
||||
peer := EvaluatePeer(*input.Peer)
|
||||
report.Peer = &peer
|
||||
}
|
||||
return report
|
||||
}
|
||||
|
||||
func TehranDay(value time.Time) string {
|
||||
return value.In(tehranLocation()).Format(time.DateOnly)
|
||||
}
|
||||
|
||||
func NextRefreshAt(lastRefresh time.Time) time.Time {
|
||||
local := lastRefresh.In(tehranLocation())
|
||||
nextLocal := time.Date(local.Year(), local.Month(), local.Day()+1, 0, 0, 0, 0, tehranLocation())
|
||||
return nextLocal.UTC()
|
||||
}
|
||||
|
||||
func CanManualRefresh(lastRefresh, now time.Time) bool {
|
||||
if lastRefresh.IsZero() {
|
||||
return true
|
||||
}
|
||||
return !now.Before(NextRefreshAt(lastRefresh))
|
||||
}
|
||||
|
||||
func BuildFallback(bundle EvidenceBundle) []CampaignBrief {
|
||||
briefs := make([]CampaignBrief, 0, len(bundle.Opportunities)+len(bundle.Risks))
|
||||
for _, opportunity := range bundle.Opportunities {
|
||||
briefs = append(briefs, CampaignBrief{
|
||||
HeadlineFA: "یک فرصت مناسب در راه است: " + opportunity.EventTitleFA,
|
||||
GuidanceFA: "دادههای قبلی شما نشان میدهد این مناسبت میتواند برای برنامهریزی فروش ارزشمند باشد.",
|
||||
OfferFA: "پیشنهاد خود را متناسب با نیاز مشتریان این مناسبت آماده کنید.",
|
||||
PreparationFA: "موجودی، پیام کمپین و مسیر پرداخت را پیش از شروع مناسبت بررسی کنید.",
|
||||
LimitationFA: "این پیشنهاد بر پایه الگوهای گذشته است و نتیجه قطعی نیست.",
|
||||
EvidenceIDs: []string{opportunity.EvidenceID},
|
||||
})
|
||||
}
|
||||
for _, risk := range bundle.Risks {
|
||||
briefs = append(briefs, CampaignBrief{
|
||||
HeadlineFA: "برای یک بازه آرامتر آماده باشید",
|
||||
GuidanceFA: "الگوی گذشته احتمال کاهش فعالیت را در این بازه نشان میدهد.",
|
||||
OfferFA: "یک پیشنهاد سبک برای حفظ ارتباط با مشتریان در نظر بگیرید.",
|
||||
PreparationFA: "پیام یادآوری و تجربه پرداخت را پیش از این بازه بازبینی کنید.",
|
||||
LimitationFA: "این هشدار بر پایه الگوهای گذشته است و نتیجه قطعی نیست.",
|
||||
EvidenceIDs: []string{risk.EvidenceID},
|
||||
})
|
||||
}
|
||||
return briefs
|
||||
}
|
||||
|
||||
func EvidenceID(kind, stableKey string) string {
|
||||
digest := sha256.Sum256([]byte(kind + "\x00" + stableKey))
|
||||
return fmt.Sprintf("%s_%s", kind, hex.EncodeToString(digest[:8]))
|
||||
}
|
||||
|
||||
func tehranLocation() *time.Location {
|
||||
location, err := time.LoadLocation("Asia/Tehran")
|
||||
if err != nil {
|
||||
return time.FixedZone("Asia/Tehran", 3*60*60+30*60)
|
||||
}
|
||||
return location
|
||||
}
|
||||
148
internal/growth/types.go
Normal file
148
internal/growth/types.go
Normal file
@@ -0,0 +1,148 @@
|
||||
package growth
|
||||
|
||||
import "time"
|
||||
|
||||
const (
|
||||
CatalogVersion = "1405.1"
|
||||
AnalyticsVersion = "1.0.0"
|
||||
|
||||
MinBaselineObservations = 4
|
||||
MinBaselineSessions = 100
|
||||
MaterialChangeBPS = 2_000
|
||||
MinSimilarityBPS = 6_000
|
||||
)
|
||||
|
||||
type Confidence string
|
||||
|
||||
const (
|
||||
ConfidenceHigh Confidence = "HIGH"
|
||||
ConfidenceMedium Confidence = "MEDIUM"
|
||||
ConfidenceInsufficientData Confidence = "INSUFFICIENT_DATA"
|
||||
)
|
||||
|
||||
type SuppressionReason string
|
||||
|
||||
const (
|
||||
SuppressionInsufficientObservations SuppressionReason = "INSUFFICIENT_BASELINE_OBSERVATIONS"
|
||||
SuppressionInsufficientBaselineSessions SuppressionReason = "INSUFFICIENT_BASELINE_SESSIONS"
|
||||
SuppressionInsufficientHistory SuppressionReason = "INSUFFICIENT_HISTORY"
|
||||
SuppressionInsufficientCohort SuppressionReason = "INSUFFICIENT_COHORT"
|
||||
SuppressionTerminalScope SuppressionReason = "TERMINAL_SCOPE_UNSUPPORTED"
|
||||
)
|
||||
|
||||
type DailyPoint struct {
|
||||
Date time.Time `json:"date"`
|
||||
SessionCount int64 `json:"sessionCount"`
|
||||
PaidSessionCount int64 `json:"paidSessionCount"`
|
||||
PaidAmountRial int64 `json:"paidAmountRial"`
|
||||
EventIDs []string `json:"eventIds,omitempty"`
|
||||
}
|
||||
|
||||
type EventKind string
|
||||
|
||||
const (
|
||||
EventOfficial EventKind = "OFFICIAL"
|
||||
EventCommercial EventKind = "COMMERCIAL"
|
||||
)
|
||||
|
||||
type Event struct {
|
||||
ID string `json:"id"`
|
||||
TitleFA string `json:"titleFa"`
|
||||
StartDate time.Time `json:"startDate"`
|
||||
EndDate time.Time `json:"endDate"`
|
||||
Family string `json:"family"`
|
||||
Tags []string `json:"tags"`
|
||||
Source string `json:"source"`
|
||||
Categories []string `json:"categories"`
|
||||
Kind EventKind `json:"kind"`
|
||||
}
|
||||
|
||||
type EventEffect struct {
|
||||
EvidenceID string `json:"evidenceId"`
|
||||
EventID string `json:"eventId"`
|
||||
Eligible bool `json:"eligible"`
|
||||
SuppressionReason SuppressionReason `json:"suppressionReason,omitempty"`
|
||||
ObservationCount int `json:"observationCount"`
|
||||
BaselineSessionCount int64 `json:"baselineSessionCount"`
|
||||
BaselinePaidSessions int64 `json:"baselinePaidSessions"`
|
||||
BaselineAmountRial int64 `json:"baselineAmountRial"`
|
||||
ActualSessionCount int64 `json:"actualSessionCount"`
|
||||
ActualPaidSessions int64 `json:"actualPaidSessions"`
|
||||
ActualAmountRial int64 `json:"actualAmountRial"`
|
||||
SessionUpliftBPS int64 `json:"sessionUpliftBps"`
|
||||
PaidSessionUpliftBPS int64 `json:"paidSessionUpliftBps"`
|
||||
AmountUpliftBPS int64 `json:"amountUpliftBps"`
|
||||
MaterialWin bool `json:"materialWin"`
|
||||
MaterialDecline bool `json:"materialDecline"`
|
||||
}
|
||||
|
||||
type EventMatch struct {
|
||||
Event Event `json:"event"`
|
||||
ScoreBPS int64 `json:"scoreBps"`
|
||||
MatchBasis string `json:"matchBasis"`
|
||||
}
|
||||
|
||||
type Range struct {
|
||||
P10 int64 `json:"p10"`
|
||||
P50 int64 `json:"p50"`
|
||||
P90 int64 `json:"p90"`
|
||||
}
|
||||
|
||||
type ForecastPoint struct {
|
||||
Date time.Time `json:"date"`
|
||||
BaselineSessions int64 `json:"baselineSessions"`
|
||||
BaselineAmountRial int64 `json:"baselineAmountRial"`
|
||||
PaidSessions Range `json:"paidSessions"`
|
||||
PaidAmountRial Range `json:"paidAmountRial"`
|
||||
EventIDs []string `json:"eventIds,omitempty"`
|
||||
}
|
||||
|
||||
type BacktestResult struct {
|
||||
Origins int `json:"origins"`
|
||||
SessionMAPEBPS int64 `json:"sessionMapeBps"`
|
||||
AmountMAPEBPS int64 `json:"amountMapeBps"`
|
||||
IntervalCoverageBPS int64 `json:"intervalCoverageBps"`
|
||||
}
|
||||
|
||||
type Forecast struct {
|
||||
StartDate time.Time `json:"startDate"`
|
||||
HorizonDays int `json:"horizonDays"`
|
||||
Confidence Confidence `json:"confidence"`
|
||||
SuppressionReason SuppressionReason `json:"suppressionReason,omitempty"`
|
||||
Points []ForecastPoint `json:"points"`
|
||||
Backtest BacktestResult `json:"backtest"`
|
||||
}
|
||||
|
||||
type Opportunity struct {
|
||||
EvidenceID string `json:"evidenceId"`
|
||||
EventID string `json:"eventId"`
|
||||
EventTitleFA string `json:"eventTitleFa"`
|
||||
Date time.Time `json:"date"`
|
||||
SimilarityBPS int64 `json:"similarityBps"`
|
||||
ExpectedSessions Range `json:"expectedSessions"`
|
||||
ExpectedAmountRial Range `json:"expectedAmountRial"`
|
||||
HistoricalEvidenceID string `json:"historicalEvidenceId"`
|
||||
}
|
||||
|
||||
type Risk struct {
|
||||
EvidenceID string `json:"evidenceId"`
|
||||
Date time.Time `json:"date"`
|
||||
SessionDeclineBPS int64 `json:"sessionDeclineBps"`
|
||||
AmountDeclineBPS int64 `json:"amountDeclineBps"`
|
||||
ExpectedSessions Range `json:"expectedSessions"`
|
||||
ExpectedAmountRial Range `json:"expectedAmountRial"`
|
||||
}
|
||||
|
||||
type EvidenceBundle struct {
|
||||
Opportunities []Opportunity `json:"opportunities"`
|
||||
Risks []Risk `json:"risks"`
|
||||
}
|
||||
|
||||
type CampaignBrief struct {
|
||||
HeadlineFA string `json:"headlineFa"`
|
||||
GuidanceFA string `json:"guidanceFa"`
|
||||
OfferFA string `json:"offerFa"`
|
||||
PreparationFA string `json:"preparationFa"`
|
||||
LimitationFA string `json:"limitationFa"`
|
||||
EvidenceIDs []string `json:"evidenceIds"`
|
||||
}
|
||||
Reference in New Issue
Block a user