thirteenth version

This commit is contained in:
2026-06-09 21:04:01 +00:00
parent f877907b20
commit 4dec14f57c
19 changed files with 602 additions and 110 deletions
+37 -31
View File
@@ -239,8 +239,8 @@ func buildScheduler(clock timeutil.Clock, sm statemachine.System, cfg config.Con
RollingLong: cfg.Strategy.RollingLong,
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
View File
@@ -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
}
+42
View File
@@ -44,6 +44,48 @@ func TestValidateLiveTradeAcceptsAllPreconditions(t *testing.T) {
}
}
func TestLoadKeepsStrategyExpectedSlippageSeparateFromBacktest(t *testing.T) {
t.Setenv("APP_MODE", "backtest")
t.Setenv("STRATEGY_EXPECTED_ENTRY_SLIPPAGE_BPS", "2")
t.Setenv("STRATEGY_EXPECTED_EXIT_SLIPPAGE_BPS", "3")
t.Setenv("BT_ENTRY_SLIPPAGE_BPS", "11")
t.Setenv("BT_EXIT_SLIPPAGE_BPS", "13")
cfg, err := Load()
if err != nil {
t.Fatal(err)
}
if !cfg.Strategy.ExpectedEntrySlippageBps.Equal(decimal.NewFromInt(2)) || !cfg.Strategy.ExpectedExitSlippageBps.Equal(decimal.NewFromInt(3)) {
t.Fatalf("strategy slippage entry=%s exit=%s, want 2/3", cfg.Strategy.ExpectedEntrySlippageBps, cfg.Strategy.ExpectedExitSlippageBps)
}
if !cfg.Backtest.EntrySlippageBps.Equal(decimal.NewFromInt(11)) || !cfg.Backtest.ExitSlippageBps.Equal(decimal.NewFromInt(13)) {
t.Fatalf("backtest slippage entry=%s exit=%s, want 11/13", cfg.Backtest.EntrySlippageBps, cfg.Backtest.ExitSlippageBps)
}
}
func TestLoadSchedulerKnobsFromEnv(t *testing.T) {
t.Setenv("APP_MODE", "backtest")
t.Setenv("STRATEGY_INTERVAL_VOLUME_LOOKBACK_DAYS", "12")
t.Setenv("RISK_SIZE_REDUCTION_WINDOW_TRADES", "7")
t.Setenv("RISK_SIZE_REDUCTION_FACTOR", "0.25")
t.Setenv("RISK_SIZE_REDUCTION_TRIGGER_BPS", "-5")
t.Setenv("TINVEST_TRADING_CALENDAR_EXCHANGE", "MOEX_FOND")
cfg, err := Load()
if err != nil {
t.Fatal(err)
}
if cfg.Strategy.IntervalVolumeLookbackDays != 12 || cfg.Risk.SizeReductionWindowTrades != 7 {
t.Fatalf("window config strategy=%d risk=%d, want 12/7", cfg.Strategy.IntervalVolumeLookbackDays, cfg.Risk.SizeReductionWindowTrades)
}
if !cfg.Risk.SizeReductionFactor.Equal(decimal.RequireFromString("0.25")) || !cfg.Risk.SizeReductionTriggerBps.Equal(decimal.NewFromInt(-5)) {
t.Fatalf("size reduction factor=%s trigger=%s, want 0.25/-5", cfg.Risk.SizeReductionFactor, cfg.Risk.SizeReductionTriggerBps)
}
if cfg.TInvest.TradingCalendarExchange != "MOEX_FOND" {
t.Fatalf("calendar exchange=%q, want MOEX_FOND", cfg.TInvest.TradingCalendarExchange)
}
}
func minimalBrokerConfig(mode domain.Mode) Config {
return Config{
App: AppConfig{
+31 -5
View File
@@ -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
}
+88
View File
@@ -111,6 +111,94 @@ func TestPlaceEntryReservesFreeOrderBudgetAtomically(t *testing.T) {
}
}
func TestPlaceEntryRequiresExitFreeOrderBudget(t *testing.T) {
ctx := context.Background()
repo := testutil.NewMemoryRepository()
gateway := tinvest.NewFakeGateway()
engine := NewEngine(domain.ModeSandbox, "account", gateway, repo)
engine.SetMaxExitOrderAttempts(3)
instrument := domain.Instrument{
InstrumentUID: "uid",
Lot: 1,
MinPriceIncrement: decimal.NewFromInt(1),
FreeOrderLimitPerDay: 3,
}
book := domain.OrderBook{
InstrumentUID: "uid",
Bids: []domain.OrderBookLevel{{Price: decimal.NewFromInt(99), QuantityLots: 10}},
Asks: []domain.OrderBookLevel{{Price: decimal.NewFromInt(101), QuantityLots: 10}},
ReceivedAt: time.Now().UTC(),
}
tradeDate := time.Date(2026, 6, 6, 0, 0, 0, 0, time.UTC)
_, err := engine.PlaceEntry(ctx, "hash", instrument, tradeDate, 1, book, 1, 1)
if !errors.Is(err, risk.ErrFreeOrderBudget) {
t.Fatalf("expected free order budget error, got %v", err)
}
if got := len(gateway.Orders); got != 0 {
t.Fatalf("broker orders=%d, want no post", got)
}
}
func TestPlaceEntryExitBudgetCountsFutureCancels(t *testing.T) {
ctx := context.Background()
repo := testutil.NewMemoryRepository()
gateway := tinvest.NewFakeGateway()
engine := NewEngine(domain.ModeSandbox, "account", gateway, repo)
engine.SetMaxExitOrderAttempts(3)
engine.SetFreeOrderCountPolicy(FreeOrderPolicyCancelCounts)
instrument := domain.Instrument{
InstrumentUID: "uid",
Lot: 1,
MinPriceIncrement: decimal.NewFromInt(1),
FreeOrderLimitPerDay: 5,
}
book := domain.OrderBook{
InstrumentUID: "uid",
Bids: []domain.OrderBookLevel{{Price: decimal.NewFromInt(99), QuantityLots: 10}},
Asks: []domain.OrderBookLevel{{Price: decimal.NewFromInt(101), QuantityLots: 10}},
ReceivedAt: time.Now().UTC(),
}
tradeDate := time.Date(2026, 6, 6, 0, 0, 0, 0, time.UTC)
_, err := engine.PlaceEntry(ctx, "hash", instrument, tradeDate, 1, book, 1, 1)
if !errors.Is(err, risk.ErrFreeOrderBudget) {
t.Fatalf("expected free order budget error, got %v", err)
}
if got := len(gateway.Orders); got != 0 {
t.Fatalf("broker orders=%d, want no post", got)
}
}
func TestPlaceEntryWithExitBudgetIncrementsOnlySubmittedEntry(t *testing.T) {
ctx := context.Background()
repo := testutil.NewMemoryRepository()
gateway := tinvest.NewFakeGateway()
engine := NewEngine(domain.ModeSandbox, "account", gateway, repo)
engine.SetMaxExitOrderAttempts(3)
instrument := domain.Instrument{
InstrumentUID: "uid",
Lot: 1,
MinPriceIncrement: decimal.NewFromInt(1),
FreeOrderLimitPerDay: 4,
}
book := domain.OrderBook{
InstrumentUID: "uid",
Bids: []domain.OrderBookLevel{{Price: decimal.NewFromInt(99), QuantityLots: 10}},
Asks: []domain.OrderBookLevel{{Price: decimal.NewFromInt(101), QuantityLots: 10}},
ReceivedAt: time.Now().UTC(),
}
tradeDate := time.Date(2026, 6, 6, 0, 0, 0, 0, time.UTC)
if _, err := engine.PlaceEntry(ctx, "hash", instrument, tradeDate, 1, book, 1, 1); err != nil {
t.Fatal(err)
}
sent, err := repo.GetFreeOrdersSent(ctx, tradeDate, "uid")
if err != nil {
t.Fatal(err)
}
if sent != 1 {
t.Fatalf("free order counter=%d, want only submitted entry counted", sent)
}
}
func TestPlaceEntryReleasesFreeOrderBudgetWhenBrokerRejects(t *testing.T) {
ctx := context.Background()
repo := testutil.NewMemoryRepository()
+31 -2
View File
@@ -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 {
+41
View File
@@ -174,6 +174,47 @@ func TestComputeAllowsWeekendGap(t *testing.T) {
}
}
func TestComputeAllowsHolidayGapWithTradingCalendar(t *testing.T) {
monday := time.Date(2026, 1, 5, 0, 0, 0, 0, time.UTC)
wednesday := monday.AddDate(0, 0, 2)
candles := []domain.Candle{
{InstrumentUID: "uid", TradeDate: monday, Open: decimal.NewFromInt(100), Close: decimal.NewFromInt(100), VolumeLots: decimal.NewFromInt(1)},
{InstrumentUID: "uid", TradeDate: wednesday, Open: decimal.NewFromInt(101), Close: decimal.NewFromInt(100), VolumeLots: decimal.NewFromInt(1)},
}
got, err := Compute(domain.Instrument{InstrumentUID: "uid", Lot: 1}, candles, wednesday.AddDate(0, 0, 1), SpreadResult{}, PipelineConfig{
RollingShort: 1,
RollingLong: 1,
EWMALambda: 0.08,
TradingDays: []time.Time{monday, wednesday},
}, decimal.Zero, decimal.Zero)
if err != nil {
t.Fatal(err)
}
want := decimal.RequireFromString("0.01")
if !got.ROn.Equal(want) {
t.Fatalf("ROn=%s, want %s across holiday gap", got.ROn, want)
}
}
func TestComputeRejectsMissingTradingDayWithTradingCalendar(t *testing.T) {
monday := time.Date(2026, 1, 5, 0, 0, 0, 0, time.UTC)
tuesday := monday.AddDate(0, 0, 1)
wednesday := monday.AddDate(0, 0, 2)
candles := []domain.Candle{
{InstrumentUID: "uid", TradeDate: monday, Open: decimal.NewFromInt(100), Close: decimal.NewFromInt(100), VolumeLots: decimal.NewFromInt(1)},
{InstrumentUID: "uid", TradeDate: wednesday, Open: decimal.NewFromInt(101), Close: decimal.NewFromInt(100), VolumeLots: decimal.NewFromInt(1)},
}
_, err := Compute(domain.Instrument{InstrumentUID: "uid", Lot: 1}, candles, wednesday.AddDate(0, 0, 1), SpreadResult{}, PipelineConfig{
RollingShort: 1,
RollingLong: 1,
EWMALambda: 0.08,
TradingDays: []time.Time{monday, tuesday, wednesday},
}, decimal.Zero, decimal.Zero)
if err == nil {
t.Fatal("expected missing trading day to make overnight pair unavailable")
}
}
func flatCandles(start time.Time, count int) []domain.Candle {
candles := make([]domain.Candle, 0, count)
for i := 0; i < count; i++ {
+10
View File
@@ -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)`),
+23
View File
@@ -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")
}
+11 -4
View File
@@ -606,9 +606,16 @@ ON DUPLICATE KEY UPDATE orders_sent=orders_sent+VALUES(orders_sent)`, dateOnly(t
}
func (r *Repository) ReserveFreeOrders(ctx context.Context, tradeDate time.Time, instrumentUID string, delta int, limit int) error {
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
+1
View File
@@ -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
+8 -3
View File
@@ -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
}
+65
View File
@@ -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{
+89 -45
View File
@@ -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
+2
View File
@@ -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{
+9 -2
View File
@@ -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
+24
View File
@@ -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 ||
+7
View File
@@ -39,6 +39,13 @@ func (g *PaperGateway) GetCandles(ctx context.Context, instrumentUID string, int
return g.Fake().GetCandles(ctx, instrumentUID, interval, from, to)
}
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)
+32 -1
View File
@@ -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