diff --git a/internal/app/app.go b/internal/app/app.go index bea5b3c..3b10143 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -239,8 +239,8 @@ func buildScheduler(clock timeutil.Clock, sm statemachine.System, cfg config.Con RollingLong: cfg.Strategy.RollingLong, EWMALambda: cfg.Strategy.EWMALambda, RiskBufferBps: cfg.Strategy.RiskBufferBps, - EntrySlippageBps: cfg.Backtest.EntrySlippageBps, - ExitSlippageBps: cfg.Backtest.ExitSlippageBps, + EntrySlippageBps: cfg.Strategy.ExpectedEntrySlippageBps, + ExitSlippageBps: cfg.Strategy.ExpectedExitSlippageBps, CommissionRoundtripBps: cfg.Backtest.CommissionRoundtripBps, EntryWindow: timeutil.Window{ Start: cfg.Execution.EntryWindowStart, @@ -250,7 +250,7 @@ func buildScheduler(clock timeutil.Clock, sm statemachine.System, cfg config.Con Start: cfg.Execution.ExitWindowStart, End: cfg.Execution.ExitWindowEnd, }, - IntervalVolumeLookback: 20, + IntervalVolumeLookback: cfg.Strategy.IntervalVolumeLookbackDays, Location: cfg.Location, }) signalEngine := signalengine.New(signalengine.Config{ @@ -288,6 +288,7 @@ func buildScheduler(clock timeutil.Clock, sm statemachine.System, cfg config.Con execEngine.SetClock(clock) execEngine.SetMaxQuoteAge(time.Duration(cfg.Execution.MaxQuoteAgeSec) * time.Second) execEngine.SetFreeOrderCountPolicy(cfg.Commission.FreeOrderCountPolicy) + execEngine.SetMaxExitOrderAttempts(cfg.Execution.MaxExitOrderAttempts) services := scheduler.Services{ Repo: repo, Gateway: gateway, @@ -307,34 +308,39 @@ func buildScheduler(clock timeutil.Clock, sm statemachine.System, cfg config.Con Log: log, } return scheduler.New(clock, sm, scheduler.Config{ - Mode: cfg.App.Mode, - Location: cfg.Location, - RollingLong: cfg.Strategy.RollingLong, - TickInterval: 30 * time.Second, - EntrySignalTime: cfg.Execution.EntrySignalTime, - EntryWindowStart: cfg.Execution.EntryWindowStart, - EntryWindowEnd: cfg.Execution.EntryWindowEnd, - NoNewEntryAfter: cfg.Execution.NoNewEntryAfter, - ExitWatchStart: cfg.Execution.ExitWatchStart, - ExitNotBefore: cfg.Execution.ExitNotBefore, - ExitWindowStart: cfg.Execution.ExitWindowStart, - ExitWindowEnd: cfg.Execution.ExitWindowEnd, - HardExitDeadline: cfg.Execution.HardExitDeadline, - MarketClose: cfg.Execution.MarketClose, - QuoteDepth: cfg.Execution.QuoteDepth, - MaxQuoteAge: time.Duration(cfg.Execution.MaxQuoteAgeSec) * time.Second, - OrderPollInterval: time.Duration(cfg.Execution.OrderPollIntervalMS) * time.Millisecond, - PassiveImproveTicks: cfg.Execution.PassiveImproveTicks, - MaxEntryOrderAttempts: cfg.Execution.MaxEntryOrderAttempts, - MaxExitOrderAttempts: cfg.Execution.MaxExitOrderAttempts, - MinTimeToClose: time.Duration(cfg.Execution.MinTimeToCloseSec) * time.Second, - MaxClockDrift: time.Duration(cfg.Risk.MaxClockDriftSec) * time.Second, - APIOutageHalt: time.Duration(cfg.Risk.APIOutageHaltSec) * time.Second, - RequireZeroCommission: cfg.Commission.RequireZeroCommission, - QuarantineOnNonZero: cfg.Commission.QuarantineOnNonZero, - FreeOrderCountPolicy: cfg.Commission.FreeOrderCountPolicy, - ReconciliationInterval: 5 * time.Minute, - MaxOpenPositions: minPositive(cfg.Strategy.MaxPositions, cfg.Risk.MaxOpenPositions), + Mode: cfg.App.Mode, + Location: cfg.Location, + RollingLong: cfg.Strategy.RollingLong, + IntervalVolumeLookbackDays: cfg.Strategy.IntervalVolumeLookbackDays, + TickInterval: 30 * time.Second, + EntrySignalTime: cfg.Execution.EntrySignalTime, + EntryWindowStart: cfg.Execution.EntryWindowStart, + EntryWindowEnd: cfg.Execution.EntryWindowEnd, + NoNewEntryAfter: cfg.Execution.NoNewEntryAfter, + ExitWatchStart: cfg.Execution.ExitWatchStart, + ExitNotBefore: cfg.Execution.ExitNotBefore, + ExitWindowStart: cfg.Execution.ExitWindowStart, + ExitWindowEnd: cfg.Execution.ExitWindowEnd, + HardExitDeadline: cfg.Execution.HardExitDeadline, + MarketClose: cfg.Execution.MarketClose, + QuoteDepth: cfg.Execution.QuoteDepth, + MaxQuoteAge: time.Duration(cfg.Execution.MaxQuoteAgeSec) * time.Second, + OrderPollInterval: time.Duration(cfg.Execution.OrderPollIntervalMS) * time.Millisecond, + PassiveImproveTicks: cfg.Execution.PassiveImproveTicks, + MaxEntryOrderAttempts: cfg.Execution.MaxEntryOrderAttempts, + MaxExitOrderAttempts: cfg.Execution.MaxExitOrderAttempts, + MinTimeToClose: time.Duration(cfg.Execution.MinTimeToCloseSec) * time.Second, + MaxClockDrift: time.Duration(cfg.Risk.MaxClockDriftSec) * time.Second, + APIOutageHalt: time.Duration(cfg.Risk.APIOutageHaltSec) * time.Second, + RequireZeroCommission: cfg.Commission.RequireZeroCommission, + QuarantineOnNonZero: cfg.Commission.QuarantineOnNonZero, + FreeOrderCountPolicy: cfg.Commission.FreeOrderCountPolicy, + 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) } diff --git a/internal/config/config.go b/internal/config/config.go index 4ef3fc6..d7f4a7f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -46,14 +46,15 @@ type AppConfig struct { } type TInvestConfig struct { - Token string `env:"TOKEN"` - AccountID string `env:"ACCOUNT_ID"` - Endpoint string `env:"ENDPOINT" envDefault:"invest-public-api.tinkoff.ru:443"` - AppName string `env:"APP_NAME" envDefault:"overnight-trading-bot"` - RequestTimeoutSec int `env:"REQUEST_TIMEOUT_SEC" envDefault:"10"` - RetryCount int `env:"RETRY_COUNT" envDefault:"3"` - RetryBackoffSec int `env:"RETRY_BACKOFF_SEC" envDefault:"2"` - UseSandbox bool `env:"USE_SANDBOX" envDefault:"false"` + Token string `env:"TOKEN"` + AccountID string `env:"ACCOUNT_ID"` + Endpoint string `env:"ENDPOINT" envDefault:"invest-public-api.tinkoff.ru:443"` + AppName string `env:"APP_NAME" envDefault:"overnight-trading-bot"` + RequestTimeoutSec int `env:"REQUEST_TIMEOUT_SEC" envDefault:"10"` + RetryCount int `env:"RETRY_COUNT" envDefault:"3"` + RetryBackoffSec int `env:"RETRY_BACKOFF_SEC" envDefault:"2"` + UseSandbox bool `env:"USE_SANDBOX" envDefault:"false"` + TradingCalendarExchange string `env:"TRADING_CALENDAR_EXCHANGE" envDefault:"MOEX"` } type DBConfig struct { @@ -74,15 +75,18 @@ type TelegramConfig struct { } type StrategyConfig struct { - RollingShort int `env:"ROLLING_SHORT" envDefault:"60"` - RollingLong int `env:"ROLLING_LONG" envDefault:"252"` - EWMALambda float64 `env:"EWMA_LAMBDA" envDefault:"0.08"` - AllocationMethod string `env:"ALLOCATION_METHOD" envDefault:"equal_weight"` - MinTStat60 decimal.Decimal `env:"MIN_TSTAT_60" envDefault:"1.25"` - MinWinRate60 decimal.Decimal `env:"MIN_WIN_RATE_60" envDefault:"0.55"` - MinNetEdgeBps decimal.Decimal `env:"MIN_NET_EDGE_BPS" envDefault:"10"` - RiskBufferBps decimal.Decimal `env:"RISK_BUFFER_BPS" envDefault:"5"` - MaxPositions int `env:"MAX_POSITIONS" envDefault:"5"` + RollingShort int `env:"ROLLING_SHORT" envDefault:"60"` + RollingLong int `env:"ROLLING_LONG" envDefault:"252"` + EWMALambda float64 `env:"EWMA_LAMBDA" envDefault:"0.08"` + AllocationMethod string `env:"ALLOCATION_METHOD" envDefault:"equal_weight"` + MinTStat60 decimal.Decimal `env:"MIN_TSTAT_60" envDefault:"1.25"` + MinWinRate60 decimal.Decimal `env:"MIN_WIN_RATE_60" envDefault:"0.55"` + MinNetEdgeBps decimal.Decimal `env:"MIN_NET_EDGE_BPS" envDefault:"10"` + RiskBufferBps decimal.Decimal `env:"RISK_BUFFER_BPS" 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 { @@ -124,6 +128,9 @@ type RiskConfig struct { CashUsageBuffer decimal.Decimal `env:"CASH_USAGE_BUFFER" envDefault:"0.95"` RiskBudgetPerInstrumentPct decimal.Decimal `env:"RISK_BUDGET_PER_INSTRUMENT_PCT" envDefault:"0.005"` 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 { @@ -194,6 +201,9 @@ func (c *Config) Validate() error { if c.TInvest.RequestTimeoutSec <= 0 { return errors.New("TINVEST_REQUEST_TIMEOUT_SEC must be positive") } + if c.TInvest.TradingCalendarExchange == "" { + c.TInvest.TradingCalendarExchange = "MOEX" + } if c.Execution.AllowMarketOrders { 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() { 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 == "" { c.Commission.FreeOrderCountPolicy = "submitted" } @@ -235,6 +257,18 @@ func (c *Config) Validate() error { if c.Strategy.AllocationMethod != "equal_weight" { 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 { return err } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index f623928..45ccb37 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -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 { return Config{ App: AppConfig{ diff --git a/internal/execution/engine.go b/internal/execution/engine.go index 689aaf6..d7ed7c2 100644 --- a/internal/execution/engine.go +++ b/internal/execution/engine.go @@ -37,6 +37,7 @@ type Engine struct { store repository.Repository maxQuoteAge time.Duration freeOrderCountPolicy string + maxExitOrderAttempts int clock timeutil.Clock 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) { if err := e.checkQuoteFresh(book); err != nil { return domain.Order{}, err @@ -116,7 +123,7 @@ func (e *Engine) PlaceEntry(ctx context.Context, accountIDHash string, instrumen Status: domain.OrderStatusNew, AttemptNo: attempt, 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) { @@ -143,14 +150,14 @@ func (e *Engine) PlaceExit(ctx context.Context, accountIDHash string, instrument Status: domain.OrderStatusNew, AttemptNo: attempt, RawStateJSON: orderContextJSON(book), - }, instrument.FreeOrderLimitPerDay) + }, instrument.FreeOrderLimitPerDay, 1) } 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.Lock() 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 { 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 { return domain.Order{}, err } @@ -587,6 +594,25 @@ func (e *Engine) ensureRepostBudget(ctx context.Context, order domain.Order, ins 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 { return e.freeOrderCountPolicy == FreeOrderPolicyCancelCounts } diff --git a/internal/execution/state_test.go b/internal/execution/state_test.go index e09f010..4c11812 100644 --- a/internal/execution/state_test.go +++ b/internal/execution/state_test.go @@ -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) { ctx := context.Background() repo := testutil.NewMemoryRepository() diff --git a/internal/features/pipeline.go b/internal/features/pipeline.go index f72aced..8e73f06 100644 --- a/internal/features/pipeline.go +++ b/internal/features/pipeline.go @@ -27,6 +27,7 @@ type PipelineConfig struct { EntryWindow timeutil.Window ExitWindow timeutil.Window IntervalVolumeLookback int + TradingDays []time.Time Location *time.Location } @@ -39,6 +40,11 @@ func NewPipeline(repo repository.Repository, cfg PipelineConfig) Pipeline { 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) { from := tradeDate.AddDate(0, 0, -p.cfg.RollingLong-5) 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 lastROn decimal.Decimal var lastRDay decimal.Decimal + calendar := tradingCalendarFrom(cfg.TradingDays) 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 } 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 } -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) currentDay := dateOnly(current) if !currentDay.After(prevDay) { 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 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 { diff --git a/internal/features/pipeline_test.go b/internal/features/pipeline_test.go index d7d1172..086f20c 100644 --- a/internal/features/pipeline_test.go +++ b/internal/features/pipeline_test.go @@ -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 { candles := make([]domain.Candle, 0, count) for i := 0; i < count; i++ { diff --git a/internal/logging/logging.go b/internal/logging/logging.go index 12e97a4..d9f4f9e 100644 --- a/internal/logging/logging.go +++ b/internal/logging/logging.go @@ -34,6 +34,12 @@ type SDKLogger struct { Logger *slog.Logger } +type NoopSDKLogger struct{} + +func NewSDKLogger(logger *slog.Logger) SDKLogger { + return SDKLogger{Logger: logger} +} + func (l SDKLogger) Infof(template string, args ...any) { if l.Logger != nil { 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{ regexp.MustCompile(`(?i)((?:account[_-]?id|token)\s*[:=]\s*)("[^"]+"|'[^']+'|[^\s,}]+)`), regexp.MustCompile(`(?i)("(?:accountID|accountId|account_id|token)"\s*:\s*)("[^"]*"|null)`), diff --git a/internal/logging/logging_test.go b/internal/logging/logging_test.go index 58ecea1..f0c165a 100644 --- a/internal/logging/logging_test.go +++ b/internal/logging/logging_test.go @@ -34,3 +34,26 @@ func TestSlogRedactsSensitiveAccountIDAttributes(t *testing.T) { 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") +} diff --git a/internal/repository/mysql/repository.go b/internal/repository/mysql/repository.go index aacbf3a..5e0bb1a 100644 --- a/internal/repository/mysql/repository.go +++ b/internal/repository/mysql/repository.go @@ -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 { + 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 { return nil } + if required < delta { + required = delta + } if limit <= 0 { return r.IncrementFreeOrders(ctx, tradeDate, instrumentUID, delta) } @@ -617,11 +624,11 @@ func (r *Repository) ReserveFreeOrders(ctx context.Context, tradeDate time.Time, if !ok { 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) if _, err := r.execer().ExecContext(ctx, ` INSERT IGNORE INTO free_order_counters (trade_date, instrument_uid, orders_sent) @@ -636,8 +643,8 @@ FOR UPDATE`, tradeDay, instrumentUID); err != nil { return err } remaining := limit - sent - if remaining < delta { - return fmt.Errorf("%w: %s remaining=%d needed=%d", risk.ErrFreeOrderBudget, instrumentUID, remaining, delta) + if remaining < required { + return fmt.Errorf("%w: %s remaining=%d needed=%d", risk.ErrFreeOrderBudget, instrumentUID, remaining, required) } _, err := r.execer().ExecContext(ctx, ` UPDATE free_order_counters diff --git a/internal/repository/repository.go b/internal/repository/repository.go index 911cc4a..641655e 100644 --- a/internal/repository/repository.go +++ b/internal/repository/repository.go @@ -39,6 +39,7 @@ type Repository interface { GetFreeOrdersSent(ctx context.Context, tradeDate time.Time, instrumentUID string) (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 + 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) SaveSystemState(ctx context.Context, state domain.SystemState, mode domain.Mode, halted bool, reason string, contextJSON string) error diff --git a/internal/risk/manager.go b/internal/risk/manager.go index 566c33b..868a4fc 100644 --- a/internal/risk/manager.go +++ b/internal/risk/manager.go @@ -3,6 +3,7 @@ package risk import ( "context" "fmt" + "os" "time" "github.com/shopspring/decimal" @@ -10,6 +11,8 @@ import ( "overnight-trading-bot/internal/domain" ) +var exitProcess = os.Exit + type EventSink interface { 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 @@ -63,6 +66,11 @@ func (m Manager) Halt(ctx context.Context, mode domain.Mode, eventType, reason s if m.sink == 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{ TS: time.Now().UTC(), 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 { 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 } diff --git a/internal/risk/manager_test.go b/internal/risk/manager_test.go index ef51411..9cda2ea 100644 --- a/internal/risk/manager_test.go +++ b/internal/risk/manager_test.go @@ -1,6 +1,8 @@ package risk import ( + "context" + "errors" "testing" "time" @@ -9,6 +11,69 @@ import ( "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) { manager := NewManager(nil, ManagerConfig{MaxOpenPositions: 1}) input := PreTradeInput{ diff --git a/internal/scheduler/scheduler.go b/internal/scheduler/scheduler.go index d77edbd..f9ecbfc 100644 --- a/internal/scheduler/scheduler.go +++ b/internal/scheduler/scheduler.go @@ -29,42 +29,40 @@ import ( "overnight-trading-bot/internal/tinvest" ) -const ( - sizeReductionWindowTrades = 20 - sizeReductionFactor = 0.5 - sizeReductionTriggerBps = -10 - intervalVolumeLookbackDays = 20 -) - type Config struct { - Mode domain.Mode - Location *time.Location - RollingLong int - TickInterval time.Duration - EntrySignalTime timeutil.TimeOfDay - EntryWindowStart timeutil.TimeOfDay - EntryWindowEnd timeutil.TimeOfDay - NoNewEntryAfter timeutil.TimeOfDay - ExitWatchStart timeutil.TimeOfDay - ExitNotBefore timeutil.TimeOfDay - ExitWindowStart timeutil.TimeOfDay - ExitWindowEnd timeutil.TimeOfDay - HardExitDeadline timeutil.TimeOfDay - MarketClose timeutil.TimeOfDay - QuoteDepth int32 - MaxQuoteAge time.Duration - OrderPollInterval time.Duration - PassiveImproveTicks int - MaxEntryOrderAttempts int - MaxExitOrderAttempts int - MinTimeToClose time.Duration - MaxClockDrift time.Duration - APIOutageHalt time.Duration - RequireZeroCommission bool - QuarantineOnNonZero bool - FreeOrderCountPolicy string - ReconciliationInterval time.Duration - MaxOpenPositions int + Mode domain.Mode + Location *time.Location + RollingLong int + IntervalVolumeLookbackDays int + TickInterval time.Duration + EntrySignalTime timeutil.TimeOfDay + EntryWindowStart timeutil.TimeOfDay + EntryWindowEnd timeutil.TimeOfDay + NoNewEntryAfter timeutil.TimeOfDay + ExitWatchStart timeutil.TimeOfDay + ExitNotBefore timeutil.TimeOfDay + ExitWindowStart timeutil.TimeOfDay + ExitWindowEnd timeutil.TimeOfDay + HardExitDeadline timeutil.TimeOfDay + MarketClose timeutil.TimeOfDay + QuoteDepth int32 + MaxQuoteAge time.Duration + OrderPollInterval time.Duration + PassiveImproveTicks int + MaxEntryOrderAttempts int + MaxExitOrderAttempts int + MinTimeToClose time.Duration + MaxClockDrift time.Duration + APIOutageHalt time.Duration + RequireZeroCommission bool + QuarantineOnNonZero bool + FreeOrderCountPolicy string + ReconciliationInterval time.Duration + MaxOpenPositions int + SizeReductionWindowTrades int + SizeReductionFactor decimal.Decimal + SizeReductionTriggerBps decimal.Decimal + TradingCalendarExchange string } type Services struct { @@ -113,6 +111,21 @@ func New(clock timeutil.Clock, sm statemachine.System, cfg Config, svc Services) if cfg.ReconciliationInterval <= 0 { 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} } @@ -234,10 +247,16 @@ func (s *Scheduler) prepareSignals(ctx context.Context, now time.Time) error { if err != nil { 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 } - 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) 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) @@ -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 { - 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 { 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)) return nil } - factor := decimal.NewFromFloat(sizeReductionFactor) + factor := s.sizeReductionFactor() s.svc.Sizer = s.svc.Sizer.WithSizeFactor(factor) if emitEvent { 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 { 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 { 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) } if !emitRecommendation { @@ -1466,9 +1489,9 @@ func (s *Scheduler) activateLiveReadonly(ctx context.Context, averageError decim return nil } message := fmt.Sprintf( - "average expected_error_bps stayed below %d for two consecutive %d-trade windows; switching to live_readonly", - sizeReductionTriggerBps, - sizeReductionWindowTrades, + "average expected_error_bps stayed below %s for two consecutive %d-trade windows; switching to live_readonly", + s.sizeReductionTriggerBps().String(), + s.sizeReductionWindowTrades(), ) s.cfg.Mode = domain.ModeLiveReadonly if s.svc.Execution != nil { @@ -1563,6 +1586,27 @@ func (s Scheduler) maxOrderAttemptsPerTrade() int { 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 { if attempts <= 0 { attempts = 1 diff --git a/internal/scheduler/scheduler_test.go b/internal/scheduler/scheduler_test.go index 79597d4..452d6c4 100644 --- a/internal/scheduler/scheduler_test.go +++ b/internal/scheduler/scheduler_test.go @@ -21,6 +21,8 @@ import ( "overnight-trading-bot/internal/tinvest" ) +const sizeReductionWindowTrades = 20 + func TestPhaseUsesMoscowWindows(t *testing.T) { loc := time.FixedZone("MSK", 3*60*60) s := Scheduler{cfg: Config{ diff --git a/internal/testutil/memory_repository.go b/internal/testutil/memory_repository.go index 773035f..d2ee22a 100644 --- a/internal/testutil/memory_repository.go +++ b/internal/testutil/memory_repository.go @@ -264,14 +264,21 @@ func (r *MemoryRepository) IncrementFreeOrders(_ context.Context, tradeDate time 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() defer r.mu.Unlock() if delta <= 0 { return nil } + if required < delta { + required = delta + } key := featureKey(instrumentUID, tradeDate) - if limit > 0 && r.FreeOrders[key]+delta > limit { + if limit > 0 && limit-r.FreeOrders[key] < required { return risk.ErrFreeOrderBudget } r.FreeOrders[key] += delta diff --git a/internal/tinvest/gateway.go b/internal/tinvest/gateway.go index c8e2dc5..8d8d3e6 100644 --- a/internal/tinvest/gateway.go +++ b/internal/tinvest/gateway.go @@ -17,6 +17,7 @@ var ErrNotFound = errors.New("not found") type Gateway interface { 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) + GetTradingDays(ctx context.Context, exchange string, from, to time.Time) ([]time.Time, error) GetOrderBook(ctx context.Context, instrumentUID string, depth int32) (domain.OrderBook, 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) @@ -34,6 +35,8 @@ type FakeGateway struct { InstrumentErrors map[string]error Candles map[string][]domain.Candle CandleErrors map[string]error + TradingDays []time.Time + TradingDayError error OrderBooks map[string]domain.OrderBook Statuses map[string]domain.TradingStatus Orders map[string]domain.Order @@ -88,6 +91,22 @@ func (f *FakeGateway) GetCandles(_ context.Context, instrumentUID string, _ stri 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) { f.mu.Lock() defer f.mu.Unlock() @@ -226,6 +245,11 @@ func (f *FakeGateway) GetServerTime(context.Context) (time.Time, error) { 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 { return status == domain.OrderStatusFilled || status == domain.OrderStatusCancelled || diff --git a/internal/tinvest/paper.go b/internal/tinvest/paper.go index 8d32dc8..30b5c79 100644 --- a/internal/tinvest/paper.go +++ b/internal/tinvest/paper.go @@ -39,6 +39,13 @@ func (g *PaperGateway) GetCandles(ctx context.Context, instrumentUID string, int 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) { if g.market != nil { return g.market.GetOrderBook(ctx, instrumentUID, depth) diff --git a/internal/tinvest/real.go b/internal/tinvest/real.go index e9c6919..e3b0f66 100644 --- a/internal/tinvest/real.go +++ b/internal/tinvest/real.go @@ -59,7 +59,7 @@ func NewRealGateway(ctx context.Context, opts Options) (*RealGateway, error) { AppName: opts.AppName, AccountId: opts.AccountID, MaxRetries: 0, - }, logging.SDKLogger{Logger: opts.Logger}) + }, logging.NewSDKLogger(opts.Logger)) if err != nil { return nil, err } @@ -156,6 +156,37 @@ func (g *RealGateway) GetCandles(ctx context.Context, instrumentUID string, inte 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) { if err := ctx.Err(); err != nil { return domain.OrderBook{}, err