alt/packages/domain/trading/risk_test.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

232 lines
7.4 KiB
Go

package trading
import (
"testing"
"git.toki-labs.com/toki/alt/packages/domain/market"
)
func krwPolicy(maxNotional string) RiskPolicy {
return RiskPolicy{
MaxOrderNotionalByCurrency: map[market.Currency]market.Decimal{
market.CurrencyKRW: {Value: maxNotional},
},
MaxDailyOrders: 5,
MaxOpenOrders: 3,
AllowShortSelling: false,
}
}
func buyIntent(qty, limitPrice string) OrderIntent {
return OrderIntent{
InstrumentID: "KRX:005930",
Side: OrderSideBuy,
Quantity: market.Quantity{Amount: market.Decimal{Value: qty}},
Type: OrderTypeLimit,
LimitPrice: market.Price{
Currency: market.CurrencyKRW,
Amount: market.Decimal{Value: limitPrice},
},
}
}
func TestEvaluateRiskKillSwitchHaltedBlocks(t *testing.T) {
ks := KillSwitchState{Halted: true, Reason: "manual halt"}
dec := EvaluateRisk(RiskPolicy{}, ks, buyIntent("1", "75000"), RiskUsage{})
if dec.Allowed {
t.Error("expected risk denied when kill switch is halted")
}
if dec.Reason != "manual halt" {
t.Errorf("unexpected reason %q", dec.Reason)
}
}
func TestEvaluateRiskKillSwitchDefaultReasonFallback(t *testing.T) {
ks := KillSwitchState{Halted: true, Reason: ""}
dec := EvaluateRisk(RiskPolicy{}, ks, buyIntent("1", "75000"), RiskUsage{})
if dec.Allowed {
t.Error("expected risk denied when kill switch is halted")
}
if dec.Reason == "" {
t.Error("expected fallback reason, got empty string")
}
}
func TestEvaluateRiskKillSwitchOffAllows(t *testing.T) {
ks := KillSwitchState{Halted: false}
policy := krwPolicy("1000000")
dec := EvaluateRisk(policy, ks, buyIntent("1", "75000"), RiskUsage{})
if !dec.Allowed {
t.Errorf("expected allowed, got reason %q", dec.Reason)
}
}
func TestEvaluateRiskShortSellingBlocked(t *testing.T) {
ks := KillSwitchState{Halted: false}
policy := RiskPolicy{AllowShortSelling: false}
intent := OrderIntent{
InstrumentID: "KRX:005930",
Side: OrderSideSell,
Quantity: market.Quantity{Amount: market.Decimal{Value: "1"}},
Type: OrderTypeMarket,
}
dec := EvaluateRisk(policy, ks, intent, RiskUsage{})
if dec.Allowed {
t.Error("expected sell order blocked when short selling is disallowed")
}
}
func TestEvaluateRiskShortSellingAllowed(t *testing.T) {
ks := KillSwitchState{Halted: false}
policy := RiskPolicy{AllowShortSelling: true}
intent := OrderIntent{
InstrumentID: "KRX:005930",
Side: OrderSideSell,
Quantity: market.Quantity{Amount: market.Decimal{Value: "1"}},
Type: OrderTypeMarket,
}
dec := EvaluateRisk(policy, ks, intent, RiskUsage{})
if !dec.Allowed {
t.Errorf("expected sell allowed when AllowShortSelling=true, got reason %q", dec.Reason)
}
}
func TestEvaluateRiskNotionalBelowLimitAllows(t *testing.T) {
ks := KillSwitchState{Halted: false}
// 10 * 75000 = 750000 < 1000000
dec := EvaluateRisk(krwPolicy("1000000"), ks, buyIntent("10", "75000"), RiskUsage{})
if !dec.Allowed {
t.Errorf("expected allowed, got %q", dec.Reason)
}
}
func TestEvaluateRiskNotionalAboveLimitBlocks(t *testing.T) {
ks := KillSwitchState{Halted: false}
// 20 * 75000 = 1500000 > 1000000
dec := EvaluateRisk(krwPolicy("1000000"), ks, buyIntent("20", "75000"), RiskUsage{})
if dec.Allowed {
t.Error("expected blocked when notional exceeds limit")
}
}
func TestEvaluateRiskNotionalExactlyAtLimitAllows(t *testing.T) {
ks := KillSwitchState{Halted: false}
// 10 * 100000 = 1000000 == 1000000
dec := EvaluateRisk(krwPolicy("1000000"), ks, buyIntent("10", "100000"), RiskUsage{})
if !dec.Allowed {
t.Errorf("expected allowed at exact limit, got %q", dec.Reason)
}
}
func TestEvaluateRiskMarketOrderSkipsNotionalCheck(t *testing.T) {
ks := KillSwitchState{Halted: false}
policy := krwPolicy("1000000")
// market order has no limit price, so no notional check
intent := OrderIntent{
InstrumentID: "KRX:005930",
Side: OrderSideBuy,
Quantity: market.Quantity{Amount: market.Decimal{Value: "9999"}},
Type: OrderTypeMarket,
}
dec := EvaluateRisk(policy, ks, intent, RiskUsage{})
if !dec.Allowed {
t.Errorf("expected market order allowed (no limit_price to check notional), got %q", dec.Reason)
}
}
func TestEvaluateRiskDifferentCurrencyNotionalUnchecked(t *testing.T) {
ks := KillSwitchState{Halted: false}
// policy only sets KRW limit; USD order should pass
policy := krwPolicy("1000000")
intent := OrderIntent{
InstrumentID: "NYSE:AAPL",
Side: OrderSideBuy,
Quantity: market.Quantity{Amount: market.Decimal{Value: "100"}},
Type: OrderTypeLimit,
LimitPrice: market.Price{
Currency: market.CurrencyUSD,
Amount: market.Decimal{Value: "200"},
},
}
dec := EvaluateRisk(policy, ks, intent, RiskUsage{})
if !dec.Allowed {
t.Errorf("expected USD order allowed when only KRW limit set, got %q", dec.Reason)
}
}
func TestEvaluateRiskMaxDailyOrdersAllowsBelowLimit(t *testing.T) {
ks := KillSwitchState{Halted: false}
policy := RiskPolicy{MaxDailyOrders: 5}
// 4 orders submitted today — one more should be allowed
dec := EvaluateRisk(policy, ks, buyIntent("1", ""), RiskUsage{DailyOrderCount: 4})
if !dec.Allowed {
t.Errorf("expected allowed when daily count below limit, got %q", dec.Reason)
}
}
func TestEvaluateRiskMaxDailyOrdersBlocksAtLimit(t *testing.T) {
ks := KillSwitchState{Halted: false}
policy := RiskPolicy{MaxDailyOrders: 5}
// exactly 5 orders already submitted — next must be blocked
dec := EvaluateRisk(policy, ks, buyIntent("1", ""), RiskUsage{DailyOrderCount: 5})
if dec.Allowed {
t.Error("expected blocked when daily order count equals limit")
}
}
func TestEvaluateRiskMaxDailyOrdersBlocksAboveLimit(t *testing.T) {
ks := KillSwitchState{Halted: false}
policy := RiskPolicy{MaxDailyOrders: 5}
dec := EvaluateRisk(policy, ks, buyIntent("1", ""), RiskUsage{DailyOrderCount: 7})
if dec.Allowed {
t.Error("expected blocked when daily order count exceeds limit")
}
}
func TestEvaluateRiskMaxDailyOrdersZeroMeansNoLimit(t *testing.T) {
ks := KillSwitchState{Halted: false}
policy := RiskPolicy{MaxDailyOrders: 0}
dec := EvaluateRisk(policy, ks, buyIntent("1", ""), RiskUsage{DailyOrderCount: 999})
if !dec.Allowed {
t.Errorf("expected allowed when MaxDailyOrders=0 (no limit), got %q", dec.Reason)
}
}
func TestEvaluateRiskMaxOpenOrdersAllowsBelowLimit(t *testing.T) {
ks := KillSwitchState{Halted: false}
policy := RiskPolicy{MaxOpenOrders: 3}
// 2 open orders — one more should be allowed
dec := EvaluateRisk(policy, ks, buyIntent("1", ""), RiskUsage{OpenOrderCount: 2})
if !dec.Allowed {
t.Errorf("expected allowed when open count below limit, got %q", dec.Reason)
}
}
func TestEvaluateRiskMaxOpenOrdersBlocksAtLimit(t *testing.T) {
ks := KillSwitchState{Halted: false}
policy := RiskPolicy{MaxOpenOrders: 3}
// exactly 3 open orders — next must be blocked
dec := EvaluateRisk(policy, ks, buyIntent("1", ""), RiskUsage{OpenOrderCount: 3})
if dec.Allowed {
t.Error("expected blocked when open order count equals limit")
}
}
func TestEvaluateRiskMaxOpenOrdersZeroMeansNoLimit(t *testing.T) {
ks := KillSwitchState{Halted: false}
policy := RiskPolicy{MaxOpenOrders: 0}
dec := EvaluateRisk(policy, ks, buyIntent("1", ""), RiskUsage{OpenOrderCount: 999})
if !dec.Allowed {
t.Errorf("expected allowed when MaxOpenOrders=0 (no limit), got %q", dec.Reason)
}
}
func TestDefaultKillSwitchIsHalted(t *testing.T) {
ks := DefaultKillSwitch()
if !ks.Halted {
t.Error("default kill switch must be halted=true")
}
if ks.Reason == "" {
t.Error("default kill switch must include a non-empty reason")
}
}