first version
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user