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 }