package aggregation import ( "fmt" "math/big" "sort" "time" "git.toki-labs.com/toki/alt/packages/domain/market" ) // Provenance records how a monthly bar was derived from daily bars. // Each monthly bar has its own provenance record linking it to the source // daily bars used for aggregation. type Provenance struct { SourceTimeframe market.Timeframe `json:"source_timeframe"` TargetTimeframe market.Timeframe `json:"target_timeframe"` InstrumentID market.InstrumentID `json:"instrument_id"` SourceStart time.Time `json:"source_start"` SourceEnd time.Time `json:"source_end"` SourceCount int `json:"source_count"` AggregationRuleID string `json:"aggregation_rule_id"` } // ProvenanceForBar creates a provenance record for a single monthly bar. func ProvenanceForBar(bar market.Bar, sourceStart, sourceEnd time.Time, sourceCount int) Provenance { return Provenance{ SourceTimeframe: market.TimeframeDaily, TargetTimeframe: market.TimeframeMonthly, InstrumentID: bar.InstrumentID, SourceStart: sourceStart, SourceEnd: sourceEnd, SourceCount: sourceCount, AggregationRuleID: "monthly-deterministic-v1", } } // MonthlyResult holds the aggregated monthly bars and their provenance. type MonthlyResult struct { Bars []market.Bar `json:"bars"` Provenance []Provenance `json:"provenance"` } // AggregateDailyToMonthly converts daily bars into monthly OHLCV bars and // returns the result with provenance. Input bars must all share the same // instrument, be strictly daily timeframe, share the same currency, and contain // valid positive price/volume values. func AggregateDailyToMonthly(input []market.Bar) (MonthlyResult, error) { var result MonthlyResult if len(input) == 0 { return result, fmt.Errorf("aggregate daily to monthly: input is empty") } // Validate and group by instrument instrumentBars := make(map[string][]market.Bar) var instrumentIDs []string seenIDs := make(map[string]bool) for _, bar := range input { idStr := string(bar.InstrumentID) if !seenIDs[idStr] { seenIDs[idStr] = true instrumentIDs = append(instrumentIDs, idStr) } instrumentBars[idStr] = append(instrumentBars[idStr], bar) } if len(instrumentBars) > 1 { return result, fmt.Errorf("aggregate daily to monthly: mixed instrument IDs: %v", instrumentIDs) } // Validate all bars are daily for _, bar := range input { if bar.Timeframe != market.TimeframeDaily { return result, fmt.Errorf("aggregate daily to monthly: expected daily timeframe, got %q", bar.Timeframe) } } // Validate currency consistency firstCurrency := input[0].Open.Currency for _, bar := range input { if bar.Open.Currency != firstCurrency || bar.High.Currency != firstCurrency || bar.Low.Currency != firstCurrency || bar.Close.Currency != firstCurrency { return result, fmt.Errorf("aggregate daily to monthly: mixed currencies in input") } } // Validate decimal values and sort by timestamp validatedBars := make([]market.Bar, 0, len(input)) for _, bar := range input { if err := validateBarDecimals(bar); err != nil { return result, err } validatedBars = append(validatedBars, bar) } sort.Slice(validatedBars, func(i, j int) bool { return validatedBars[i].Timestamp.Before(validatedBars[j].Timestamp) }) // Group by month and aggregate type monthKey struct { year int month int } type monthBars struct { timestamp time.Time open *big.Rat high *big.Rat low *big.Rat close *big.Rat volume *big.Rat srcStart time.Time srcEnd time.Time srcCount int } monthOrder := []monthKey{} monthMap := make(map[monthKey]*monthBars) for _, bar := range validatedBars { t := bar.Timestamp.UTC() // Use the day of the month to determine which month this bar belongs to. // The timestamp is already in UTC and represents a daily bar. // For aggregation, we group by the month component of the timestamp. mk := monthKey{year: t.Year(), month: int(t.Month())} amountToRat := func(amount market.Decimal) *big.Rat { r := new(big.Rat) r.SetString(amount.Value) return r } if _, exists := monthMap[mk]; !exists { monthOrder = append(monthOrder, mk) monthMap[mk] = &monthBars{ timestamp: time.Date(t.Year(), t.Month(), 1, 0, 0, 0, 0, time.UTC), open: amountToRat(bar.Open.Amount), high: amountToRat(bar.High.Amount), low: amountToRat(bar.Low.Amount), close: amountToRat(bar.Close.Amount), volume: amountToRat(bar.Volume.Amount), srcStart: bar.Timestamp, srcEnd: bar.Timestamp, srcCount: 1, } } else { mb := monthMap[mk] // Open: keep first // High: max if amountToRat(bar.High.Amount).Cmp(mb.high) > 0 { mb.high = amountToRat(bar.High.Amount) } // Low: min if amountToRat(bar.Low.Amount).Cmp(mb.low) < 0 { mb.low = amountToRat(bar.Low.Amount) } // Close: keep last (always overwritten) mb.close = amountToRat(bar.Close.Amount) // Volume: sum mb.volume.Add(mb.volume, amountToRat(bar.Volume.Amount)) // Track per-month source range/count if bar.Timestamp.Before(mb.srcStart) { mb.srcStart = bar.Timestamp } if bar.Timestamp.After(mb.srcEnd) { mb.srcEnd = bar.Timestamp } mb.srcCount++ } } // Sort months sort.Slice(monthOrder, func(i, j int) bool { a, b := monthOrder[i], monthOrder[j] if a.year != b.year { return a.year < b.year } return a.month < b.month }) // Build result bars and per-month provenance for _, mk := range monthOrder { mb := monthMap[mk] bar := market.Bar{ InstrumentID: input[0].InstrumentID, Timeframe: market.TimeframeMonthly, Timestamp: mb.timestamp, Open: market.Price{Currency: firstCurrency, Amount: ratToDecimal(mb.open)}, High: market.Price{Currency: firstCurrency, Amount: ratToDecimal(mb.high)}, Low: market.Price{Currency: firstCurrency, Amount: ratToDecimal(mb.low)}, Close: market.Price{Currency: firstCurrency, Amount: ratToDecimal(mb.close)}, Volume: market.Quantity{Amount: ratToDecimal(mb.volume)}, } result.Bars = append(result.Bars, bar) // Build provenance with per-month source range/count. result.Provenance = append(result.Provenance, ProvenanceForBar(bar, mb.srcStart, mb.srcEnd, mb.srcCount)) } return result, nil } // validateBarDecimals checks that all price and volume values are valid and positive. func validateBarDecimals(bar market.Bar) error { prices := []market.Decimal{bar.Open.Amount, bar.High.Amount, bar.Low.Amount, bar.Close.Amount} for _, p := range prices { if err := validateDecimal(p); err != nil { return fmt.Errorf("invalid price decimal: %w", err) } } if err := validateDecimal(bar.Volume.Amount); err != nil { return fmt.Errorf("invalid volume decimal: %w", err) } return nil } // validateDecimal checks that the decimal string is a valid positive number. func validateDecimal(d market.Decimal) error { if d.Value == "" { return fmt.Errorf("empty decimal value") } r := new(big.Rat) r.SetString(d.Value) if r.Sign() <= 0 { return fmt.Errorf("decimal must be positive, got %s", d.Value) } return nil } // ratToDecimal converts a big.Rat to market.Decimal, producing a finite // decimal string with at most 8 decimal places. func ratToDecimal(r *big.Rat) market.Decimal { f := r.FloatString(8) return market.Decimal{Value: f} }