first version

This commit is contained in:
2026-06-07 21:01:40 +00:00
parent ee7167accf
commit f19bab1100
79 changed files with 10355 additions and 145 deletions
+27
View File
@@ -0,0 +1,27 @@
package risk
import (
"context"
"errors"
"testing"
"time"
"overnight-trading-bot/internal/domain"
)
func TestFreeOrderBudgetSubmittedPolicy(t *testing.T) {
ctx := context.Background()
store := NewMemoryFreeOrderStore()
budget := NewFreeOrderBudget(store)
instr := domain.Instrument{InstrumentUID: "uid", FreeOrderLimitPerDay: 2}
date := time.Date(2024, 1, 2, 0, 0, 0, 0, time.UTC)
if _, err := budget.Check(ctx, date, instr, 2); err != nil {
t.Fatal(err)
}
if err := budget.Submitted(ctx, date, instr.InstrumentUID); err != nil {
t.Fatal(err)
}
if _, err := budget.Check(ctx, date, instr, 2); !errors.Is(err, ErrFreeOrderBudget) {
t.Fatalf("expected ErrFreeOrderBudget, got %v", err)
}
}
+127
View File
@@ -0,0 +1,127 @@
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
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 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}
}
+166
View File
@@ -0,0 +1,166 @@
package risk
import (
"context"
"errors"
"sync"
"time"
"github.com/shopspring/decimal"
"overnight-trading-bot/internal/domain"
"overnight-trading-bot/internal/money"
)
var (
ErrNoSizingCapacity = errors.New("no sizing capacity")
ErrFreeOrderBudget = errors.New("free order budget is insufficient")
)
type SizingConfig struct {
MaxPositionPct decimal.Decimal
MaxTotalExposurePct decimal.Decimal
MaxParticipationRate decimal.Decimal
CashUsageBuffer decimal.Decimal
RiskBudgetPerInstrumentPct decimal.Decimal
MinOrderNotionalRUB decimal.Decimal
}
type SizingInput struct {
Portfolio domain.Portfolio
SelectedInstruments int
LimitPrice decimal.Decimal
Lot int64
EntryIntervalVolume decimal.Decimal
ExitIntervalVolume decimal.Decimal
Q05OvernightAbs decimal.Decimal
}
type SizingResult struct {
TargetNotional decimal.Decimal
Lots int64
Reason string
Limits map[string]decimal.Decimal
}
type Sizer struct {
cfg SizingConfig
sizeFactor decimal.Decimal
}
func NewSizer(cfg SizingConfig) Sizer {
return Sizer{cfg: cfg, sizeFactor: decimal.NewFromInt(1)}
}
func (s Sizer) WithSizeFactor(factor decimal.Decimal) Sizer {
if !factor.IsPositive() {
factor = decimal.NewFromInt(1)
}
s.sizeFactor = factor
return s
}
func (s Sizer) Size(input SizingInput) SizingResult {
limits := make(map[string]decimal.Decimal, 6)
if input.SelectedInstruments <= 0 {
input.SelectedInstruments = 1
}
capLimit := input.Portfolio.Equity.Mul(s.cfg.MaxPositionPct)
exposureLimit := input.Portfolio.Equity.Mul(s.cfg.MaxTotalExposurePct).
Div(decimal.NewFromInt(int64(input.SelectedInstruments)))
liquidityLimit := money.Min(input.EntryIntervalVolume, input.ExitIntervalVolume).
Mul(s.cfg.MaxParticipationRate)
cashLimit := input.Portfolio.Cash.Mul(s.cfg.CashUsageBuffer)
riskLimit := capLimit
if input.Q05OvernightAbs.IsPositive() {
riskBudget := input.Portfolio.Equity.Mul(s.cfg.RiskBudgetPerInstrumentPct)
riskLimit = riskBudget.Div(input.Q05OvernightAbs)
}
limits["cap"] = capLimit
limits["exposure"] = exposureLimit
limits["liquidity"] = liquidityLimit
limits["risk"] = riskLimit
limits["cash"] = cashLimit
sizeFactor := s.effectiveSizeFactor()
limits["size_factor"] = sizeFactor
target := money.Min(capLimit, exposureLimit, liquidityLimit, riskLimit, cashLimit).Mul(sizeFactor)
if !target.IsPositive() || !input.LimitPrice.IsPositive() || input.Lot <= 0 {
return SizingResult{Reason: "non_positive_limit", Limits: limits}
}
lotNotional := input.LimitPrice.Mul(decimal.NewFromInt(input.Lot))
lots := target.Div(lotNotional).Floor().IntPart()
notional := lotNotional.Mul(decimal.NewFromInt(lots))
if lots < 1 {
return SizingResult{TargetNotional: notional, Lots: lots, Reason: "lots_below_one", Limits: limits}
}
if notional.LessThan(s.cfg.MinOrderNotionalRUB) {
return SizingResult{TargetNotional: notional, Lots: 0, Reason: "min_order_notional", Limits: limits}
}
return SizingResult{TargetNotional: notional, Lots: lots, Limits: limits}
}
func (s Sizer) effectiveSizeFactor() decimal.Decimal {
if !s.sizeFactor.IsPositive() {
return decimal.NewFromInt(1)
}
return s.sizeFactor
}
type FreeOrderStore interface {
GetFreeOrdersSent(ctx context.Context, tradeDate time.Time, instrumentUID string) (int, error)
IncrementFreeOrders(ctx context.Context, tradeDate time.Time, instrumentUID string, delta int) error
}
type FreeOrderBudget struct {
store FreeOrderStore
}
func NewFreeOrderBudget(store FreeOrderStore) FreeOrderBudget {
return FreeOrderBudget{store: store}
}
func (b FreeOrderBudget) Check(ctx context.Context, tradeDate time.Time, instr domain.Instrument, ordersNeeded int) (int, error) {
if instr.FreeOrderLimitPerDay <= 0 {
return 0, nil
}
sent, err := b.store.GetFreeOrdersSent(ctx, tradeDate, instr.InstrumentUID)
if err != nil {
return 0, err
}
remaining := instr.FreeOrderLimitPerDay - sent
if remaining < ordersNeeded {
return remaining, ErrFreeOrderBudget
}
return remaining, nil
}
func (b FreeOrderBudget) Submitted(ctx context.Context, tradeDate time.Time, instrumentUID string) error {
return b.store.IncrementFreeOrders(ctx, tradeDate, instrumentUID, 1)
}
type MemoryFreeOrderStore struct {
mu sync.Mutex
counts map[string]int
}
func NewMemoryFreeOrderStore() *MemoryFreeOrderStore {
return &MemoryFreeOrderStore{counts: make(map[string]int)}
}
func (s *MemoryFreeOrderStore) GetFreeOrdersSent(_ context.Context, tradeDate time.Time, instrumentUID string) (int, error) {
s.mu.Lock()
defer s.mu.Unlock()
return s.counts[freeOrderKey(tradeDate, instrumentUID)], nil
}
func (s *MemoryFreeOrderStore) IncrementFreeOrders(_ context.Context, tradeDate time.Time, instrumentUID string, delta int) error {
s.mu.Lock()
defer s.mu.Unlock()
s.counts[freeOrderKey(tradeDate, instrumentUID)] += delta
return nil
}
func freeOrderKey(tradeDate time.Time, instrumentUID string) string {
return tradeDate.Format("2006-01-02") + "|" + instrumentUID
}
+172
View File
@@ -0,0 +1,172 @@
package risk
import (
"testing"
"github.com/shopspring/decimal"
"overnight-trading-bot/internal/domain"
)
func rd(raw string) decimal.Decimal {
v, err := decimal.NewFromString(raw)
if err != nil {
panic(err)
}
return v
}
func TestSizerTakesMinimumOfLimits(t *testing.T) {
sizer := NewSizer(SizingConfig{
MaxPositionPct: rd("0.10"),
MaxTotalExposurePct: rd("0.50"),
MaxParticipationRate: rd("0.01"),
CashUsageBuffer: rd("0.95"),
RiskBudgetPerInstrumentPct: rd("0.005"),
MinOrderNotionalRUB: rd("1000"),
})
got := sizer.Size(SizingInput{
Portfolio: domain.Portfolio{Equity: rd("100000"), Cash: rd("90000")},
SelectedInstruments: 5,
LimitPrice: rd("100"),
Lot: 1,
EntryIntervalVolume: rd("1000000"),
ExitIntervalVolume: rd("1000000"),
Q05OvernightAbs: rd("0.05"),
})
if got.Lots != 100 || !got.TargetNotional.Equal(rd("10000")) {
t.Fatalf("unexpected sizing: %+v", got)
}
}
func TestSizerMinOrderGate(t *testing.T) {
sizer := NewSizer(SizingConfig{
MaxPositionPct: rd("0.10"),
MaxTotalExposurePct: rd("0.50"),
MaxParticipationRate: rd("0.01"),
CashUsageBuffer: rd("0.95"),
RiskBudgetPerInstrumentPct: rd("0.005"),
MinOrderNotionalRUB: rd("1000"),
})
got := sizer.Size(SizingInput{
Portfolio: domain.Portfolio{Equity: rd("10000"), Cash: rd("10000")},
SelectedInstruments: 1,
LimitPrice: rd("999"),
Lot: 1,
EntryIntervalVolume: rd("1000000"),
ExitIntervalVolume: rd("1000000"),
Q05OvernightAbs: rd("0.05"),
})
if got.Lots != 0 || got.Reason != "min_order_notional" {
t.Fatalf("unexpected min order gate: %+v", got)
}
}
func TestSizerBindingLimits(t *testing.T) {
sizer := NewSizer(SizingConfig{
MaxPositionPct: rd("0.10"),
MaxTotalExposurePct: rd("0.50"),
MaxParticipationRate: rd("0.01"),
CashUsageBuffer: rd("0.95"),
RiskBudgetPerInstrumentPct: rd("0.005"),
MinOrderNotionalRUB: rd("1"),
})
tests := []struct {
name string
input SizingInput
want decimal.Decimal
}{
{
name: "cap",
input: SizingInput{
Portfolio: domain.Portfolio{Equity: rd("100000"), Cash: rd("100000")},
SelectedInstruments: 1,
LimitPrice: rd("100"),
Lot: 1,
EntryIntervalVolume: rd("5000000"),
ExitIntervalVolume: rd("5000000"),
},
want: rd("10000"),
},
{
name: "exposure",
input: SizingInput{
Portfolio: domain.Portfolio{Equity: rd("100000"), Cash: rd("100000")},
SelectedInstruments: 10,
LimitPrice: rd("100"),
Lot: 1,
EntryIntervalVolume: rd("5000000"),
ExitIntervalVolume: rd("5000000"),
},
want: rd("5000"),
},
{
name: "liquidity",
input: SizingInput{
Portfolio: domain.Portfolio{Equity: rd("100000"), Cash: rd("100000")},
SelectedInstruments: 1,
LimitPrice: rd("100"),
Lot: 1,
EntryIntervalVolume: rd("300000"),
ExitIntervalVolume: rd("500000"),
},
want: rd("3000"),
},
{
name: "risk",
input: SizingInput{
Portfolio: domain.Portfolio{Equity: rd("100000"), Cash: rd("100000")},
SelectedInstruments: 1,
LimitPrice: rd("100"),
Lot: 1,
EntryIntervalVolume: rd("5000000"),
ExitIntervalVolume: rd("5000000"),
Q05OvernightAbs: rd("0.10"),
},
want: rd("5000"),
},
{
name: "cash",
input: SizingInput{
Portfolio: domain.Portfolio{Equity: rd("100000"), Cash: rd("2000")},
SelectedInstruments: 1,
LimitPrice: rd("100"),
Lot: 1,
EntryIntervalVolume: rd("5000000"),
ExitIntervalVolume: rd("5000000"),
},
want: rd("1900"),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := sizer.Size(tt.input)
if !got.TargetNotional.Equal(tt.want) {
t.Fatalf("target=%s, want %s limits=%v", got.TargetNotional, tt.want, got.Limits)
}
})
}
}
func TestSizerAppliesSizeReductionFactor(t *testing.T) {
sizer := NewSizer(SizingConfig{
MaxPositionPct: rd("1"),
MaxTotalExposurePct: rd("1"),
MaxParticipationRate: rd("1"),
CashUsageBuffer: rd("1"),
RiskBudgetPerInstrumentPct: rd("1"),
MinOrderNotionalRUB: rd("1"),
}).WithSizeFactor(rd("0.5"))
got := sizer.Size(SizingInput{
Portfolio: domain.Portfolio{Equity: rd("10000"), Cash: rd("10000")},
SelectedInstruments: 1,
LimitPrice: rd("100"),
Lot: 1,
EntryIntervalVolume: rd("10000"),
ExitIntervalVolume: rd("10000"),
Q05OvernightAbs: rd("1"),
})
if got.Lots != 50 || !got.TargetNotional.Equal(rd("5000")) {
t.Fatalf("unexpected reduced sizing: %+v", got)
}
}