thirteenth version

This commit is contained in:
2026-06-09 21:04:01 +00:00
parent f877907b20
commit 4dec14f57c
19 changed files with 602 additions and 110 deletions
+37 -31
View File
@@ -239,8 +239,8 @@ func buildScheduler(clock timeutil.Clock, sm statemachine.System, cfg config.Con
RollingLong: cfg.Strategy.RollingLong, RollingLong: cfg.Strategy.RollingLong,
EWMALambda: cfg.Strategy.EWMALambda, EWMALambda: cfg.Strategy.EWMALambda,
RiskBufferBps: cfg.Strategy.RiskBufferBps, RiskBufferBps: cfg.Strategy.RiskBufferBps,
EntrySlippageBps: cfg.Backtest.EntrySlippageBps, EntrySlippageBps: cfg.Strategy.ExpectedEntrySlippageBps,
ExitSlippageBps: cfg.Backtest.ExitSlippageBps, ExitSlippageBps: cfg.Strategy.ExpectedExitSlippageBps,
CommissionRoundtripBps: cfg.Backtest.CommissionRoundtripBps, CommissionRoundtripBps: cfg.Backtest.CommissionRoundtripBps,
EntryWindow: timeutil.Window{ EntryWindow: timeutil.Window{
Start: cfg.Execution.EntryWindowStart, Start: cfg.Execution.EntryWindowStart,
@@ -250,7 +250,7 @@ func buildScheduler(clock timeutil.Clock, sm statemachine.System, cfg config.Con
Start: cfg.Execution.ExitWindowStart, Start: cfg.Execution.ExitWindowStart,
End: cfg.Execution.ExitWindowEnd, End: cfg.Execution.ExitWindowEnd,
}, },
IntervalVolumeLookback: 20, IntervalVolumeLookback: cfg.Strategy.IntervalVolumeLookbackDays,
Location: cfg.Location, Location: cfg.Location,
}) })
signalEngine := signalengine.New(signalengine.Config{ signalEngine := signalengine.New(signalengine.Config{
@@ -288,6 +288,7 @@ func buildScheduler(clock timeutil.Clock, sm statemachine.System, cfg config.Con
execEngine.SetClock(clock) execEngine.SetClock(clock)
execEngine.SetMaxQuoteAge(time.Duration(cfg.Execution.MaxQuoteAgeSec) * time.Second) execEngine.SetMaxQuoteAge(time.Duration(cfg.Execution.MaxQuoteAgeSec) * time.Second)
execEngine.SetFreeOrderCountPolicy(cfg.Commission.FreeOrderCountPolicy) execEngine.SetFreeOrderCountPolicy(cfg.Commission.FreeOrderCountPolicy)
execEngine.SetMaxExitOrderAttempts(cfg.Execution.MaxExitOrderAttempts)
services := scheduler.Services{ services := scheduler.Services{
Repo: repo, Repo: repo,
Gateway: gateway, Gateway: gateway,
@@ -307,34 +308,39 @@ func buildScheduler(clock timeutil.Clock, sm statemachine.System, cfg config.Con
Log: log, Log: log,
} }
return scheduler.New(clock, sm, scheduler.Config{ return scheduler.New(clock, sm, scheduler.Config{
Mode: cfg.App.Mode, Mode: cfg.App.Mode,
Location: cfg.Location, Location: cfg.Location,
RollingLong: cfg.Strategy.RollingLong, RollingLong: cfg.Strategy.RollingLong,
TickInterval: 30 * time.Second, IntervalVolumeLookbackDays: cfg.Strategy.IntervalVolumeLookbackDays,
EntrySignalTime: cfg.Execution.EntrySignalTime, TickInterval: 30 * time.Second,
EntryWindowStart: cfg.Execution.EntryWindowStart, EntrySignalTime: cfg.Execution.EntrySignalTime,
EntryWindowEnd: cfg.Execution.EntryWindowEnd, EntryWindowStart: cfg.Execution.EntryWindowStart,
NoNewEntryAfter: cfg.Execution.NoNewEntryAfter, EntryWindowEnd: cfg.Execution.EntryWindowEnd,
ExitWatchStart: cfg.Execution.ExitWatchStart, NoNewEntryAfter: cfg.Execution.NoNewEntryAfter,
ExitNotBefore: cfg.Execution.ExitNotBefore, ExitWatchStart: cfg.Execution.ExitWatchStart,
ExitWindowStart: cfg.Execution.ExitWindowStart, ExitNotBefore: cfg.Execution.ExitNotBefore,
ExitWindowEnd: cfg.Execution.ExitWindowEnd, ExitWindowStart: cfg.Execution.ExitWindowStart,
HardExitDeadline: cfg.Execution.HardExitDeadline, ExitWindowEnd: cfg.Execution.ExitWindowEnd,
MarketClose: cfg.Execution.MarketClose, HardExitDeadline: cfg.Execution.HardExitDeadline,
QuoteDepth: cfg.Execution.QuoteDepth, MarketClose: cfg.Execution.MarketClose,
MaxQuoteAge: time.Duration(cfg.Execution.MaxQuoteAgeSec) * time.Second, QuoteDepth: cfg.Execution.QuoteDepth,
OrderPollInterval: time.Duration(cfg.Execution.OrderPollIntervalMS) * time.Millisecond, MaxQuoteAge: time.Duration(cfg.Execution.MaxQuoteAgeSec) * time.Second,
PassiveImproveTicks: cfg.Execution.PassiveImproveTicks, OrderPollInterval: time.Duration(cfg.Execution.OrderPollIntervalMS) * time.Millisecond,
MaxEntryOrderAttempts: cfg.Execution.MaxEntryOrderAttempts, PassiveImproveTicks: cfg.Execution.PassiveImproveTicks,
MaxExitOrderAttempts: cfg.Execution.MaxExitOrderAttempts, MaxEntryOrderAttempts: cfg.Execution.MaxEntryOrderAttempts,
MinTimeToClose: time.Duration(cfg.Execution.MinTimeToCloseSec) * time.Second, MaxExitOrderAttempts: cfg.Execution.MaxExitOrderAttempts,
MaxClockDrift: time.Duration(cfg.Risk.MaxClockDriftSec) * time.Second, MinTimeToClose: time.Duration(cfg.Execution.MinTimeToCloseSec) * time.Second,
APIOutageHalt: time.Duration(cfg.Risk.APIOutageHaltSec) * time.Second, MaxClockDrift: time.Duration(cfg.Risk.MaxClockDriftSec) * time.Second,
RequireZeroCommission: cfg.Commission.RequireZeroCommission, APIOutageHalt: time.Duration(cfg.Risk.APIOutageHaltSec) * time.Second,
QuarantineOnNonZero: cfg.Commission.QuarantineOnNonZero, RequireZeroCommission: cfg.Commission.RequireZeroCommission,
FreeOrderCountPolicy: cfg.Commission.FreeOrderCountPolicy, QuarantineOnNonZero: cfg.Commission.QuarantineOnNonZero,
ReconciliationInterval: 5 * time.Minute, FreeOrderCountPolicy: cfg.Commission.FreeOrderCountPolicy,
MaxOpenPositions: minPositive(cfg.Strategy.MaxPositions, cfg.Risk.MaxOpenPositions), ReconciliationInterval: 5 * time.Minute,
MaxOpenPositions: minPositive(cfg.Strategy.MaxPositions, cfg.Risk.MaxOpenPositions),
SizeReductionWindowTrades: cfg.Risk.SizeReductionWindowTrades,
SizeReductionFactor: cfg.Risk.SizeReductionFactor,
SizeReductionTriggerBps: cfg.Risk.SizeReductionTriggerBps,
TradingCalendarExchange: cfg.TInvest.TradingCalendarExchange,
}, services) }, services)
} }
+51 -17
View File
@@ -46,14 +46,15 @@ type AppConfig struct {
} }
type TInvestConfig struct { type TInvestConfig struct {
Token string `env:"TOKEN"` Token string `env:"TOKEN"`
AccountID string `env:"ACCOUNT_ID"` AccountID string `env:"ACCOUNT_ID"`
Endpoint string `env:"ENDPOINT" envDefault:"invest-public-api.tinkoff.ru:443"` Endpoint string `env:"ENDPOINT" envDefault:"invest-public-api.tinkoff.ru:443"`
AppName string `env:"APP_NAME" envDefault:"overnight-trading-bot"` AppName string `env:"APP_NAME" envDefault:"overnight-trading-bot"`
RequestTimeoutSec int `env:"REQUEST_TIMEOUT_SEC" envDefault:"10"` RequestTimeoutSec int `env:"REQUEST_TIMEOUT_SEC" envDefault:"10"`
RetryCount int `env:"RETRY_COUNT" envDefault:"3"` RetryCount int `env:"RETRY_COUNT" envDefault:"3"`
RetryBackoffSec int `env:"RETRY_BACKOFF_SEC" envDefault:"2"` RetryBackoffSec int `env:"RETRY_BACKOFF_SEC" envDefault:"2"`
UseSandbox bool `env:"USE_SANDBOX" envDefault:"false"` UseSandbox bool `env:"USE_SANDBOX" envDefault:"false"`
TradingCalendarExchange string `env:"TRADING_CALENDAR_EXCHANGE" envDefault:"MOEX"`
} }
type DBConfig struct { type DBConfig struct {
@@ -74,15 +75,18 @@ type TelegramConfig struct {
} }
type StrategyConfig struct { type StrategyConfig struct {
RollingShort int `env:"ROLLING_SHORT" envDefault:"60"` RollingShort int `env:"ROLLING_SHORT" envDefault:"60"`
RollingLong int `env:"ROLLING_LONG" envDefault:"252"` RollingLong int `env:"ROLLING_LONG" envDefault:"252"`
EWMALambda float64 `env:"EWMA_LAMBDA" envDefault:"0.08"` EWMALambda float64 `env:"EWMA_LAMBDA" envDefault:"0.08"`
AllocationMethod string `env:"ALLOCATION_METHOD" envDefault:"equal_weight"` AllocationMethod string `env:"ALLOCATION_METHOD" envDefault:"equal_weight"`
MinTStat60 decimal.Decimal `env:"MIN_TSTAT_60" envDefault:"1.25"` MinTStat60 decimal.Decimal `env:"MIN_TSTAT_60" envDefault:"1.25"`
MinWinRate60 decimal.Decimal `env:"MIN_WIN_RATE_60" envDefault:"0.55"` MinWinRate60 decimal.Decimal `env:"MIN_WIN_RATE_60" envDefault:"0.55"`
MinNetEdgeBps decimal.Decimal `env:"MIN_NET_EDGE_BPS" envDefault:"10"` MinNetEdgeBps decimal.Decimal `env:"MIN_NET_EDGE_BPS" envDefault:"10"`
RiskBufferBps decimal.Decimal `env:"RISK_BUFFER_BPS" envDefault:"5"` RiskBufferBps decimal.Decimal `env:"RISK_BUFFER_BPS" envDefault:"5"`
MaxPositions int `env:"MAX_POSITIONS" envDefault:"5"` ExpectedEntrySlippageBps decimal.Decimal `env:"EXPECTED_ENTRY_SLIPPAGE_BPS" envDefault:"8"`
ExpectedExitSlippageBps decimal.Decimal `env:"EXPECTED_EXIT_SLIPPAGE_BPS" envDefault:"8"`
IntervalVolumeLookbackDays int `env:"INTERVAL_VOLUME_LOOKBACK_DAYS" envDefault:"20"`
MaxPositions int `env:"MAX_POSITIONS" envDefault:"5"`
} }
type ExecutionConfig struct { type ExecutionConfig struct {
@@ -124,6 +128,9 @@ type RiskConfig struct {
CashUsageBuffer decimal.Decimal `env:"CASH_USAGE_BUFFER" envDefault:"0.95"` CashUsageBuffer decimal.Decimal `env:"CASH_USAGE_BUFFER" envDefault:"0.95"`
RiskBudgetPerInstrumentPct decimal.Decimal `env:"RISK_BUDGET_PER_INSTRUMENT_PCT" envDefault:"0.005"` RiskBudgetPerInstrumentPct decimal.Decimal `env:"RISK_BUDGET_PER_INSTRUMENT_PCT" envDefault:"0.005"`
MinOrderNotionalRUB decimal.Decimal `env:"MIN_ORDER_NOTIONAL_RUB" envDefault:"1000"` MinOrderNotionalRUB decimal.Decimal `env:"MIN_ORDER_NOTIONAL_RUB" envDefault:"1000"`
SizeReductionWindowTrades int `env:"SIZE_REDUCTION_WINDOW_TRADES" envDefault:"20"`
SizeReductionFactor decimal.Decimal `env:"SIZE_REDUCTION_FACTOR" envDefault:"0.5"`
SizeReductionTriggerBps decimal.Decimal `env:"SIZE_REDUCTION_TRIGGER_BPS" envDefault:"-10"`
} }
type LiquidityConfig struct { type LiquidityConfig struct {
@@ -194,6 +201,9 @@ func (c *Config) Validate() error {
if c.TInvest.RequestTimeoutSec <= 0 { if c.TInvest.RequestTimeoutSec <= 0 {
return errors.New("TINVEST_REQUEST_TIMEOUT_SEC must be positive") return errors.New("TINVEST_REQUEST_TIMEOUT_SEC must be positive")
} }
if c.TInvest.TradingCalendarExchange == "" {
c.TInvest.TradingCalendarExchange = "MOEX"
}
if c.Execution.AllowMarketOrders { if c.Execution.AllowMarketOrders {
return errors.New("EXEC_ALLOW_MARKET_ORDERS must remain false: strategy is LIMIT-only") return errors.New("EXEC_ALLOW_MARKET_ORDERS must remain false: strategy is LIMIT-only")
} }
@@ -221,6 +231,18 @@ func (c *Config) Validate() error {
if c.Risk.CommissionToleranceRUB.IsNegative() { if c.Risk.CommissionToleranceRUB.IsNegative() {
return errors.New("RISK_COMMISSION_TOLERANCE_RUB must be non-negative") return errors.New("RISK_COMMISSION_TOLERANCE_RUB must be non-negative")
} }
if c.Risk.SizeReductionWindowTrades == 0 {
c.Risk.SizeReductionWindowTrades = 20
}
if c.Risk.SizeReductionWindowTrades < 0 {
return errors.New("RISK_SIZE_REDUCTION_WINDOW_TRADES must be positive")
}
if c.Risk.SizeReductionFactor.IsZero() {
c.Risk.SizeReductionFactor = decimal.RequireFromString("0.5")
}
if !c.Risk.SizeReductionFactor.IsPositive() || c.Risk.SizeReductionFactor.GreaterThan(decimal.NewFromInt(1)) {
return errors.New("RISK_SIZE_REDUCTION_FACTOR must be in (0, 1]")
}
if c.Commission.FreeOrderCountPolicy == "" { if c.Commission.FreeOrderCountPolicy == "" {
c.Commission.FreeOrderCountPolicy = "submitted" c.Commission.FreeOrderCountPolicy = "submitted"
} }
@@ -235,6 +257,18 @@ func (c *Config) Validate() error {
if c.Strategy.AllocationMethod != "equal_weight" { if c.Strategy.AllocationMethod != "equal_weight" {
return fmt.Errorf("STRATEGY_ALLOCATION_METHOD must be equal_weight, got %q", c.Strategy.AllocationMethod) return fmt.Errorf("STRATEGY_ALLOCATION_METHOD must be equal_weight, got %q", c.Strategy.AllocationMethod)
} }
if c.Strategy.ExpectedEntrySlippageBps.IsNegative() {
return errors.New("STRATEGY_EXPECTED_ENTRY_SLIPPAGE_BPS must be non-negative")
}
if c.Strategy.ExpectedExitSlippageBps.IsNegative() {
return errors.New("STRATEGY_EXPECTED_EXIT_SLIPPAGE_BPS must be non-negative")
}
if c.Strategy.IntervalVolumeLookbackDays == 0 {
c.Strategy.IntervalVolumeLookbackDays = 20
}
if c.Strategy.IntervalVolumeLookbackDays < 0 {
return errors.New("STRATEGY_INTERVAL_VOLUME_LOOKBACK_DAYS must be positive")
}
if err := c.validateWindows(); err != nil { if err := c.validateWindows(); err != nil {
return err return err
} }
+42
View File
@@ -44,6 +44,48 @@ func TestValidateLiveTradeAcceptsAllPreconditions(t *testing.T) {
} }
} }
func TestLoadKeepsStrategyExpectedSlippageSeparateFromBacktest(t *testing.T) {
t.Setenv("APP_MODE", "backtest")
t.Setenv("STRATEGY_EXPECTED_ENTRY_SLIPPAGE_BPS", "2")
t.Setenv("STRATEGY_EXPECTED_EXIT_SLIPPAGE_BPS", "3")
t.Setenv("BT_ENTRY_SLIPPAGE_BPS", "11")
t.Setenv("BT_EXIT_SLIPPAGE_BPS", "13")
cfg, err := Load()
if err != nil {
t.Fatal(err)
}
if !cfg.Strategy.ExpectedEntrySlippageBps.Equal(decimal.NewFromInt(2)) || !cfg.Strategy.ExpectedExitSlippageBps.Equal(decimal.NewFromInt(3)) {
t.Fatalf("strategy slippage entry=%s exit=%s, want 2/3", cfg.Strategy.ExpectedEntrySlippageBps, cfg.Strategy.ExpectedExitSlippageBps)
}
if !cfg.Backtest.EntrySlippageBps.Equal(decimal.NewFromInt(11)) || !cfg.Backtest.ExitSlippageBps.Equal(decimal.NewFromInt(13)) {
t.Fatalf("backtest slippage entry=%s exit=%s, want 11/13", cfg.Backtest.EntrySlippageBps, cfg.Backtest.ExitSlippageBps)
}
}
func TestLoadSchedulerKnobsFromEnv(t *testing.T) {
t.Setenv("APP_MODE", "backtest")
t.Setenv("STRATEGY_INTERVAL_VOLUME_LOOKBACK_DAYS", "12")
t.Setenv("RISK_SIZE_REDUCTION_WINDOW_TRADES", "7")
t.Setenv("RISK_SIZE_REDUCTION_FACTOR", "0.25")
t.Setenv("RISK_SIZE_REDUCTION_TRIGGER_BPS", "-5")
t.Setenv("TINVEST_TRADING_CALENDAR_EXCHANGE", "MOEX_FOND")
cfg, err := Load()
if err != nil {
t.Fatal(err)
}
if cfg.Strategy.IntervalVolumeLookbackDays != 12 || cfg.Risk.SizeReductionWindowTrades != 7 {
t.Fatalf("window config strategy=%d risk=%d, want 12/7", cfg.Strategy.IntervalVolumeLookbackDays, cfg.Risk.SizeReductionWindowTrades)
}
if !cfg.Risk.SizeReductionFactor.Equal(decimal.RequireFromString("0.25")) || !cfg.Risk.SizeReductionTriggerBps.Equal(decimal.NewFromInt(-5)) {
t.Fatalf("size reduction factor=%s trigger=%s, want 0.25/-5", cfg.Risk.SizeReductionFactor, cfg.Risk.SizeReductionTriggerBps)
}
if cfg.TInvest.TradingCalendarExchange != "MOEX_FOND" {
t.Fatalf("calendar exchange=%q, want MOEX_FOND", cfg.TInvest.TradingCalendarExchange)
}
}
func minimalBrokerConfig(mode domain.Mode) Config { func minimalBrokerConfig(mode domain.Mode) Config {
return Config{ return Config{
App: AppConfig{ App: AppConfig{
+31 -5
View File
@@ -37,6 +37,7 @@ type Engine struct {
store repository.Repository store repository.Repository
maxQuoteAge time.Duration maxQuoteAge time.Duration
freeOrderCountPolicy string freeOrderCountPolicy string
maxExitOrderAttempts int
clock timeutil.Clock clock timeutil.Clock
mu sync.Map mu sync.Map
} }
@@ -92,6 +93,12 @@ func (e *Engine) SetFreeOrderCountPolicy(policy string) {
} }
} }
func (e *Engine) SetMaxExitOrderAttempts(attempts int) {
if attempts > 0 {
e.maxExitOrderAttempts = attempts
}
}
func (e *Engine) PlaceEntry(ctx context.Context, accountIDHash string, instrument domain.Instrument, tradeDate time.Time, lots int64, book domain.OrderBook, improveTicks int, attempt int) (domain.Order, error) { func (e *Engine) PlaceEntry(ctx context.Context, accountIDHash string, instrument domain.Instrument, tradeDate time.Time, lots int64, book domain.OrderBook, improveTicks int, attempt int) (domain.Order, error) {
if err := e.checkQuoteFresh(book); err != nil { if err := e.checkQuoteFresh(book); err != nil {
return domain.Order{}, err return domain.Order{}, err
@@ -116,7 +123,7 @@ func (e *Engine) PlaceEntry(ctx context.Context, accountIDHash string, instrumen
Status: domain.OrderStatusNew, Status: domain.OrderStatusNew,
AttemptNo: attempt, AttemptNo: attempt,
RawStateJSON: orderContextJSON(book), RawStateJSON: orderContextJSON(book),
}, instrument.FreeOrderLimitPerDay) }, instrument.FreeOrderLimitPerDay, e.entryFreeOrderRequired())
} }
func (e *Engine) PlaceExit(ctx context.Context, accountIDHash string, instrument domain.Instrument, tradeDate time.Time, lots int64, book domain.OrderBook, improveTicks int, attempt int) (domain.Order, error) { func (e *Engine) PlaceExit(ctx context.Context, accountIDHash string, instrument domain.Instrument, tradeDate time.Time, lots int64, book domain.OrderBook, improveTicks int, attempt int) (domain.Order, error) {
@@ -143,14 +150,14 @@ func (e *Engine) PlaceExit(ctx context.Context, accountIDHash string, instrument
Status: domain.OrderStatusNew, Status: domain.OrderStatusNew,
AttemptNo: attempt, AttemptNo: attempt,
RawStateJSON: orderContextJSON(book), RawStateJSON: orderContextJSON(book),
}, instrument.FreeOrderLimitPerDay) }, instrument.FreeOrderLimitPerDay, 1)
} }
func (e *Engine) PlaceLimit(ctx context.Context, order domain.Order) (domain.Order, error) { func (e *Engine) PlaceLimit(ctx context.Context, order domain.Order) (domain.Order, error) {
return e.placeLimit(ctx, order, 0) return e.placeLimit(ctx, order, 0, 1)
} }
func (e *Engine) placeLimit(ctx context.Context, order domain.Order, freeOrderLimit int) (domain.Order, error) { func (e *Engine) placeLimit(ctx context.Context, order domain.Order, freeOrderLimit int, freeOrdersRequired int) (domain.Order, error) {
lock := e.lockFor(order.InstrumentUID) lock := e.lockFor(order.InstrumentUID)
lock.Lock() lock.Lock()
defer lock.Unlock() defer lock.Unlock()
@@ -183,7 +190,7 @@ func (e *Engine) placeLimit(ctx context.Context, order domain.Order, freeOrderLi
if err := repo.UpsertOrder(ctx, draft); err != nil { if err := repo.UpsertOrder(ctx, draft); err != nil {
return fmt.Errorf("persist draft order: %w", err) return fmt.Errorf("persist draft order: %w", err)
} }
return repo.ReserveFreeOrders(ctx, order.TradeDate, order.InstrumentUID, 1, freeOrderLimit) return repo.ReserveFreeOrdersWithRequired(ctx, order.TradeDate, order.InstrumentUID, 1, freeOrdersRequired, freeOrderLimit)
}); err != nil { }); err != nil {
return domain.Order{}, err return domain.Order{}, err
} }
@@ -587,6 +594,25 @@ func (e *Engine) ensureRepostBudget(ctx context.Context, order domain.Order, ins
return nil return nil
} }
func (e *Engine) entryFreeOrderRequired() int {
required := 1
if e.maxExitOrderAttempts <= 0 {
return required
}
return required + e.orderBudgetNeededForAttempts(e.maxExitOrderAttempts)
}
func (e *Engine) orderBudgetNeededForAttempts(attempts int) int {
if attempts <= 0 {
attempts = 1
}
needed := attempts
if e.cancelCountsAsFreeOrder() {
needed += attempts - 1
}
return needed
}
func (e *Engine) cancelCountsAsFreeOrder() bool { func (e *Engine) cancelCountsAsFreeOrder() bool {
return e.freeOrderCountPolicy == FreeOrderPolicyCancelCounts return e.freeOrderCountPolicy == FreeOrderPolicyCancelCounts
} }
+88
View File
@@ -111,6 +111,94 @@ func TestPlaceEntryReservesFreeOrderBudgetAtomically(t *testing.T) {
} }
} }
func TestPlaceEntryRequiresExitFreeOrderBudget(t *testing.T) {
ctx := context.Background()
repo := testutil.NewMemoryRepository()
gateway := tinvest.NewFakeGateway()
engine := NewEngine(domain.ModeSandbox, "account", gateway, repo)
engine.SetMaxExitOrderAttempts(3)
instrument := domain.Instrument{
InstrumentUID: "uid",
Lot: 1,
MinPriceIncrement: decimal.NewFromInt(1),
FreeOrderLimitPerDay: 3,
}
book := domain.OrderBook{
InstrumentUID: "uid",
Bids: []domain.OrderBookLevel{{Price: decimal.NewFromInt(99), QuantityLots: 10}},
Asks: []domain.OrderBookLevel{{Price: decimal.NewFromInt(101), QuantityLots: 10}},
ReceivedAt: time.Now().UTC(),
}
tradeDate := time.Date(2026, 6, 6, 0, 0, 0, 0, time.UTC)
_, err := engine.PlaceEntry(ctx, "hash", instrument, tradeDate, 1, book, 1, 1)
if !errors.Is(err, risk.ErrFreeOrderBudget) {
t.Fatalf("expected free order budget error, got %v", err)
}
if got := len(gateway.Orders); got != 0 {
t.Fatalf("broker orders=%d, want no post", got)
}
}
func TestPlaceEntryExitBudgetCountsFutureCancels(t *testing.T) {
ctx := context.Background()
repo := testutil.NewMemoryRepository()
gateway := tinvest.NewFakeGateway()
engine := NewEngine(domain.ModeSandbox, "account", gateway, repo)
engine.SetMaxExitOrderAttempts(3)
engine.SetFreeOrderCountPolicy(FreeOrderPolicyCancelCounts)
instrument := domain.Instrument{
InstrumentUID: "uid",
Lot: 1,
MinPriceIncrement: decimal.NewFromInt(1),
FreeOrderLimitPerDay: 5,
}
book := domain.OrderBook{
InstrumentUID: "uid",
Bids: []domain.OrderBookLevel{{Price: decimal.NewFromInt(99), QuantityLots: 10}},
Asks: []domain.OrderBookLevel{{Price: decimal.NewFromInt(101), QuantityLots: 10}},
ReceivedAt: time.Now().UTC(),
}
tradeDate := time.Date(2026, 6, 6, 0, 0, 0, 0, time.UTC)
_, err := engine.PlaceEntry(ctx, "hash", instrument, tradeDate, 1, book, 1, 1)
if !errors.Is(err, risk.ErrFreeOrderBudget) {
t.Fatalf("expected free order budget error, got %v", err)
}
if got := len(gateway.Orders); got != 0 {
t.Fatalf("broker orders=%d, want no post", got)
}
}
func TestPlaceEntryWithExitBudgetIncrementsOnlySubmittedEntry(t *testing.T) {
ctx := context.Background()
repo := testutil.NewMemoryRepository()
gateway := tinvest.NewFakeGateway()
engine := NewEngine(domain.ModeSandbox, "account", gateway, repo)
engine.SetMaxExitOrderAttempts(3)
instrument := domain.Instrument{
InstrumentUID: "uid",
Lot: 1,
MinPriceIncrement: decimal.NewFromInt(1),
FreeOrderLimitPerDay: 4,
}
book := domain.OrderBook{
InstrumentUID: "uid",
Bids: []domain.OrderBookLevel{{Price: decimal.NewFromInt(99), QuantityLots: 10}},
Asks: []domain.OrderBookLevel{{Price: decimal.NewFromInt(101), QuantityLots: 10}},
ReceivedAt: time.Now().UTC(),
}
tradeDate := time.Date(2026, 6, 6, 0, 0, 0, 0, time.UTC)
if _, err := engine.PlaceEntry(ctx, "hash", instrument, tradeDate, 1, book, 1, 1); err != nil {
t.Fatal(err)
}
sent, err := repo.GetFreeOrdersSent(ctx, tradeDate, "uid")
if err != nil {
t.Fatal(err)
}
if sent != 1 {
t.Fatalf("free order counter=%d, want only submitted entry counted", sent)
}
}
func TestPlaceEntryReleasesFreeOrderBudgetWhenBrokerRejects(t *testing.T) { func TestPlaceEntryReleasesFreeOrderBudgetWhenBrokerRejects(t *testing.T) {
ctx := context.Background() ctx := context.Background()
repo := testutil.NewMemoryRepository() repo := testutil.NewMemoryRepository()
+31 -2
View File
@@ -27,6 +27,7 @@ type PipelineConfig struct {
EntryWindow timeutil.Window EntryWindow timeutil.Window
ExitWindow timeutil.Window ExitWindow timeutil.Window
IntervalVolumeLookback int IntervalVolumeLookback int
TradingDays []time.Time
Location *time.Location Location *time.Location
} }
@@ -39,6 +40,11 @@ func NewPipeline(repo repository.Repository, cfg PipelineConfig) Pipeline {
return Pipeline{repo: repo, cfg: cfg} return Pipeline{repo: repo, cfg: cfg}
} }
func (p Pipeline) WithTradingDays(days []time.Time) Pipeline {
p.cfg.TradingDays = days
return p
}
func (p Pipeline) Recompute(ctx context.Context, instrument domain.Instrument, tradeDate time.Time, spread SpreadResult) (domain.FeatureSet, error) { func (p Pipeline) Recompute(ctx context.Context, instrument domain.Instrument, tradeDate time.Time, spread SpreadResult) (domain.FeatureSet, error) {
from := tradeDate.AddDate(0, 0, -p.cfg.RollingLong-5) from := tradeDate.AddDate(0, 0, -p.cfg.RollingLong-5)
to := dateOnly(tradeDate).AddDate(0, 0, -1) to := dateOnly(tradeDate).AddDate(0, 0, -1)
@@ -94,8 +100,9 @@ func Compute(instrument domain.Instrument, candles []domain.Candle, tradeDate ti
var overnight []float64 var overnight []float64
var lastROn decimal.Decimal var lastROn decimal.Decimal
var lastRDay decimal.Decimal var lastRDay decimal.Decimal
calendar := tradingCalendarFrom(cfg.TradingDays)
for i := 1; i < len(candles); i++ { for i := 1; i < len(candles); i++ {
if !consecutiveDailyCandles(candles[i-1].TradeDate, candles[i].TradeDate) { if !consecutiveDailyCandles(candles[i-1].TradeDate, candles[i].TradeDate, calendar) {
continue continue
} }
rOn, err := OvernightReturn(candles[i].Open, candles[i-1].Close) rOn, err := OvernightReturn(candles[i].Open, candles[i-1].Close)
@@ -207,12 +214,34 @@ func historicalDailyCandles(candles []domain.Candle, tradeDate time.Time) []doma
return out return out
} }
func consecutiveDailyCandles(previous, current time.Time) bool { type tradingCalendar map[string]struct{}
func tradingCalendarFrom(days []time.Time) tradingCalendar {
if len(days) == 0 {
return nil
}
calendar := make(tradingCalendar, len(days))
for _, day := range days {
calendar[dateOnly(day).Format("2006-01-02")] = struct{}{}
}
return calendar
}
func consecutiveDailyCandles(previous, current time.Time, calendar tradingCalendar) bool {
prevDay := dateOnly(previous) prevDay := dateOnly(previous)
currentDay := dateOnly(current) currentDay := dateOnly(current)
if !currentDay.After(prevDay) { if !currentDay.After(prevDay) {
return false return false
} }
if len(calendar) > 0 {
tradingDays := 0
for day := prevDay.AddDate(0, 0, 1); !day.After(currentDay); day = day.AddDate(0, 0, 1) {
if _, ok := calendar[day.Format("2006-01-02")]; ok {
tradingDays++
}
}
return tradingDays == 1
}
weekdays := 0 weekdays := 0
for day := prevDay.AddDate(0, 0, 1); !day.After(currentDay); day = day.AddDate(0, 0, 1) { for day := prevDay.AddDate(0, 0, 1); !day.After(currentDay); day = day.AddDate(0, 0, 1) {
if day.Weekday() != time.Saturday && day.Weekday() != time.Sunday { if day.Weekday() != time.Saturday && day.Weekday() != time.Sunday {
+41
View File
@@ -174,6 +174,47 @@ func TestComputeAllowsWeekendGap(t *testing.T) {
} }
} }
func TestComputeAllowsHolidayGapWithTradingCalendar(t *testing.T) {
monday := time.Date(2026, 1, 5, 0, 0, 0, 0, time.UTC)
wednesday := monday.AddDate(0, 0, 2)
candles := []domain.Candle{
{InstrumentUID: "uid", TradeDate: monday, Open: decimal.NewFromInt(100), Close: decimal.NewFromInt(100), VolumeLots: decimal.NewFromInt(1)},
{InstrumentUID: "uid", TradeDate: wednesday, Open: decimal.NewFromInt(101), Close: decimal.NewFromInt(100), VolumeLots: decimal.NewFromInt(1)},
}
got, err := Compute(domain.Instrument{InstrumentUID: "uid", Lot: 1}, candles, wednesday.AddDate(0, 0, 1), SpreadResult{}, PipelineConfig{
RollingShort: 1,
RollingLong: 1,
EWMALambda: 0.08,
TradingDays: []time.Time{monday, wednesday},
}, decimal.Zero, decimal.Zero)
if err != nil {
t.Fatal(err)
}
want := decimal.RequireFromString("0.01")
if !got.ROn.Equal(want) {
t.Fatalf("ROn=%s, want %s across holiday gap", got.ROn, want)
}
}
func TestComputeRejectsMissingTradingDayWithTradingCalendar(t *testing.T) {
monday := time.Date(2026, 1, 5, 0, 0, 0, 0, time.UTC)
tuesday := monday.AddDate(0, 0, 1)
wednesday := monday.AddDate(0, 0, 2)
candles := []domain.Candle{
{InstrumentUID: "uid", TradeDate: monday, Open: decimal.NewFromInt(100), Close: decimal.NewFromInt(100), VolumeLots: decimal.NewFromInt(1)},
{InstrumentUID: "uid", TradeDate: wednesday, Open: decimal.NewFromInt(101), Close: decimal.NewFromInt(100), VolumeLots: decimal.NewFromInt(1)},
}
_, err := Compute(domain.Instrument{InstrumentUID: "uid", Lot: 1}, candles, wednesday.AddDate(0, 0, 1), SpreadResult{}, PipelineConfig{
RollingShort: 1,
RollingLong: 1,
EWMALambda: 0.08,
TradingDays: []time.Time{monday, tuesday, wednesday},
}, decimal.Zero, decimal.Zero)
if err == nil {
t.Fatal("expected missing trading day to make overnight pair unavailable")
}
}
func flatCandles(start time.Time, count int) []domain.Candle { func flatCandles(start time.Time, count int) []domain.Candle {
candles := make([]domain.Candle, 0, count) candles := make([]domain.Candle, 0, count)
for i := 0; i < count; i++ { for i := 0; i < count; i++ {
+10
View File
@@ -34,6 +34,12 @@ type SDKLogger struct {
Logger *slog.Logger Logger *slog.Logger
} }
type NoopSDKLogger struct{}
func NewSDKLogger(logger *slog.Logger) SDKLogger {
return SDKLogger{Logger: logger}
}
func (l SDKLogger) Infof(template string, args ...any) { func (l SDKLogger) Infof(template string, args ...any) {
if l.Logger != nil { if l.Logger != nil {
l.Logger.Info(RedactString(template), "args", redactArgs(args)) l.Logger.Info(RedactString(template), "args", redactArgs(args))
@@ -52,6 +58,10 @@ func (l SDKLogger) Fatalf(template string, args ...any) {
} }
} }
func (NoopSDKLogger) Infof(string, ...any) {}
func (NoopSDKLogger) Errorf(string, ...any) {}
func (NoopSDKLogger) Fatalf(string, ...any) {}
var sensitiveStringPatterns = []*regexp.Regexp{ var sensitiveStringPatterns = []*regexp.Regexp{
regexp.MustCompile(`(?i)((?:account[_-]?id|token)\s*[:=]\s*)("[^"]+"|'[^']+'|[^\s,}]+)`), regexp.MustCompile(`(?i)((?:account[_-]?id|token)\s*[:=]\s*)("[^"]+"|'[^']+'|[^\s,}]+)`),
regexp.MustCompile(`(?i)("(?:accountID|accountId|account_id|token)"\s*:\s*)("[^"]*"|null)`), regexp.MustCompile(`(?i)("(?:accountID|accountId|account_id|token)"\s*:\s*)("[^"]*"|null)`),
+23
View File
@@ -34,3 +34,26 @@ func TestSlogRedactsSensitiveAccountIDAttributes(t *testing.T) {
t.Fatalf("log did not redact account ids: %s", got) t.Fatalf("log did not redact account ids: %s", got)
} }
} }
func TestSDKLoggerRedactsTemplateAndArgs(t *testing.T) {
var buf bytes.Buffer
logger := New("info", &buf)
sdk := NewSDKLogger(logger)
sdk.Infof("token=plain-token account_id=plain-account", "accountID=arg-account", `{"token":"json-token"}`)
got := buf.String()
for _, secret := range []string{"plain-token", "plain-account", "arg-account", "json-token"} {
if strings.Contains(got, secret) {
t.Fatalf("SDK log leaked %q: %s", secret, got)
}
}
if !strings.Contains(got, "[REDACTED]") {
t.Fatalf("SDK log did not redact sensitive data: %s", got)
}
}
func TestSDKLoggerNilIsNoop(t *testing.T) {
sdk := NewSDKLogger(nil)
sdk.Infof("token=plain-token")
sdk.Errorf("account_id=plain-account")
sdk.Fatalf("token=plain-token")
}
+11 -4
View File
@@ -606,9 +606,16 @@ ON DUPLICATE KEY UPDATE orders_sent=orders_sent+VALUES(orders_sent)`, dateOnly(t
} }
func (r *Repository) ReserveFreeOrders(ctx context.Context, tradeDate time.Time, instrumentUID string, delta int, limit int) error { func (r *Repository) ReserveFreeOrders(ctx context.Context, tradeDate time.Time, instrumentUID string, delta int, limit int) error {
return r.ReserveFreeOrdersWithRequired(ctx, tradeDate, instrumentUID, delta, delta, limit)
}
func (r *Repository) ReserveFreeOrdersWithRequired(ctx context.Context, tradeDate time.Time, instrumentUID string, delta int, required int, limit int) error {
if delta <= 0 { if delta <= 0 {
return nil return nil
} }
if required < delta {
required = delta
}
if limit <= 0 { if limit <= 0 {
return r.IncrementFreeOrders(ctx, tradeDate, instrumentUID, delta) return r.IncrementFreeOrders(ctx, tradeDate, instrumentUID, delta)
} }
@@ -617,11 +624,11 @@ func (r *Repository) ReserveFreeOrders(ctx context.Context, tradeDate time.Time,
if !ok { if !ok {
return errors.New("unexpected repository implementation") return errors.New("unexpected repository implementation")
} }
return txRepo.reserveFreeOrdersLocked(ctx, tradeDate, instrumentUID, delta, limit) return txRepo.reserveFreeOrdersLocked(ctx, tradeDate, instrumentUID, delta, required, limit)
}) })
} }
func (r *Repository) reserveFreeOrdersLocked(ctx context.Context, tradeDate time.Time, instrumentUID string, delta int, limit int) error { func (r *Repository) reserveFreeOrdersLocked(ctx context.Context, tradeDate time.Time, instrumentUID string, delta int, required int, limit int) error {
tradeDay := dateOnly(tradeDate) tradeDay := dateOnly(tradeDate)
if _, err := r.execer().ExecContext(ctx, ` if _, err := r.execer().ExecContext(ctx, `
INSERT IGNORE INTO free_order_counters (trade_date, instrument_uid, orders_sent) INSERT IGNORE INTO free_order_counters (trade_date, instrument_uid, orders_sent)
@@ -636,8 +643,8 @@ FOR UPDATE`, tradeDay, instrumentUID); err != nil {
return err return err
} }
remaining := limit - sent remaining := limit - sent
if remaining < delta { if remaining < required {
return fmt.Errorf("%w: %s remaining=%d needed=%d", risk.ErrFreeOrderBudget, instrumentUID, remaining, delta) return fmt.Errorf("%w: %s remaining=%d needed=%d", risk.ErrFreeOrderBudget, instrumentUID, remaining, required)
} }
_, err := r.execer().ExecContext(ctx, ` _, err := r.execer().ExecContext(ctx, `
UPDATE free_order_counters UPDATE free_order_counters
+1
View File
@@ -39,6 +39,7 @@ type Repository interface {
GetFreeOrdersSent(ctx context.Context, tradeDate time.Time, instrumentUID string) (int, error) GetFreeOrdersSent(ctx context.Context, tradeDate time.Time, instrumentUID string) (int, error)
IncrementFreeOrders(ctx context.Context, tradeDate time.Time, instrumentUID string, delta int) error IncrementFreeOrders(ctx context.Context, tradeDate time.Time, instrumentUID string, delta int) error
ReserveFreeOrders(ctx context.Context, tradeDate time.Time, instrumentUID string, delta int, limit int) error ReserveFreeOrders(ctx context.Context, tradeDate time.Time, instrumentUID string, delta int, limit int) error
ReserveFreeOrdersWithRequired(ctx context.Context, tradeDate time.Time, instrumentUID string, delta int, required int, limit int) error
GetSystemState(ctx context.Context) (domain.SystemState, bool, string, error) GetSystemState(ctx context.Context) (domain.SystemState, bool, string, error)
SaveSystemState(ctx context.Context, state domain.SystemState, mode domain.Mode, halted bool, reason string, contextJSON string) error SaveSystemState(ctx context.Context, state domain.SystemState, mode domain.Mode, halted bool, reason string, contextJSON string) error
+8 -3
View File
@@ -3,6 +3,7 @@ package risk
import ( import (
"context" "context"
"fmt" "fmt"
"os"
"time" "time"
"github.com/shopspring/decimal" "github.com/shopspring/decimal"
@@ -10,6 +11,8 @@ import (
"overnight-trading-bot/internal/domain" "overnight-trading-bot/internal/domain"
) )
var exitProcess = os.Exit
type EventSink interface { type EventSink interface {
InsertRiskEvent(ctx context.Context, event domain.RiskEvent) error InsertRiskEvent(ctx context.Context, event domain.RiskEvent) error
SaveSystemState(ctx context.Context, state domain.SystemState, mode domain.Mode, halted bool, reason string, contextJSON string) error SaveSystemState(ctx context.Context, state domain.SystemState, mode domain.Mode, halted bool, reason string, contextJSON string) error
@@ -63,6 +66,11 @@ func (m Manager) Halt(ctx context.Context, mode domain.Mode, eventType, reason s
if m.sink == nil { if m.sink == nil {
return nil return nil
} }
if err := m.sink.SaveSystemState(ctx, domain.StateHalted, mode, true, reason, "{}"); err != nil {
_, _ = fmt.Fprintf(os.Stderr, "fail-stop: persist halt state: %v\n", err)
exitProcess(1)
return fmt.Errorf("persist halt state: %w", err)
}
event := domain.RiskEvent{ event := domain.RiskEvent{
TS: time.Now().UTC(), TS: time.Now().UTC(),
Severity: domain.SeverityCritical, Severity: domain.SeverityCritical,
@@ -73,9 +81,6 @@ func (m Manager) Halt(ctx context.Context, mode domain.Mode, eventType, reason s
if err := m.sink.InsertRiskEvent(ctx, event); err != nil { if err := m.sink.InsertRiskEvent(ctx, event); err != nil {
return fmt.Errorf("insert halt risk event: %w", err) return fmt.Errorf("insert halt risk event: %w", err)
} }
if err := m.sink.SaveSystemState(ctx, domain.StateHalted, mode, true, reason, "{}"); err != nil {
return fmt.Errorf("persist halt state: %w", err)
}
return nil return nil
} }
+65
View File
@@ -1,6 +1,8 @@
package risk package risk
import ( import (
"context"
"errors"
"testing" "testing"
"time" "time"
@@ -9,6 +11,69 @@ import (
"overnight-trading-bot/internal/domain" "overnight-trading-bot/internal/domain"
) )
func TestHaltPersistsStateBeforeRiskEvent(t *testing.T) {
sink := &recordingHaltSink{}
manager := NewManager(sink, ManagerConfig{})
if err := manager.Halt(context.Background(), domain.ModeLiveTrade, "risk", "stop", "uid"); err != nil {
t.Fatal(err)
}
if len(sink.calls) != 2 || sink.calls[0] != "state" || sink.calls[1] != "event" {
t.Fatalf("calls=%v, want state before event", sink.calls)
}
if sink.state != domain.StateHalted || !sink.halted || sink.reason != "stop" {
t.Fatalf("state=%s halted=%v reason=%q", sink.state, sink.halted, sink.reason)
}
}
func TestHaltFailStopsWhenStatePersistFails(t *testing.T) {
oldExit := exitProcess
defer func() { exitProcess = oldExit }()
exitCode := -1
exitProcess = func(code int) {
exitCode = code
panic("exit")
}
sink := &recordingHaltSink{saveErr: errors.New("db down")}
defer func() {
if r := recover(); r == nil {
t.Fatal("expected fail-stop panic from exit hook")
}
if exitCode != 1 {
t.Fatalf("exit code=%d, want 1", exitCode)
}
if sink.eventInserted {
t.Fatal("risk event inserted before failed halt state persist")
}
}()
_ = NewManager(sink, ManagerConfig{}).Halt(context.Background(), domain.ModeLiveTrade, "risk", "stop", "")
}
type recordingHaltSink struct {
calls []string
saveErr error
eventInserted bool
state domain.SystemState
halted bool
reason string
}
func (s *recordingHaltSink) InsertRiskEvent(context.Context, domain.RiskEvent) error {
s.calls = append(s.calls, "event")
s.eventInserted = true
return nil
}
func (s *recordingHaltSink) SaveSystemState(_ context.Context, state domain.SystemState, _ domain.Mode, halted bool, reason string, _ string) error {
s.calls = append(s.calls, "state")
if s.saveErr != nil {
return s.saveErr
}
s.state = state
s.halted = halted
s.reason = reason
return nil
}
func TestPreTradeClosingPositionBypassesOpenPositionLimit(t *testing.T) { func TestPreTradeClosingPositionBypassesOpenPositionLimit(t *testing.T) {
manager := NewManager(nil, ManagerConfig{MaxOpenPositions: 1}) manager := NewManager(nil, ManagerConfig{MaxOpenPositions: 1})
input := PreTradeInput{ input := PreTradeInput{
+89 -45
View File
@@ -29,42 +29,40 @@ import (
"overnight-trading-bot/internal/tinvest" "overnight-trading-bot/internal/tinvest"
) )
const (
sizeReductionWindowTrades = 20
sizeReductionFactor = 0.5
sizeReductionTriggerBps = -10
intervalVolumeLookbackDays = 20
)
type Config struct { type Config struct {
Mode domain.Mode Mode domain.Mode
Location *time.Location Location *time.Location
RollingLong int RollingLong int
TickInterval time.Duration IntervalVolumeLookbackDays int
EntrySignalTime timeutil.TimeOfDay TickInterval time.Duration
EntryWindowStart timeutil.TimeOfDay EntrySignalTime timeutil.TimeOfDay
EntryWindowEnd timeutil.TimeOfDay EntryWindowStart timeutil.TimeOfDay
NoNewEntryAfter timeutil.TimeOfDay EntryWindowEnd timeutil.TimeOfDay
ExitWatchStart timeutil.TimeOfDay NoNewEntryAfter timeutil.TimeOfDay
ExitNotBefore timeutil.TimeOfDay ExitWatchStart timeutil.TimeOfDay
ExitWindowStart timeutil.TimeOfDay ExitNotBefore timeutil.TimeOfDay
ExitWindowEnd timeutil.TimeOfDay ExitWindowStart timeutil.TimeOfDay
HardExitDeadline timeutil.TimeOfDay ExitWindowEnd timeutil.TimeOfDay
MarketClose timeutil.TimeOfDay HardExitDeadline timeutil.TimeOfDay
QuoteDepth int32 MarketClose timeutil.TimeOfDay
MaxQuoteAge time.Duration QuoteDepth int32
OrderPollInterval time.Duration MaxQuoteAge time.Duration
PassiveImproveTicks int OrderPollInterval time.Duration
MaxEntryOrderAttempts int PassiveImproveTicks int
MaxExitOrderAttempts int MaxEntryOrderAttempts int
MinTimeToClose time.Duration MaxExitOrderAttempts int
MaxClockDrift time.Duration MinTimeToClose time.Duration
APIOutageHalt time.Duration MaxClockDrift time.Duration
RequireZeroCommission bool APIOutageHalt time.Duration
QuarantineOnNonZero bool RequireZeroCommission bool
FreeOrderCountPolicy string QuarantineOnNonZero bool
ReconciliationInterval time.Duration FreeOrderCountPolicy string
MaxOpenPositions int ReconciliationInterval time.Duration
MaxOpenPositions int
SizeReductionWindowTrades int
SizeReductionFactor decimal.Decimal
SizeReductionTriggerBps decimal.Decimal
TradingCalendarExchange string
} }
type Services struct { type Services struct {
@@ -113,6 +111,21 @@ func New(clock timeutil.Clock, sm statemachine.System, cfg Config, svc Services)
if cfg.ReconciliationInterval <= 0 { if cfg.ReconciliationInterval <= 0 {
cfg.ReconciliationInterval = 5 * time.Minute cfg.ReconciliationInterval = 5 * time.Minute
} }
if cfg.IntervalVolumeLookbackDays <= 0 {
cfg.IntervalVolumeLookbackDays = 20
}
if cfg.SizeReductionWindowTrades <= 0 {
cfg.SizeReductionWindowTrades = 20
}
if !cfg.SizeReductionFactor.IsPositive() {
cfg.SizeReductionFactor = decimal.RequireFromString("0.5")
}
if cfg.SizeReductionTriggerBps.IsZero() {
cfg.SizeReductionTriggerBps = decimal.NewFromInt(-10)
}
if cfg.TradingCalendarExchange == "" {
cfg.TradingCalendarExchange = "MOEX"
}
return Scheduler{clock: clock, sm: sm, cfg: cfg, svc: svc} return Scheduler{clock: clock, sm: sm, cfg: cfg, svc: svc}
} }
@@ -234,10 +247,16 @@ func (s *Scheduler) prepareSignals(ctx context.Context, now time.Time) error {
if err != nil { if err != nil {
return err return err
} }
if err := s.svc.MarketData.BackfillDaily(ctx, instrumentsList, tradeDate.AddDate(0, 0, -s.cfg.RollingLong-10), tradeDate); err != nil { dailyFrom := tradeDate.AddDate(0, 0, -s.cfg.RollingLong-10)
if err := s.svc.MarketData.BackfillDaily(ctx, instrumentsList, dailyFrom, tradeDate); err != nil {
return err return err
} }
minuteFrom := s.cfg.EntryWindowStart.On(tradeDate.AddDate(0, 0, -intervalVolumeLookbackDays), s.cfg.Location) tradingDays, err := s.svc.Gateway.GetTradingDays(ctx, s.cfg.TradingCalendarExchange, dailyFrom, tradeDate)
if err != nil {
return fmt.Errorf("load trading calendar %s: %w", s.cfg.TradingCalendarExchange, err)
}
s.svc.Features = s.svc.Features.WithTradingDays(tradingDays)
minuteFrom := s.cfg.EntryWindowStart.On(tradeDate.AddDate(0, 0, -s.cfg.IntervalVolumeLookbackDays), s.cfg.Location)
minuteTo := s.cfg.ExitWindowEnd.On(tradeDate, s.cfg.Location) minuteTo := s.cfg.ExitWindowEnd.On(tradeDate, s.cfg.Location)
if err := s.svc.MarketData.BackfillMinute(ctx, instrumentsList, minuteFrom, minuteTo); err != nil { if err := s.svc.MarketData.BackfillMinute(ctx, instrumentsList, minuteFrom, minuteTo); err != nil {
s.logWarn("minute backfill failed; liquidity will fall back to ADV", "err", err) s.logWarn("minute backfill failed; liquidity will fall back to ADV", "err", err)
@@ -904,15 +923,17 @@ func (s *Scheduler) sendDailyReport(ctx context.Context, now time.Time, riskStat
} }
func (s *Scheduler) applySizeReductionRule(ctx context.Context, tradeDate time.Time, emitEvent bool) error { func (s *Scheduler) applySizeReductionRule(ctx context.Context, tradeDate time.Time, emitEvent bool) error {
averageError, count, ok, err := s.averageExpectedErrorBps(ctx, tradeDate, sizeReductionWindowTrades) window := s.sizeReductionWindowTrades()
trigger := s.sizeReductionTriggerBps()
averageError, count, ok, err := s.averageExpectedErrorBps(ctx, tradeDate, window)
if err != nil { if err != nil {
return err return err
} }
if !ok || count < sizeReductionWindowTrades || averageError.GreaterThanOrEqual(decimal.NewFromInt(sizeReductionTriggerBps)) { if !ok || count < window || averageError.GreaterThanOrEqual(trigger) {
s.svc.Sizer = s.svc.Sizer.WithSizeFactor(decimal.NewFromInt(1)) s.svc.Sizer = s.svc.Sizer.WithSizeFactor(decimal.NewFromInt(1))
return nil return nil
} }
factor := decimal.NewFromFloat(sizeReductionFactor) factor := s.sizeReductionFactor()
s.svc.Sizer = s.svc.Sizer.WithSizeFactor(factor) s.svc.Sizer = s.svc.Sizer.WithSizeFactor(factor)
if emitEvent { if emitEvent {
if err := s.svc.Repo.InsertRiskEvent(ctx, domain.RiskEvent{ if err := s.svc.Repo.InsertRiskEvent(ctx, domain.RiskEvent{
@@ -1438,11 +1459,13 @@ func (s *Scheduler) handleLiveReadonlyAfterSizeReduction(ctx context.Context, tr
if s.cfg.Mode != domain.ModeLiveTrade { if s.cfg.Mode != domain.ModeLiveTrade {
return nil return nil
} }
previousAverage, previousCount, previousOK, err := s.averageExpectedErrorBpsWindow(ctx, tradeDate, sizeReductionWindowTrades, sizeReductionWindowTrades) window := s.sizeReductionWindowTrades()
trigger := s.sizeReductionTriggerBps()
previousAverage, previousCount, previousOK, err := s.averageExpectedErrorBpsWindow(ctx, tradeDate, window, window)
if err != nil { if err != nil {
return err return err
} }
if previousOK && previousCount == sizeReductionWindowTrades && previousAverage.LessThan(decimal.NewFromInt(sizeReductionTriggerBps)) { if previousOK && previousCount == window && previousAverage.LessThan(trigger) {
return s.activateLiveReadonly(ctx, averageError, count, previousAverage, previousCount, factor) return s.activateLiveReadonly(ctx, averageError, count, previousAverage, previousCount, factor)
} }
if !emitRecommendation { if !emitRecommendation {
@@ -1466,9 +1489,9 @@ func (s *Scheduler) activateLiveReadonly(ctx context.Context, averageError decim
return nil return nil
} }
message := fmt.Sprintf( message := fmt.Sprintf(
"average expected_error_bps stayed below %d for two consecutive %d-trade windows; switching to live_readonly", "average expected_error_bps stayed below %s for two consecutive %d-trade windows; switching to live_readonly",
sizeReductionTriggerBps, s.sizeReductionTriggerBps().String(),
sizeReductionWindowTrades, s.sizeReductionWindowTrades(),
) )
s.cfg.Mode = domain.ModeLiveReadonly s.cfg.Mode = domain.ModeLiveReadonly
if s.svc.Execution != nil { if s.svc.Execution != nil {
@@ -1563,6 +1586,27 @@ func (s Scheduler) maxOrderAttemptsPerTrade() int {
return needed return needed
} }
func (s Scheduler) sizeReductionWindowTrades() int {
if s.cfg.SizeReductionWindowTrades <= 0 {
return 20
}
return s.cfg.SizeReductionWindowTrades
}
func (s Scheduler) sizeReductionFactor() decimal.Decimal {
if !s.cfg.SizeReductionFactor.IsPositive() {
return decimal.RequireFromString("0.5")
}
return s.cfg.SizeReductionFactor
}
func (s Scheduler) sizeReductionTriggerBps() decimal.Decimal {
if s.cfg.SizeReductionTriggerBps.IsZero() {
return decimal.NewFromInt(-10)
}
return s.cfg.SizeReductionTriggerBps
}
func (s Scheduler) orderBudgetNeededForAttempts(attempts int) int { func (s Scheduler) orderBudgetNeededForAttempts(attempts int) int {
if attempts <= 0 { if attempts <= 0 {
attempts = 1 attempts = 1
+2
View File
@@ -21,6 +21,8 @@ import (
"overnight-trading-bot/internal/tinvest" "overnight-trading-bot/internal/tinvest"
) )
const sizeReductionWindowTrades = 20
func TestPhaseUsesMoscowWindows(t *testing.T) { func TestPhaseUsesMoscowWindows(t *testing.T) {
loc := time.FixedZone("MSK", 3*60*60) loc := time.FixedZone("MSK", 3*60*60)
s := Scheduler{cfg: Config{ s := Scheduler{cfg: Config{
+9 -2
View File
@@ -264,14 +264,21 @@ func (r *MemoryRepository) IncrementFreeOrders(_ context.Context, tradeDate time
return nil return nil
} }
func (r *MemoryRepository) ReserveFreeOrders(_ context.Context, tradeDate time.Time, instrumentUID string, delta int, limit int) error { func (r *MemoryRepository) ReserveFreeOrders(ctx context.Context, tradeDate time.Time, instrumentUID string, delta int, limit int) error {
return r.ReserveFreeOrdersWithRequired(ctx, tradeDate, instrumentUID, delta, delta, limit)
}
func (r *MemoryRepository) ReserveFreeOrdersWithRequired(_ context.Context, tradeDate time.Time, instrumentUID string, delta int, required int, limit int) error {
r.mu.Lock() r.mu.Lock()
defer r.mu.Unlock() defer r.mu.Unlock()
if delta <= 0 { if delta <= 0 {
return nil return nil
} }
if required < delta {
required = delta
}
key := featureKey(instrumentUID, tradeDate) key := featureKey(instrumentUID, tradeDate)
if limit > 0 && r.FreeOrders[key]+delta > limit { if limit > 0 && limit-r.FreeOrders[key] < required {
return risk.ErrFreeOrderBudget return risk.ErrFreeOrderBudget
} }
r.FreeOrders[key] += delta r.FreeOrders[key] += delta
+24
View File
@@ -17,6 +17,7 @@ var ErrNotFound = errors.New("not found")
type Gateway interface { type Gateway interface {
GetInstrument(ctx context.Context, ticker, classCode string) (domain.Instrument, error) GetInstrument(ctx context.Context, ticker, classCode string) (domain.Instrument, error)
GetCandles(ctx context.Context, instrumentUID string, interval string, from, to time.Time) ([]domain.Candle, error) GetCandles(ctx context.Context, instrumentUID string, interval string, from, to time.Time) ([]domain.Candle, error)
GetTradingDays(ctx context.Context, exchange string, from, to time.Time) ([]time.Time, error)
GetOrderBook(ctx context.Context, instrumentUID string, depth int32) (domain.OrderBook, error) GetOrderBook(ctx context.Context, instrumentUID string, depth int32) (domain.OrderBook, error)
GetTradingStatus(ctx context.Context, instrumentUID string) (domain.TradingStatus, error) GetTradingStatus(ctx context.Context, instrumentUID string) (domain.TradingStatus, error)
PostLimitOrder(ctx context.Context, accountID, instrumentUID string, side domain.Side, lots int64, price decimal.Decimal, clientOrderID string) (domain.Order, error) PostLimitOrder(ctx context.Context, accountID, instrumentUID string, side domain.Side, lots int64, price decimal.Decimal, clientOrderID string) (domain.Order, error)
@@ -34,6 +35,8 @@ type FakeGateway struct {
InstrumentErrors map[string]error InstrumentErrors map[string]error
Candles map[string][]domain.Candle Candles map[string][]domain.Candle
CandleErrors map[string]error CandleErrors map[string]error
TradingDays []time.Time
TradingDayError error
OrderBooks map[string]domain.OrderBook OrderBooks map[string]domain.OrderBook
Statuses map[string]domain.TradingStatus Statuses map[string]domain.TradingStatus
Orders map[string]domain.Order Orders map[string]domain.Order
@@ -88,6 +91,22 @@ func (f *FakeGateway) GetCandles(_ context.Context, instrumentUID string, _ stri
return out, nil return out, nil
} }
func (f *FakeGateway) GetTradingDays(_ context.Context, _ string, from, to time.Time) ([]time.Time, error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.TradingDayError != nil {
return nil, f.TradingDayError
}
var out []time.Time
for _, day := range f.TradingDays {
day = dateOnly(day)
if !day.Before(dateOnly(from)) && !day.After(dateOnly(to)) {
out = append(out, day)
}
}
return out, nil
}
func (f *FakeGateway) GetOrderBook(_ context.Context, instrumentUID string, _ int32) (domain.OrderBook, error) { func (f *FakeGateway) GetOrderBook(_ context.Context, instrumentUID string, _ int32) (domain.OrderBook, error) {
f.mu.Lock() f.mu.Lock()
defer f.mu.Unlock() defer f.mu.Unlock()
@@ -226,6 +245,11 @@ func (f *FakeGateway) GetServerTime(context.Context) (time.Time, error) {
return f.ServerTime, nil return f.ServerTime, nil
} }
func dateOnly(ts time.Time) time.Time {
year, month, day := ts.UTC().Date()
return time.Date(year, month, day, 0, 0, 0, 0, time.UTC)
}
func isTerminalFakeOrder(status domain.OrderStatus) bool { func isTerminalFakeOrder(status domain.OrderStatus) bool {
return status == domain.OrderStatusFilled || return status == domain.OrderStatusFilled ||
status == domain.OrderStatusCancelled || status == domain.OrderStatusCancelled ||
+7
View File
@@ -39,6 +39,13 @@ func (g *PaperGateway) GetCandles(ctx context.Context, instrumentUID string, int
return g.Fake().GetCandles(ctx, instrumentUID, interval, from, to) return g.Fake().GetCandles(ctx, instrumentUID, interval, from, to)
} }
func (g *PaperGateway) GetTradingDays(ctx context.Context, exchange string, from, to time.Time) ([]time.Time, error) {
if g.market != nil {
return g.market.GetTradingDays(ctx, exchange, from, to)
}
return g.Fake().GetTradingDays(ctx, exchange, from, to)
}
func (g *PaperGateway) GetOrderBook(ctx context.Context, instrumentUID string, depth int32) (domain.OrderBook, error) { func (g *PaperGateway) GetOrderBook(ctx context.Context, instrumentUID string, depth int32) (domain.OrderBook, error) {
if g.market != nil { if g.market != nil {
return g.market.GetOrderBook(ctx, instrumentUID, depth) return g.market.GetOrderBook(ctx, instrumentUID, depth)
+32 -1
View File
@@ -59,7 +59,7 @@ func NewRealGateway(ctx context.Context, opts Options) (*RealGateway, error) {
AppName: opts.AppName, AppName: opts.AppName,
AccountId: opts.AccountID, AccountId: opts.AccountID,
MaxRetries: 0, MaxRetries: 0,
}, logging.SDKLogger{Logger: opts.Logger}) }, logging.NewSDKLogger(opts.Logger))
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -156,6 +156,37 @@ func (g *RealGateway) GetCandles(ctx context.Context, instrumentUID string, inte
return out, nil return out, nil
} }
func (g *RealGateway) GetTradingDays(ctx context.Context, exchange string, from, to time.Time) ([]time.Time, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
resp, err := requestWithTimeout(ctx, g.requestTimeout, func(callCtx context.Context) (*pb.TradingSchedulesResponse, error) {
return retryValue(callCtx, g.retryAttempts, g.retryBackoff, func() (*pb.TradingSchedulesResponse, error) {
return g.instrumentsPB.TradingSchedules(callCtx, &pb.TradingSchedulesRequest{
Exchange: &exchange,
From: investgo.TimeToTimestamp(from),
To: investgo.TimeToTimestamp(to),
})
})
})
if err != nil {
return nil, err
}
var days []time.Time
for _, schedule := range resp.GetExchanges() {
if !strings.EqualFold(schedule.GetExchange(), exchange) {
continue
}
for _, day := range schedule.GetDays() {
if !day.GetIsTradingDay() || day.GetDate() == nil {
continue
}
days = append(days, dateOnly(day.GetDate().AsTime()))
}
}
return days, nil
}
func (g *RealGateway) GetOrderBook(ctx context.Context, instrumentUID string, depth int32) (domain.OrderBook, error) { func (g *RealGateway) GetOrderBook(ctx context.Context, instrumentUID string, depth int32) (domain.OrderBook, error) {
if err := ctx.Err(); err != nil { if err := ctx.Err(); err != nil {
return domain.OrderBook{}, err return domain.OrderBook{}, err