Live Trading Boundary의 남은 account-sync/audit-trail 작업을 완료해 운영자 headless workflow와 roadmap 완료 후보 상태를 함께 반영한다.
556 lines
19 KiB
Go
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
|
|
}
|