Paper trading readiness에서 API/worker/CLI가 같은 protobuf 계약으로 paper state를 시작하고 조회할 수 있어야 한다. Headless 운영 경로를 먼저 닫기 위해 contract, worker runtime, API forwarding, CLI scenario, client parser map과 검증 artifact를 함께 반영한다.
228 lines
7 KiB
Go
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
|
|
}
|