Files
overnight-trading-bot/internal/signal/engine.go
T
2026-06-08 07:05:01 +00:00

153 lines
4.3 KiB
Go

package signal
import (
"encoding/json"
"strings"
"time"
"github.com/shopspring/decimal"
"overnight-trading-bot/internal/domain"
)
const (
ReasonDisabled = "instrument_disabled"
ReasonQuarantine = "instrument_quarantine"
ReasonMetadataInvalid = "metadata_invalid"
ReasonTradingStatus = "trading_status_not_normal"
ReasonCommission = "commission_nonzero"
ReasonMuShort = "mu_on_60_non_positive"
ReasonMuLong = "mu_on_252_non_positive"
ReasonSigmaZero = "sigma_on_60_zero"
ReasonTStat = "tstat_on_60_below_threshold"
ReasonWinRate = "win_on_60_below_threshold"
ReasonNetEdge = "net_edge_bps_below_threshold"
ReasonSpread = "spread_bps_above_limit"
ReasonTick = "tick_bps_above_limit"
ReasonADV = "adv_20_below_limit"
ReasonFreeOrders = "free_order_budget_insufficient"
ReasonMaxPositions = "max_positions_reached"
)
type Config struct {
MinTStat60 decimal.Decimal
MinWinRate60 decimal.Decimal
MinNetEdgeBps decimal.Decimal
MinADVRUB decimal.Decimal
MaxSpreadBpsDefault decimal.Decimal
MaxSpreadBpsMoneyMarket decimal.Decimal
MaxSpreadBpsBondFunds decimal.Decimal
MaxSpreadBpsEquityFunds decimal.Decimal
MaxTickBps decimal.Decimal
RequireZeroCommission bool
MaxPositions int
}
type Candidate struct {
Instrument domain.Instrument
Features domain.FeatureSet
TradingStatus domain.TradingStatus
FreeOrderOK bool
OpenPositions int
TradeDate time.Time
ExtraContext map[string]any
}
type Engine struct {
cfg Config
}
func New(cfg Config) Engine {
return Engine{cfg: cfg}
}
func (e Engine) Evaluate(c Candidate) domain.Signal {
reason := e.firstRejectReason(c)
decision := domain.DecisionEnter
if reason != "" {
decision = domain.DecisionReject
}
if isSkipReason(reason) {
decision = domain.DecisionSkip
}
context := map[string]any{
"ticker": c.Instrument.Ticker,
"fund_type": c.Instrument.FundType,
"trading_status": c.TradingStatus,
"spread_limit": e.SpreadLimit(c.Instrument).String(),
}
for k, v := range c.ExtraContext {
context[k] = v
}
raw, _ := json.Marshal(context)
return domain.Signal{
TradeDate: c.TradeDate,
InstrumentUID: c.Instrument.InstrumentUID,
Decision: decision,
Score: c.Features.NetEdgeBps,
NetEdgeBps: c.Features.NetEdgeBps,
RejectReason: reason,
ContextJSON: string(raw),
CreatedAt: time.Now().UTC(),
}
}
func isSkipReason(reason string) bool {
return reason == ReasonFreeOrders || reason == ReasonMaxPositions
}
func (e Engine) firstRejectReason(c Candidate) string {
instr := c.Instrument
features := c.Features
switch {
case !instr.Enabled:
return ReasonDisabled
case instr.Quarantine:
return ReasonQuarantine
case !instr.MetadataValid():
return ReasonMetadataInvalid
case c.TradingStatus != domain.TradingStatusNormal:
return ReasonTradingStatus
case e.cfg.RequireZeroCommission && instr.ExpectedCommissionBpsPerSide.IsPositive():
return ReasonCommission
case !features.MuOn60.IsPositive():
return ReasonMuShort
case !features.MuOn252.IsPositive():
return ReasonMuLong
case !features.SigmaOn60.IsPositive():
return ReasonSigmaZero
case features.TStatOn60.LessThan(e.cfg.MinTStat60):
return ReasonTStat
case features.WinOn60.LessThan(e.cfg.MinWinRate60):
return ReasonWinRate
case features.NetEdgeBps.LessThan(e.cfg.MinNetEdgeBps):
return ReasonNetEdge
case features.SpreadBps.GreaterThan(e.SpreadLimit(instr)):
return ReasonSpread
case features.TickBps.GreaterThan(e.cfg.MaxTickBps):
return ReasonTick
case features.ADV20.LessThan(e.cfg.MinADVRUB):
return ReasonADV
case !c.FreeOrderOK:
return ReasonFreeOrders
case e.cfg.MaxPositions > 0 && c.OpenPositions >= e.cfg.MaxPositions:
return ReasonMaxPositions
default:
return ""
}
}
func (e Engine) SpreadLimit(instr domain.Instrument) decimal.Decimal {
fundType := strings.ToLower(instr.FundType)
switch {
case strings.Contains(fundType, "money"):
return e.cfg.MaxSpreadBpsMoneyMarket
case strings.Contains(fundType, "bond"):
return e.cfg.MaxSpreadBpsBondFunds
case strings.Contains(fundType, "equity"):
return e.cfg.MaxSpreadBpsEquityFunds
default:
return e.cfg.MaxSpreadBpsDefault
}
}