alt/services/worker/internal/storage/postgres/mapping_test.go
toki c0db4b24c1 feat(live-trading): 계좌 동기화와 감사 추적을 추가한다
Live Trading Boundary의 남은 account-sync/audit-trail 작업을 완료해 운영자 headless workflow와 roadmap 완료 후보 상태를 함께 반영한다.
2026-06-08 11:18:41 +09:00

513 lines
18 KiB
Go

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)
}
}