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

172 lines
5.2 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
}
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
}