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