first version

This commit is contained in:
2026-06-07 21:01:40 +00:00
parent ee7167accf
commit f19bab1100
79 changed files with 10355 additions and 145 deletions
+152
View File
@@ -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
}
}
+87
View File
@@ -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)
}
}