package trading import ( "math/big" "strings" "git.toki-labs.com/toki/alt/packages/domain/market" ) // RiskPolicy defines the live order submission limits applied before any broker // call. All fields are adjustable at runtime via the worker livetrading config. // A zero-value policy with no currency limits and zero max-counts means no // numeric limits are applied, but the kill switch still applies separately. type RiskPolicy struct { // MaxOrderNotionalByCurrency is the maximum allowed notional value per // order, keyed by currency. An absent currency key means no limit for that // currency. Notional is evaluated only when a limit_price is present in the // order intent (i.e. for limit orders). MaxOrderNotionalByCurrency map[market.Currency]market.Decimal // MaxDailyOrders is the maximum number of orders allowed per calendar day. // Zero means no limit is applied. MaxDailyOrders int // MaxOpenOrders is the maximum number of orders that may be in a non- // terminal status at the same time. Zero means no limit is applied. MaxOpenOrders int // AllowShortSelling, when false, rejects sell-side orders without checking // the current position (strict mode). Set to true to allow short selling. AllowShortSelling bool } // KillSwitchState captures whether live order submission is globally halted. // The safe default is Halted=true; operators must explicitly disable the kill // switch before any live order can reach the broker. type KillSwitchState struct { // Halted, when true, blocks all live order submissions regardless of risk // policy. Safe default is true. Halted bool // Reason is a human-readable explanation for the current kill switch state. Reason string } // DefaultKillSwitch returns the safe initial kill switch state (halted=true). func DefaultKillSwitch() KillSwitchState { return KillSwitchState{Halted: true, Reason: "kill switch active by default"} } // RiskDecision is the outcome of a risk policy evaluation. type RiskDecision struct { Allowed bool Reason string } // RiskUsage carries the account-level counters used to enforce count-based risk // limits. The service computes these from its in-memory order registry before // calling EvaluateRisk. type RiskUsage struct { // DailyOrderCount is the number of orders already submitted for the account // on the current UTC calendar day (including all statuses). DailyOrderCount int // OpenOrderCount is the number of orders in a non-terminal status for the // account. Terminal statuses are filled, canceled, and rejected. OpenOrderCount int } // EvaluateRisk evaluates the kill switch and risk policy for a given order // intent. It returns a RiskDecision with Allowed=false and a human-readable // Reason when any guard is triggered. // // Evaluation order: // 1. Kill switch halted → block immediately. // 2. Short selling disallowed → block sell orders (strict, no position check). // 3. Order notional exceeds limit for the limit_price currency → block. // 4. Daily order count at or above MaxDailyOrders (when > 0) → block. // 5. Open order count at or above MaxOpenOrders (when > 0) → block. func EvaluateRisk(policy RiskPolicy, ks KillSwitchState, intent OrderIntent, usage RiskUsage) RiskDecision { if ks.Halted { reason := "kill switch is active" if ks.Reason != "" { reason = ks.Reason } return RiskDecision{Allowed: false, Reason: reason} } if !policy.AllowShortSelling && intent.Side == OrderSideSell { return RiskDecision{Allowed: false, Reason: "short selling is not allowed"} } if len(policy.MaxOrderNotionalByCurrency) > 0 && intent.LimitPrice.Amount.Value != "" { cur := intent.LimitPrice.Currency if limit, ok := policy.MaxOrderNotionalByCurrency[cur]; ok && limit.Value != "" { notional := computeNotional(intent.Quantity.Amount.Value, intent.LimitPrice.Amount.Value) if notional != nil { limitRat := new(big.Rat) if _, ok := limitRat.SetString(strings.TrimSpace(limit.Value)); ok { if notional.Cmp(limitRat) > 0 { return RiskDecision{Allowed: false, Reason: "order notional exceeds limit"} } } } } } if policy.MaxDailyOrders > 0 && usage.DailyOrderCount >= policy.MaxDailyOrders { return RiskDecision{Allowed: false, Reason: "daily order limit reached"} } if policy.MaxOpenOrders > 0 && usage.OpenOrderCount >= policy.MaxOpenOrders { return RiskDecision{Allowed: false, Reason: "open order limit reached"} } return RiskDecision{Allowed: true, Reason: "risk check passed"} } // computeNotional returns qty * price as a *big.Rat, or nil when either value // is not a valid decimal string. func computeNotional(qty, price string) *big.Rat { qtyRat := new(big.Rat) if _, ok := qtyRat.SetString(strings.TrimSpace(qty)); !ok { return nil } priceRat := new(big.Rat) if _, ok := priceRat.SetString(strings.TrimSpace(price)); !ok { return nil } return new(big.Rat).Mul(qtyRat, priceRat) }