실거래 주문이 브로커에 도달하기 전에 kill switch와 계정별 주문 한도를 검증해야 한다. API, CLI, client parser surface와 완료된 review archive를 함께 정리한다.
126 lines
4.8 KiB
Go
126 lines
4.8 KiB
Go
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)
|
|
}
|