alt/packages/domain/trading/risk.go
toki c23fea2342 feat(live-trading): 실거래 risk guard를 추가한다
실거래 주문이 브로커에 도달하기 전에 kill switch와 계정별 주문 한도를 검증해야 한다. API, CLI, client parser surface와 완료된 review archive를 함께 정리한다.
2026-06-08 05:36:59 +09:00

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)
}