558 lines
19 KiB
Go
558 lines
19 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) GetBarsForRun(ctx context.Context, spec backtest.RunSpec) ([]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),
|
|
Open: market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "1000"}},
|
|
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),
|
|
Open: market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "1100"}},
|
|
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),
|
|
Open: market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "1200"}},
|
|
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 TestEngineRunWithUSMarketReturnsEquityInUSD(t *testing.T) {
|
|
// This smoke test validates the MarketUS path: USD starting cash,
|
|
// USD-denominated prices, and a final equity figure expressed in USD.
|
|
// Next-bar fill: buy fills on day 2 at Open=110, sell fills on day 3 at Open=120.
|
|
//
|
|
// Starting cash = 10,000 USD
|
|
// Day 1: strategy decides buy 2. Pending.
|
|
// Day 2: fill buy 2@110 → cash = 10,000 - 220 = 9,780, pos = 2
|
|
// strategy decides sell 1. Pending.
|
|
// Day 3: fill sell 1@120 → cash = 9,780 + 120 = 9,900, pos = 1
|
|
// pos marked at 120.
|
|
// Final equity = cash 9,900 + 1*120 = 10,020 USD
|
|
//
|
|
// Trades: buy 2@110, sell 1@120
|
|
// Positions: 1 unit NASDAQ:AAPL at last price 120 USD
|
|
// Total return = (10020 - 10000) / 10000 = 0.002
|
|
|
|
instID := market.InstrumentID("NASDAQ:AAPL")
|
|
bar1 := market.Bar{
|
|
InstrumentID: instID,
|
|
Timeframe: market.TimeframeDaily,
|
|
Timestamp: time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC),
|
|
Open: market.Price{Currency: market.CurrencyUSD, Amount: market.Decimal{Value: "100"}},
|
|
Close: market.Price{Currency: market.CurrencyUSD, Amount: market.Decimal{Value: "100"}},
|
|
}
|
|
bar2 := market.Bar{
|
|
InstrumentID: instID,
|
|
Timeframe: market.TimeframeDaily,
|
|
Timestamp: time.Date(2026, 5, 2, 0, 0, 0, 0, time.UTC),
|
|
Open: market.Price{Currency: market.CurrencyUSD, Amount: market.Decimal{Value: "110"}},
|
|
Close: market.Price{Currency: market.CurrencyUSD, Amount: market.Decimal{Value: "110"}},
|
|
}
|
|
bar3 := market.Bar{
|
|
InstrumentID: instID,
|
|
Timeframe: market.TimeframeDaily,
|
|
Timestamp: time.Date(2026, 5, 3, 0, 0, 0, 0, time.UTC),
|
|
Open: market.Price{Currency: market.CurrencyUSD, Amount: market.Decimal{Value: "120"}},
|
|
Close: market.Price{Currency: market.CurrencyUSD, Amount: market.Decimal{Value: "120"}},
|
|
}
|
|
|
|
barSource := &inMemoryBarSource{bars: []market.Bar{bar1, bar2, bar3}}
|
|
strat := &deterministicStrategy{id: "det-strat-us"}
|
|
strategyPort := &inMemoryStrategyPort{strategy: strat}
|
|
resultStore := newInMemoryResultStore()
|
|
|
|
engine := NewEngine(barSource, strategyPort, resultStore)
|
|
|
|
run := backtest.Run{
|
|
ID: "run-us-smoke",
|
|
Spec: backtest.RunSpec{
|
|
StrategyID: "det-strat-us",
|
|
Market: market.MarketUS,
|
|
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("US execution failed: %v", err)
|
|
}
|
|
|
|
res, err := resultStore.GetResult(context.Background(), run.ID)
|
|
if err != nil {
|
|
t.Fatalf("failed to get result: %v", err)
|
|
}
|
|
|
|
// Verify starting cash is USD 10,000
|
|
if res.StartingCash.Currency != market.CurrencyUSD {
|
|
t.Errorf("starting cash currency: got %q, want %q", res.StartingCash.Currency, market.CurrencyUSD)
|
|
}
|
|
if res.StartingCash.Amount.Value != "10000" {
|
|
t.Errorf("expected starting cash 10000, got %s", res.StartingCash.Amount.Value)
|
|
}
|
|
|
|
// Verify final equity
|
|
// Day 1: pending buy, mark pos=0 → equity = 10,000
|
|
// Day 2: buy 2@110 → cash 9,780 + 2*110 = 10,000
|
|
// Day 3: sell 1@120 → cash 9,900 + 1*120 = 10,020
|
|
if res.EndingEquity.Currency != market.CurrencyUSD {
|
|
t.Errorf("ending equity currency: got %q, want %q", res.EndingEquity.Currency, market.CurrencyUSD)
|
|
}
|
|
if res.EndingEquity.Amount.Value != "10020" {
|
|
t.Errorf("expected ending equity 10020, 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 != "110" {
|
|
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 != "120" {
|
|
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 != "120" {
|
|
t.Errorf("unexpected position: %+v", pos)
|
|
}
|
|
|
|
// Verify derived summary metrics
|
|
if res.Summary.EndingEquity.Amount.Value != "10020" {
|
|
t.Errorf("summary ending equity: got %s, want 10020", res.Summary.EndingEquity.Amount.Value)
|
|
}
|
|
if res.Summary.TradeCount != 2 {
|
|
t.Errorf("summary trade count: got %d, want 2", res.Summary.TradeCount)
|
|
}
|
|
// (10020 - 10000) / 10000 = 0.002
|
|
if res.Summary.TotalReturn.Value != "0.002" {
|
|
t.Errorf("summary total return: got %s, want 0.002", res.Summary.TotalReturn.Value)
|
|
}
|
|
|
|
// Verify equity curve: one point per bar
|
|
// Day 1: no fill yet, equity = 10,000
|
|
// Day 2: buy 2@110 → cash 9,780 + 2*110 = 10,000
|
|
// Day 3: sell 1@120 → cash 9,900 + 1*120 = 10,020
|
|
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, "10000"},
|
|
{bar2.Timestamp, "10000"},
|
|
{bar3.Timestamp, "10020"},
|
|
}
|
|
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)
|
|
}
|
|
if point.Equity.Currency != market.CurrencyUSD {
|
|
t.Errorf("equity curve point %d currency: got %q, want %q", i, point.Equity.Currency, market.CurrencyUSD)
|
|
}
|
|
}
|
|
|
|
// Final equity curve point must match 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)
|
|
}
|
|
}
|
|
|
|
// TestEngineProducesDeterministicResultAcrossTimeframes verifies that the
|
|
// same strategy logic produces identical financial results across monthly,
|
|
// daily, and minute timeframes when bar prices are identical.
|
|
func TestEngineProducesDeterministicResultAcrossTimeframes(t *testing.T) {
|
|
instID := market.InstrumentID("KRX:005930")
|
|
|
|
type barData struct {
|
|
day int
|
|
price string
|
|
}
|
|
baseBars := []barData{
|
|
{1, "1000"},
|
|
{2, "1100"},
|
|
{3, "1200"},
|
|
}
|
|
|
|
for _, tf := range []market.Timeframe{market.TimeframeMonthly, market.TimeframeDaily, market.TimeframeMin1} {
|
|
tfLabel := string(tf)
|
|
t.Run(tfLabel, func(t *testing.T) {
|
|
var bars []market.Bar
|
|
for _, b := range baseBars {
|
|
bars = append(bars, market.Bar{
|
|
InstrumentID: instID,
|
|
Timeframe: tf,
|
|
Timestamp: time.Date(2026, 5, b.day, 0, 0, 0, 0, time.UTC),
|
|
Open: market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: b.price}},
|
|
High: market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: b.price}},
|
|
Low: market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: b.price}},
|
|
Close: market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: b.price}},
|
|
})
|
|
}
|
|
|
|
barSource := &inMemoryBarSource{bars: bars}
|
|
strat := &deterministicStrategy{id: "det-strat-tf"}
|
|
strategyPort := &inMemoryStrategyPort{strategy: strat}
|
|
resultStore := newInMemoryResultStore()
|
|
|
|
engine := NewEngine(barSource, strategyPort, resultStore)
|
|
|
|
run := backtest.Run{
|
|
ID: backtest.RunID("run-tf-" + tfLabel),
|
|
Spec: backtest.RunSpec{
|
|
StrategyID: "det-strat-tf",
|
|
Market: market.MarketKR,
|
|
Timeframe: tf,
|
|
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)
|
|
}
|
|
|
|
// Next-bar fill: buy 2 at day 2 Open=1100, sell 1 at day 3 Open=1200.
|
|
// cash = 10,000,000 - 2*1100 = 9,997,800, pos = 2
|
|
// cash = 9,997,800 + 1200 = 9,999,000, pos = 1
|
|
// equity = 9,999,000 + 1*1200 = 10,000,200
|
|
if res.StartingCash.Amount.Value != "10000000" {
|
|
t.Errorf("starting cash: got %s, want 10000000", res.StartingCash.Amount.Value)
|
|
}
|
|
if res.EndingEquity.Amount.Value != "10000200" {
|
|
t.Errorf("ending equity: got %s, want 10000200", res.EndingEquity.Amount.Value)
|
|
}
|
|
if res.Summary.TradeCount != 2 {
|
|
t.Errorf("trade count: got %d, want 2", res.Summary.TradeCount)
|
|
}
|
|
if len(res.Trades) != 2 {
|
|
t.Fatalf("trades length: got %d, want 2", len(res.Trades))
|
|
}
|
|
if res.Trades[0].Price.Amount.Value != "1100" {
|
|
t.Errorf("trade 0 price: got %s, want 1100", res.Trades[0].Price.Amount.Value)
|
|
}
|
|
if res.Trades[1].Price.Amount.Value != "1200" {
|
|
t.Errorf("trade 1 price: got %s, want 1200", res.Trades[1].Price.Amount.Value)
|
|
}
|
|
if len(res.EquityCurve) != 3 {
|
|
t.Fatalf("equity curve length: got %d, want 3", len(res.EquityCurve))
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestEngineStoresAndQueriesFixtureResult verifies the full engine pipeline:
|
|
// bars → strategy → next-bar fills → store → query with deterministic values.
|
|
// Next-bar fill: buy 2 on day 1 fills on day 2 at 1100, sell 1 on day 2 fills on day 3 at 1200.
|
|
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),
|
|
Open: market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "1000"}},
|
|
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),
|
|
Open: market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "1100"}},
|
|
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),
|
|
Open: market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "1200"}},
|
|
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)
|
|
}
|
|
|
|
// Next-bar fill calculation:
|
|
// Starting cash = 10,000,000 KRW
|
|
//
|
|
// Day 1: strategy decides buy 2. Pending.
|
|
// Day 2: fill buy 2@1100 → cash = 10,000,000 - 2,200 = 9,997,800, pos = 2
|
|
// strategy decides sell 1. Pending.
|
|
// Day 3: fill sell 1@1200 → cash = 9,997,800 + 1,200 = 9,999,000, pos = 1
|
|
// pos marked at 1200.
|
|
// Final Equity = 9,999,000 + 1*1200 = 10,000,200 KRW
|
|
//
|
|
// Expected trades:
|
|
// 1. Buy 2 units at 1100 KRW on May 2 (next-bar fill of day 1 order)
|
|
// 2. Sell 1 unit at 1200 KRW on May 3 (next-bar fill of day 2 order)
|
|
//
|
|
// 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 != "10000200" {
|
|
t.Errorf("expected ending equity 10000200, 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 != "1100" {
|
|
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 != "1200" {
|
|
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 != "10000200" {
|
|
t.Errorf("summary ending equity: got %s, want 10000200", res.Summary.EndingEquity.Amount.Value)
|
|
}
|
|
if res.Summary.TradeCount != 2 {
|
|
t.Errorf("summary trade count: got %d, want 2", res.Summary.TradeCount)
|
|
}
|
|
// (10000200 - 10000000) / 10000000 = 0.00002
|
|
if res.Summary.TotalReturn.Value != "0.00002" {
|
|
t.Errorf("summary total return: got %s, want 0.00002", res.Summary.TotalReturn.Value)
|
|
}
|
|
|
|
// Verify deterministic equity curve: one point per bar, ordered by timestamp.
|
|
// Day 1: no fill, equity = 10,000,000
|
|
// Day 2: buy 2@1100 → cash 9,997,800 + 2*1100 = 10,000,000
|
|
// Day 3: sell 1@1200 → cash 9,999,000 + 1*1200 = 10,000,200
|
|
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, "10000000"},
|
|
{bar3.Timestamp, "10000200"},
|
|
}
|
|
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)
|
|
}
|
|
}
|