234 lines
7.3 KiB
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}
|
|
}
|