Files
overnight-trading-bot/internal/risk/manager.go
T
Valentin Popov e074eeedf2
Deploy / Test, build and deploy (push) Failing after 2m15s
eleventh version
2026-06-08 15:33:56 +00:00

136 lines
4.4 KiB
Go

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
ServerTimeUnavailable bool
ServerClockDrift time.Duration
MaxClockDrift time.Duration
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.ServerTimeUnavailable:
return reject("server_time_unavailable")
case input.MaxClockDrift > 0 && input.ServerClockDrift > input.MaxClockDrift:
return reject("server_clock_drift_too_high")
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}
}