- Add backtest proto definitions and generated code - Update domain types for backtest results and fixtures - Add PostgreSQL migrations for backtest tables - Implement storage layer for backtest result persistence - Add backtest job definitions and execution pipeline - Remove obsolete agent-task documents for completed items
126 lines
4 KiB
Go
126 lines
4 KiB
Go
package backtest
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.toki-labs.com/toki/alt/packages/domain/backtest"
|
|
"git.toki-labs.com/toki/alt/packages/domain/market"
|
|
)
|
|
|
|
type mockBarSource struct {
|
|
bars []market.Bar
|
|
}
|
|
|
|
func (m *mockBarSource) GetBars(ctx context.Context, mkt market.Market, timeframe market.Timeframe, from, to time.Time) ([]market.Bar, error) {
|
|
return m.bars, nil
|
|
}
|
|
|
|
type mockStrategy struct {
|
|
decideCalls []backtest.StrategyInput
|
|
}
|
|
|
|
func (m *mockStrategy) ID() backtest.StrategyID {
|
|
return "test-strategy"
|
|
}
|
|
|
|
func (m *mockStrategy) Decide(input backtest.StrategyInput) ([]backtest.OrderIntent, error) {
|
|
m.decideCalls = append(m.decideCalls, input)
|
|
|
|
// Buy 2 units of the instrument on the first bar
|
|
if len(m.decideCalls) == 1 {
|
|
return []backtest.OrderIntent{
|
|
{
|
|
InstrumentID: input.Bar.InstrumentID,
|
|
Side: backtest.OrderSideBuy,
|
|
Quantity: market.Quantity{Amount: market.Decimal{Value: "2"}},
|
|
},
|
|
}, nil
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
type mockStrategyPort struct {
|
|
strategy backtest.Strategy
|
|
}
|
|
|
|
func (m *mockStrategyPort) GetStrategy(ctx context.Context, id backtest.StrategyID) (backtest.Strategy, error) {
|
|
return m.strategy, nil
|
|
}
|
|
|
|
func TestEngineCallsStrategyForBars(t *testing.T) {
|
|
// Create fixture bars in unsorted order to verify sorting
|
|
bar1 := market.Bar{
|
|
InstrumentID: market.InstrumentID("KRX:005930"),
|
|
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: market.InstrumentID("KRX:005930"),
|
|
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"}},
|
|
}
|
|
|
|
barSource := &mockBarSource{bars: []market.Bar{bar2, bar1}} // unsorted
|
|
strat := &mockStrategy{}
|
|
strategyPort := &mockStrategyPort{strategy: strat}
|
|
|
|
engine := NewEngine(barSource, strategyPort, nil)
|
|
|
|
run := backtest.Run{
|
|
ID: "run-1",
|
|
Spec: backtest.RunSpec{
|
|
StrategyID: "test-strategy",
|
|
Market: market.MarketKR,
|
|
Timeframe: market.TimeframeDaily,
|
|
From: time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC),
|
|
To: time.Date(2026, 5, 2, 0, 0, 0, 0, time.UTC),
|
|
},
|
|
}
|
|
|
|
err := engine.Execute(context.Background(), run)
|
|
if err != nil {
|
|
t.Fatalf("Engine execution failed: %v", err)
|
|
}
|
|
|
|
if len(strat.decideCalls) != 2 {
|
|
t.Fatalf("expected 2 strategy Decide calls, got %d", len(strat.decideCalls))
|
|
}
|
|
|
|
// Verify chronological order: first call should have bar1 (May 1), second should have bar2 (May 2)
|
|
firstCall := strat.decideCalls[0]
|
|
if !firstCall.Bar.Timestamp.Equal(bar1.Timestamp) {
|
|
t.Errorf("expected first bar to be May 1, got %s", firstCall.Bar.Timestamp)
|
|
}
|
|
// Initial portfolio cash should be 10000000 (KRW default)
|
|
if firstCall.Portfolio.Cash.Amount.Value != "10000000" {
|
|
t.Errorf("expected starting cash 10000000, got %s", firstCall.Portfolio.Cash.Amount.Value)
|
|
}
|
|
|
|
secondCall := strat.decideCalls[1]
|
|
if !secondCall.Bar.Timestamp.Equal(bar2.Timestamp) {
|
|
t.Errorf("expected second bar to be May 2, got %s", secondCall.Bar.Timestamp)
|
|
}
|
|
// History in second call should contain bar1
|
|
if len(secondCall.History) != 1 {
|
|
t.Fatalf("expected history size 1 in second call, got %d", len(secondCall.History))
|
|
}
|
|
if !secondCall.History[0].Timestamp.Equal(bar1.Timestamp) {
|
|
t.Errorf("expected history[0] to be May 1 bar, got %s", secondCall.History[0].Timestamp)
|
|
}
|
|
// Portfolio cash in second call should reflect the purchase: 10000000 - 2 * 1000 = 9998000
|
|
if secondCall.Portfolio.Cash.Amount.Value != "9998000" {
|
|
t.Errorf("expected cash after buy to be 9998000, got %s", secondCall.Portfolio.Cash.Amount.Value)
|
|
}
|
|
// Position quantity should be 2
|
|
pos, ok := secondCall.Portfolio.Position(market.InstrumentID("KRX:005930"))
|
|
if !ok {
|
|
t.Fatal("expected position in second call portfolio")
|
|
}
|
|
if pos.Quantity.Amount.Value != "2" {
|
|
t.Errorf("expected position quantity 2, got %s", pos.Quantity.Amount.Value)
|
|
}
|
|
}
|