alt/services/worker/internal/backtest/fixture_test.go
toki 9efc0a68dc feat: backtest analysis surface implementation
- Add backtest analysis results migration (000002)
- Update backtest proto definitions and generated code
- Extend backtest domain types and engine
- Add parser map for backtest analysis
- Update storage layer with backtest analysis queries and keys
- Add client contract tests and generated code
- Add m-backtest-analysis-surface task documentation
2026-05-30 14:43:40 +09:00

305 lines
9.6 KiB
Go

package backtest
import (
"context"
"reflect"
"sync"
"testing"
"time"
"git.toki-labs.com/toki/alt/packages/domain/backtest"
"git.toki-labs.com/toki/alt/packages/domain/market"
"git.toki-labs.com/toki/alt/services/worker/internal/storage"
)
type inMemoryResultStore struct {
mu sync.RWMutex
results map[backtest.RunID]backtest.Result
}
func newInMemoryResultStore() *inMemoryResultStore {
return &inMemoryResultStore{
results: make(map[backtest.RunID]backtest.Result),
}
}
func (m *inMemoryResultStore) UpsertResult(ctx context.Context, result backtest.Result) error {
m.mu.Lock()
defer m.mu.Unlock()
m.results[result.RunID] = result
return nil
}
func (m *inMemoryResultStore) GetResult(ctx context.Context, id backtest.RunID) (backtest.Result, error) {
m.mu.RLock()
defer m.mu.RUnlock()
res, ok := m.results[id]
if !ok {
return backtest.Result{}, storage.ErrResultNotFound
}
return res, nil
}
type inMemoryBarSource struct {
bars []market.Bar
}
func (m *inMemoryBarSource) GetBars(ctx context.Context, mkt market.Market, timeframe market.Timeframe, from, to time.Time) ([]market.Bar, error) {
return m.bars, nil
}
type inMemoryStrategyPort struct {
strategy backtest.Strategy
}
func (m *inMemoryStrategyPort) GetStrategy(ctx context.Context, id backtest.StrategyID) (backtest.Strategy, error) {
return m.strategy, nil
}
type deterministicStrategy struct {
id backtest.StrategyID
}
func (s *deterministicStrategy) ID() backtest.StrategyID {
return s.id
}
func (s *deterministicStrategy) Decide(input backtest.StrategyInput) ([]backtest.OrderIntent, error) {
// Buy 2 units of the instrument on the first bar (May 1),
// and sell 1 unit on the second bar (May 2).
day := input.Bar.Timestamp.Day()
if day == 1 {
return []backtest.OrderIntent{
{
InstrumentID: input.Bar.InstrumentID,
Side: backtest.OrderSideBuy,
Quantity: market.Quantity{Amount: market.Decimal{Value: "2"}},
},
}, nil
} else if day == 2 {
return []backtest.OrderIntent{
{
InstrumentID: input.Bar.InstrumentID,
Side: backtest.OrderSideSell,
Quantity: market.Quantity{Amount: market.Decimal{Value: "1"}},
},
}, nil
}
return nil, nil
}
func TestEngineProducesDeterministicResultFromFixtureBars(t *testing.T) {
instID := market.InstrumentID("KRX:005930")
bar1 := market.Bar{
InstrumentID: instID,
Timeframe: market.TimeframeDaily,
Timestamp: time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC),
Close: market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "1000"}},
}
bar2 := market.Bar{
InstrumentID: instID,
Timeframe: market.TimeframeDaily,
Timestamp: time.Date(2026, 5, 2, 0, 0, 0, 0, time.UTC),
Close: market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "1100"}},
}
bar3 := market.Bar{
InstrumentID: instID,
Timeframe: market.TimeframeDaily,
Timestamp: time.Date(2026, 5, 3, 0, 0, 0, 0, time.UTC),
Close: market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "1200"}},
}
barSource := &inMemoryBarSource{bars: []market.Bar{bar3, bar1, bar2}} // unsorted
strat := &deterministicStrategy{id: "det-strat"}
strategyPort := &inMemoryStrategyPort{strategy: strat}
resultStore := newInMemoryResultStore()
engine := NewEngine(barSource, strategyPort, resultStore)
run := backtest.Run{
ID: "run-deterministic",
Spec: backtest.RunSpec{
StrategyID: "det-strat",
Market: market.MarketKR,
Timeframe: market.TimeframeDaily,
From: time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC),
To: time.Date(2026, 5, 3, 0, 0, 0, 0, time.UTC),
},
}
// First execution
err := engine.Execute(context.Background(), run)
if err != nil {
t.Fatalf("first execution failed: %v", err)
}
res1, err := resultStore.GetResult(context.Background(), run.ID)
if err != nil {
t.Fatalf("failed to get first result: %v", err)
}
// Clear the store to make sure upsert actually writes the second result
resultStore.mu.Lock()
delete(resultStore.results, run.ID)
resultStore.mu.Unlock()
// Second execution
err = engine.Execute(context.Background(), run)
if err != nil {
t.Fatalf("second execution failed: %v", err)
}
res2, err := resultStore.GetResult(context.Background(), run.ID)
if err != nil {
t.Fatalf("failed to get second result: %v", err)
}
// Verify deep equality
if !reflect.DeepEqual(res1, res2) {
t.Errorf("results are not identical.\nResult 1: %+v\nResult 2: %+v", res1, res2)
}
}
func TestEngineStoresAndQueriesFixtureResult(t *testing.T) {
instID := market.InstrumentID("KRX:005930")
bar1 := market.Bar{
InstrumentID: instID,
Timeframe: market.TimeframeDaily,
Timestamp: time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC),
Close: market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "1000"}},
}
bar2 := market.Bar{
InstrumentID: instID,
Timeframe: market.TimeframeDaily,
Timestamp: time.Date(2026, 5, 2, 0, 0, 0, 0, time.UTC),
Close: market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "1100"}},
}
bar3 := market.Bar{
InstrumentID: instID,
Timeframe: market.TimeframeDaily,
Timestamp: time.Date(2026, 5, 3, 0, 0, 0, 0, time.UTC),
Close: market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "1200"}},
}
barSource := &inMemoryBarSource{bars: []market.Bar{bar1, bar2, bar3}}
strat := &deterministicStrategy{id: "det-strat"}
strategyPort := &inMemoryStrategyPort{strategy: strat}
resultStore := newInMemoryResultStore()
engine := NewEngine(barSource, strategyPort, resultStore)
run := backtest.Run{
ID: "run-query",
Spec: backtest.RunSpec{
StrategyID: "det-strat",
Market: market.MarketKR,
Timeframe: market.TimeframeDaily,
From: time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC),
To: time.Date(2026, 5, 3, 0, 0, 0, 0, time.UTC),
},
}
err := engine.Execute(context.Background(), run)
if err != nil {
t.Fatalf("execution failed: %v", err)
}
res, err := resultStore.GetResult(context.Background(), run.ID)
if err != nil {
t.Fatalf("failed to get result: %v", err)
}
// Let's manually calculate the expected values:
// Starting cash = 10,000,000 KRW
//
// Day 1: Buy 2 units at 1000 KRW
// Cash = 10,000,000 - 2,000 = 9,998,000 KRW
// Position = 2 units, mark price = 1000 KRW
//
// Day 2: Sell 1 unit at 1100 KRW
// Cash = 9,998,000 + 1,100 = 9,999,100 KRW
// Position = 1 unit, mark price = 1100 KRW
//
// Day 3: No trades.
// Position = 1 unit, marked at last price = 1200 KRW
// Final Equity = Cash (9,999,100) + 1 * 1200 = 10,000,300 KRW
//
// Expected trades:
// 1. Buy 2 units at 1000 KRW on May 1
// 2. Sell 1 unit at 1100 KRW on May 2
//
// Expected positions:
// 1. 1 unit of KRX:005930 at last price 1200 KRW
if res.StartingCash.Amount.Value != "10000000" {
t.Errorf("expected starting cash 10000000, got %s", res.StartingCash.Amount.Value)
}
if res.EndingEquity.Amount.Value != "10000300" {
t.Errorf("expected ending equity 10000300, got %s", res.EndingEquity.Amount.Value)
}
// Verify Trades
if len(res.Trades) != 2 {
t.Fatalf("expected 2 trades, got %d", len(res.Trades))
}
trade1 := res.Trades[0]
if trade1.Side != backtest.OrderSideBuy || trade1.Quantity.Amount.Value != "2" || trade1.Price.Amount.Value != "1000" {
t.Errorf("unexpected trade 1: %+v", trade1)
}
trade2 := res.Trades[1]
if trade2.Side != backtest.OrderSideSell || trade2.Quantity.Amount.Value != "1" || trade2.Price.Amount.Value != "1100" {
t.Errorf("unexpected trade 2: %+v", trade2)
}
// Verify Positions
if len(res.Positions) != 1 {
t.Fatalf("expected 1 position, got %d", len(res.Positions))
}
pos := res.Positions[0]
if pos.InstrumentID != instID || pos.Quantity.Amount.Value != "1" || pos.LastPrice.Amount.Value != "1200" {
t.Errorf("unexpected position: %+v", pos)
}
// Verify derived summary metrics.
if res.Summary.StartingCash.Amount.Value != "10000000" {
t.Errorf("summary starting cash: got %s, want 10000000", res.Summary.StartingCash.Amount.Value)
}
if res.Summary.EndingEquity.Amount.Value != "10000300" {
t.Errorf("summary ending equity: got %s, want 10000300", res.Summary.EndingEquity.Amount.Value)
}
if res.Summary.TradeCount != 2 {
t.Errorf("summary trade count: got %d, want 2", res.Summary.TradeCount)
}
// (10000300 - 10000000) / 10000000 = 0.00003
if res.Summary.TotalReturn.Value != "0.00003" {
t.Errorf("summary total return: got %s, want 0.00003", res.Summary.TotalReturn.Value)
}
// Verify deterministic equity curve: one point per bar, ordered by timestamp.
// Day 1: cash 9,998,000 + 2*1000 = 10,000,000
// Day 2: cash 9,999,100 + 1*1100 = 10,000,200
// Day 3: cash 9,999,100 + 1*1200 = 10,000,300
if len(res.EquityCurve) != 3 {
t.Fatalf("equity curve length: got %d, want 3", len(res.EquityCurve))
}
wantCurve := []struct {
ts time.Time
equity string
}{
{bar1.Timestamp, "10000000"},
{bar2.Timestamp, "10000200"},
{bar3.Timestamp, "10000300"},
}
for i, want := range wantCurve {
point := res.EquityCurve[i]
if !point.Timestamp.Equal(want.ts) {
t.Errorf("equity curve point %d timestamp: got %v, want %v", i, point.Timestamp, want.ts)
}
if point.Equity.Amount.Value != want.equity {
t.Errorf("equity curve point %d equity: got %s, want %s", i, point.Equity.Amount.Value, want.equity)
}
}
// Final equity curve point must match the reported ending equity.
finalPoint := res.EquityCurve[len(res.EquityCurve)-1]
if finalPoint.Equity.Amount.Value != res.EndingEquity.Amount.Value {
t.Errorf("final equity point %s does not match ending equity %s", finalPoint.Equity.Amount.Value, res.EndingEquity.Amount.Value)
}
}