package livetrading import ( "context" "errors" "fmt" "math/big" "strings" "sync" "time" "git.toki-labs.com/toki/alt/packages/domain/trading" ) // BrokerPort is the abstract boundary to a live broker adapter. When nil, the // Service treats all order operations as unavailable/disabled. type BrokerPort interface { GetCapabilities(ctx context.Context, accountID string) (trading.BrokerCapability, error) SubmitOrder(ctx context.Context, accountID string, intent trading.OrderIntent) (OrderResult, error) CancelOrder(ctx context.Context, accountID, brokerOrderID string) (OrderResult, error) GetOrderStatus(ctx context.Context, accountID, brokerOrderID string) (OrderResult, error) // FetchAccountSnapshot retrieves a sanitized account snapshot from the broker. // Raw account numbers and provider payload must be stripped inside the adapter // before returning; the snapshot AccountID must be an operator alias or internal id. FetchAccountSnapshot(ctx context.Context, accountID string) (trading.AccountSnapshot, error) } // OrderResult carries the broker-reported outcome of an order operation. type OrderResult struct { BrokerOrderID string Status trading.OrderStatus BrokerStatus trading.BrokerStatus RejectionReason string } // OperatorConfirmation is the explicit operator gate required before any live // order reaches the broker. type OperatorConfirmation struct { Confirmed bool OperatorID string Reason string ConfirmedAtMs int64 } // SubmitRequest carries the validated parameters for submitting a live order. type SubmitRequest struct { AccountID string Intent trading.OrderIntent Confirmation OperatorConfirmation IdempotencyKey string } // LiveOrder is the in-memory record for a submitted live order, holding both // the ALT-domain status and the raw broker status so neither is discarded. type LiveOrder struct { ID string AccountID string Intent trading.OrderIntent Status trading.OrderStatus BrokerStatus trading.BrokerStatus BrokerOrderID string RejectionReason string CreatedAt time.Time UpdatedAt time.Time } var ( // ErrConfirmationRequired is returned when SubmitLiveOrder is called without // a confirmed OperatorConfirmation. ErrConfirmationRequired = errors.New("operator confirmation required") // ErrBrokerUnavailable is returned when the BrokerPort is nil (disabled) or // not reachable. ErrBrokerUnavailable = errors.New("live broker is not available") // ErrOrderNotFound is returned when the requested live order id does not // exist in the in-memory registry for the given account. ErrOrderNotFound = errors.New("live order not found") // ErrInvalidIntent is returned when the live order intent is malformed: // missing instrument_id, invalid side, non-positive quantity, or missing type. ErrInvalidIntent = errors.New("invalid live order intent") // ErrRiskBlocked is returned when the kill switch is halted or the order // intent violates the active risk policy. The broker is never called. ErrRiskBlocked = errors.New("live order blocked by risk policy") // ErrAccountSnapshotNotFound is returned when GetLiveAccountSnapshot is // called for an account that has not yet been synced. ErrAccountSnapshotNotFound = errors.New("live account snapshot not found") // ErrAuditFailed is returned when an audit event cannot be persisted. The // operation itself may have succeeded, but the audit trail is mandatory. ErrAuditFailed = errors.New("live audit event failed to persist") ) // AuditStore is the narrow interface the Service uses to persist and query live // operation audit events. storage.LiveAuditStore satisfies this interface. type AuditStore interface { AppendLiveAuditEvent(ctx context.Context, event trading.AuditEvent) error ListLiveAuditEvents(ctx context.Context, filter trading.AuditFilter) ([]trading.AuditEvent, error) } // Service is the worker-owned live trading runtime. It keeps an in-memory order // registry and delegates execution to a BrokerPort. A nil BrokerPort means live // trading is disabled; all order operations return ErrBrokerUnavailable. type Service struct { broker BrokerPort mu sync.RWMutex orders map[string]*LiveOrder seq int64 policyMu sync.RWMutex policy trading.RiskPolicy killSwitch trading.KillSwitchState acctMuMu sync.Mutex // protects acctMus acctMus map[string]*sync.Mutex // per-account submit serialization snapshotMu sync.RWMutex snapshots map[string]*trading.AccountSnapshot // keyed by accountID auditStore AuditStore // optional; nil means no-op audit trail } // NewService creates a Service backed by broker with the default risk policy // and a halted kill switch. Pass nil broker to operate in disabled/unavailable // mode (broker capability probe and order operations all return ErrBrokerUnavailable). func NewService(broker BrokerPort) *Service { return &Service{ broker: broker, orders: make(map[string]*LiveOrder), policy: DefaultRiskPolicy(), killSwitch: trading.DefaultKillSwitch(), acctMus: make(map[string]*sync.Mutex), snapshots: make(map[string]*trading.AccountSnapshot), } } // SetAuditStore injects an optional audit store for durable audit trail. When // nil (the default), audit events are silently discarded. func (s *Service) SetAuditStore(store AuditStore) { s.auditStore = store } // newAuditEventID returns a process-unique audit event identifier. func newAuditEventID() string { return fmt.Sprintf("aud-%d", time.Now().UnixNano()) } // appendAudit persists ev to the audit store when one is configured. Returns // ErrAuditFailed (wrapping the store error) on failure so callers can propagate // it as a typed internal error. Forbidden payload keys (raw secrets, tokens, // account numbers) are rejected at this boundary before reaching the store. func (s *Service) appendAudit(ctx context.Context, ev trading.AuditEvent) error { if s.auditStore == nil { return nil } if trading.HasRawSecretKey(ev.Payload) { return fmt.Errorf("%w: audit payload contains forbidden key", ErrAuditFailed) } if err := s.auditStore.AppendLiveAuditEvent(ctx, ev); err != nil { return fmt.Errorf("%w: %v", ErrAuditFailed, err) } return nil } // ListLiveAuditEvents delegates to the configured AuditStore. Returns nil when // no audit store is configured. func (s *Service) ListLiveAuditEvents(ctx context.Context, filter trading.AuditFilter) ([]trading.AuditEvent, error) { if s.auditStore == nil { return nil, nil } return s.auditStore.ListLiveAuditEvents(ctx, filter) } // submitLockForAccount returns the per-account mutex for submit serialization, // creating it if it does not yet exist. func (s *Service) submitLockForAccount(accountID string) *sync.Mutex { s.acctMuMu.Lock() mu, ok := s.acctMus[accountID] if !ok { mu = &sync.Mutex{} s.acctMus[accountID] = mu } s.acctMuMu.Unlock() return mu } // GetRiskPolicy returns the current risk policy. func (s *Service) GetRiskPolicy() trading.RiskPolicy { s.policyMu.RLock() defer s.policyMu.RUnlock() return s.policy } // SetRiskPolicy replaces the active risk policy. func (s *Service) SetRiskPolicy(policy trading.RiskPolicy) { s.policyMu.Lock() s.policy = policy s.policyMu.Unlock() } // GetKillSwitch returns the current kill switch state. func (s *Service) GetKillSwitch() trading.KillSwitchState { s.policyMu.RLock() defer s.policyMu.RUnlock() return s.killSwitch } // SetKillSwitch replaces the active kill switch state and returns the new state. // The kill_switch_set audit event is emitted best-effort (failure is silently // discarded) because the kill switch must succeed even when audit storage is // unavailable. func (s *Service) SetKillSwitch(ks trading.KillSwitchState) trading.KillSwitchState { s.policyMu.Lock() s.killSwitch = ks s.policyMu.Unlock() now := time.Now().UTC() _ = s.appendAudit(context.Background(), trading.AuditEvent{ EventID: newAuditEventID(), Type: trading.AuditEventTypeKillSwitchSet, Timestamp: now, Status: fmt.Sprintf("halted=%v", ks.Halted), Reason: ks.Reason, Payload: map[string]string{"halted": fmt.Sprintf("%v", ks.Halted)}, }) return ks } // GetLiveBrokerCapabilities proxies the capability probe to the BrokerPort. func (s *Service) GetLiveBrokerCapabilities(ctx context.Context, accountID string) (trading.BrokerCapability, error) { if s.broker == nil { return trading.BrokerCapability{}, ErrBrokerUnavailable } return s.broker.GetCapabilities(ctx, accountID) } // validateIntent checks the minimum required fields of a live order intent so // malformed requests never reach the broker port even when operator-confirmed. func validateIntent(intent trading.OrderIntent) error { if intent.InstrumentID == "" { return fmt.Errorf("%w: instrument_id is required", ErrInvalidIntent) } if intent.Side != trading.OrderSideBuy && intent.Side != trading.OrderSideSell { return fmt.Errorf("%w: side must be buy or sell", ErrInvalidIntent) } qty := strings.TrimSpace(intent.Quantity.Amount.Value) if qty == "" { return fmt.Errorf("%w: quantity must be greater than zero", ErrInvalidIntent) } qtyRat := new(big.Rat) if _, ok := qtyRat.SetString(qty); !ok { return fmt.Errorf("%w: quantity is not a valid decimal", ErrInvalidIntent) } if qtyRat.Sign() <= 0 { return fmt.Errorf("%w: quantity must be greater than zero", ErrInvalidIntent) } if intent.Type == "" { return fmt.Errorf("%w: order type is required", ErrInvalidIntent) } if lp := strings.TrimSpace(intent.LimitPrice.Amount.Value); lp != "" { lpRat := new(big.Rat) if _, ok := lpRat.SetString(lp); !ok { return fmt.Errorf("%w: limit_price is not a valid decimal", ErrInvalidIntent) } if lpRat.Sign() < 0 { return fmt.Errorf("%w: limit_price must be non-negative", ErrInvalidIntent) } } return nil } // SubmitLiveOrder validates the operator confirmation gate and the order // intent shape, then delegates order submission to the BrokerPort before // recording the order in the in-memory registry. The order type in // req.Intent.Type is preserved verbatim so custom broker strings are not // narrowed to market/limit. func (s *Service) SubmitLiveOrder(ctx context.Context, req SubmitRequest) (LiveOrder, error) { if !req.Confirmation.Confirmed { return LiveOrder{}, ErrConfirmationRequired } if req.AccountID == "" { return LiveOrder{}, fmt.Errorf("%w: account_id is required", ErrInvalidIntent) } if err := validateIntent(req.Intent); err != nil { return LiveOrder{}, err } // Nil broker check before acquiring account mutex so ErrBrokerUnavailable // takes precedence over risk evaluation regardless of kill switch state. if s.broker == nil { return LiveOrder{}, ErrBrokerUnavailable } // Serialize same-account submits from risk evaluation through broker call and // order storage. Different accounts proceed independently. acctMu := s.submitLockForAccount(req.AccountID) acctMu.Lock() defer acctMu.Unlock() // Snapshot policy and kill switch after acquiring the account mutex so // queued submits see the current state rather than a stale pre-wait copy. s.policyMu.RLock() policy := s.policy ks := s.killSwitch s.policyMu.RUnlock() now := time.Now().UTC() s.mu.RLock() usage := s.riskUsageForAccount(req.AccountID, now) s.mu.RUnlock() if decision := trading.EvaluateRisk(policy, ks, req.Intent, usage); !decision.Allowed { order, riskErr := s.recordRiskRejectedOrder(ctx, req, decision, now) if auditErr := s.appendAudit(ctx, trading.AuditEvent{ EventID: newAuditEventID(), Type: trading.AuditEventTypeRiskBlocked, Timestamp: now, AccountID: req.AccountID, OrderID: order.ID, Status: string(trading.OrderStatusRejected), Reason: decision.Reason, Actor: req.Confirmation.OperatorID, Payload: map[string]string{"instrument_id": string(req.Intent.InstrumentID), "reason": decision.Reason}, }); auditErr != nil { return order, auditErr } return order, riskErr } // Emit submit_confirmed before calling the broker so the operator gate // decision is recorded even if the broker call later fails. if auditErr := s.appendAudit(ctx, trading.AuditEvent{ EventID: newAuditEventID(), Type: trading.AuditEventTypeSubmitConfirmed, Timestamp: now, AccountID: req.AccountID, Actor: req.Confirmation.OperatorID, Payload: map[string]string{ "instrument_id": string(req.Intent.InstrumentID), "side": string(req.Intent.Side), "type": string(req.Intent.Type), }, }); auditErr != nil { return LiveOrder{}, auditErr } result, err := s.broker.SubmitOrder(ctx, req.AccountID, req.Intent) if err != nil { return LiveOrder{}, err } s.mu.Lock() s.seq++ id := fmt.Sprintf("lo-%s-%d", req.AccountID, s.seq) order := &LiveOrder{ ID: id, AccountID: req.AccountID, Intent: req.Intent, Status: result.Status, BrokerStatus: result.BrokerStatus, BrokerOrderID: result.BrokerOrderID, RejectionReason: result.RejectionReason, CreatedAt: now, UpdatedAt: now, } s.orders[id] = order s.mu.Unlock() // Emit broker_accepted or broker_rejected to record the broker's decision. brokerEvType := trading.AuditEventTypeBrokerAccepted if order.Status == trading.OrderStatusRejected { brokerEvType = trading.AuditEventTypeBrokerRejected } if auditErr := s.appendAudit(ctx, trading.AuditEvent{ EventID: newAuditEventID(), Type: brokerEvType, Timestamp: order.CreatedAt, AccountID: order.AccountID, OrderID: order.ID, Status: string(order.Status), Reason: order.RejectionReason, Actor: req.Confirmation.OperatorID, Payload: map[string]string{ "broker_order_id": order.BrokerOrderID, }, }); auditErr != nil { return *order, auditErr } return *order, nil } // isTerminalStatus returns true when status represents a final order state. // Terminal orders are excluded from the open order count for risk evaluation. func isTerminalStatus(status trading.OrderStatus) bool { switch status { case trading.OrderStatusFilled, trading.OrderStatusCanceled, trading.OrderStatusRejected: return true } return false } // riskUsageForAccount returns the current daily and open order counts for // accountID. Callers must hold s.mu (at least RLock) while calling this. func (s *Service) riskUsageForAccount(accountID string, now time.Time) trading.RiskUsage { todayUTC := now.Truncate(24 * time.Hour) var daily, open int for _, o := range s.orders { if o.AccountID != accountID { continue } if !o.CreatedAt.Before(todayUTC) { daily++ } if !isTerminalStatus(o.Status) { open++ } } return trading.RiskUsage{DailyOrderCount: daily, OpenOrderCount: open} } // recordRiskRejectedOrder creates an in-memory rejected live order for a // risk-blocked submission, stores it, and returns both the order and the // typed ErrRiskBlocked error so the socket layer can surface both. func (s *Service) recordRiskRejectedOrder(ctx context.Context, req SubmitRequest, decision trading.RiskDecision, now time.Time) (LiveOrder, error) { s.mu.Lock() s.seq++ id := fmt.Sprintf("lo-%s-%d", req.AccountID, s.seq) order := &LiveOrder{ ID: id, AccountID: req.AccountID, Intent: req.Intent, Status: trading.OrderStatusRejected, RejectionReason: decision.Reason, CreatedAt: now, UpdatedAt: now, } s.orders[id] = order s.mu.Unlock() return *order, fmt.Errorf("%w: %s", ErrRiskBlocked, decision.Reason) } // CancelLiveOrder asks the BrokerPort to cancel the order identified by // liveOrderID and updates the in-memory status with the broker response. func (s *Service) CancelLiveOrder(ctx context.Context, accountID, liveOrderID string) (LiveOrder, error) { if s.broker == nil { return LiveOrder{}, ErrBrokerUnavailable } s.mu.RLock() order, ok := s.orders[liveOrderID] s.mu.RUnlock() if !ok || order.AccountID != accountID { return LiveOrder{}, ErrOrderNotFound } result, err := s.broker.CancelOrder(ctx, accountID, order.BrokerOrderID) if err != nil { return LiveOrder{}, err } s.mu.Lock() order.Status = result.Status order.BrokerStatus = result.BrokerStatus order.UpdatedAt = time.Now().UTC() updatedAt := order.UpdatedAt s.mu.Unlock() if auditErr := s.appendAudit(ctx, trading.AuditEvent{ EventID: newAuditEventID(), Type: trading.AuditEventTypeCancelConfirmed, Timestamp: updatedAt, AccountID: accountID, OrderID: liveOrderID, Status: string(order.Status), Payload: map[string]string{"broker_order_id": order.BrokerOrderID}, }); auditErr != nil { return *order, auditErr } return *order, nil } // SyncLiveAccount fetches a fresh account snapshot from the broker, stores it // in memory, and returns it. A nil broker returns ErrBrokerUnavailable. // The snapshot is sanitized by the BrokerPort adapter; no raw account numbers // or provider payload reach this layer. func (s *Service) SyncLiveAccount(ctx context.Context, accountID string) (trading.AccountSnapshot, error) { if s.broker == nil { return trading.AccountSnapshot{}, ErrBrokerUnavailable } snap, err := s.broker.FetchAccountSnapshot(ctx, accountID) if err != nil { // Emit failure audit best-effort; don't mask the original broker error. _ = s.appendAudit(ctx, trading.AuditEvent{ EventID: newAuditEventID(), Type: trading.AuditEventTypeAccountSyncFailure, Timestamp: time.Now().UTC(), AccountID: accountID, Status: "failed", Reason: err.Error(), }) return trading.AccountSnapshot{}, err } s.snapshotMu.Lock() s.snapshots[accountID] = &snap s.snapshotMu.Unlock() if auditErr := s.appendAudit(ctx, trading.AuditEvent{ EventID: newAuditEventID(), Type: trading.AuditEventTypeAccountSyncSuccess, Timestamp: snap.SyncedAt, Broker: snap.Broker, AccountID: accountID, Status: "synced", Payload: map[string]string{"cash_count": fmt.Sprintf("%d", len(snap.Cash)), "position_count": fmt.Sprintf("%d", len(snap.Positions))}, }); auditErr != nil { return trading.AccountSnapshot{}, auditErr } return snap, nil } // GetLiveAccountSnapshot returns the last synced account snapshot for // accountID. Returns ErrAccountSnapshotNotFound when no sync has been // performed yet for that account. func (s *Service) GetLiveAccountSnapshot(ctx context.Context, accountID string) (trading.AccountSnapshot, error) { s.snapshotMu.RLock() snap, ok := s.snapshots[accountID] s.snapshotMu.RUnlock() if !ok { return trading.AccountSnapshot{}, ErrAccountSnapshotNotFound } return *snap, nil } // GetLiveOrder fetches the current broker status for a live order and refreshes // the in-memory record with the broker response before returning it. func (s *Service) GetLiveOrder(ctx context.Context, accountID, liveOrderID string) (LiveOrder, error) { if s.broker == nil { return LiveOrder{}, ErrBrokerUnavailable } s.mu.RLock() order, ok := s.orders[liveOrderID] s.mu.RUnlock() if !ok || order.AccountID != accountID { return LiveOrder{}, ErrOrderNotFound } result, err := s.broker.GetOrderStatus(ctx, accountID, order.BrokerOrderID) if err != nil { return LiveOrder{}, err } s.mu.Lock() order.Status = result.Status order.BrokerStatus = result.BrokerStatus order.UpdatedAt = time.Now().UTC() s.mu.Unlock() return *order, nil }