first version
This commit is contained in:
@@ -0,0 +1,152 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package signal
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/shopspring/decimal"
|
||||
|
||||
"overnight-trading-bot/internal/domain"
|
||||
)
|
||||
|
||||
func sd(raw string) decimal.Decimal {
|
||||
v, err := decimal.NewFromString(raw)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func baseCandidate() Candidate {
|
||||
return Candidate{
|
||||
TradeDate: time.Date(2024, 1, 2, 0, 0, 0, 0, time.UTC),
|
||||
Instrument: domain.Instrument{
|
||||
InstrumentUID: "uid",
|
||||
Ticker: "TRUR",
|
||||
ClassCode: "TQTF",
|
||||
Lot: 1,
|
||||
MinPriceIncrement: sd("0.01"),
|
||||
Currency: "RUB",
|
||||
Enabled: true,
|
||||
},
|
||||
Features: domain.FeatureSet{
|
||||
MuOn60: sd("0.002"),
|
||||
MuOn252: sd("0.001"),
|
||||
SigmaOn60: sd("0.01"),
|
||||
TStatOn60: sd("2"),
|
||||
WinOn60: sd("0.60"),
|
||||
NetEdgeBps: sd("20"),
|
||||
SpreadBps: sd("5"),
|
||||
TickBps: sd("1"),
|
||||
ADV20: sd("10000000"),
|
||||
},
|
||||
TradingStatus: domain.TradingStatusNormal,
|
||||
FreeOrderOK: true,
|
||||
}
|
||||
}
|
||||
|
||||
func TestEngineEnter(t *testing.T) {
|
||||
engine := New(Config{
|
||||
MinTStat60: sd("1.25"),
|
||||
MinWinRate60: sd("0.55"),
|
||||
MinNetEdgeBps: sd("10"),
|
||||
MinADVRUB: sd("5000000"),
|
||||
MaxSpreadBpsDefault: sd("20"),
|
||||
MaxSpreadBpsMoneyMarket: sd("5"),
|
||||
MaxSpreadBpsBondFunds: sd("10"),
|
||||
MaxSpreadBpsEquityFunds: sd("25"),
|
||||
MaxTickBps: sd("10"),
|
||||
RequireZeroCommission: true,
|
||||
MaxPositions: 5,
|
||||
})
|
||||
sig := engine.Evaluate(baseCandidate())
|
||||
if sig.Decision != domain.DecisionEnter || sig.RejectReason != "" {
|
||||
t.Fatalf("unexpected signal: %+v", sig)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEngineFirstRejectReason(t *testing.T) {
|
||||
engine := New(Config{MinTStat60: sd("1.25"), MinWinRate60: sd("0.55"), MinNetEdgeBps: sd("10"), MinADVRUB: sd("5000000"), MaxSpreadBpsDefault: sd("20"), MaxTickBps: sd("10"), RequireZeroCommission: true})
|
||||
c := baseCandidate()
|
||||
c.Features.MuOn60 = decimal.Zero
|
||||
c.Features.NetEdgeBps = decimal.Zero
|
||||
sig := engine.Evaluate(c)
|
||||
if sig.RejectReason != ReasonMuShort {
|
||||
t.Fatalf("reason=%s", sig.RejectReason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEngineUsesSkipForCapacityReasons(t *testing.T) {
|
||||
engine := New(Config{MinTStat60: sd("1.25"), MinWinRate60: sd("0.55"), MinNetEdgeBps: sd("10"), MinADVRUB: sd("5000000"), MaxSpreadBpsDefault: sd("20"), MaxTickBps: sd("10"), RequireZeroCommission: true, MaxPositions: 1})
|
||||
c := baseCandidate()
|
||||
c.OpenPositions = 1
|
||||
sig := engine.Evaluate(c)
|
||||
if sig.Decision != domain.DecisionSkip || sig.RejectReason != ReasonMaxPositions {
|
||||
t.Fatalf("unexpected skip signal: %+v", sig)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user