153 lines
4.3 KiB
Go
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
|
|
}
|
|
}
|