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 { 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 DatabaseUnavailable bool UnknownBrokerOrder bool UnknownBrokerHolding bool } 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") 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") case !input.ClosingPosition && m.cfg.MaxOpenPositions > 0 && input.OpenPositions >= m.cfg.MaxOpenPositions: 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} }