alt/services/worker/internal/marketdata/aggregation/monthly.go

234 lines
7.3 KiB
Go

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}
}