alt/services/worker/internal/livetrading/service.go
toki c0db4b24c1 feat(live-trading): 계좌 동기화와 감사 추적을 추가한다
Live Trading Boundary의 남은 account-sync/audit-trail 작업을 완료해 운영자 headless workflow와 roadmap 완료 후보 상태를 함께 반영한다.
2026-06-08 11:18:41 +09:00

556 lines
19 KiB
Go

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
}