- Archive completed subtasks (02+01_risk_command, 03+02_order_lifecycle) - Add paper_order_lifecycle test data and expected output - Update paper trading proto and regenerate code (Dart, Go) - Fix order lifecycle handling in CLI operator, API socket, worker socket - Update parser maps across CLI, API, and worker services - Update backtest and paper trading tests
540 lines
19 KiB
Go
540 lines
19 KiB
Go
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
|
|
}
|