2026-06-07 21:01:40 +00:00
|
|
|
package risk
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"fmt"
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
"github.com/shopspring/decimal"
|
|
|
|
|
|
|
|
|
|
"overnight-trading-bot/internal/domain"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
type EventSink interface {
|
|
|
|
|
InsertRiskEvent(ctx context.Context, event domain.RiskEvent) error
|
|
|
|
|
SaveSystemState(ctx context.Context, state domain.SystemState, mode domain.Mode, halted bool, reason string, contextJSON string) error
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type Manager struct {
|
|
|
|
|
sink EventSink
|
|
|
|
|
cfg ManagerConfig
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type ManagerConfig struct {
|
|
|
|
|
MaxDailyLossPct decimal.Decimal
|
|
|
|
|
MaxWeeklyLossPct decimal.Decimal
|
|
|
|
|
MaxMonthlyDrawdownPct decimal.Decimal
|
|
|
|
|
MaxAvgSlippageBps10Trades decimal.Decimal
|
|
|
|
|
MaxOpenPositions int
|
|
|
|
|
MinTimeToClose time.Duration
|
|
|
|
|
MaxQuoteAge time.Duration
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type PreTradeInput struct {
|
2026-06-08 15:33:56 +00:00
|
|
|
Portfolio domain.Portfolio
|
|
|
|
|
OpenPositions int
|
|
|
|
|
ClosingPosition bool
|
|
|
|
|
DailyPnL decimal.Decimal
|
|
|
|
|
WeeklyPnL decimal.Decimal
|
|
|
|
|
MonthlyDrawdownPct decimal.Decimal
|
|
|
|
|
AvgSlippageBps10 decimal.Decimal
|
|
|
|
|
TradingStatus domain.TradingStatus
|
|
|
|
|
QuoteReceivedAt time.Time
|
|
|
|
|
Now time.Time
|
|
|
|
|
MarketClose time.Time
|
|
|
|
|
ServerTimeUnavailable bool
|
|
|
|
|
ServerClockDrift time.Duration
|
|
|
|
|
MaxClockDrift time.Duration
|
|
|
|
|
DatabaseUnavailable bool
|
|
|
|
|
UnknownBrokerOrder bool
|
|
|
|
|
UnknownBrokerHolding bool
|
2026-06-07 21:01:40 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type PreTradeResult struct {
|
|
|
|
|
Allowed bool
|
|
|
|
|
Reason string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func NewManager(sink EventSink, cfg ManagerConfig) Manager {
|
|
|
|
|
return Manager{sink: sink, cfg: cfg}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (m Manager) Halt(ctx context.Context, mode domain.Mode, eventType, reason string, instrumentUID string) error {
|
|
|
|
|
if m.sink == nil {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
event := domain.RiskEvent{
|
|
|
|
|
TS: time.Now().UTC(),
|
|
|
|
|
Severity: domain.SeverityCritical,
|
|
|
|
|
EventType: eventType,
|
|
|
|
|
InstrumentUID: instrumentUID,
|
|
|
|
|
Message: reason,
|
|
|
|
|
}
|
|
|
|
|
if err := m.sink.InsertRiskEvent(ctx, event); err != nil {
|
|
|
|
|
return fmt.Errorf("insert halt risk event: %w", err)
|
|
|
|
|
}
|
|
|
|
|
if err := m.sink.SaveSystemState(ctx, domain.StateHalted, mode, true, reason, "{}"); err != nil {
|
|
|
|
|
return fmt.Errorf("persist halt state: %w", err)
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (m Manager) PreTradeCheck(input PreTradeInput) PreTradeResult {
|
|
|
|
|
now := input.Now
|
|
|
|
|
if now.IsZero() {
|
|
|
|
|
now = time.Now().UTC()
|
|
|
|
|
}
|
|
|
|
|
switch {
|
|
|
|
|
case input.DatabaseUnavailable:
|
|
|
|
|
return reject("database_unavailable")
|
2026-06-08 15:33:56 +00:00
|
|
|
case input.ServerTimeUnavailable:
|
|
|
|
|
return reject("server_time_unavailable")
|
|
|
|
|
case input.MaxClockDrift > 0 && input.ServerClockDrift > input.MaxClockDrift:
|
|
|
|
|
return reject("server_clock_drift_too_high")
|
2026-06-07 21:01:40 +00:00
|
|
|
case input.UnknownBrokerOrder:
|
|
|
|
|
return reject("unknown_broker_order")
|
|
|
|
|
case input.UnknownBrokerHolding:
|
|
|
|
|
return reject("unknown_broker_position")
|
|
|
|
|
case input.TradingStatus == domain.TradingStatusUnknown:
|
|
|
|
|
return reject("trading_status_unknown_before_order")
|
|
|
|
|
case input.TradingStatus != domain.TradingStatusNormal:
|
|
|
|
|
return reject("trading_status_not_normal")
|
2026-06-08 11:11:50 +00:00
|
|
|
case !input.ClosingPosition && m.cfg.MaxOpenPositions > 0 && input.OpenPositions >= m.cfg.MaxOpenPositions:
|
2026-06-07 21:01:40 +00:00
|
|
|
return reject("max_open_positions")
|
|
|
|
|
case DailyLossBreached(input.DailyPnL, input.Portfolio.Equity, m.cfg.MaxDailyLossPct):
|
|
|
|
|
return reject("max_daily_loss")
|
|
|
|
|
case DailyLossBreached(input.WeeklyPnL, input.Portfolio.Equity, m.cfg.MaxWeeklyLossPct):
|
|
|
|
|
return reject("max_weekly_loss")
|
|
|
|
|
case m.cfg.MaxMonthlyDrawdownPct.IsPositive() && input.MonthlyDrawdownPct.GreaterThanOrEqual(m.cfg.MaxMonthlyDrawdownPct):
|
|
|
|
|
return reject("max_monthly_drawdown")
|
|
|
|
|
case m.cfg.MaxAvgSlippageBps10Trades.IsPositive() && input.AvgSlippageBps10.GreaterThan(m.cfg.MaxAvgSlippageBps10Trades):
|
|
|
|
|
return reject("max_avg_slippage_bps_10_trades")
|
|
|
|
|
case m.cfg.MaxQuoteAge > 0 && !input.QuoteReceivedAt.IsZero() && now.Sub(input.QuoteReceivedAt) > m.cfg.MaxQuoteAge:
|
|
|
|
|
return reject("quote_age_too_high")
|
|
|
|
|
case m.cfg.MinTimeToClose > 0 && !input.MarketClose.IsZero() && input.MarketClose.Sub(now) < m.cfg.MinTimeToClose:
|
|
|
|
|
return reject("min_time_to_close_sec")
|
|
|
|
|
default:
|
|
|
|
|
return PreTradeResult{Allowed: true}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func DailyLossBreached(pnl, equity, maxLossPct decimal.Decimal) bool {
|
|
|
|
|
if !equity.IsPositive() || !maxLossPct.IsPositive() {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
limit := equity.Mul(maxLossPct).Neg()
|
|
|
|
|
return pnl.LessThanOrEqual(limit)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func CommissionBreached(actualCommission decimal.Decimal, requireZero bool) bool {
|
|
|
|
|
return requireZero && actualCommission.IsPositive()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func reject(reason string) PreTradeResult {
|
|
|
|
|
return PreTradeResult{Allowed: false, Reason: reason}
|
|
|
|
|
}
|