Files
overnight-trading-bot/internal/risk/sizing.go
T
2026-06-07 21:01:40 +00:00

167 lines
5.0 KiB
Go

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
}