alt/services/worker/internal/papertrading/service.go
toki daa29d6807 feat: paper trading command workflow completed (G08, G09)
- 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
2026-06-06 11:36:50 +09:00

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
}