- 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
340 lines
12 KiB
Go
340 lines
12 KiB
Go
package postgres
|
|
|
|
import (
|
|
"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/postgres/sqlc"
|
|
)
|
|
|
|
func TestBacktestRunMappingRoundTrip(t *testing.T) {
|
|
now := time.Now().UTC().Truncate(time.Microsecond) // Truncate because postgres timestamptz resolves to microsecond precision
|
|
run := backtest.Run{
|
|
ID: backtest.RunID("run-123"),
|
|
Spec: backtest.RunSpec{
|
|
StrategyID: backtest.StrategyID("strat-abc"),
|
|
Market: market.MarketKR,
|
|
Timeframe: market.TimeframeDaily,
|
|
From: now.Add(-24 * time.Hour),
|
|
To: now,
|
|
},
|
|
Status: backtest.RunStatusSucceeded,
|
|
CreatedAt: now.Add(-25 * time.Hour),
|
|
UpdatedAt: now,
|
|
}
|
|
|
|
params, err := mapRunToParams(run)
|
|
if err != nil {
|
|
t.Fatalf("failed to map run to params: %v", err)
|
|
}
|
|
|
|
row := sqlc.BacktestRun{
|
|
ID: params.ID,
|
|
StrategyID: params.StrategyID,
|
|
Market: params.Market,
|
|
Timeframe: params.Timeframe,
|
|
FromTime: params.FromTime,
|
|
ToTime: params.ToTime,
|
|
Status: params.Status,
|
|
CreatedAt: params.CreatedAt,
|
|
UpdatedAt: params.UpdatedAt,
|
|
}
|
|
|
|
mappedBack, err := mapRowToRun(row)
|
|
if err != nil {
|
|
t.Fatalf("failed to map row back to run: %v", err)
|
|
}
|
|
|
|
if mappedBack.ID != run.ID {
|
|
t.Errorf("ID mismatch: got %v, want %v", mappedBack.ID, run.ID)
|
|
}
|
|
if mappedBack.Spec.StrategyID != run.Spec.StrategyID {
|
|
t.Errorf("StrategyID mismatch: got %v, want %v", mappedBack.Spec.StrategyID, run.Spec.StrategyID)
|
|
}
|
|
if mappedBack.Spec.Market != run.Spec.Market {
|
|
t.Errorf("Market mismatch: got %v, want %v", mappedBack.Spec.Market, run.Spec.Market)
|
|
}
|
|
if mappedBack.Spec.Timeframe != run.Spec.Timeframe {
|
|
t.Errorf("Timeframe mismatch: got %v, want %v", mappedBack.Spec.Timeframe, run.Spec.Timeframe)
|
|
}
|
|
if !mappedBack.Spec.From.Equal(run.Spec.From) {
|
|
t.Errorf("From mismatch: got %v, want %v", mappedBack.Spec.From, run.Spec.From)
|
|
}
|
|
if !mappedBack.Spec.To.Equal(run.Spec.To) {
|
|
t.Errorf("To mismatch: got %v, want %v", mappedBack.Spec.To, run.Spec.To)
|
|
}
|
|
if mappedBack.Status != run.Status {
|
|
t.Errorf("Status mismatch: got %v, want %v", mappedBack.Status, run.Status)
|
|
}
|
|
if !mappedBack.CreatedAt.Equal(run.CreatedAt) {
|
|
t.Errorf("CreatedAt mismatch: got %v, want %v", mappedBack.CreatedAt, run.CreatedAt)
|
|
}
|
|
if !mappedBack.UpdatedAt.Equal(run.UpdatedAt) {
|
|
t.Errorf("UpdatedAt mismatch: got %v, want %v", mappedBack.UpdatedAt, run.UpdatedAt)
|
|
}
|
|
}
|
|
|
|
func TestMarketBarMappingRejectsInvalidDecimal(t *testing.T) {
|
|
now := time.Now().UTC()
|
|
bar := market.Bar{
|
|
InstrumentID: market.InstrumentID("KRX:005930"),
|
|
Timeframe: market.TimeframeDaily,
|
|
Timestamp: now,
|
|
Open: market.Price{
|
|
Currency: market.CurrencyKRW,
|
|
Amount: market.Decimal{Value: "invalid_decimal"},
|
|
},
|
|
High: market.Price{
|
|
Currency: market.CurrencyKRW,
|
|
Amount: market.Decimal{Value: "50000"},
|
|
},
|
|
Low: market.Price{
|
|
Currency: market.CurrencyKRW,
|
|
Amount: market.Decimal{Value: "49000"},
|
|
},
|
|
Close: market.Price{
|
|
Currency: market.CurrencyKRW,
|
|
Amount: market.Decimal{Value: "49500"},
|
|
},
|
|
Volume: market.Quantity{
|
|
Amount: market.Decimal{Value: "1000000"},
|
|
},
|
|
}
|
|
|
|
_, err := mapBarToParams(bar)
|
|
if err == nil {
|
|
t.Fatal("expected error when mapping bar with invalid open decimal, got nil")
|
|
}
|
|
|
|
// Make Open valid but Volume invalid
|
|
bar.Open.Amount.Value = "50000"
|
|
bar.Volume.Amount.Value = "abc"
|
|
_, err = mapBarToParams(bar)
|
|
if err == nil {
|
|
t.Fatal("expected error when mapping bar with invalid volume decimal, got nil")
|
|
}
|
|
}
|
|
|
|
func TestBacktestResultMappingRoundTrip(t *testing.T) {
|
|
now := time.Now().UTC().Truncate(time.Second)
|
|
res := backtest.Result{
|
|
RunID: backtest.RunID("run-123"),
|
|
StartingCash: market.Price{
|
|
Currency: market.CurrencyKRW,
|
|
Amount: market.Decimal{Value: "100000000"},
|
|
},
|
|
EndingEquity: market.Price{
|
|
Currency: market.CurrencyKRW,
|
|
Amount: market.Decimal{Value: "123456789.12"},
|
|
},
|
|
Trades: []backtest.TradeSummary{
|
|
{
|
|
InstrumentID: market.InstrumentID("KRX:005930"),
|
|
Side: backtest.OrderSideBuy,
|
|
Quantity: market.Quantity{Amount: market.Decimal{Value: "10"}},
|
|
Price: market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "50000"}},
|
|
Timestamp: now.Add(-1 * time.Hour),
|
|
},
|
|
},
|
|
Positions: []backtest.PositionSummary{
|
|
{
|
|
InstrumentID: market.InstrumentID("KRX:005930"),
|
|
Quantity: market.Quantity{Amount: market.Decimal{Value: "10"}},
|
|
LastPrice: market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "52000"}},
|
|
},
|
|
},
|
|
Summary: backtest.SummaryMetrics{
|
|
StartingCash: market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "100000000"}},
|
|
EndingEquity: market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "123456789.12"}},
|
|
TotalReturn: market.Decimal{Value: "0.2345678912"},
|
|
TradeCount: 1,
|
|
},
|
|
EquityCurve: []backtest.EquityPoint{
|
|
{Timestamp: now.Add(-2 * time.Hour), Equity: market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "100000000"}}},
|
|
{Timestamp: now.Add(-1 * time.Hour), Equity: market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "123456789.12"}}},
|
|
},
|
|
}
|
|
|
|
params, err := mapResultToParams(res)
|
|
if err != nil {
|
|
t.Fatalf("failed to map result to params: %v", err)
|
|
}
|
|
|
|
row := sqlc.BacktestResult{
|
|
RunID: params.RunID,
|
|
StartingCashCurrency: params.StartingCashCurrency,
|
|
StartingCashAmount: params.StartingCashAmount,
|
|
EndingEquityCurrency: params.EndingEquityCurrency,
|
|
EndingEquityAmount: params.EndingEquityAmount,
|
|
TotalReturn: params.TotalReturn,
|
|
TradeCount: params.TradeCount,
|
|
Trades: params.Trades,
|
|
Positions: params.Positions,
|
|
EquityCurve: params.EquityCurve,
|
|
}
|
|
|
|
mappedBack, err := mapRowToResult(row)
|
|
if err != nil {
|
|
t.Fatalf("failed to map row back to result: %v", err)
|
|
}
|
|
|
|
if mappedBack.RunID != res.RunID {
|
|
t.Errorf("RunID mismatch: got %v, want %v", mappedBack.RunID, res.RunID)
|
|
}
|
|
if mappedBack.StartingCash.Currency != res.StartingCash.Currency || mappedBack.StartingCash.Amount.Value != res.StartingCash.Amount.Value {
|
|
t.Errorf("StartingCash mismatch: got %v, want %v", mappedBack.StartingCash, res.StartingCash)
|
|
}
|
|
if mappedBack.EndingEquity.Currency != res.EndingEquity.Currency || mappedBack.EndingEquity.Amount.Value != res.EndingEquity.Amount.Value {
|
|
t.Errorf("EndingEquity mismatch: got %v, want %v", mappedBack.EndingEquity, res.EndingEquity)
|
|
}
|
|
if len(mappedBack.Trades) != len(res.Trades) {
|
|
t.Fatalf("Trades length mismatch: got %d, want %d", len(mappedBack.Trades), len(res.Trades))
|
|
}
|
|
if mappedBack.Trades[0].InstrumentID != res.Trades[0].InstrumentID || mappedBack.Trades[0].Side != res.Trades[0].Side || mappedBack.Trades[0].Quantity.Amount.Value != res.Trades[0].Quantity.Amount.Value {
|
|
t.Errorf("Trade mismatch: got %v, want %v", mappedBack.Trades[0], res.Trades[0])
|
|
}
|
|
if !mappedBack.Trades[0].Timestamp.Equal(res.Trades[0].Timestamp) {
|
|
t.Errorf("Trade timestamp mismatch: got %v, want %v", mappedBack.Trades[0].Timestamp, res.Trades[0].Timestamp)
|
|
}
|
|
|
|
if len(mappedBack.Positions) != len(res.Positions) {
|
|
t.Fatalf("Positions length mismatch: got %d, want %d", len(mappedBack.Positions), len(res.Positions))
|
|
}
|
|
if mappedBack.Positions[0].InstrumentID != res.Positions[0].InstrumentID || mappedBack.Positions[0].Quantity.Amount.Value != res.Positions[0].Quantity.Amount.Value {
|
|
t.Errorf("Position mismatch: got %v, want %v", mappedBack.Positions[0], res.Positions[0])
|
|
}
|
|
|
|
// Summary round-trips, reconstructed from persisted columns.
|
|
if mappedBack.Summary.TotalReturn.Value != res.Summary.TotalReturn.Value {
|
|
t.Errorf("Summary TotalReturn mismatch: got %q, want %q", mappedBack.Summary.TotalReturn.Value, res.Summary.TotalReturn.Value)
|
|
}
|
|
if mappedBack.Summary.TradeCount != res.Summary.TradeCount {
|
|
t.Errorf("Summary TradeCount mismatch: got %d, want %d", mappedBack.Summary.TradeCount, res.Summary.TradeCount)
|
|
}
|
|
if mappedBack.Summary.StartingCash.Amount.Value != res.Summary.StartingCash.Amount.Value || mappedBack.Summary.EndingEquity.Amount.Value != res.Summary.EndingEquity.Amount.Value {
|
|
t.Errorf("Summary cash/equity mismatch: got %+v, want %+v", mappedBack.Summary, res.Summary)
|
|
}
|
|
|
|
// Equity curve round-trips with order and timestamps preserved.
|
|
if len(mappedBack.EquityCurve) != len(res.EquityCurve) {
|
|
t.Fatalf("EquityCurve length mismatch: got %d, want %d", len(mappedBack.EquityCurve), len(res.EquityCurve))
|
|
}
|
|
for i := range res.EquityCurve {
|
|
if !mappedBack.EquityCurve[i].Timestamp.Equal(res.EquityCurve[i].Timestamp) {
|
|
t.Errorf("EquityCurve[%d] timestamp mismatch: got %v, want %v", i, mappedBack.EquityCurve[i].Timestamp, res.EquityCurve[i].Timestamp)
|
|
}
|
|
if mappedBack.EquityCurve[i].Equity.Amount.Value != res.EquityCurve[i].Equity.Amount.Value {
|
|
t.Errorf("EquityCurve[%d] equity mismatch: got %q, want %q", i, mappedBack.EquityCurve[i].Equity.Amount.Value, res.EquityCurve[i].Equity.Amount.Value)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBacktestResultMappingDefaultsEmptySummary(t *testing.T) {
|
|
// A result persisted without a computed summary or equity curve must still map
|
|
// to valid NOT NULL columns ("0" total return, empty JSON arrays).
|
|
res := backtest.Result{
|
|
RunID: backtest.RunID("run-empty"),
|
|
StartingCash: market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "100"}},
|
|
EndingEquity: market.Price{Currency: market.CurrencyKRW, Amount: market.Decimal{Value: "100"}},
|
|
}
|
|
|
|
params, err := mapResultToParams(res)
|
|
if err != nil {
|
|
t.Fatalf("failed to map result to params: %v", err)
|
|
}
|
|
if string(params.EquityCurve) != "[]" {
|
|
t.Errorf("expected empty equity curve JSON array, got %q", string(params.EquityCurve))
|
|
}
|
|
|
|
mappedBack, err := mapRowToResult(sqlc.BacktestResult{
|
|
RunID: params.RunID,
|
|
StartingCashCurrency: params.StartingCashCurrency,
|
|
StartingCashAmount: params.StartingCashAmount,
|
|
EndingEquityCurrency: params.EndingEquityCurrency,
|
|
EndingEquityAmount: params.EndingEquityAmount,
|
|
TotalReturn: params.TotalReturn,
|
|
TradeCount: params.TradeCount,
|
|
Trades: params.Trades,
|
|
Positions: params.Positions,
|
|
EquityCurve: params.EquityCurve,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("failed to map row back to result: %v", err)
|
|
}
|
|
if mappedBack.Summary.TotalReturn.Value != "0" {
|
|
t.Errorf("expected default total return 0, got %q", mappedBack.Summary.TotalReturn.Value)
|
|
}
|
|
if len(mappedBack.EquityCurve) != 0 {
|
|
t.Errorf("expected empty equity curve, got %d points", len(mappedBack.EquityCurve))
|
|
}
|
|
}
|
|
|
|
func TestBacktestRunListRowMapping(t *testing.T) {
|
|
// ListRuns returns sqlc.BacktestRun rows; verify they map to domain runs the same
|
|
// way single-run detail rows do, so list and detail surfaces stay consistent.
|
|
now := time.Now().UTC().Truncate(time.Microsecond)
|
|
run := backtest.Run{
|
|
ID: backtest.RunID("run-list"),
|
|
Spec: backtest.RunSpec{
|
|
StrategyID: backtest.StrategyID("strat-list"),
|
|
Market: market.MarketUS,
|
|
Timeframe: market.TimeframeDaily,
|
|
From: now.Add(-48 * time.Hour),
|
|
To: now,
|
|
},
|
|
Status: backtest.RunStatusRunning,
|
|
CreatedAt: now.Add(-49 * time.Hour),
|
|
UpdatedAt: now,
|
|
}
|
|
|
|
params, err := mapRunToParams(run)
|
|
if err != nil {
|
|
t.Fatalf("failed to map run to params: %v", err)
|
|
}
|
|
|
|
mappedBack, err := mapRowToRun(sqlc.BacktestRun{
|
|
ID: params.ID,
|
|
StrategyID: params.StrategyID,
|
|
Market: params.Market,
|
|
Timeframe: params.Timeframe,
|
|
FromTime: params.FromTime,
|
|
ToTime: params.ToTime,
|
|
Status: params.Status,
|
|
CreatedAt: params.CreatedAt,
|
|
UpdatedAt: params.UpdatedAt,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("failed to map list row to run: %v", err)
|
|
}
|
|
if mappedBack.ID != run.ID || mappedBack.Status != run.Status || mappedBack.Spec.Market != run.Spec.Market {
|
|
t.Errorf("list run mapping mismatch: got %+v, want %+v", mappedBack, run)
|
|
}
|
|
}
|
|
|
|
func TestBacktestResultMappingRejectsInvalidDecimal(t *testing.T) {
|
|
res := backtest.Result{
|
|
RunID: backtest.RunID("run-123"),
|
|
StartingCash: market.Price{
|
|
Currency: market.CurrencyKRW,
|
|
Amount: market.Decimal{Value: "invalid"},
|
|
},
|
|
EndingEquity: market.Price{
|
|
Currency: market.CurrencyKRW,
|
|
Amount: market.Decimal{Value: "12345"},
|
|
},
|
|
}
|
|
|
|
_, err := mapResultToParams(res)
|
|
if err == nil {
|
|
t.Fatal("expected error when mapping result with invalid starting cash decimal, got nil")
|
|
}
|
|
|
|
res.StartingCash.Amount.Value = "100"
|
|
res.EndingEquity.Amount.Value = "invalid"
|
|
_, err = mapResultToParams(res)
|
|
if err == nil {
|
|
t.Fatal("expected error when mapping result with invalid ending equity decimal, got nil")
|
|
}
|
|
}
|