package postgres import ( "context" "testing" "time" "github.com/jackc/pgx/v5/pgtype" "git.toki-labs.com/toki/alt/packages/domain/backtest" "git.toki-labs.com/toki/alt/packages/domain/market" "git.toki-labs.com/toki/alt/packages/domain/trading" "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") } } func TestAuditEventMappingRoundTrip(t *testing.T) { now := time.Now().UTC().Truncate(time.Microsecond) ev := trading.AuditEvent{ EventID: "evt-001", Type: trading.AuditEventTypeSubmitConfirmed, Timestamp: now, Broker: trading.BrokerKIS, AccountID: "acct-1", OrderID: "lo-acct-1-1", Status: "submitted", Reason: "operator confirmed", Actor: "op-001", CorrelationID: "corr-001", Payload: map[string]string{"instrument_id": "005930.KS"}, } params, err := mapAuditEventToParams(ev) if err != nil { t.Fatalf("mapAuditEventToParams failed: %v", err) } row := sqlc.LiveAuditEvent{ EventID: params.EventID, AccountID: params.AccountID, Broker: params.Broker, OrderID: params.OrderID, EventType: params.EventType, Status: params.Status, Reason: params.Reason, Actor: params.Actor, CorrelationID: params.CorrelationID, Payload: params.Payload, CreatedAt: pgtype.Timestamptz{Time: now, Valid: true}, } got, err := mapRowToAuditEvent(row) if err != nil { t.Fatalf("mapRowToAuditEvent failed: %v", err) } if got.EventID != ev.EventID { t.Errorf("EventID mismatch: got %q, want %q", got.EventID, ev.EventID) } if got.Type != ev.Type { t.Errorf("Type mismatch: got %q, want %q", got.Type, ev.Type) } if got.AccountID != ev.AccountID { t.Errorf("AccountID mismatch: got %q, want %q", got.AccountID, ev.AccountID) } if got.OrderID != ev.OrderID { t.Errorf("OrderID mismatch: got %q, want %q", got.OrderID, ev.OrderID) } if got.Payload["instrument_id"] != ev.Payload["instrument_id"] { t.Errorf("Payload mismatch: got %v, want %v", got.Payload, ev.Payload) } if !got.Timestamp.Equal(now) { t.Errorf("Timestamp mismatch: got %v, want %v", got.Timestamp, now) } } func TestAuditEventMappingRejectsRawSecretKey(t *testing.T) { ev := trading.AuditEvent{ EventID: "evt-bad", Type: trading.AuditEventTypeSubmitConfirmed, AccountID: "acct-1", Payload: map[string]string{"token": "raw-token-value"}, } // mapAuditEventToParams itself does not block raw secrets; Store.AppendLiveAuditEvent // is the enforcement boundary. The mapping must still preserve the payload bytes // so the store-level check sees the original data. params, err := mapAuditEventToParams(ev) if err != nil { t.Fatalf("mapAuditEventToParams failed: %v", err) } if len(params.Payload) == 0 { t.Error("expected non-empty payload bytes after mapping") } // Verify HasRawSecretKey detects the forbidden key in the original payload. if !trading.HasRawSecretKey(ev.Payload) { t.Error("expected HasRawSecretKey=true for payload containing 'token'") } } func TestStoreLiveAuditAppendRejectsForbiddenPayload(t *testing.T) { // Store.AppendLiveAuditEvent must reject forbidden payload keys before // touching the database. Using a nil-pool store so no DB is needed. store := NewStore(nil) forbidden := []map[string]string{ {"token": "abc"}, {"secret": "shh"}, {"password": "hunter2"}, {"raw_account_number": "123456"}, {"api_key": "key"}, } for _, payload := range forbidden { ev := trading.AuditEvent{ EventID: "evt-forbidden", Type: trading.AuditEventTypeSubmitConfirmed, AccountID: "acct-1", Payload: payload, } err := store.AppendLiveAuditEvent(context.Background(), ev) if err == nil { t.Errorf("expected error for forbidden payload %v, got nil", payload) } } } func TestInstrumentMappingRoundTrip(t *testing.T) { inst := market.Instrument{ ID: "NASDAQ:AAPL", Market: market.MarketUS, Venue: market.VenueNASDAQ, Symbol: "AAPL", Name: "Apple Inc.", Currency: market.CurrencyUSD, AssetType: market.AssetTypeEquity, ProviderSymbols: map[string]string{ "kis": "AAPL", }, } params, err := mapInstrumentToParams(inst) if err != nil { t.Fatalf("failed to map instrument to params: %v", err) } row := sqlc.Instrument{ ID: params.ID, Market: params.Market, Venue: params.Venue, Symbol: params.Symbol, Name: params.Name, Currency: params.Currency, ProviderSymbols: params.ProviderSymbols, AssetType: params.AssetType, } mappedBack, err := mapRowToInstrument(row) if err != nil { t.Fatalf("failed to map row back to instrument: %v", err) } if mappedBack.ID != inst.ID { t.Errorf("ID mismatch: got %v, want %v", mappedBack.ID, inst.ID) } if mappedBack.Market != inst.Market { t.Errorf("Market mismatch: got %v, want %v", mappedBack.Market, inst.Market) } if mappedBack.Venue != inst.Venue { t.Errorf("Venue mismatch: got %v, want %v", mappedBack.Venue, inst.Venue) } if mappedBack.Symbol != inst.Symbol { t.Errorf("Symbol mismatch: got %v, want %v", mappedBack.Symbol, inst.Symbol) } if mappedBack.Name != inst.Name { t.Errorf("Name mismatch: got %v, want %v", mappedBack.Name, inst.Name) } if mappedBack.Currency != inst.Currency { t.Errorf("Currency mismatch: got %v, want %v", mappedBack.Currency, inst.Currency) } if mappedBack.AssetType != inst.AssetType { t.Errorf("AssetType mismatch: got %v, want %v", mappedBack.AssetType, inst.AssetType) } if mappedBack.ProviderSymbols["kis"] != inst.ProviderSymbols["kis"] { t.Errorf("ProviderSymbols mismatch: got %+v, want %+v", mappedBack.ProviderSymbols, inst.ProviderSymbols) } }