thirteenth version
This commit is contained in:
+37
-31
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
+51
-17
@@ -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
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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++ {
|
||||
|
||||
@@ -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)`),
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user