Live Trading Boundary의 남은 account-sync/audit-trail 작업을 완료해 운영자 headless workflow와 roadmap 완료 후보 상태를 함께 반영한다.
378 lines
11 KiB
Go
378 lines
11 KiB
Go
package postgres
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"strconv"
|
|
|
|
"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 validateDecimal(val string) error {
|
|
if val == "" {
|
|
return fmt.Errorf("empty decimal value")
|
|
}
|
|
if _, err := strconv.ParseFloat(val, 64); err != nil {
|
|
return fmt.Errorf("invalid decimal format: %s", val)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func stringToNumeric(val string) (pgtype.Numeric, error) {
|
|
var num pgtype.Numeric
|
|
if err := validateDecimal(val); err != nil {
|
|
return num, err
|
|
}
|
|
if err := num.Scan(val); err != nil {
|
|
return num, fmt.Errorf("failed to scan numeric: %w", err)
|
|
}
|
|
return num, nil
|
|
}
|
|
|
|
func numericToString(num pgtype.Numeric) (string, error) {
|
|
if !num.Valid {
|
|
return "0", nil
|
|
}
|
|
val, err := num.Value()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
switch v := val.(type) {
|
|
case string:
|
|
return v, nil
|
|
case []byte:
|
|
return string(v), nil
|
|
default:
|
|
return fmt.Sprintf("%v", v), nil
|
|
}
|
|
}
|
|
|
|
func mapInstrumentToParams(inst market.Instrument) (sqlc.UpsertInstrumentParams, error) {
|
|
provBytes, err := json.Marshal(inst.ProviderSymbols)
|
|
if err != nil {
|
|
return sqlc.UpsertInstrumentParams{}, fmt.Errorf("failed to marshal provider symbols: %w", err)
|
|
}
|
|
if provBytes == nil {
|
|
provBytes = []byte("{}")
|
|
}
|
|
return sqlc.UpsertInstrumentParams{
|
|
ID: string(inst.ID),
|
|
Market: string(inst.Market),
|
|
Venue: string(inst.Venue),
|
|
Symbol: inst.Symbol,
|
|
Name: inst.Name,
|
|
Currency: string(inst.Currency),
|
|
ProviderSymbols: provBytes,
|
|
AssetType: string(inst.AssetType),
|
|
}, nil
|
|
}
|
|
|
|
func mapRowToInstrument(row sqlc.Instrument) (market.Instrument, error) {
|
|
var provSymbols map[string]string
|
|
if len(row.ProviderSymbols) > 0 {
|
|
if err := json.Unmarshal(row.ProviderSymbols, &provSymbols); err != nil {
|
|
return market.Instrument{}, fmt.Errorf("failed to unmarshal provider symbols: %w", err)
|
|
}
|
|
}
|
|
if provSymbols == nil {
|
|
provSymbols = make(map[string]string)
|
|
}
|
|
return market.Instrument{
|
|
ID: market.InstrumentID(row.ID),
|
|
Market: market.Market(row.Market),
|
|
Venue: market.Venue(row.Venue),
|
|
Symbol: row.Symbol,
|
|
Name: row.Name,
|
|
Currency: market.Currency(row.Currency),
|
|
AssetType: market.AssetType(row.AssetType),
|
|
ProviderSymbols: provSymbols,
|
|
}, nil
|
|
}
|
|
|
|
func mapBarToParams(bar market.Bar) (sqlc.UpsertBarParams, error) {
|
|
open, err := stringToNumeric(bar.Open.Amount.Value)
|
|
if err != nil {
|
|
return sqlc.UpsertBarParams{}, fmt.Errorf("invalid open amount: %w", err)
|
|
}
|
|
high, err := stringToNumeric(bar.High.Amount.Value)
|
|
if err != nil {
|
|
return sqlc.UpsertBarParams{}, fmt.Errorf("invalid high amount: %w", err)
|
|
}
|
|
low, err := stringToNumeric(bar.Low.Amount.Value)
|
|
if err != nil {
|
|
return sqlc.UpsertBarParams{}, fmt.Errorf("invalid low amount: %w", err)
|
|
}
|
|
closePrice, err := stringToNumeric(bar.Close.Amount.Value)
|
|
if err != nil {
|
|
return sqlc.UpsertBarParams{}, fmt.Errorf("invalid close amount: %w", err)
|
|
}
|
|
volume, err := stringToNumeric(bar.Volume.Amount.Value)
|
|
if err != nil {
|
|
return sqlc.UpsertBarParams{}, fmt.Errorf("invalid volume amount: %w", err)
|
|
}
|
|
|
|
return sqlc.UpsertBarParams{
|
|
InstrumentID: string(bar.InstrumentID),
|
|
Timeframe: string(bar.Timeframe),
|
|
Timestamp: pgtype.Timestamptz{Time: bar.Timestamp, Valid: !bar.Timestamp.IsZero()},
|
|
Open: open,
|
|
High: high,
|
|
Low: low,
|
|
Close: closePrice,
|
|
Volume: volume,
|
|
}, nil
|
|
}
|
|
|
|
func mapRowToBar(row sqlc.Bar, currency market.Currency) (market.Bar, error) {
|
|
openStr, err := numericToString(row.Open)
|
|
if err != nil {
|
|
return market.Bar{}, err
|
|
}
|
|
highStr, err := numericToString(row.High)
|
|
if err != nil {
|
|
return market.Bar{}, err
|
|
}
|
|
lowStr, err := numericToString(row.Low)
|
|
if err != nil {
|
|
return market.Bar{}, err
|
|
}
|
|
closeStr, err := numericToString(row.Close)
|
|
if err != nil {
|
|
return market.Bar{}, err
|
|
}
|
|
volumeStr, err := numericToString(row.Volume)
|
|
if err != nil {
|
|
return market.Bar{}, err
|
|
}
|
|
|
|
return market.Bar{
|
|
InstrumentID: market.InstrumentID(row.InstrumentID),
|
|
Timeframe: market.Timeframe(row.Timeframe),
|
|
Timestamp: row.Timestamp.Time,
|
|
Open: market.Price{
|
|
Currency: currency,
|
|
Amount: market.Decimal{Value: openStr},
|
|
},
|
|
High: market.Price{
|
|
Currency: currency,
|
|
Amount: market.Decimal{Value: highStr},
|
|
},
|
|
Low: market.Price{
|
|
Currency: currency,
|
|
Amount: market.Decimal{Value: lowStr},
|
|
},
|
|
Close: market.Price{
|
|
Currency: currency,
|
|
Amount: market.Decimal{Value: closeStr},
|
|
},
|
|
Volume: market.Quantity{
|
|
Amount: market.Decimal{Value: volumeStr},
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
func mapRunToParams(run backtest.Run) (sqlc.UpsertRunParams, error) {
|
|
return sqlc.UpsertRunParams{
|
|
ID: string(run.ID),
|
|
StrategyID: string(run.Spec.StrategyID),
|
|
Market: string(run.Spec.Market),
|
|
Timeframe: string(run.Spec.Timeframe),
|
|
FromTime: pgtype.Timestamptz{Time: run.Spec.From, Valid: !run.Spec.From.IsZero()},
|
|
ToTime: pgtype.Timestamptz{Time: run.Spec.To, Valid: !run.Spec.To.IsZero()},
|
|
Status: string(run.Status),
|
|
CreatedAt: pgtype.Timestamptz{Time: run.CreatedAt, Valid: !run.CreatedAt.IsZero()},
|
|
UpdatedAt: pgtype.Timestamptz{Time: run.UpdatedAt, Valid: !run.UpdatedAt.IsZero()},
|
|
}, nil
|
|
}
|
|
|
|
func mapRowToRun(row sqlc.BacktestRun) (backtest.Run, error) {
|
|
return backtest.Run{
|
|
ID: backtest.RunID(row.ID),
|
|
Spec: backtest.RunSpec{
|
|
StrategyID: backtest.StrategyID(row.StrategyID),
|
|
Market: market.Market(row.Market),
|
|
Timeframe: market.Timeframe(row.Timeframe),
|
|
From: row.FromTime.Time,
|
|
To: row.ToTime.Time,
|
|
},
|
|
Status: backtest.RunStatus(row.Status),
|
|
CreatedAt: row.CreatedAt.Time,
|
|
UpdatedAt: row.UpdatedAt.Time,
|
|
}, nil
|
|
}
|
|
|
|
func mapResultToParams(res backtest.Result) (sqlc.UpsertResultParams, error) {
|
|
startingCashAmount, err := stringToNumeric(res.StartingCash.Amount.Value)
|
|
if err != nil {
|
|
return sqlc.UpsertResultParams{}, fmt.Errorf("invalid starting cash amount: %w", err)
|
|
}
|
|
endingEquityAmount, err := stringToNumeric(res.EndingEquity.Amount.Value)
|
|
if err != nil {
|
|
return sqlc.UpsertResultParams{}, fmt.Errorf("invalid ending equity amount: %w", err)
|
|
}
|
|
|
|
var tradesBytes []byte
|
|
if res.Trades == nil {
|
|
tradesBytes = []byte("[]")
|
|
} else {
|
|
tradesBytes, err = json.Marshal(res.Trades)
|
|
if err != nil {
|
|
return sqlc.UpsertResultParams{}, fmt.Errorf("failed to marshal trades: %w", err)
|
|
}
|
|
}
|
|
|
|
var positionsBytes []byte
|
|
if res.Positions == nil {
|
|
positionsBytes = []byte("[]")
|
|
} else {
|
|
positionsBytes, err = json.Marshal(res.Positions)
|
|
if err != nil {
|
|
return sqlc.UpsertResultParams{}, fmt.Errorf("failed to marshal positions: %w", err)
|
|
}
|
|
}
|
|
|
|
// Summary defaults to "0" total return when callers persist a result without a
|
|
// computed summary so the NOT NULL numeric column never receives an empty value.
|
|
totalReturnValue := res.Summary.TotalReturn.Value
|
|
if totalReturnValue == "" {
|
|
totalReturnValue = "0"
|
|
}
|
|
totalReturn, err := stringToNumeric(totalReturnValue)
|
|
if err != nil {
|
|
return sqlc.UpsertResultParams{}, fmt.Errorf("invalid total return: %w", err)
|
|
}
|
|
|
|
var equityCurveBytes []byte
|
|
if res.EquityCurve == nil {
|
|
equityCurveBytes = []byte("[]")
|
|
} else {
|
|
equityCurveBytes, err = json.Marshal(res.EquityCurve)
|
|
if err != nil {
|
|
return sqlc.UpsertResultParams{}, fmt.Errorf("failed to marshal equity curve: %w", err)
|
|
}
|
|
}
|
|
|
|
return sqlc.UpsertResultParams{
|
|
RunID: string(res.RunID),
|
|
StartingCashCurrency: string(res.StartingCash.Currency),
|
|
StartingCashAmount: startingCashAmount,
|
|
EndingEquityCurrency: string(res.EndingEquity.Currency),
|
|
EndingEquityAmount: endingEquityAmount,
|
|
TotalReturn: totalReturn,
|
|
TradeCount: int32(res.Summary.TradeCount),
|
|
Trades: tradesBytes,
|
|
Positions: positionsBytes,
|
|
EquityCurve: equityCurveBytes,
|
|
}, nil
|
|
}
|
|
|
|
func mapRowToResult(row sqlc.BacktestResult) (backtest.Result, error) {
|
|
startingCashAmountStr, err := numericToString(row.StartingCashAmount)
|
|
if err != nil {
|
|
return backtest.Result{}, fmt.Errorf("failed to parse starting cash amount: %w", err)
|
|
}
|
|
endingEquityAmountStr, err := numericToString(row.EndingEquityAmount)
|
|
if err != nil {
|
|
return backtest.Result{}, fmt.Errorf("failed to parse ending equity amount: %w", err)
|
|
}
|
|
|
|
var trades []backtest.TradeSummary
|
|
if len(row.Trades) > 0 {
|
|
if err := json.Unmarshal(row.Trades, &trades); err != nil {
|
|
return backtest.Result{}, fmt.Errorf("failed to unmarshal trades: %w", err)
|
|
}
|
|
}
|
|
|
|
var positions []backtest.PositionSummary
|
|
if len(row.Positions) > 0 {
|
|
if err := json.Unmarshal(row.Positions, &positions); err != nil {
|
|
return backtest.Result{}, fmt.Errorf("failed to unmarshal positions: %w", err)
|
|
}
|
|
}
|
|
|
|
totalReturnStr, err := numericToString(row.TotalReturn)
|
|
if err != nil {
|
|
return backtest.Result{}, fmt.Errorf("failed to parse total return: %w", err)
|
|
}
|
|
|
|
var equityCurve []backtest.EquityPoint
|
|
if len(row.EquityCurve) > 0 {
|
|
if err := json.Unmarshal(row.EquityCurve, &equityCurve); err != nil {
|
|
return backtest.Result{}, fmt.Errorf("failed to unmarshal equity curve: %w", err)
|
|
}
|
|
}
|
|
|
|
startingCash := market.Price{
|
|
Currency: market.Currency(row.StartingCashCurrency),
|
|
Amount: market.Decimal{Value: startingCashAmountStr},
|
|
}
|
|
endingEquity := market.Price{
|
|
Currency: market.Currency(row.EndingEquityCurrency),
|
|
Amount: market.Decimal{Value: endingEquityAmountStr},
|
|
}
|
|
|
|
return backtest.Result{
|
|
RunID: backtest.RunID(row.RunID),
|
|
StartingCash: startingCash,
|
|
EndingEquity: endingEquity,
|
|
Trades: trades,
|
|
Positions: positions,
|
|
Summary: backtest.SummaryMetrics{
|
|
StartingCash: startingCash,
|
|
EndingEquity: endingEquity,
|
|
TotalReturn: market.Decimal{Value: totalReturnStr},
|
|
TradeCount: int(row.TradeCount),
|
|
},
|
|
EquityCurve: equityCurve,
|
|
}, nil
|
|
}
|
|
|
|
func mapAuditEventToParams(event trading.AuditEvent) (sqlc.AppendLiveAuditEventParams, error) {
|
|
payloadBytes, err := json.Marshal(event.Payload)
|
|
if err != nil {
|
|
return sqlc.AppendLiveAuditEventParams{}, fmt.Errorf("failed to marshal audit payload: %w", err)
|
|
}
|
|
return sqlc.AppendLiveAuditEventParams{
|
|
EventID: event.EventID,
|
|
AccountID: event.AccountID,
|
|
Broker: string(event.Broker),
|
|
OrderID: event.OrderID,
|
|
EventType: string(event.Type),
|
|
Status: event.Status,
|
|
Reason: event.Reason,
|
|
Actor: event.Actor,
|
|
CorrelationID: event.CorrelationID,
|
|
Payload: payloadBytes,
|
|
CreatedAt: pgtype.Timestamptz{Time: event.Timestamp, Valid: !event.Timestamp.IsZero()},
|
|
}, nil
|
|
}
|
|
|
|
func mapRowToAuditEvent(row sqlc.LiveAuditEvent) (trading.AuditEvent, error) {
|
|
var payload map[string]string
|
|
if len(row.Payload) > 0 {
|
|
if err := json.Unmarshal(row.Payload, &payload); err != nil {
|
|
return trading.AuditEvent{}, fmt.Errorf("failed to unmarshal audit payload: %w", err)
|
|
}
|
|
}
|
|
if payload == nil {
|
|
payload = make(map[string]string)
|
|
}
|
|
return trading.AuditEvent{
|
|
EventID: row.EventID,
|
|
Type: trading.AuditEventType(row.EventType),
|
|
Timestamp: row.CreatedAt.Time,
|
|
Broker: trading.Broker(row.Broker),
|
|
AccountID: row.AccountID,
|
|
OrderID: row.OrderID,
|
|
Status: row.Status,
|
|
Reason: row.Reason,
|
|
Actor: row.Actor,
|
|
CorrelationID: row.CorrelationID,
|
|
Payload: payload,
|
|
}, nil
|
|
}
|