alt/services/worker/internal/papertrading/engine.go
toki aaf1af6387 feat(paper-trading): paper 상태 모니터링을 연결한다
Paper trading readiness에서 API/worker/CLI가 같은 protobuf 계약으로 paper state를 시작하고 조회할 수 있어야 한다. Headless 운영 경로를 먼저 닫기 위해 contract, worker runtime, API forwarding, CLI scenario, client parser map과 검증 artifact를 함께 반영한다.
2026-06-05 15:14:41 +09:00

228 lines
7 KiB
Go

package papertrading
import (
"context"
"fmt"
"sort"
"time"
"git.toki-labs.com/toki/alt/packages/domain/backtest"
"git.toki-labs.com/toki/alt/packages/domain/market"
)
// BarSource resolves daily bars for a paper trading run.
type BarSource interface {
GetBars(ctx context.Context, mkt market.Market, timeframe market.Timeframe, from, to time.Time) ([]market.Bar, error)
}
// StrategyPort resolves strategies for a paper trading run.
type StrategyPort interface {
GetStrategy(ctx context.Context, id backtest.StrategyID) (backtest.Strategy, error)
}
// Engine implements the daily paper execution loop. Orders decided in one bar
// fill on the next bar: market orders at next-bar open, limit orders when the
// bar's high/low crosses the limit price.
type Engine struct {
barSource BarSource
strategyPort StrategyPort
}
// NewEngine creates a new paper trading Engine instance.
func NewEngine(barSource BarSource, strategyPort StrategyPort) *Engine {
return &Engine{
barSource: barSource,
strategyPort: strategyPort,
}
}
// RunRequest carries all inputs for one paper execution run.
type RunRequest struct {
Account backtest.PaperAccount
Run backtest.Run
}
// RejectedOrder records an order that was denied by a risk gate or could not
// be applied to the portfolio.
type RejectedOrder struct {
Order backtest.OrderIntent
Reason string
BarTime time.Time
Instrument market.InstrumentID
CashBefore backtest.PaperAccount
}
// Snapshot captures the terminal state of a paper run.
type Snapshot struct {
Account backtest.PaperAccount
Fills []backtest.Fill
Rejected []RejectedOrder
EquityCurve []backtest.EquityPoint
}
// Run executes a daily paper trading simulation and returns the final snapshot.
func (e *Engine) Run(ctx context.Context, req RunRequest) (*Snapshot, error) {
strategy, err := e.strategyPort.GetStrategy(ctx, req.Run.Spec.StrategyID)
if err != nil {
return nil, fmt.Errorf("failed to get strategy %s: %w", req.Run.Spec.StrategyID, err)
}
bars, err := e.barSource.GetBars(ctx, req.Run.Spec.Market, req.Run.Spec.Timeframe, req.Run.Spec.From, req.Run.Spec.To)
if err != nil {
return nil, fmt.Errorf("failed to get bars: %w", err)
}
sort.Slice(bars, func(i, j int) bool {
if bars[i].Timestamp.Equal(bars[j].Timestamp) {
return bars[i].InstrumentID < bars[j].InstrumentID
}
return bars[i].Timestamp.Before(bars[j].Timestamp)
})
if len(bars) == 0 {
return &Snapshot{
Account: req.Account,
Fills: nil,
Rejected: nil,
EquityCurve: nil,
}, nil
}
// Clone starting account state so we don't mutate the input
positionsCopy := make(map[market.InstrumentID]backtest.Position, len(req.Account.Portfolio.Positions))
for k, v := range req.Account.Portfolio.Positions {
positionsCopy[k] = v
}
account := req.Account
account.Portfolio = backtest.PortfolioState{
Cash: req.Account.Portfolio.Cash,
Positions: positionsCopy,
}
var equityCurve []backtest.EquityPoint
var fills []backtest.Fill
var rejected []RejectedOrder
// Instrument-scoped pending: an order is only eligible to fill on a later bar
// of the *same* instrument. This prevents orders for instrument A from
// accidentally matching on the same timestamp of instrument B when
// StorageBarSource returns multi-instrument bars sorted by time.
pendingOrders := make(map[market.InstrumentID][]backtest.OrderIntent)
for _, bar := range bars {
// --- Phase 1: Fill pending orders for this instrument ---
instrumentPending, hasPending := pendingOrders[bar.InstrumentID]
if hasPending {
delete(pendingOrders, bar.InstrumentID)
}
var newFills []backtest.Fill
var newRejected []RejectedOrder
var nextForThisInst []backtest.OrderIntent
for _, order := range instrumentPending {
decision := backtest.CheckRisk(account, order)
if !decision.Allowed {
newRejected = append(newRejected, RejectedOrder{
Order: order,
Reason: decision.Reason,
BarTime: bar.Timestamp,
Instrument: order.InstrumentID,
CashBefore: account,
})
continue
}
fill, ok, err := backtest.FillOrderOnDailyBar(order, bar)
if err != nil {
return nil, fmt.Errorf("fill order on bar at %s: %w", bar.Timestamp, err)
}
if !ok {
// Limit not crossed, carry forward for this instrument
nextForThisInst = append(nextForThisInst, order)
continue
}
nextPortfolio, err := account.Portfolio.ApplyFill(fill)
if err != nil {
// ApplyFill failed (e.g. insufficient cash for buy, insufficient
// position for sell). Record as rejected and discard from pending
// to avoid infinite retry across bars.
newRejected = append(newRejected, RejectedOrder{
Order: order,
Reason: err.Error(),
BarTime: bar.Timestamp,
Instrument: order.InstrumentID,
CashBefore: account,
})
continue
}
account.Portfolio = nextPortfolio
newFills = append(newFills, fill)
}
rejected = append(rejected, newRejected...)
// Only merge remaining pending orders for this instrument back if any
if len(nextForThisInst) > 0 {
pendingOrders[bar.InstrumentID] = nextForThisInst
}
fills = append(fills, newFills...)
// --- Phase 2: Run strategy with current account state (after fills) ---
input := backtest.StrategyInput{
Run: req.Run,
Bar: bar,
Portfolio: account.Portfolio,
History: nil,
}
strategyOrders, err := strategy.Decide(input)
if err != nil {
return nil, fmt.Errorf("strategy decide failed at %s: %w", bar.Timestamp, err)
}
// Store strategy decisions in instrument-scoped pending keyed by each
// order's own InstrumentID so that a strategy can return orders for an
// instrument different from the current bar. Empty InstrumentID is treated
// as a strategy bug: the order is rejected immediately with no retry so the
// fault is visible early.
for _, order := range strategyOrders {
if order.InstrumentID == "" {
rejected = append(rejected, RejectedOrder{
Order: order,
Reason: "empty order.InstrumentID",
BarTime: bar.Timestamp,
Instrument: bar.InstrumentID,
CashBefore: account,
})
continue
}
pendingOrders[order.InstrumentID] = append(pendingOrders[order.InstrumentID], order)
}
// --- Phase 3: Mark current position price and record equity ---
if _, ok := account.Portfolio.Position(bar.InstrumentID); ok {
account.Portfolio, err = account.Portfolio.MarkPrice(bar.InstrumentID, bar.Close)
if err != nil {
return nil, fmt.Errorf("failed to mark price for %s: %w", bar.InstrumentID, err)
}
}
equity, err := account.Portfolio.Equity()
if err != nil {
return nil, fmt.Errorf("failed to calculate equity at %s: %w", bar.Timestamp, err)
}
equityCurve = append(equityCurve, backtest.EquityPoint{
Timestamp: bar.Timestamp,
Equity: equity,
})
account.UpdatedAt = bar.Timestamp
}
return &Snapshot{
Account: account, // req.Account.ID is now preserved
Fills: fills,
Rejected: rejected,
EquityCurve: equityCurve,
}, nil
}