package papertrading import ( "context" "errors" "fmt" "math/big" "sort" "strings" "sync" "time" "git.toki-labs.com/toki/alt/packages/domain/backtest" "git.toki-labs.com/toki/alt/packages/domain/market" ) // ErrAccountNotFound is returned when a paper trading state is requested for an // account that has not been started in this process. var ErrAccountNotFound = errors.New("paper trading account not found") // Order lifecycle sentinels. They let the socket layer map a lifecycle failure // onto the shared typed error vocabulary: a missing order is not_found, an // out-of-sequence transition or a missing market fill price is invalid_request. var ( // ErrOrderNotFound is returned when a submit/cancel/fill targets an order id // that was never recorded for the account. ErrOrderNotFound = errors.New("paper order not found") // ErrOrderNotPending is returned when cancel/fill targets an order that has // already reached a terminal status (filled, canceled, rejected). ErrOrderNotPending = errors.New("paper order is not pending") // ErrFillPriceRequired is returned when a market order is filled without a // simulated execution price; a market order has no inherent price. ErrFillPriceRequired = errors.New("fill_price is required for a market order") // ErrInvalidOrderInput is returned when an order lifecycle decimal // (quantity, limit price, fill price) is non-numeric or has the wrong sign. // The socket layer maps it to invalid_request so malformed input is rejected // before it can be stored as a pending order or applied as a fill. ErrInvalidOrderInput = errors.New("invalid paper order input") ) // StartRequest carries the validated inputs needed to start a paper run for an // account. The socket layer maps the proto request onto this shape so the // service stays decoupled from the wire contract. type StartRequest struct { AccountID backtest.PaperAccountID Spec backtest.RunSpec StartingCash market.Price } // State is the terminal snapshot of a paper account after a run. Positions are // kept as a stable, instrument-sorted slice so socket output is deterministic. type State struct { Run backtest.Run Cash market.Price Positions []backtest.Position Fills []backtest.Fill EquityCurve []backtest.EquityPoint // Rejected carries orders the run's risk gate or portfolio refused, preserved // from the engine snapshot so the socket layer can expose a risk summary // without re-running risk evaluation. Rejected []RejectedOrder } // Service is the worker-owned, process-local paper trading runtime. It runs the // daily paper Engine on start and keeps the resulting state in memory keyed by // account id so the operator can inspect it through the API/CLI without a // durable store. Paper readiness is intentionally in-memory: the milestone only // needs deterministic start/inspect, not persistence. type Service struct { engine *Engine now func() time.Time mu sync.Mutex states map[backtest.PaperAccountID]State // books holds operator-submitted order lifecycle state per account. It is // seeded lazily from a started account's terminal run state on first submit, // so the manual order path continues from where the run left off without a // durable store. books map[backtest.PaperAccountID]*orderBook } // NewService builds a paper trading service around an Engine. now defaults to // time.Now when nil so callers can inject a clock in tests. func NewService(engine *Engine, now func() time.Time) *Service { if now == nil { now = time.Now } return &Service{ engine: engine, now: now, states: make(map[backtest.PaperAccountID]State), books: make(map[backtest.PaperAccountID]*orderBook), } } // StartPaperTrading runs a paper simulation for the request and records the // terminal state under the account id, replacing any earlier state for that // account. The run is executed synchronously: unlike a backtest it is a bounded // daily replay over already-imported bars, so the operator gets the terminal // account state in the start response. func (s *Service) StartPaperTrading(ctx context.Context, req StartRequest) (State, error) { if s == nil || s.engine == nil { return State{}, fmt.Errorf("paper trading engine is not configured") } if req.AccountID == "" { return State{}, fmt.Errorf("account_id is required") } now := s.now().UTC() run := backtest.Run{ ID: backtest.RunID(fmt.Sprintf("paper-%s-%d", req.AccountID, now.UnixNano())), Spec: req.Spec, Status: backtest.RunStatusRunning, CreatedAt: now, UpdatedAt: now, } account := backtest.PaperAccount{ ID: req.AccountID, Portfolio: backtest.NewPortfolioState(req.StartingCash), UpdatedAt: now, FillPolicy: backtest.FillPolicyDailyNextBarOHLC, RiskSettings: backtest.RiskSettings{AllowShortSelling: false}, } snap, err := s.engine.Run(ctx, RunRequest{Account: account, Run: run}) if err != nil { return State{}, fmt.Errorf("paper run failed: %w", err) } run.Status = backtest.RunStatusSucceeded run.UpdatedAt = s.now().UTC() state := stateFromSnapshot(run, snap) s.mu.Lock() s.states[req.AccountID] = state // Restarting an account discards any prior order book so the new run state is // not shadowed by a stale manual portfolio/order sequence. Without this, // GetPaperTradingState would keep returning the old order-book snapshot and // subsequent orders would continue the old id sequence. delete(s.books, req.AccountID) s.mu.Unlock() return state, nil } // GetPaperTradingState returns the recorded state for an account or // ErrAccountNotFound when the account was never started in this process. Once an // order book exists for the account (the operator submitted virtual orders), the // live order-book snapshot is returned so the account view reflects manual fills // instead of the stale terminal run state. func (s *Service) GetPaperTradingState(ctx context.Context, accountID backtest.PaperAccountID) (State, error) { if accountID == "" { return State{}, fmt.Errorf("account_id is required") } s.mu.Lock() defer s.mu.Unlock() if book, ok := s.books[accountID]; ok { return book.snapshot(), nil } state, ok := s.states[accountID] if !ok { return State{}, ErrAccountNotFound } return state, nil } // stateFromSnapshot flattens the engine snapshot into the inspectable State, // sorting positions by instrument id for stable output. func stateFromSnapshot(run backtest.Run, snap *Snapshot) State { positions := make([]backtest.Position, 0, len(snap.Account.Portfolio.Positions)) for _, p := range snap.Account.Portfolio.Positions { positions = append(positions, p) } sort.Slice(positions, func(i, j int) bool { return positions[i].InstrumentID < positions[j].InstrumentID }) return State{ Run: run, Cash: snap.Account.Portfolio.Cash, Positions: positions, Fills: snap.Fills, EquityCurve: snap.EquityCurve, Rejected: snap.Rejected, } } // PaperOrderStatus is the lifecycle status of an operator-submitted virtual // order. The set is closed: an order starts pending and moves to exactly one of // the terminal statuses. type PaperOrderStatus string const ( PaperOrderStatusPending PaperOrderStatus = "pending" PaperOrderStatusFilled PaperOrderStatus = "filled" PaperOrderStatusCanceled PaperOrderStatus = "canceled" PaperOrderStatusRejected PaperOrderStatus = "rejected" ) // PaperOrder is a single virtual order tracked in memory against an account. It // is paper-only: no real broker order is created, and fills are simulated // against the account's in-memory portfolio. type PaperOrder struct { OrderID string AccountID backtest.PaperAccountID Intent backtest.OrderIntent Status PaperOrderStatus // Reason carries the risk/portfolio denial cause when Status is rejected. Reason string // Fill is set when Status is filled. Fill *backtest.Fill CreatedAt time.Time UpdatedAt time.Time } // SubmitOrderRequest carries a validated order intent to record against an // account. The socket layer maps the proto request onto this shape so the // service stays decoupled from the wire contract. type SubmitOrderRequest struct { AccountID backtest.PaperAccountID Intent backtest.OrderIntent } // orderBook is the per-account, in-memory lifecycle state. It holds a live // portfolio that manual fills mutate, the seed run state for the unchanged // fields (equity curve, run, risk rejections), the manual fills applied so far, // and the recorded orders keyed by order id. type orderBook struct { seed State portfolio backtest.PortfolioState risk backtest.RiskSettings manualFills []backtest.Fill seq int orders map[string]*PaperOrder } // snapshot renders the live order book as an inspectable State. Cash, positions // and the cumulative fill list reflect manual fills, while equity curve and risk // rejections are carried unchanged from the seed run. func (b *orderBook) snapshot() State { positions := make([]backtest.Position, 0, len(b.portfolio.Positions)) for _, p := range b.portfolio.Positions { positions = append(positions, p) } sort.Slice(positions, func(i, j int) bool { return positions[i].InstrumentID < positions[j].InstrumentID }) fills := make([]backtest.Fill, 0, len(b.seed.Fills)+len(b.manualFills)) fills = append(fills, b.seed.Fills...) fills = append(fills, b.manualFills...) return State{ Run: b.seed.Run, Cash: b.portfolio.Cash, Positions: positions, Fills: fills, EquityCurve: b.seed.EquityCurve, Rejected: b.seed.Rejected, } } // orderBookLocked returns the account's order book, seeding it from the recorded // run state on first access. It must be called with s.mu held. func (s *Service) orderBookLocked(accountID backtest.PaperAccountID) (*orderBook, error) { if book, ok := s.books[accountID]; ok { return book, nil } seed, ok := s.states[accountID] if !ok { return nil, ErrAccountNotFound } positions := make(map[market.InstrumentID]backtest.Position, len(seed.Positions)) for _, p := range seed.Positions { positions[p.InstrumentID] = p } book := &orderBook{ seed: seed, portfolio: backtest.PortfolioState{Cash: seed.Cash, Positions: positions}, // Paper risk mirrors StartPaperTrading: short selling stays disabled so a // sell without an open position is blocked rather than silently shorting. risk: backtest.RiskSettings{AllowShortSelling: false}, orders: make(map[string]*PaperOrder), } s.books[accountID] = book return book, nil } // SubmitPaperOrder records a new pending virtual order for the account. The // account must already have been started; the order book is seeded from its run // state. The intent is expected to be pre-validated by the socket mapping, but a // defensive check keeps the registry from storing a malformed order. func (s *Service) SubmitPaperOrder(ctx context.Context, req SubmitOrderRequest) (PaperOrder, error) { if req.AccountID == "" { return PaperOrder{}, fmt.Errorf("account_id is required") } if err := validateOrderIntent(req.Intent); err != nil { return PaperOrder{}, err } s.mu.Lock() defer s.mu.Unlock() book, err := s.orderBookLocked(req.AccountID) if err != nil { return PaperOrder{}, err } book.seq++ now := s.now().UTC() order := &PaperOrder{ OrderID: fmt.Sprintf("paper-order-%s-%d", req.AccountID, book.seq), AccountID: req.AccountID, Intent: req.Intent, Status: PaperOrderStatusPending, CreatedAt: now, UpdatedAt: now, } book.orders[order.OrderID] = order return *order, nil } // CancelPaperOrder transitions a pending order to canceled. A missing order is // ErrOrderNotFound; an already-terminal order is ErrOrderNotPending. func (s *Service) CancelPaperOrder(ctx context.Context, accountID backtest.PaperAccountID, orderID string) (PaperOrder, error) { if accountID == "" { return PaperOrder{}, fmt.Errorf("account_id is required") } if orderID == "" { return PaperOrder{}, fmt.Errorf("order_id is required") } s.mu.Lock() defer s.mu.Unlock() order, err := s.findOrderLocked(accountID, orderID) if err != nil { return PaperOrder{}, err } if order.Status != PaperOrderStatusPending { return PaperOrder{}, ErrOrderNotPending } order.Status = PaperOrderStatusCanceled order.UpdatedAt = s.now().UTC() return *order, nil } // FillPaperOrder simulates the execution of a pending order against the account // portfolio and returns the resulting order plus the post-fill account state. // // Request-level problems (missing account/order, non-pending order, missing // market fill price) return a typed error. Business denials (risk gate or // insufficient cash/position) are not errors: the order transitions to rejected // with a reason so the operator sees the status change, and the returned state // is the unchanged account snapshot. func (s *Service) FillPaperOrder(ctx context.Context, accountID backtest.PaperAccountID, orderID string, fillPrice market.Price) (PaperOrder, State, error) { if accountID == "" { return PaperOrder{}, State{}, fmt.Errorf("account_id is required") } if orderID == "" { return PaperOrder{}, State{}, fmt.Errorf("order_id is required") } s.mu.Lock() defer s.mu.Unlock() book, ok := s.books[accountID] if !ok { // No order book means the account was never started or never received an // order; either way the order id cannot exist. if _, started := s.states[accountID]; !started { return PaperOrder{}, State{}, ErrAccountNotFound } return PaperOrder{}, State{}, ErrOrderNotFound } order, ok := book.orders[orderID] if !ok { return PaperOrder{}, State{}, ErrOrderNotFound } if order.Status != PaperOrderStatusPending { return PaperOrder{}, State{}, ErrOrderNotPending } // A supplied fill price must be a non-negative number before it can drive a // fill; reject malformed/negative input as invalid request, not an internal // fill error. An empty fill price is handled per order type in syntheticFillBar. if fillPrice.Amount.Value != "" { if err := ValidateOrderDecimal("fill_price", fillPrice.Amount.Value, false); err != nil { return PaperOrder{}, State{}, err } } now := s.now().UTC() // Resolve the execution bar before any portfolio change so a missing market // price is reported as a request error, not an order rejection. bar, err := syntheticFillBar(order.Intent, fillPrice, now) if err != nil { return PaperOrder{}, State{}, err } account := backtest.PaperAccount{ ID: accountID, Portfolio: book.portfolio, RiskSettings: book.risk, } if decision := backtest.CheckRisk(account, order.Intent); !decision.Allowed { order.Status = PaperOrderStatusRejected order.Reason = decision.Reason order.UpdatedAt = now return *order, book.snapshot(), nil } fill, filled, err := backtest.FillOrderOnDailyBar(order.Intent, bar) if err != nil { return PaperOrder{}, State{}, err } if !filled { // With a synthetic single-price bar a market order always fills and a limit // order fills at its own price, so this is unreachable in practice. Treat it // as a rejection rather than silently leaving the order pending. order.Status = PaperOrderStatusRejected order.Reason = "order did not fill at the simulated price" order.UpdatedAt = now return *order, book.snapshot(), nil } next, err := book.portfolio.ApplyFill(fill) if err != nil { order.Status = PaperOrderStatusRejected order.Reason = err.Error() order.UpdatedAt = now return *order, book.snapshot(), nil } book.portfolio = next book.manualFills = append(book.manualFills, fill) filledCopy := fill order.Status = PaperOrderStatusFilled order.Fill = &filledCopy order.UpdatedAt = now return *order, book.snapshot(), nil } // findOrderLocked resolves an order id within an account, distinguishing a // never-started account from a missing order. It must be called with s.mu held. func (s *Service) findOrderLocked(accountID backtest.PaperAccountID, orderID string) (*PaperOrder, error) { book, ok := s.books[accountID] if !ok { if _, started := s.states[accountID]; !started { return nil, ErrAccountNotFound } return nil, ErrOrderNotFound } order, ok := book.orders[orderID] if !ok { return nil, ErrOrderNotFound } return order, nil } // ValidateOrderDecimal checks that an order lifecycle decimal string parses as a // number and meets its sign requirement, returning an error wrapping // ErrInvalidOrderInput so the worker socket boundary maps malformed input to // invalid_request. requirePositive enforces strictly positive (quantities); // otherwise the value must be non-negative (prices). It is exported so the socket // conversion can reject malformed input before building a domain order, while the // service stays defensive for direct package callers. func ValidateOrderDecimal(label, value string, requirePositive bool) error { rat, ok := new(big.Rat).SetString(strings.TrimSpace(value)) if !ok { return fmt.Errorf("%w: %s must be a number", ErrInvalidOrderInput, label) } if requirePositive { if rat.Sign() <= 0 { return fmt.Errorf("%w: %s must be greater than zero", ErrInvalidOrderInput, label) } } else if rat.Sign() < 0 { return fmt.Errorf("%w: %s must not be negative", ErrInvalidOrderInput, label) } return nil } // validateOrderIntent enforces the minimum shape of an order intent: an // instrument, a known side, a positive numeric quantity, a known type, and a // non-negative numeric limit price for limit orders. func validateOrderIntent(intent backtest.OrderIntent) error { if intent.InstrumentID == "" { return fmt.Errorf("%w: instrument_id is required", ErrInvalidOrderInput) } switch intent.Side { case backtest.OrderSideBuy, backtest.OrderSideSell: default: return fmt.Errorf("%w: unsupported order side %q", ErrInvalidOrderInput, intent.Side) } if intent.Quantity.Amount.Value == "" { return fmt.Errorf("%w: quantity is required", ErrInvalidOrderInput) } if err := ValidateOrderDecimal("quantity", intent.Quantity.Amount.Value, true); err != nil { return err } switch intent.OrderType() { case backtest.OrderTypeMarket: case backtest.OrderTypeLimit: if intent.LimitPrice.Currency == "" || intent.LimitPrice.Amount.Value == "" { return fmt.Errorf("%w: limit order requires a limit price", ErrInvalidOrderInput) } if err := ValidateOrderDecimal("limit_price", intent.LimitPrice.Amount.Value, false); err != nil { return err } default: return fmt.Errorf("%w: unsupported order type %q", ErrInvalidOrderInput, intent.Type) } return nil } // syntheticFillBar builds the single-bar input for FillOrderOnDailyBar so the // command-driven fill reuses the same domain fill helper as the run loop. A // market order fills at the operator-supplied price; a limit order fills at its // own limit price (the operator is asserting the market reached it). func syntheticFillBar(intent backtest.OrderIntent, fillPrice market.Price, ts time.Time) (market.Bar, error) { var price market.Price switch intent.OrderType() { case backtest.OrderTypeMarket: if fillPrice.Currency == "" || fillPrice.Amount.Value == "" { return market.Bar{}, ErrFillPriceRequired } price = fillPrice case backtest.OrderTypeLimit: price = intent.LimitPrice default: return market.Bar{}, fmt.Errorf("unsupported order type %q", intent.Type) } return market.Bar{ InstrumentID: intent.InstrumentID, Timeframe: market.TimeframeDaily, Timestamp: ts, Open: price, High: price, Low: price, Close: price, }, nil }