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

223 lines
7.0 KiB
Go

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
actualComplete := true
for _, eventDay := range eventDays {
point, ok := byDate[dayKey(eventDay)]
if !ok {
actualComplete = false
break
}
effect.ActualSessionCount += point.SessionCount
effect.ActualPaidSessions += point.PaidSessionCount
effect.ActualAmountRial += point.PaidAmountRial
}
if !actualComplete {
effect.SuppressionReason = SuppressionIncompleteEventWindow
return effect
}
for week := 1; week <= 8; week++ {
var windowSessions, windowPaid, windowAmount int64
complete := true
for _, eventDay := range eventDays {
candidate := eventDay.AddDate(0, 0, -7*week)
if eventDates[dayKey(candidate)] {
complete = false
break
}
point, ok := byDate[dayKey(candidate)]
if !ok {
complete = false
break
}
windowSessions += point.SessionCount
windowPaid += point.PaidSessionCount
windowAmount += point.PaidAmountRial
}
if !complete {
continue
}
effect.ObservationCount++
baselineSessions += windowSessions
baselinePaid += windowPaid
baselineAmount += windowAmount
}
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, effect.BaselineSessionCount)
effect.PaidSessionUpliftBPS = changeBPS(effect.ActualPaidSessions, effect.BaselinePaidSessions)
effect.AmountUpliftBPS = changeBPS(effect.ActualAmountRial, effect.BaselineAmountRial)
effect.MaterialWin = effect.PaidSessionUpliftBPS >= MaterialChangeBPS || effect.AmountUpliftBPS >= MaterialChangeBPS
effect.MaterialDecline = effect.PaidSessionUpliftBPS <= -MaterialChangeBPS || effect.AmountUpliftBPS <= -MaterialChangeBPS
return effect
}
// MostRecentExactOccurrence returns only a completed occurrence with the same
// canonical occasion identity and variant. Similar titles, tags, and families
// are deliberately ignored so they cannot be represented as merchant history
// for the upcoming event.
func MostRecentExactOccurrence(upcoming Event, catalog []Event) (Event, bool) {
if strings.TrimSpace(upcoming.OccasionKey) == "" || strings.TrimSpace(upcoming.EventVariant) == "" {
return Event{}, false
}
var result Event
found := false
for _, candidate := range catalog {
if candidate.OccasionKey != upcoming.OccasionKey || candidate.EventVariant != upcoming.EventVariant || !dateBefore(candidate.EndDate, upcoming.StartDate) {
continue
}
if !found || dateAfter(candidate.EndDate, result.EndDate) || dayKey(candidate.EndDate) == dayKey(result.EndDate) && candidate.ID < result.ID {
result = candidate
found = true
}
}
return result, found
}
func RankEvent(event Event, input RankInput) EventRank {
rank := EventRank{Promotable: event.CommercialPosture != "NO_PROMOTION" && event.CommercialPosture != "CAUTION" && event.Tone != "MOURNING" && event.Tone != "MEMORIAL"}
rank.ScoreBPS = event.ImportanceBPS + event.ConfidenceBPS + event.RelevanceBPS
merchantTerms := normalizedSet(input.MerchantTerms)
for value := range normalizedSet(append(slices.Clone(event.Categories), event.Tags...)) {
if _, ok := merchantTerms[value]; ok {
rank.ScoreBPS += 2_000
rank.Factors = append(rank.Factors, value)
}
}
slices.Sort(rank.Factors)
if input.WithinPreparationWindow {
rank.ScoreBPS += 1_000
rank.Factors = append(rank.Factors, "PREPARATION_WINDOW")
}
if input.HistoricalEligible {
rank.ScoreBPS += 20_000 + abs64(input.HistoricalUpliftBPS)/10
rank.Factors = append(rank.Factors, "HISTORICAL_EVIDENCE")
}
return rank
}
func abs64(value int64) int64 {
if value < 0 {
return -value
}
return value
}
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 dateBefore(a, b time.Time) bool { return dayKey(a) < dayKey(b) }
func dateAfter(a, b time.Time) bool { return dayKey(a) > dayKey(b) }
func changeBPS(actual, baseline int64) int64 {
if baseline <= 0 {
return 0
}
return (actual - baseline) * 10_000 / baseline
}