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