second version

This commit is contained in:
2026-06-07 21:51:20 +00:00
parent 8e2d7efc32
commit 282c841e11
23 changed files with 869 additions and 151 deletions
+1
View File
@@ -66,6 +66,7 @@ RISK_API_OUTAGE_HALT_SEC=180
RISK_MAX_CLOCK_DRIFT_SEC=2 RISK_MAX_CLOCK_DRIFT_SEC=2
RISK_RECONCILIATION_WINDOW_HOURS=72 RISK_RECONCILIATION_WINDOW_HOURS=72
RISK_RECONCILIATION_SKEW_SEC=10 RISK_RECONCILIATION_SKEW_SEC=10
RISK_COMMISSION_TOLERANCE_RUB=0.01
RISK_CASH_USAGE_BUFFER=0.95 RISK_CASH_USAGE_BUFFER=0.95
RISK_RISK_BUDGET_PER_INSTRUMENT_PCT=0.005 RISK_RISK_BUDGET_PER_INSTRUMENT_PCT=0.005
RISK_MIN_ORDER_NOTIONAL_RUB=1000 RISK_MIN_ORDER_NOTIONAL_RUB=1000
+4 -3
View File
@@ -40,7 +40,7 @@ APP_MODE=backtest go run ./cmd/bot
| Переменная | Что указывать | Дефолт | Границы/валидация | За что отвечает и что меняется | | Переменная | Что указывать | Дефолт | Границы/валидация | За что отвечает и что меняется |
| --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- |
| `TINVEST_TOKEN` | токен T-Invest API | пусто | обязателен для `sandbox`, `live_readonly`, `live_trade` | Доступ к реальному или sandbox API. В `paper` и `backtest` не нужен. | | `TINVEST_TOKEN` | токен T-Invest API | пусто | обязателен для `sandbox`, `live_readonly`, `live_trade` | Доступ к реальному или sandbox API. В `paper` и `backtest` не нужен. |
| `TINVEST_ACCOUNT_ID` | идентификатор брокерского счёта | пусто | строка; в коде непустота не проверяется | Счёт для портфеля, заявок и сверки. Для API-режимов нужно указать реальный account id, иначе операции у брокера могут падать. | | `TINVEST_ACCOUNT_ID` | идентификатор брокерского счёта | пусто | обязателен для `sandbox`, `live_readonly`, `live_trade` | Счёт для портфеля, заявок и сверки. Для API-режимов бот падает на старте, если account id не указан. |
| `TINVEST_ENDPOINT` | gRPC endpoint T-Invest, обычно `host:port` | `invest-public-api.tinkoff.ru:443` | строка; валидации формата нет | Endpoint для API. В `sandbox` код принудительно использует sandbox endpoint. | | `TINVEST_ENDPOINT` | gRPC endpoint T-Invest, обычно `host:port` | `invest-public-api.tinkoff.ru:443` | строка; валидации формата нет | Endpoint для API. В `sandbox` код принудительно использует sandbox endpoint. |
| `TINVEST_APP_NAME` | имя приложения | `overnight-trading-bot` | строка | Передаётся в SDK как имя клиента. Меняет идентификацию приложения на стороне API/логов. | | `TINVEST_APP_NAME` | имя приложения | `overnight-trading-bot` | строка | Передаётся в SDK как имя клиента. Меняет идентификацию приложения на стороне API/логов. |
| `TINVEST_REQUEST_TIMEOUT_SEC` | целое число секунд | `10` | рекомендуется `> 0`; сейчас не применяется | Зарезервировано под таймаут API-запросов. На текущий код не влияет. | | `TINVEST_REQUEST_TIMEOUT_SEC` | целое число секунд | `10` | рекомендуется `> 0`; сейчас не применяется | Зарезервировано под таймаут API-запросов. На текущий код не влияет. |
@@ -121,6 +121,7 @@ APP_MODE=backtest go run ./cmd/bot
| `RISK_MAX_CLOCK_DRIFT_SEC` | целое число секунд | `2` | `> 0` включает проверку drift; `<= 0` отключает | Максимальный рассинхрон локального времени и серверного времени API в `/ready`. | | `RISK_MAX_CLOCK_DRIFT_SEC` | целое число секунд | `2` | `> 0` включает проверку drift; `<= 0` отключает | Максимальный рассинхрон локального времени и серверного времени API в `/ready`. |
| `RISK_RECONCILIATION_WINDOW_HOURS` | целое число часов | `72` | должно быть `> 0` | Глубина сверки последних заявок и операций брокера. Больше - больше история сверки, но тяжелее запросы. | | `RISK_RECONCILIATION_WINDOW_HOURS` | целое число часов | `72` | должно быть `> 0` | Глубина сверки последних заявок и операций брокера. Больше - больше история сверки, но тяжелее запросы. |
| `RISK_RECONCILIATION_SKEW_SEC` | целое число секунд | `10` | `>= 0` | Grace-window для только что отправленных локальных заявок: свежие in-flight orders не считаются diff, пока брокерский active-list догоняет запись. | | `RISK_RECONCILIATION_SKEW_SEC` | целое число секунд | `10` | `>= 0` | Grace-window для только что отправленных локальных заявок: свежие in-flight orders не считаются diff, пока брокерский active-list догоняет запись. |
| `RISK_COMMISSION_TOLERANCE_RUB` | сумма в рублях | `0.01` | `>= 0` | Допуск для reconciliation по расхождению локальной и брокерской комиссии. Ненулевая брокерская комиссия всё равно считается нарушением при `COMM_REQUIRE_ZERO_COMMISSION=true`. |
| `RISK_CASH_USAGE_BUFFER` | доля cash | `0.95` | рекомендуется `0..1`; `0` запрещает использование cash | Какая часть свободных денег может идти в sizing. Меньше - больше денежный буфер. | | `RISK_CASH_USAGE_BUFFER` | доля cash | `0.95` | рекомендуется `0..1`; `0` запрещает использование cash | Какая часть свободных денег может идти в sizing. Меньше - больше денежный буфер. |
| `RISK_RISK_BUDGET_PER_INSTRUMENT_PCT` | доля equity | `0.005` | рекомендуется `> 0` | Риск-бюджет на инструмент, используется вместе с оценкой неблагоприятного overnight-движения. Больше - крупнее позиции при прочих равных. | | `RISK_RISK_BUDGET_PER_INSTRUMENT_PCT` | доля equity | `0.005` | рекомендуется `> 0` | Риск-бюджет на инструмент, используется вместе с оценкой неблагоприятного overnight-движения. Больше - крупнее позиции при прочих равных. |
| `RISK_MIN_ORDER_NOTIONAL_RUB` | сумма в рублях | `1000` | `> 0` включает минимум; `<= 0` фактически отключает | Минимальный notional заявки. Если рассчитанная позиция меньше, сигнал отклоняется по sizing. | | `RISK_MIN_ORDER_NOTIONAL_RUB` | сумма в рублях | `1000` | `> 0` включает минимум; `<= 0` фактически отключает | Минимальный notional заявки. Если рассчитанная позиция меньше, сигнал отклоняется по sizing. |
@@ -144,7 +145,7 @@ APP_MODE=backtest go run ./cmd/bot
| Переменная | Что указывать | Дефолт | Границы/валидация | За что отвечает и что меняется | | Переменная | Что указывать | Дефолт | Границы/валидация | За что отвечает и что меняется |
| --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- |
| `COMM_REQUIRE_ZERO_COMMISSION` | `true` или `false` | `true` | boolean | При `true` сигналы по инструментам с ожидаемой комиссией `> 0` отклоняются. | | `COMM_REQUIRE_ZERO_COMMISSION` | `true` или `false` | `true` | boolean | При `true` сигналы по инструментам с ожидаемой комиссией `> 0` отклоняются. |
| `COMM_QUARANTINE_ON_NONZERO` | `true` или `false` | `true` | boolean; сейчас не применяется | Зарезервировано под автоматический quarantine при ненулевой комиссии. На текущий код не влияет. | | `COMM_QUARANTINE_ON_NONZERO` | `true` или `false` | `true` | boolean | При фактической брокерской комиссии `> 0` инструмент переводится в quarantine, а система останавливается через HALT по zero-commission policy. |
| `COMM_FREE_ORDER_COUNT_POLICY` | `submitted` | `submitted` | жёстко только `submitted` | Политика учёта бесплатных заявок: счётчик увеличивается при отправке заявки. Другие значения запрещены валидацией. | | `COMM_FREE_ORDER_COUNT_POLICY` | `submitted` | `submitted` | жёстко только `submitted` | Политика учёта бесплатных заявок: счётчик увеличивается при отправке заявки. Другие значения запрещены валидацией. |
### BT ### BT
@@ -192,7 +193,7 @@ TRUR,2024-01-09,100,101,99,100.5,10000
Для minute-модели используется тот же формат, но `trade_date` может быть timestamp (`2024-01-09T18:25:00Z` или `2024-01-09 18:25:00`). Для minute-модели используется тот же формат, но `trade_date` может быть timestamp (`2024-01-09T18:25:00Z` или `2024-01-09 18:25:00`).
`ClientOrderID` детерминирован по `(date, instrument_uid, side, attempt)` и содержит 8 hex символов SHA-256. Для дневного числа retry этого достаточно; при ручных массовых перезапусках с теми же параметрами id остаётся тем же, что намеренно подавляет дубли. `ClientOrderID` детерминирован по `(date, instrument_uid, side, attempt)`, укладывается в лимит T-Invest `order_id <= 36` и содержит SHA-256 suffix. При ручных массовых перезапусках с теми же параметрами id остаётся тем же, что намеренно подавляет дубли.
## Deploy ## Deploy
+32 -26
View File
@@ -114,8 +114,9 @@ func Run(ctx context.Context, opts Options) error {
} }
accountIDHash := accountHash(cfg.TInvest.AccountID) accountIDHash := accountHash(cfg.TInvest.AccountID)
recon := reconciliation.New(repo, gateway, cfg.TInvest.AccountID, accountIDHash). recon := reconciliation.New(repo, gateway, cfg.TInvest.AccountID, accountIDHash).
WithWindow(time.Duration(cfg.Risk.ReconciliationWindowHours) * time.Hour). WithWindow(time.Duration(cfg.Risk.ReconciliationWindowHours)*time.Hour).
WithInFlightGrace(time.Duration(cfg.Risk.ReconciliationSkewSec) * time.Second) WithInFlightGrace(time.Duration(cfg.Risk.ReconciliationSkewSec)*time.Second).
WithCommissionPolicy(cfg.Commission.RequireZeroCommission, cfg.Commission.QuarantineOnNonZero, cfg.Risk.CommissionToleranceRUB)
diffs, err := recon.Run(ctx) diffs, err := recon.Run(ctx)
if err != nil { if err != nil {
return fmt.Errorf("pre-unhalt reconciliation: %w", err) return fmt.Errorf("pre-unhalt reconciliation: %w", err)
@@ -155,8 +156,9 @@ func Run(ctx context.Context, opts Options) error {
accountIDHash := accountHash(cfg.TInvest.AccountID) accountIDHash := accountHash(cfg.TInvest.AccountID)
recon := reconciliation.New(repo, gateway, cfg.TInvest.AccountID, accountIDHash). recon := reconciliation.New(repo, gateway, cfg.TInvest.AccountID, accountIDHash).
WithWindow(time.Duration(cfg.Risk.ReconciliationWindowHours) * time.Hour). WithWindow(time.Duration(cfg.Risk.ReconciliationWindowHours)*time.Hour).
WithInFlightGrace(time.Duration(cfg.Risk.ReconciliationSkewSec) * time.Second) WithInFlightGrace(time.Duration(cfg.Risk.ReconciliationSkewSec)*time.Second).
WithCommissionPolicy(cfg.Commission.RequireZeroCommission, cfg.Commission.QuarantineOnNonZero, cfg.Risk.CommissionToleranceRUB)
sm := statemachine.New(repo, cfg.App.Mode) sm := statemachine.New(repo, cfg.App.Mode)
if _, err := sm.Recover(ctx, recon); err != nil { if _, err := sm.Recover(ctx, recon); err != nil {
log.Warn("state recovery did not resume trading", "err", err) log.Warn("state recovery did not resume trading", "err", err)
@@ -201,7 +203,8 @@ 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,
}, },
Location: cfg.Location, IntervalVolumeLookback: 20,
Location: cfg.Location,
}) })
signalEngine := signalengine.New(signalengine.Config{ signalEngine := signalengine.New(signalengine.Config{
MinTStat60: cfg.Strategy.MinTStat60, MinTStat60: cfg.Strategy.MinTStat60,
@@ -255,27 +258,30 @@ 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, TickInterval: 30 * time.Second,
EntrySignalTime: cfg.Execution.EntrySignalTime, EntrySignalTime: cfg.Execution.EntrySignalTime,
EntryWindowStart: cfg.Execution.EntryWindowStart, EntryWindowStart: cfg.Execution.EntryWindowStart,
EntryWindowEnd: cfg.Execution.EntryWindowEnd, EntryWindowEnd: cfg.Execution.EntryWindowEnd,
NoNewEntryAfter: cfg.Execution.NoNewEntryAfter, NoNewEntryAfter: cfg.Execution.NoNewEntryAfter,
ExitWatchStart: cfg.Execution.ExitWatchStart, ExitWatchStart: cfg.Execution.ExitWatchStart,
ExitWindowStart: cfg.Execution.ExitWindowStart, ExitWindowStart: cfg.Execution.ExitWindowStart,
ExitWindowEnd: cfg.Execution.ExitWindowEnd, ExitWindowEnd: cfg.Execution.ExitWindowEnd,
HardExitDeadline: cfg.Execution.HardExitDeadline, HardExitDeadline: cfg.Execution.HardExitDeadline,
QuoteDepth: cfg.Execution.QuoteDepth, QuoteDepth: cfg.Execution.QuoteDepth,
MaxQuoteAge: time.Duration(cfg.Execution.MaxQuoteAgeSec) * time.Second, MaxQuoteAge: time.Duration(cfg.Execution.MaxQuoteAgeSec) * time.Second,
OrderPollInterval: time.Duration(cfg.Execution.OrderPollIntervalMS) * time.Millisecond, OrderPollInterval: time.Duration(cfg.Execution.OrderPollIntervalMS) * time.Millisecond,
PassiveImproveTicks: cfg.Execution.PassiveImproveTicks, PassiveImproveTicks: cfg.Execution.PassiveImproveTicks,
MaxEntryOrderAttempts: cfg.Execution.MaxEntryOrderAttempts, MaxEntryOrderAttempts: cfg.Execution.MaxEntryOrderAttempts,
MaxExitOrderAttempts: cfg.Execution.MaxExitOrderAttempts, MaxExitOrderAttempts: cfg.Execution.MaxExitOrderAttempts,
MinTimeToClose: time.Duration(cfg.Execution.MinTimeToCloseSec) * time.Second, MinTimeToClose: time.Duration(cfg.Execution.MinTimeToCloseSec) * time.Second,
MaxClockDrift: time.Duration(cfg.Risk.MaxClockDriftSec) * time.Second, MaxClockDrift: time.Duration(cfg.Risk.MaxClockDriftSec) * time.Second,
APIOutageHalt: time.Duration(cfg.Risk.APIOutageHaltSec) * time.Second, APIOutageHalt: time.Duration(cfg.Risk.APIOutageHaltSec) * time.Second,
RequireZeroCommission: cfg.Commission.RequireZeroCommission,
QuarantineOnNonZero: cfg.Commission.QuarantineOnNonZero,
ReconciliationInterval: 5 * time.Minute,
}, services) }, services)
} }
+51 -13
View File
@@ -45,6 +45,13 @@ type Config struct {
AssumedTickBps decimal.Decimal AssumedTickBps decimal.Decimal
Lot int64 Lot int64
UseMinuteModel bool UseMinuteModel bool
EntryWindow TimeWindow
ExitWindow TimeWindow
}
type TimeWindow struct {
Start time.Duration
End time.Duration
} }
type Trade struct { type Trade struct {
@@ -142,6 +149,12 @@ func (cfg Config) withDefaults() Config {
if cfg.Lot == 0 { if cfg.Lot == 0 {
cfg.Lot = 1 cfg.Lot = 1
} }
if cfg.EntryWindow.Start == 0 && cfg.EntryWindow.End == 0 {
cfg.EntryWindow = TimeWindow{Start: durationOfDay(18, 20, 0), End: durationOfDay(18, 38, 30)}
}
if cfg.ExitWindow.Start == 0 && cfg.ExitWindow.End == 0 {
cfg.ExitWindow = TimeWindow{Start: durationOfDay(10, 5, 0), End: durationOfDay(10, 25, 0)}
}
return cfg return cfg
} }
@@ -153,8 +166,12 @@ func (e Engine) RunWithMinuteCandles(candlesByInstrument map[string][]domain.Can
prepared := prepareCandles(candlesByInstrument) prepared := prepareCandles(candlesByInstrument)
preparedMinutes := prepareCandles(minuteCandlesByInstrument) preparedMinutes := prepareCandles(minuteCandlesByInstrument)
candidatesByExitDate := make(map[string][]candidate) candidatesByExitDate := make(map[string][]candidate)
tradingDateSet := make(map[string]struct{})
for instrumentUID, candles := range prepared { for instrumentUID, candles := range prepared {
for i := 1; i < len(candles); i++ { for i := 1; i < len(candles); i++ {
if i >= e.cfg.RollingShort {
tradingDateSet[candles[i].TradeDate.Format("2006-01-02")] = struct{}{}
}
candidate, ok, err := e.evaluateCandidate(instrumentUID, candles, i) candidate, ok, err := e.evaluateCandidate(instrumentUID, candles, i)
if err != nil { if err != nil {
return Result{}, err return Result{}, err
@@ -164,8 +181,8 @@ func (e Engine) RunWithMinuteCandles(candlesByInstrument map[string][]domain.Can
} }
} }
} }
dates := make([]string, 0, len(candidatesByExitDate)) dates := make([]string, 0, len(tradingDateSet))
for date := range candidatesByExitDate { for date := range tradingDateSet {
dates = append(dates, date) dates = append(dates, date)
} }
sort.Strings(dates) sort.Strings(dates)
@@ -239,15 +256,17 @@ func (e Engine) RunWithMinuteCandles(candlesByInstrument map[string][]domain.Can
CapacityRUB: capacity, CapacityRUB: capacity,
}) })
} }
if !dayPnL.IsZero() { equity = equity.Add(dayPnL)
equity = equity.Add(dayPnL) cash = equity
cash = equity dayReturn := decimal.Zero
points = append(points, Point{ if dayStartEquity.IsPositive() {
Date: date, dayReturn = dayPnL.Div(dayStartEquity)
Equity: equity,
Return: dayPnL.Div(dayStartEquity),
})
} }
points = append(points, Point{
Date: date,
Equity: equity,
Return: dayReturn,
})
} }
sort.Slice(trades, func(i, j int) bool { sort.Slice(trades, func(i, j int) bool {
if trades[i].ExitDate == trades[j].ExitDate { if trades[i].ExitDate == trades[j].ExitDate {
@@ -266,8 +285,8 @@ func (e Engine) minuteExecution(c candidate, minutes []domain.Candle, requestedL
if requestedLots <= 0 || len(minutes) == 0 { if requestedLots <= 0 || len(minutes) == 0 {
return 0, decimal.Zero, false return 0, decimal.Zero, false
} }
entryLots, entryCapacity := e.fillableMinuteLots(minutes, c.entry.TradeDate, c.buy, domain.SideBuy) entryLots, entryCapacity := e.fillableMinuteLots(minutes, c.entry.TradeDate, c.buy, domain.SideBuy, e.cfg.EntryWindow)
exitLots, exitCapacity := e.fillableMinuteLots(minutes, c.exit.TradeDate, c.sell, domain.SideSell) exitLots, exitCapacity := e.fillableMinuteLots(minutes, c.exit.TradeDate, c.sell, domain.SideSell, e.cfg.ExitWindow)
lots := min(requestedLots, entryLots) lots := min(requestedLots, entryLots)
lots = min(lots, exitLots) lots = min(lots, exitLots)
if lots <= 0 { if lots <= 0 {
@@ -276,7 +295,7 @@ func (e Engine) minuteExecution(c candidate, minutes []domain.Candle, requestedL
return lots, money.Min(entryCapacity, exitCapacity), true return lots, money.Min(entryCapacity, exitCapacity), true
} }
func (e Engine) fillableMinuteLots(minutes []domain.Candle, date time.Time, limitPrice decimal.Decimal, side domain.Side) (int64, decimal.Decimal) { func (e Engine) fillableMinuteLots(minutes []domain.Candle, date time.Time, limitPrice decimal.Decimal, side domain.Side, window TimeWindow) (int64, decimal.Decimal) {
if !limitPrice.IsPositive() || e.cfg.Lot <= 0 { if !limitPrice.IsPositive() || e.cfg.Lot <= 0 {
return 0, decimal.Zero return 0, decimal.Zero
} }
@@ -289,6 +308,9 @@ func (e Engine) fillableMinuteLots(minutes []domain.Candle, date time.Time, limi
if !sameDate(candle.TradeDate, date) { if !sameDate(candle.TradeDate, date) {
continue continue
} }
if !window.Contains(candle.TradeDate) {
continue
}
reachable := side == domain.SideBuy && candle.Low.LessThanOrEqual(limitPrice) reachable := side == domain.SideBuy && candle.Low.LessThanOrEqual(limitPrice)
reachable = reachable || side == domain.SideSell && candle.High.GreaterThanOrEqual(limitPrice) reachable = reachable || side == domain.SideSell && candle.High.GreaterThanOrEqual(limitPrice)
if !reachable { if !reachable {
@@ -300,6 +322,22 @@ func (e Engine) fillableMinuteLots(minutes []domain.Candle, date time.Time, limi
return capacity.Div(lotNotional).Floor().IntPart(), capacity return capacity.Div(lotNotional).Floor().IntPart(), capacity
} }
func (w TimeWindow) Contains(ts time.Time) bool {
if w.Start == 0 && w.End == 0 {
return true
}
tod := time.Duration(ts.Hour())*time.Hour +
time.Duration(ts.Minute())*time.Minute +
time.Duration(ts.Second())*time.Second
return tod >= w.Start && tod <= w.End
}
func durationOfDay(hour, minute, second int) time.Duration {
return time.Duration(hour)*time.Hour +
time.Duration(minute)*time.Minute +
time.Duration(second)*time.Second
}
type candidate struct { type candidate struct {
instrumentUID string instrumentUID string
entry domain.Candle entry domain.Candle
+1
View File
@@ -51,6 +51,7 @@ func TestMinuteExecutionRequiresReachableLimitAndParticipation(t *testing.T) {
} }
minutes := []domain.Candle{ minutes := []domain.Candle{
{TradeDate: entryDate, Low: decimal.NewFromInt(99), High: decimal.NewFromInt(101), VolumeLots: decimal.NewFromInt(20)}, {TradeDate: entryDate, Low: decimal.NewFromInt(99), High: decimal.NewFromInt(101), VolumeLots: decimal.NewFromInt(20)},
{TradeDate: time.Date(2024, 1, 2, 12, 0, 0, 0, time.UTC), Low: decimal.NewFromInt(1), High: decimal.NewFromInt(200), VolumeLots: decimal.NewFromInt(1_000_000)},
{TradeDate: exitDate, Low: decimal.NewFromInt(104), High: decimal.NewFromInt(106), VolumeLots: decimal.NewFromInt(20)}, {TradeDate: exitDate, Low: decimal.NewFromInt(104), High: decimal.NewFromInt(106), VolumeLots: decimal.NewFromInt(20)},
} }
lots, capacity, ok := engine.minuteExecution(c, minutes, 5) lots, capacity, ok := engine.minuteExecution(c, minutes, 5)
+7
View File
@@ -112,6 +112,7 @@ type RiskConfig struct {
MaxClockDriftSec int `env:"MAX_CLOCK_DRIFT_SEC" envDefault:"2"` MaxClockDriftSec int `env:"MAX_CLOCK_DRIFT_SEC" envDefault:"2"`
ReconciliationWindowHours int `env:"RECONCILIATION_WINDOW_HOURS" envDefault:"72"` ReconciliationWindowHours int `env:"RECONCILIATION_WINDOW_HOURS" envDefault:"72"`
ReconciliationSkewSec int `env:"RECONCILIATION_SKEW_SEC" envDefault:"10"` ReconciliationSkewSec int `env:"RECONCILIATION_SKEW_SEC" envDefault:"10"`
CommissionToleranceRUB decimal.Decimal `env:"COMMISSION_TOLERANCE_RUB" envDefault:"0.01"`
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"`
@@ -198,6 +199,9 @@ func (c *Config) Validate() error {
if c.Risk.ReconciliationSkewSec < 0 { if c.Risk.ReconciliationSkewSec < 0 {
return errors.New("RISK_RECONCILIATION_SKEW_SEC must be non-negative") return errors.New("RISK_RECONCILIATION_SKEW_SEC must be non-negative")
} }
if c.Risk.CommissionToleranceRUB.IsNegative() {
return errors.New("RISK_COMMISSION_TOLERANCE_RUB must be non-negative")
}
if c.Commission.FreeOrderCountPolicy != "submitted" { if c.Commission.FreeOrderCountPolicy != "submitted" {
return fmt.Errorf("COMM_FREE_ORDER_COUNT_POLICY must be submitted, got %q", c.Commission.FreeOrderCountPolicy) return fmt.Errorf("COMM_FREE_ORDER_COUNT_POLICY must be submitted, got %q", c.Commission.FreeOrderCountPolicy)
} }
@@ -210,6 +214,9 @@ func (c *Config) Validate() error {
if (c.App.Mode == domain.ModeSandbox || c.App.Mode == domain.ModeLiveReadonly || c.App.Mode == domain.ModeLiveTrade) && c.TInvest.Token == "" { if (c.App.Mode == domain.ModeSandbox || c.App.Mode == domain.ModeLiveReadonly || c.App.Mode == domain.ModeLiveTrade) && c.TInvest.Token == "" {
return fmt.Errorf("TINVEST_TOKEN is required for APP_MODE=%s", c.App.Mode) return fmt.Errorf("TINVEST_TOKEN is required for APP_MODE=%s", c.App.Mode)
} }
if (c.App.Mode == domain.ModeSandbox || c.App.Mode == domain.ModeLiveReadonly || c.App.Mode == domain.ModeLiveTrade) && c.TInvest.AccountID == "" {
return fmt.Errorf("TINVEST_ACCOUNT_ID is required for APP_MODE=%s", c.App.Mode)
}
if c.TInvest.UseSandbox && c.App.Mode != domain.ModeSandbox { if c.TInvest.UseSandbox && c.App.Mode != domain.ModeSandbox {
return errors.New("TINVEST_USE_SANDBOX=true is only valid with APP_MODE=sandbox") return errors.New("TINVEST_USE_SANDBOX=true is only valid with APP_MODE=sandbox")
} }
+63
View File
@@ -0,0 +1,63 @@
package config
import (
"strings"
"testing"
"github.com/shopspring/decimal"
"overnight-trading-bot/internal/domain"
"overnight-trading-bot/internal/timeutil"
)
func TestValidateRequiresAccountIDForBrokerModes(t *testing.T) {
cfg := minimalBrokerConfig(domain.ModeSandbox)
cfg.TInvest.AccountID = ""
err := cfg.Validate()
if err == nil || !strings.Contains(err.Error(), "TINVEST_ACCOUNT_ID") {
t.Fatalf("Validate err=%v, want TINVEST_ACCOUNT_ID requirement", err)
}
}
func minimalBrokerConfig(mode domain.Mode) Config {
return Config{
App: AppConfig{
Mode: mode,
Timezone: "Europe/Moscow",
ShutdownTimeoutSec: 30,
},
TInvest: TInvestConfig{
Token: "token",
AccountID: "account",
},
DB: DBConfig{DSN: "user:pass@tcp(localhost:3306)/bot"},
Execution: ExecutionConfig{
EntrySignalTime: mustTOD("18:10:00"),
EntryWindowStart: mustTOD("18:20:00"),
EntryWindowEnd: mustTOD("18:38:30"),
NoNewEntryAfter: mustTOD("18:38:30"),
ExitWatchStart: mustTOD("09:50:00"),
ExitNotBefore: mustTOD("10:03:00"),
ExitWindowStart: mustTOD("10:05:00"),
ExitWindowEnd: mustTOD("10:25:00"),
HardExitDeadline: mustTOD("10:45:00"),
QuoteDepth: 20,
OrderPollIntervalMS: 500,
},
Risk: RiskConfig{
APIOutageHaltSec: 180,
ReconciliationWindowHours: 72,
ReconciliationSkewSec: 10,
CommissionToleranceRUB: decimal.NewFromFloat(0.01),
},
Commission: CommissionConfig{FreeOrderCountPolicy: "submitted"},
}
}
func mustTOD(raw string) timeutil.TimeOfDay {
tod, err := timeutil.ParseTimeOfDay(raw)
if err != nil {
panic(err)
}
return tod
}
+46 -7
View File
@@ -11,6 +11,7 @@ import (
"overnight-trading-bot/internal/domain" "overnight-trading-bot/internal/domain"
"overnight-trading-bot/internal/repository" "overnight-trading-bot/internal/repository"
"overnight-trading-bot/internal/risk"
) )
var ErrBrokerOrdersDisabled = errors.New("broker orders are disabled for current mode") var ErrBrokerOrdersDisabled = errors.New("broker orders are disabled for current mode")
@@ -113,6 +114,9 @@ func (e *Engine) PlaceLimit(ctx context.Context, order domain.Order) (domain.Ord
return existing, nil return existing, nil
} }
} }
if e.mode == domain.ModePaper {
return e.placePaperLimit(ctx, order)
}
if !e.mode.AllowsBrokerOrders() { if !e.mode.AllowsBrokerOrders() {
order.Status = domain.OrderStatusNew order.Status = domain.OrderStatusNew
if e.store != nil { if e.store != nil {
@@ -159,6 +163,28 @@ func (e *Engine) PlaceLimit(ctx context.Context, order domain.Order) (domain.Ord
return posted, nil return posted, nil
} }
func (e *Engine) placePaperLimit(ctx context.Context, order domain.Order) (domain.Order, error) {
now := time.Now().UTC()
order.BrokerOrderID = "paper-" + order.ClientOrderID
order.FilledLots = order.QuantityLots
order.AvgFillPrice = order.LimitPrice
order.Status = domain.OrderStatusFilled
order.RawStateJSON = `{"paper_fill":true}`
order.CreatedAt = now
order.UpdatedAt = now
if e.store != nil {
if err := e.store.RunInTx(ctx, func(ctx context.Context, repo repository.Repository) error {
if err := repo.UpsertOrder(ctx, order); err != nil {
return fmt.Errorf("persist paper order: %w", err)
}
return repo.IncrementFreeOrders(ctx, order.TradeDate, order.InstrumentUID, 1)
}); err != nil {
return domain.Order{}, err
}
}
return order, nil
}
func (e *Engine) findExisting(ctx context.Context, order domain.Order) (domain.Order, error) { func (e *Engine) findExisting(ctx context.Context, order domain.Order) (domain.Order, error) {
orders, err := e.store.ListOrders(ctx, order.AccountIDHash, order.TradeDate, order.TradeDate) orders, err := e.store.ListOrders(ctx, order.AccountIDHash, order.TradeDate, order.TradeDate)
if err != nil { if err != nil {
@@ -286,6 +312,9 @@ func (e *Engine) MonitorUntil(ctx context.Context, order domain.Order, cfg Monit
} }
func (e *Engine) repost(ctx context.Context, order domain.Order, cfg MonitorConfig, remaining int64) (domain.Order, error) { func (e *Engine) repost(ctx context.Context, order domain.Order, cfg MonitorConfig, remaining int64) (domain.Order, error) {
if err := e.ensureRepostBudget(ctx, order, cfg.Instrument); err != nil {
return domain.Order{}, err
}
if err := e.Cancel(ctx, order); err != nil { if err := e.Cancel(ctx, order); err != nil {
return domain.Order{}, err return domain.Order{}, err
} }
@@ -308,18 +337,28 @@ func (e *Engine) repost(ctx context.Context, order domain.Order, cfg MonitorConf
} }
} }
func (e *Engine) ensureRepostBudget(ctx context.Context, order domain.Order, instrument domain.Instrument) error {
if e.store == nil || instrument.FreeOrderLimitPerDay <= 0 {
return nil
}
sent, err := e.store.GetFreeOrdersSent(ctx, order.TradeDate, instrument.InstrumentUID)
if err != nil {
return err
}
if instrument.FreeOrderLimitPerDay-sent < 1 {
return fmt.Errorf("%w: %s remaining=0", risk.ErrFreeOrderBudget, instrument.InstrumentUID)
}
return nil
}
func (e *Engine) checkQuoteFresh(book domain.OrderBook) error { func (e *Engine) checkQuoteFresh(book domain.OrderBook) error {
if e.maxQuoteAge <= 0 { if e.maxQuoteAge <= 0 {
return nil return nil
} }
receivedAt := book.ReceivedAt if book.ReceivedAt.IsZero() {
if receivedAt.IsZero() { return fmt.Errorf("quote received timestamp is missing")
receivedAt = book.Time
} }
if receivedAt.IsZero() { age := time.Since(book.ReceivedAt)
return fmt.Errorf("quote timestamp is missing")
}
age := time.Since(receivedAt)
if age > e.maxQuoteAge { if age > e.maxQuoteAge {
return fmt.Errorf("quote age %s exceeds %s", age, e.maxQuoteAge) return fmt.Errorf("quote age %s exceeds %s", age, e.maxQuoteAge)
} }
+22 -7
View File
@@ -4,7 +4,7 @@ import (
"crypto/sha256" "crypto/sha256"
"encoding/hex" "encoding/hex"
"fmt" "fmt"
"regexp" "strconv"
"strings" "strings"
"time" "time"
@@ -14,7 +14,7 @@ import (
"overnight-trading-bot/internal/money" "overnight-trading-bot/internal/money"
) )
var nonIDChar = regexp.MustCompile(`[^A-Za-z0-9_-]+`) const maxClientOrderIDLen = 36
func LimitBuyPrice(bestBid, bestAsk, tick decimal.Decimal, improveTicks int) (decimal.Decimal, error) { func LimitBuyPrice(bestBid, bestAsk, tick decimal.Decimal, improveTicks int) (decimal.Decimal, error) {
if improveTicks < 0 { if improveTicks < 0 {
@@ -49,10 +49,25 @@ func LimitSellPrice(bestBid, bestAsk, tick decimal.Decimal, improveTicks int) (d
func ClientOrderID(tradeDate time.Time, instrumentUID string, side domain.Side, attempt int) string { func ClientOrderID(tradeDate time.Time, instrumentUID string, side domain.Side, attempt int) string {
base := fmt.Sprintf("%s|%s|%s|%d", tradeDate.Format("20060102"), instrumentUID, side, attempt) base := fmt.Sprintf("%s|%s|%s|%d", tradeDate.Format("20060102"), instrumentUID, side, attempt)
sum := sha256.Sum256([]byte(base)) sum := sha256.Sum256([]byte(base))
suffix := hex.EncodeToString(sum[:])[:8] suffix := hex.EncodeToString(sum[:])
cleanUID := nonIDChar.ReplaceAllString(instrumentUID, "_") sideToken := "b"
if len(cleanUID) > 24 { if side == domain.SideSell {
cleanUID = cleanUID[:24] sideToken = "s"
} }
return strings.ToLower(fmt.Sprintf("otb-%s-%s-%s-%02d-%s", tradeDate.Format("20060102"), cleanUID, side, attempt, suffix)) prefix := fmt.Sprintf("otb-%s-%s-%s-", tradeDate.Format("20060102"), sideToken, attemptToken(attempt))
return strings.ToLower(prefix + suffix[:maxClientOrderIDLen-len(prefix)])
}
func attemptToken(attempt int) string {
if attempt < 0 {
attempt = 0
}
token := strings.ToLower(strconv.FormatInt(int64(attempt), 36))
if len(token) > 2 {
token = token[len(token)-2:]
}
for len(token) < 2 {
token = "0" + token
}
return token
} }
+7 -3
View File
@@ -40,10 +40,14 @@ func TestLimitPricesDoNotCross(t *testing.T) {
func TestClientOrderIDDeterministic(t *testing.T) { func TestClientOrderIDDeterministic(t *testing.T) {
date := time.Date(2024, 1, 2, 0, 0, 0, 0, time.UTC) date := time.Date(2024, 1, 2, 0, 0, 0, 0, time.UTC)
a := ClientOrderID(date, "uid", domain.SideBuy, 1) longUID := "a-realistic-instrument-uid-that-is-much-longer-than-the-order-id-limit"
b := ClientOrderID(date, "uid", domain.SideBuy, 1) a := ClientOrderID(date, longUID, domain.SideBuy, 1)
c := ClientOrderID(date, "uid", domain.SideBuy, 2) b := ClientOrderID(date, longUID, domain.SideBuy, 1)
c := ClientOrderID(date, longUID, domain.SideBuy, 2)
if a != b || a == c { if a != b || a == c {
t.Fatalf("unexpected ids: %s %s %s", a, b, c) t.Fatalf("unexpected ids: %s %s %s", a, b, c)
} }
if len(a) > maxClientOrderIDLen {
t.Fatalf("client order id len=%d, want <=%d: %s", len(a), maxClientOrderIDLen, a)
}
} }
+30
View File
@@ -66,6 +66,36 @@ func TestPlaceLimitSuppressesDuplicateSubmit(t *testing.T) {
} }
} }
func TestPaperPlaceEntryFillsAndCountsSubmittedOrder(t *testing.T) {
ctx := context.Background()
repo := testutil.NewMemoryRepository()
engine := NewEngine(domain.ModePaper, "account", tinvest.NewFakeGateway(), repo)
tradeDate := time.Date(2026, 6, 6, 0, 0, 0, 0, time.UTC)
order, err := engine.PlaceEntry(ctx, "hash", domain.Instrument{
InstrumentUID: "uid",
Lot: 1,
MinPriceIncrement: decimal.NewFromInt(1),
}, tradeDate, 2, 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(),
}, 1, 1)
if err != nil {
t.Fatal(err)
}
if order.Status != domain.OrderStatusFilled || order.FilledLots != 2 || order.BrokerOrderID == "" {
t.Fatalf("paper order=%+v, want filled broker-like order", order)
}
sent, err := repo.GetFreeOrdersSent(ctx, tradeDate, "uid")
if err != nil {
t.Fatal(err)
}
if sent != 1 {
t.Fatalf("free order counter=%d, want 1", sent)
}
}
func TestPlaceEntryRejectsStaleQuote(t *testing.T) { func TestPlaceEntryRejectsStaleQuote(t *testing.T) {
ctx := context.Background() ctx := context.Background()
engine := NewEngine(domain.ModeSandbox, "account", tinvest.NewFakeGateway(), testutil.NewMemoryRepository()) engine := NewEngine(domain.ModeSandbox, "account", tinvest.NewFakeGateway(), testutil.NewMemoryRepository())
+45 -3
View File
@@ -3,6 +3,7 @@ package features
import ( import (
"context" "context"
"fmt" "fmt"
"sort"
"time" "time"
"github.com/shopspring/decimal" "github.com/shopspring/decimal"
@@ -12,6 +13,8 @@ import (
"overnight-trading-bot/internal/timeutil" "overnight-trading-bot/internal/timeutil"
) )
const defaultIntervalVolumeLookback = 20
type PipelineConfig struct { type PipelineConfig struct {
RollingShort int RollingShort int
RollingLong int RollingLong int
@@ -22,6 +25,7 @@ type PipelineConfig struct {
CommissionRoundtripBps decimal.Decimal CommissionRoundtripBps decimal.Decimal
EntryWindow timeutil.Window EntryWindow timeutil.Window
ExitWindow timeutil.Window ExitWindow timeutil.Window
IntervalVolumeLookback int
Location *time.Location Location *time.Location
} }
@@ -44,7 +48,7 @@ func (p Pipeline) Recompute(ctx context.Context, instrument domain.Instrument, t
if err != nil { if err != nil {
return domain.FeatureSet{}, err return domain.FeatureSet{}, err
} }
exitVolume, err := p.intervalVolume(ctx, instrument, tradeDate.AddDate(0, 0, 1), p.cfg.ExitWindow) exitVolume, err := p.intervalVolume(ctx, instrument, tradeDate, p.cfg.ExitWindow)
if err != nil { if err != nil {
return domain.FeatureSet{}, err return domain.FeatureSet{}, err
} }
@@ -66,13 +70,17 @@ func (p Pipeline) intervalVolume(ctx context.Context, instrument domain.Instrume
if loc == nil { if loc == nil {
loc = time.UTC loc = time.UTC
} }
from := window.Start.On(date, loc).UTC() lookback := p.cfg.IntervalVolumeLookback
if lookback <= 0 {
lookback = defaultIntervalVolumeLookback
}
from := window.Start.On(date.AddDate(0, 0, -lookback), loc).UTC()
to := window.End.On(date, loc).UTC() to := window.End.On(date, loc).UTC()
candles, err := p.repo.ListMinuteCandles(ctx, instrument.InstrumentUID, from, to) candles, err := p.repo.ListMinuteCandles(ctx, instrument.InstrumentUID, from, to)
if err != nil { if err != nil {
return decimal.Zero, err return decimal.Zero, err
} }
return IntervalVolume(candles, instrument.Lot), nil return AverageIntervalVolume(candles, instrument.Lot, window, loc), nil
} }
func Compute(instrument domain.Instrument, candles []domain.Candle, tradeDate time.Time, spread SpreadResult, cfg PipelineConfig, entryVolume, exitVolume decimal.Decimal) (domain.FeatureSet, error) { func Compute(instrument domain.Instrument, candles []domain.Candle, tradeDate time.Time, spread SpreadResult, cfg PipelineConfig, entryVolume, exitVolume decimal.Decimal) (domain.FeatureSet, error) {
@@ -146,3 +154,37 @@ func IntervalVolume(candles []domain.Candle, lot int64) decimal.Decimal {
} }
return total return total
} }
func AverageIntervalVolume(candles []domain.Candle, lot int64, window timeutil.Window, loc *time.Location) decimal.Decimal {
if lot <= 0 || len(candles) == 0 {
return decimal.Zero
}
if loc == nil {
loc = time.UTC
}
byDate := make(map[string][]domain.Candle)
for _, candle := range candles {
local := candle.TradeDate.In(loc)
tod := time.Duration(local.Hour())*time.Hour +
time.Duration(local.Minute())*time.Minute +
time.Duration(local.Second())*time.Second
if tod < window.Start.Duration || tod > window.End.Duration {
continue
}
key := local.Format("2006-01-02")
byDate[key] = append(byDate[key], candle)
}
if len(byDate) == 0 {
return decimal.Zero
}
keys := make([]string, 0, len(byDate))
for key := range byDate {
keys = append(keys, key)
}
sort.Strings(keys)
sum := decimal.Zero
for _, key := range keys {
sum = sum.Add(IntervalVolume(byDate[key], lot))
}
return sum.Div(decimal.NewFromInt(int64(len(keys))))
}
+26
View File
@@ -7,6 +7,7 @@ import (
"github.com/shopspring/decimal" "github.com/shopspring/decimal"
"overnight-trading-bot/internal/domain" "overnight-trading-bot/internal/domain"
"overnight-trading-bot/internal/timeutil"
) )
func TestComputeExpectedCostIncludesCommissionAndSlippage(t *testing.T) { func TestComputeExpectedCostIncludesCommissionAndSlippage(t *testing.T) {
@@ -55,3 +56,28 @@ func TestIntervalVolume(t *testing.T) {
t.Fatalf("interval volume=%s, want 6040", got) t.Fatalf("interval volume=%s, want 6040", got)
} }
} }
func TestAverageIntervalVolumeUsesExecutionWindowsAcrossDays(t *testing.T) {
loc := time.FixedZone("MSK", 3*60*60)
window := timeutil.Window{
Start: mustTOD("18:20:00"),
End: mustTOD("18:40:00"),
}
candles := []domain.Candle{
{TradeDate: time.Date(2026, 6, 1, 15, 20, 0, 0, time.UTC), Close: decimal.NewFromInt(100), VolumeLots: decimal.NewFromInt(10)},
{TradeDate: time.Date(2026, 6, 1, 15, 50, 0, 0, time.UTC), Close: decimal.NewFromInt(999), VolumeLots: decimal.NewFromInt(999)},
{TradeDate: time.Date(2026, 6, 2, 15, 25, 0, 0, time.UTC), Close: decimal.NewFromInt(200), VolumeLots: decimal.NewFromInt(10)},
}
got := AverageIntervalVolume(candles, 1, window, loc)
if !got.Equal(decimal.NewFromInt(1500)) {
t.Fatalf("average interval volume=%s, want 1500", got)
}
}
func mustTOD(raw string) timeutil.TimeOfDay {
tod, err := timeutil.ParseTimeOfDay(raw)
if err != nil {
panic(err)
}
return tod
}
+2 -1
View File
@@ -89,7 +89,8 @@ func CheckEndpoint(ctx context.Context, url string) error {
if err != nil { if err != nil {
return err return err
} }
resp, err := http.DefaultClient.Do(req) client := http.Client{Timeout: 5 * time.Second}
resp, err := client.Do(req)
if err != nil { if err != nil {
return err return err
} }
+13 -16
View File
@@ -3,7 +3,6 @@ package instruments
import ( import (
"context" "context"
"fmt" "fmt"
"strings"
"overnight-trading-bot/internal/domain" "overnight-trading-bot/internal/domain"
"overnight-trading-bot/internal/repository" "overnight-trading-bot/internal/repository"
@@ -25,21 +24,19 @@ func (r Registry) SyncMetadata(ctx context.Context) error {
return err return err
} }
for _, instrument := range instruments { for _, instrument := range instruments {
if strings.HasPrefix(instrument.InstrumentUID, "PENDING:") || !instrument.MetadataValid() { remote, err := r.gateway.GetInstrument(ctx, instrument.Ticker, instrument.ClassCode)
remote, err := r.gateway.GetInstrument(ctx, instrument.Ticker, instrument.ClassCode) if err != nil {
if err != nil { return fmt.Errorf("sync %s: %w", instrument.Ticker, err)
return fmt.Errorf("sync %s: %w", instrument.Ticker, err) }
} remote.Enabled = instrument.Enabled && remote.Enabled
remote.Enabled = instrument.Enabled && remote.Enabled remote.FundType = instrument.FundType
remote.FundType = instrument.FundType remote.ExpectedCommissionBpsPerSide = instrument.ExpectedCommissionBpsPerSide
remote.ExpectedCommissionBpsPerSide = instrument.ExpectedCommissionBpsPerSide remote.FreeOrderLimitPerDay = instrument.FreeOrderLimitPerDay
remote.FreeOrderLimitPerDay = instrument.FreeOrderLimitPerDay remote.Quarantine = instrument.Quarantine
remote.Quarantine = instrument.Quarantine remote.QuarantineReason = instrument.QuarantineReason
remote.QuarantineReason = instrument.QuarantineReason remote.ExcludeReason = instrument.ExcludeReason
remote.ExcludeReason = instrument.ExcludeReason if err := r.repo.ReplaceInstrument(ctx, instrument.InstrumentUID, remote); err != nil {
if err := r.repo.ReplaceInstrument(ctx, instrument.InstrumentUID, remote); err != nil { return fmt.Errorf("replace synced instrument %s: %w", instrument.Ticker, err)
return fmt.Errorf("replace synced instrument %s: %w", instrument.Ticker, err)
}
} }
} }
return nil return nil
+2 -2
View File
@@ -56,10 +56,10 @@ func (l Loader) LatestQuote(ctx context.Context, instrumentUID string, depth int
if err != nil { if err != nil {
return domain.OrderBook{}, err return domain.OrderBook{}, err
} }
age := time.Since(book.ReceivedAt)
if book.ReceivedAt.IsZero() { if book.ReceivedAt.IsZero() {
age = time.Since(book.Time) return domain.OrderBook{}, fmt.Errorf("quote received timestamp is missing")
} }
age := time.Since(book.ReceivedAt)
if maxAge > 0 && age > maxAge { if maxAge > 0 && age > maxAge {
return domain.OrderBook{}, fmt.Errorf("quote age %s exceeds %s", age, maxAge) return domain.OrderBook{}, fmt.Errorf("quote age %s exceeds %s", age, maxAge)
} }
+3
View File
@@ -59,6 +59,9 @@ type outbound struct {
func NewTelegram(cfg TelegramConfig, log *slog.Logger) (Notifier, error) { func NewTelegram(cfg TelegramConfig, log *slog.Logger) (Notifier, error) {
if cfg.BotToken == "" || cfg.ChatID == 0 { if cfg.BotToken == "" || cfg.ChatID == 0 {
if log != nil {
log.Warn("telegram notifier disabled; TELEGRAM_BOT_TOKEN or TELEGRAM_CHAT_ID is empty")
}
return Noop{}, nil return Noop{}, nil
} }
bot, err := tgbotapi.NewBotAPI(cfg.BotToken) bot, err := tgbotapi.NewBotAPI(cfg.BotToken)
+53 -9
View File
@@ -16,16 +16,26 @@ import (
) )
type Engine struct { type Engine struct {
repo repository.Repository repo repository.Repository
gateway tinvest.Gateway gateway tinvest.Gateway
accountID string accountID string
accountIDHash string accountIDHash string
window time.Duration window time.Duration
inFlightGrace time.Duration inFlightGrace time.Duration
commissionTolerance decimal.Decimal
requireZeroCommission bool
quarantineOnNonZero bool
} }
func New(repo repository.Repository, gateway tinvest.Gateway, accountID, accountIDHash string) Engine { func New(repo repository.Repository, gateway tinvest.Gateway, accountID, accountIDHash string) Engine {
return Engine{repo: repo, gateway: gateway, accountID: accountID, accountIDHash: accountIDHash, window: 72 * time.Hour} return Engine{
repo: repo,
gateway: gateway,
accountID: accountID,
accountIDHash: accountIDHash,
window: 72 * time.Hour,
commissionTolerance: decimal.NewFromFloat(0.01),
}
} }
func (e Engine) WithWindow(window time.Duration) Engine { func (e Engine) WithWindow(window time.Duration) Engine {
@@ -42,6 +52,15 @@ func (e Engine) WithInFlightGrace(grace time.Duration) Engine {
return e return e
} }
func (e Engine) WithCommissionPolicy(requireZero, quarantineOnNonZero bool, tolerance decimal.Decimal) Engine {
e.requireZeroCommission = requireZero
e.quarantineOnNonZero = quarantineOnNonZero
if !tolerance.IsNegative() {
e.commissionTolerance = tolerance
}
return e
}
func (e Engine) Run(ctx context.Context) ([]domain.ReconciliationDiff, error) { func (e Engine) Run(ctx context.Context) ([]domain.ReconciliationDiff, error) {
localOrders, err := e.repo.ListActiveOrders(ctx, e.accountIDHash) localOrders, err := e.repo.ListActiveOrders(ctx, e.accountIDHash)
if err != nil { if err != nil {
@@ -138,7 +157,17 @@ func (e Engine) Run(ctx context.Context) ([]domain.ReconciliationDiff, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
diffs = append(diffs, compareOperations(recentOrders, operations)...) diffs = append(diffs, compareOperationsWithPolicy(recentOrders, operations, e.requireZeroCommission, e.commissionTolerance)...)
if e.requireZeroCommission && e.quarantineOnNonZero {
for _, diff := range diffs {
if diff.Kind != "actual_commission_nonzero" || diff.InstrumentUID == "" {
continue
}
if err := e.repo.QuarantineInstrument(ctx, diff.InstrumentUID, diff.Message); err != nil {
return nil, err
}
}
}
raw, _ := json.Marshal(diffs) raw, _ := json.Marshal(diffs)
if err := e.repo.InsertReconciliation(ctx, now, string(raw), len(diffs) > 0); err != nil { if err := e.repo.InsertReconciliation(ctx, now, string(raw), len(diffs) > 0); err != nil {
return nil, err return nil, err
@@ -163,7 +192,14 @@ func HasCritical(diffs []domain.ReconciliationDiff) bool {
} }
func compareOperations(orders []domain.Order, operations []domain.Operation) []domain.ReconciliationDiff { func compareOperations(orders []domain.Order, operations []domain.Operation) []domain.ReconciliationDiff {
return compareOperationsWithPolicy(orders, operations, false, decimal.NewFromFloat(0.01))
}
func compareOperationsWithPolicy(orders []domain.Order, operations []domain.Operation, requireZeroCommission bool, commissionTolerance decimal.Decimal) []domain.ReconciliationDiff {
var diffs []domain.ReconciliationDiff var diffs []domain.ReconciliationDiff
if commissionTolerance.IsNegative() {
commissionTolerance = decimal.Zero
}
localCommissionByInstrument := make(map[string]decimal.Decimal) localCommissionByInstrument := make(map[string]decimal.Decimal)
localTraded := make(map[string]bool) localTraded := make(map[string]bool)
for _, order := range orders { for _, order := range orders {
@@ -192,7 +228,15 @@ func compareOperations(orders []domain.Order, operations []domain.Operation) []d
for instrumentUID := range instruments { for instrumentUID := range instruments {
localCommission := localCommissionByInstrument[instrumentUID] localCommission := localCommissionByInstrument[instrumentUID]
brokerCommission := brokerCommissionByInstrument[instrumentUID] brokerCommission := brokerCommissionByInstrument[instrumentUID]
if diff := money.Abs(localCommission.Sub(brokerCommission)); diff.GreaterThan(decimal.NewFromFloat(0.01)) { if requireZeroCommission && brokerCommission.IsPositive() {
diffs = append(diffs, domain.ReconciliationDiff{
Kind: "actual_commission_nonzero",
InstrumentUID: instrumentUID,
Message: fmt.Sprintf("broker commission=%s", brokerCommission.StringFixed(2)),
Critical: true,
})
}
if diff := money.Abs(localCommission.Sub(brokerCommission)); diff.GreaterThan(commissionTolerance) {
diffs = append(diffs, domain.ReconciliationDiff{ diffs = append(diffs, domain.ReconciliationDiff{
Kind: "commission_mismatch", Kind: "commission_mismatch",
InstrumentUID: instrumentUID, InstrumentUID: instrumentUID,
+41
View File
@@ -101,6 +101,47 @@ func TestCompareOperationsCommissionPerInstrument(t *testing.T) {
} }
} }
func TestReconciliationQuarantinesOnNonZeroBrokerCommission(t *testing.T) {
ctx := context.Background()
repo := testutil.NewMemoryRepository()
gateway := tinvest.NewFakeGateway()
if err := repo.UpsertInstrument(ctx, domain.Instrument{
InstrumentUID: "uid",
Ticker: "TRUR",
Enabled: true,
}); err != nil {
t.Fatal(err)
}
gateway.Operations = []domain.Operation{{
InstrumentUID: "uid",
Type: "OPERATION_TYPE_BROKER_FEE",
Commission: decimal.NewFromFloat(0.01),
ExecutedAt: time.Now().UTC(),
}}
diffs, err := New(repo, gateway, "account", "hash").
WithCommissionPolicy(true, true, decimal.NewFromFloat(0.01)).
Run(ctx)
if err != nil {
t.Fatal(err)
}
found := false
for _, diff := range diffs {
if diff.Kind == "actual_commission_nonzero" && diff.Critical {
found = true
}
}
if !found {
t.Fatalf("expected actual_commission_nonzero diff, got %+v", diffs)
}
instruments, err := repo.ListInstruments(ctx, true)
if err != nil {
t.Fatal(err)
}
if len(instruments) != 1 || !instruments[0].Quarantine {
t.Fatalf("instrument not quarantined: %+v", instruments)
}
}
func TestReconciliationSkipsFreshInFlightLocalOrders(t *testing.T) { func TestReconciliationSkipsFreshInFlightLocalOrders(t *testing.T) {
ctx := context.Background() ctx := context.Background()
repo := testutil.NewMemoryRepository() repo := testutil.NewMemoryRepository()
+119 -1
View File
@@ -1,7 +1,9 @@
package report package report
import ( import (
"encoding/json"
"fmt" "fmt"
"sort"
"strings" "strings"
"time" "time"
@@ -15,6 +17,7 @@ type DailyInput struct {
Mode domain.Mode Mode domain.Mode
Signals []domain.Signal Signals []domain.Signal
Positions []domain.Position Positions []domain.Position
Orders []domain.Order
AverageSpreadBps decimal.Decimal AverageSpreadBps decimal.Decimal
AverageSlipBps decimal.Decimal AverageSlipBps decimal.Decimal
RiskStatus string RiskStatus string
@@ -28,19 +31,134 @@ func ComposeDaily(input DailyInput) string {
for _, signal := range input.Signals { for _, signal := range input.Signals {
fmt.Fprintf(&b, "- %s %s edge=%s reason=%s\n", signal.InstrumentUID, signal.Decision, signal.NetEdgeBps.StringFixed(2), signal.RejectReason) fmt.Fprintf(&b, "- %s %s edge=%s reason=%s\n", signal.InstrumentUID, signal.Decision, signal.NetEdgeBps.StringFixed(2), signal.RejectReason)
} }
reasons := groupedReasons(input.Signals)
if len(reasons) > 0 {
fmt.Fprintf(&b, "Причины skip/reject:\n")
for _, reason := range sortedKeys(reasons) {
count := reasons[reason]
fmt.Fprintf(&b, "- %s: %d\n", reason, count)
}
}
gross := decimal.Zero gross := decimal.Zero
net := decimal.Zero net := decimal.Zero
commission := decimal.Zero commission := decimal.Zero
expectedByInstrument := expectedEdgeByInstrument(input.Signals)
for _, pos := range input.Positions { for _, pos := range input.Positions {
gross = gross.Add(pos.GrossPnL) gross = gross.Add(pos.GrossPnL)
net = net.Add(pos.NetPnL) net = net.Add(pos.NetPnL)
commission = commission.Add(pos.CommissionTotal) commission = commission.Add(pos.CommissionTotal)
} }
if len(input.Positions) > 0 {
fmt.Fprintf(&b, "Позиции:\n")
for _, pos := range input.Positions {
expected := expectedByInstrument[pos.InstrumentUID]
expectedError := pos.RealizedEdgeBps.Sub(expected)
fmt.Fprintf(&b, "- %s status=%s net=%s commission=%s realized_edge_bps=%s expected_error_bps=%s\n",
pos.InstrumentUID,
pos.Status,
pos.NetPnL.StringFixed(2),
pos.CommissionTotal.StringFixed(2),
pos.RealizedEdgeBps.StringFixed(2),
expectedError.StringFixed(2),
)
}
}
fmt.Fprintf(&b, "Gross PnL: %s\n", gross.StringFixed(2)) fmt.Fprintf(&b, "Gross PnL: %s\n", gross.StringFixed(2))
fmt.Fprintf(&b, "Net PnL: %s\n", net.StringFixed(2)) fmt.Fprintf(&b, "Net PnL: %s\n", net.StringFixed(2))
fmt.Fprintf(&b, "Комиссии: %s\n", commission.StringFixed(2)) fmt.Fprintf(&b, "Комиссии: %s\n", commission.StringFixed(2))
fmt.Fprintf(&b, "Средний spread: %s bps\n", input.AverageSpreadBps.StringFixed(2)) averageSpread := input.AverageSpreadBps
if averageSpread.IsZero() {
averageSpread = averageContextDecimal(input.Signals, "spread_bps")
}
fmt.Fprintf(&b, "Средний spread: %s bps\n", averageSpread.StringFixed(2))
fmt.Fprintf(&b, "Среднее проскальзывание: %s bps\n", input.AverageSlipBps.StringFixed(2)) fmt.Fprintf(&b, "Среднее проскальзывание: %s bps\n", input.AverageSlipBps.StringFixed(2))
writeExecutionErrors(&b, input.Orders)
fmt.Fprintf(&b, "Risk: %s", input.RiskStatus) fmt.Fprintf(&b, "Risk: %s", input.RiskStatus)
return b.String() return b.String()
} }
func groupedReasons(signals []domain.Signal) map[string]int {
out := make(map[string]int)
for _, sig := range signals {
if sig.Decision == domain.DecisionEnter || sig.RejectReason == "" {
continue
}
out[sig.RejectReason]++
}
return out
}
func sortedKeys(values map[string]int) []string {
keys := make([]string, 0, len(values))
for key := range values {
keys = append(keys, key)
}
sort.Strings(keys)
return keys
}
func expectedEdgeByInstrument(signals []domain.Signal) map[string]decimal.Decimal {
out := make(map[string]decimal.Decimal)
for _, sig := range signals {
if sig.Decision == domain.DecisionEnter {
out[sig.InstrumentUID] = sig.NetEdgeBps
}
}
return out
}
func averageContextDecimal(signals []domain.Signal, key string) decimal.Decimal {
sum := decimal.Zero
count := int64(0)
for _, sig := range signals {
var context map[string]any
if err := json.Unmarshal([]byte(sig.ContextJSON), &context); err != nil {
continue
}
value, ok := decimalFromAny(context[key])
if !ok {
continue
}
sum = sum.Add(value)
count++
}
if count == 0 {
return decimal.Zero
}
return sum.Div(decimal.NewFromInt(count))
}
func decimalFromAny(value any) (decimal.Decimal, bool) {
switch typed := value.(type) {
case string:
parsed, err := decimal.NewFromString(typed)
return parsed, err == nil
case float64:
return decimal.NewFromFloat(typed), true
default:
return decimal.Zero, false
}
}
func writeExecutionErrors(b *strings.Builder, orders []domain.Order) {
wroteHeader := false
for _, order := range orders {
if !isExecutionError(order.Status) {
continue
}
if !wroteHeader {
fmt.Fprintf(b, "Ошибки исполнения:\n")
wroteHeader = true
}
fmt.Fprintf(b, "- %s %s status=%s filled=%d/%d\n", order.InstrumentUID, order.Side, order.Status, order.FilledLots, order.QuantityLots)
}
}
func isExecutionError(status domain.OrderStatus) bool {
switch status {
case domain.OrderStatusFailed, domain.OrderStatusRejected, domain.OrderStatusExpired:
return true
default:
return false
}
}
+227 -59
View File
@@ -30,32 +30,36 @@ import (
) )
const ( const (
sizeReductionWindowTrades = 20 sizeReductionWindowTrades = 20
sizeReductionFactor = 0.5 sizeReductionFactor = 0.5
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 TickInterval time.Duration
EntrySignalTime timeutil.TimeOfDay EntrySignalTime timeutil.TimeOfDay
EntryWindowStart timeutil.TimeOfDay EntryWindowStart timeutil.TimeOfDay
EntryWindowEnd timeutil.TimeOfDay EntryWindowEnd timeutil.TimeOfDay
NoNewEntryAfter timeutil.TimeOfDay NoNewEntryAfter timeutil.TimeOfDay
ExitWatchStart timeutil.TimeOfDay ExitWatchStart timeutil.TimeOfDay
ExitWindowStart timeutil.TimeOfDay ExitWindowStart timeutil.TimeOfDay
ExitWindowEnd timeutil.TimeOfDay ExitWindowEnd timeutil.TimeOfDay
HardExitDeadline timeutil.TimeOfDay HardExitDeadline timeutil.TimeOfDay
QuoteDepth int32 QuoteDepth int32
MaxQuoteAge time.Duration MaxQuoteAge time.Duration
OrderPollInterval time.Duration OrderPollInterval time.Duration
PassiveImproveTicks int PassiveImproveTicks int
MaxEntryOrderAttempts int MaxEntryOrderAttempts int
MaxExitOrderAttempts int MaxExitOrderAttempts int
MinTimeToClose time.Duration MinTimeToClose time.Duration
MaxClockDrift time.Duration MaxClockDrift time.Duration
APIOutageHalt time.Duration APIOutageHalt time.Duration
RequireZeroCommission bool
QuarantineOnNonZero bool
ReconciliationInterval time.Duration
} }
type Services struct { type Services struct {
@@ -84,6 +88,7 @@ type Scheduler struct {
svc Services svc Services
infraFailedSince time.Time infraFailedSince time.Time
lastReconciledAt time.Time
} }
func New(clock timeutil.Clock, sm statemachine.System, cfg Config, svc Services) Scheduler { func New(clock timeutil.Clock, sm statemachine.System, cfg Config, svc Services) Scheduler {
@@ -93,6 +98,9 @@ func New(clock timeutil.Clock, sm statemachine.System, cfg Config, svc Services)
if cfg.Location == nil { if cfg.Location == nil {
cfg.Location = time.UTC cfg.Location = time.UTC
} }
if cfg.ReconciliationInterval <= 0 {
cfg.ReconciliationInterval = 5 * time.Minute
}
return Scheduler{clock: clock, sm: sm, cfg: cfg, svc: svc} return Scheduler{clock: clock, sm: sm, cfg: cfg, svc: svc}
} }
@@ -181,8 +189,8 @@ func (s *Scheduler) prepareSignals(ctx context.Context, now time.Time) error {
if err := s.svc.MarketData.BackfillDaily(ctx, instrumentsList, tradeDate.AddDate(0, 0, -s.cfg.RollingLong-10), tradeDate); err != nil { if err := s.svc.MarketData.BackfillDaily(ctx, instrumentsList, tradeDate.AddDate(0, 0, -s.cfg.RollingLong-10), tradeDate); err != nil {
return err return err
} }
minuteFrom := s.cfg.EntryWindowStart.On(tradeDate, s.cfg.Location) minuteFrom := s.cfg.EntryWindowStart.On(tradeDate.AddDate(0, 0, -intervalVolumeLookbackDays), s.cfg.Location)
minuteTo := s.cfg.ExitWindowEnd.On(tradeDate.AddDate(0, 0, 1), 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)
} }
@@ -222,7 +230,7 @@ func (s Scheduler) generateInstrumentSignal(ctx context.Context, now, tradeDate
if err != nil { if err != nil {
return s.saveRejectedSignal(ctx, tradeDate, instrument, "features_unavailable", err) return s.saveRejectedSignal(ctx, tradeDate, instrument, "features_unavailable", err)
} }
remaining, err := s.svc.FreeOrders.Check(ctx, tradeDate, instrument, 1) remaining, err := s.svc.FreeOrders.Check(ctx, tradeDate, instrument, s.maxOrderAttemptsPerTrade())
freeOrderOK := err == nil freeOrderOK := err == nil
sig := s.svc.Signals.Evaluate(signal.Candidate{ sig := s.svc.Signals.Evaluate(signal.Candidate{
Instrument: instrument, Instrument: instrument,
@@ -234,6 +242,7 @@ func (s Scheduler) generateInstrumentSignal(ctx context.Context, now, tradeDate
ExtraContext: map[string]any{ ExtraContext: map[string]any{
"free_orders_remaining": remaining, "free_orders_remaining": remaining,
"quote_time": book.Time.Format(time.RFC3339), "quote_time": book.Time.Format(time.RFC3339),
"spread_bps": spread.SpreadBps.String(),
}, },
}) })
if sig.Decision == domain.DecisionEnter { if sig.Decision == domain.DecisionEnter {
@@ -244,6 +253,9 @@ func (s Scheduler) generateInstrumentSignal(ctx context.Context, now, tradeDate
sig.RejectReason = sizingErr.Error() sig.RejectReason = sizingErr.Error()
case sized.Lots <= 0: case sized.Lots <= 0:
sig.Decision = domain.DecisionReject sig.Decision = domain.DecisionReject
if isSizingSkipReason(sized.Reason) {
sig.Decision = domain.DecisionSkip
}
sig.RejectReason = sized.Reason sig.RejectReason = sized.Reason
default: default:
sig.TargetLots = sized.Lots sig.TargetLots = sized.Lots
@@ -288,11 +300,15 @@ func (s Scheduler) sizeSignal(_ context.Context, portfolio domain.Portfolio, ins
}), nil }), nil
} }
func (s Scheduler) placeEntryOrders(ctx context.Context, now time.Time) error { func (s *Scheduler) placeEntryOrders(ctx context.Context, now time.Time) error {
if err := s.transitionTo(ctx, domain.StatePlaceEntryOrders); err != nil { if err := s.transitionTo(ctx, domain.StatePlaceEntryOrders); err != nil {
return err return err
} }
tradeDate := tradingDate(now) tradeDate := tradingDate(now)
entryDeadline := s.cfg.NoNewEntryAfter.On(now, s.cfg.Location).UTC()
if !s.nowUTC().Before(entryDeadline) {
return s.closeEntryWindow(ctx)
}
signals, err := s.svc.Repo.ListSignals(ctx, tradeDate) signals, err := s.svc.Repo.ListSignals(ctx, tradeDate)
if err != nil { if err != nil {
return err return err
@@ -317,6 +333,21 @@ func (s Scheduler) placeEntryOrders(ctx context.Context, now time.Time) error {
if !ok { if !ok {
return fmt.Errorf("instrument %s is not in registry", sig.InstrumentUID) return fmt.Errorf("instrument %s is not in registry", sig.InstrumentUID)
} }
if !s.nowUTC().Before(entryDeadline) {
return s.closeEntryWindow(ctx)
}
if _, err := s.svc.FreeOrders.Check(ctx, tradeDate, instrument, s.maxOrderAttemptsPerTrade()); err != nil {
if insertErr := s.svc.Repo.InsertRiskEvent(ctx, domain.RiskEvent{
Severity: domain.SeverityWarn,
EventType: "pre_trade_reject",
InstrumentUID: sig.InstrumentUID,
Message: err.Error(),
ContextJSON: `{"reason":"free_order_budget_insufficient"}`,
}); insertErr != nil {
return insertErr
}
continue
}
book, err := s.svc.MarketData.LatestQuote(ctx, sig.InstrumentUID, s.cfg.QuoteDepth, s.cfg.MaxQuoteAge) book, err := s.svc.MarketData.LatestQuote(ctx, sig.InstrumentUID, s.cfg.QuoteDepth, s.cfg.MaxQuoteAge)
if err != nil { if err != nil {
return err return err
@@ -354,12 +385,17 @@ func (s Scheduler) placeEntryOrders(ctx context.Context, now time.Time) error {
return err return err
} }
_ = s.svc.Notifier.Info(ctx, fmt.Sprintf("entry order %s %s lots=%d status=%s", instrument.Ticker, placed.Side, placed.QuantityLots, placed.Status)) _ = s.svc.Notifier.Info(ctx, fmt.Sprintf("entry order %s %s lots=%d status=%s", instrument.Ticker, placed.Side, placed.QuantityLots, placed.Status))
if placed.FilledLots > 0 {
if err := s.recordEntryFill(ctx, instrument, placed); err != nil {
return err
}
}
existing = append(existing, placed) existing = append(existing, placed)
} }
return s.transitionTo(ctx, domain.StateMonitorEntryOrders) return s.transitionTo(ctx, domain.StateMonitorEntryOrders)
} }
func (s Scheduler) monitorEntryOrders(ctx context.Context, now time.Time) error { func (s *Scheduler) monitorEntryOrders(ctx context.Context, now time.Time) error {
if err := s.transitionTo(ctx, domain.StateMonitorEntryOrders); err != nil { if err := s.transitionTo(ctx, domain.StateMonitorEntryOrders); err != nil {
return err return err
} }
@@ -372,6 +408,9 @@ func (s Scheduler) monitorEntryOrders(ctx context.Context, now time.Time) error
return err return err
} }
deadline := s.cfg.NoNewEntryAfter.On(now, s.cfg.Location).UTC() deadline := s.cfg.NoNewEntryAfter.On(now, s.cfg.Location).UTC()
if !s.nowUTC().Before(deadline) {
return s.closeEntryWindow(ctx)
}
for _, order := range orders { for _, order := range orders {
if order.Side != domain.SideBuy || order.BrokerOrderID == "" { if order.Side != domain.SideBuy || order.BrokerOrderID == "" {
continue continue
@@ -395,18 +434,13 @@ func (s Scheduler) monitorEntryOrders(ctx context.Context, now time.Time) error
return err return err
} }
if monitored.FilledLots > order.FilledLots || monitored.Commission.GreaterThan(order.Commission) { if monitored.FilledLots > order.FilledLots || monitored.Commission.GreaterThan(order.Commission) {
pos, err := s.svc.Positions.OnEntryFill(ctx, s.svc.AccountIDHash, instrument, monitored) if err := s.recordEntryFill(ctx, instrument, monitored); err != nil {
if err != nil {
return err return err
} }
_ = s.svc.Notifier.Info(ctx, fmt.Sprintf("entry fill %s lots=%d status=%s", monitored.InstrumentUID, monitored.FilledLots, pos.Status))
} }
} }
if sinceMidnight(s.nowUTC().In(s.cfg.Location)) >= s.cfg.NoNewEntryAfter.Duration { if sinceMidnight(s.nowUTC().In(s.cfg.Location)) >= s.cfg.NoNewEntryAfter.Duration {
if err := s.cancelActiveOrders(ctx, domain.SideBuy, domain.OrderStatusCancelled, "entry_window_closed"); err != nil { return s.closeEntryWindow(ctx)
return err
}
return s.transitionTo(ctx, domain.StateHoldOvernight)
} }
return nil return nil
} }
@@ -415,14 +449,14 @@ func (s Scheduler) waitExit(ctx context.Context, _ time.Time) error {
return s.transitionTo(ctx, domain.StateWaitExitWindow) return s.transitionTo(ctx, domain.StateWaitExitWindow)
} }
func (s Scheduler) holdOvernight(ctx context.Context) error { func (s *Scheduler) holdOvernight(ctx context.Context) error {
if err := s.cancelActiveOrders(ctx, domain.SideBuy, domain.OrderStatusCancelled, "entry_window_closed"); err != nil { if err := s.closeEntryWindow(ctx); err != nil {
return err return err
} }
return s.transitionTo(ctx, domain.StateHoldOvernight) return s.periodicReconcile(ctx)
} }
func (s Scheduler) placeExitOrders(ctx context.Context, now time.Time) error { func (s *Scheduler) placeExitOrders(ctx context.Context, now time.Time) error {
if err := s.transitionTo(ctx, domain.StatePlaceExitOrders); err != nil { if err := s.transitionTo(ctx, domain.StatePlaceExitOrders); err != nil {
return err return err
} }
@@ -473,6 +507,13 @@ func (s Scheduler) placeExitOrders(ctx context.Context, now time.Time) error {
if err != nil && !errors.Is(err, execution.ErrBrokerOrdersDisabled) { if err != nil && !errors.Is(err, execution.ErrBrokerOrdersDisabled) {
return err return err
} }
if placed.FilledLots > 0 || placed.Commission.IsPositive() {
if err := s.recordExitFill(ctx, pos, placed); err != nil {
return err
}
existing = append(existing, placed)
continue
}
pos.Status = domain.PositionExitOrderSent pos.Status = domain.PositionExitOrderSent
if err := s.svc.Repo.UpsertPosition(ctx, pos); err != nil { if err := s.svc.Repo.UpsertPosition(ctx, pos); err != nil {
return err return err
@@ -483,7 +524,7 @@ func (s Scheduler) placeExitOrders(ctx context.Context, now time.Time) error {
return s.transitionTo(ctx, domain.StateMonitorExitOrders) return s.transitionTo(ctx, domain.StateMonitorExitOrders)
} }
func (s Scheduler) monitorExitOrders(ctx context.Context, now time.Time) error { func (s *Scheduler) monitorExitOrders(ctx context.Context, now time.Time) error {
if err := s.transitionTo(ctx, domain.StateMonitorExitOrders); err != nil { if err := s.transitionTo(ctx, domain.StateMonitorExitOrders); err != nil {
return err return err
} }
@@ -535,12 +576,11 @@ func (s Scheduler) monitorExitOrders(ctx context.Context, now time.Time) error {
if !ok { if !ok {
return fmt.Errorf("exit fill for unknown local position %s", monitored.InstrumentUID) return fmt.Errorf("exit fill for unknown local position %s", monitored.InstrumentUID)
} }
updated, err := s.svc.Positions.OnExitFill(ctx, pos, fill) updated, err := s.recordExitFillWithPosition(ctx, pos, fill)
if err != nil { if err != nil {
return err return err
} }
positionByInstrument[monitored.InstrumentUID] = updated positionByInstrument[monitored.InstrumentUID] = updated
_ = s.svc.Notifier.Info(ctx, fmt.Sprintf("exit fill %s lots=%d status=%s pnl=%s", monitored.InstrumentUID, monitored.FilledLots, updated.Status, updated.NetPnL.StringFixed(2)))
} }
} }
if sinceMidnight(s.nowUTC().In(s.cfg.Location)) >= s.cfg.HardExitDeadline.Duration { if sinceMidnight(s.nowUTC().In(s.cfg.Location)) >= s.cfg.HardExitDeadline.Duration {
@@ -550,16 +590,6 @@ func (s Scheduler) monitorExitOrders(ctx context.Context, now time.Time) error {
} }
func (s *Scheduler) reconcileAndReport(ctx context.Context, now time.Time) error { func (s *Scheduler) reconcileAndReport(ctx context.Context, now time.Time) error {
if err := s.transitionTo(ctx, domain.StateReconcile); err != nil {
return err
}
diffs, err := s.svc.Reconcile.Run(ctx)
if err != nil {
return err
}
if reconciliation.HasCritical(diffs) {
return s.halt(ctx, "reconciliation_critical", "critical reconciliation diff", "")
}
tradeDate := tradingDate(now) tradeDate := tradingDate(now)
sent, err := s.svc.Repo.WasDailyReportSent(ctx, tradeDate, s.svc.AccountIDHash) sent, err := s.svc.Repo.WasDailyReportSent(ctx, tradeDate, s.svc.AccountIDHash)
if err != nil { if err != nil {
@@ -569,6 +599,28 @@ func (s *Scheduler) reconcileAndReport(ctx context.Context, now time.Time) error
s.logWarn("daily report already sent; skipping duplicate", "date", tradeDate.Format("2006-01-02")) s.logWarn("daily report already sent; skipping duplicate", "date", tradeDate.Format("2006-01-02"))
return s.transitionTo(ctx, domain.StateSleep) return s.transitionTo(ctx, domain.StateSleep)
} }
if err := s.transitionTo(ctx, domain.StateReconcile); err != nil {
return err
}
if err := s.reconcileCritical(ctx, "reconciliation_critical"); err != nil {
return err
}
return s.sendDailyReport(ctx, now, "ok")
}
func (s *Scheduler) sendDailyReport(ctx context.Context, now time.Time, riskStatus string) error {
tradeDate := tradingDate(now)
sent, err := s.svc.Repo.WasDailyReportSent(ctx, tradeDate, s.svc.AccountIDHash)
if err != nil {
return err
}
if sent {
s.logWarn("daily report already sent; skipping duplicate", "date", tradeDate.Format("2006-01-02"))
if !s.hasStateMachine() {
return nil
}
return s.transitionTo(ctx, domain.StateSleep)
}
signals, err := s.svc.Repo.ListSignals(ctx, tradeDate) signals, err := s.svc.Repo.ListSignals(ctx, tradeDate)
if err != nil && !errors.Is(err, sql.ErrNoRows) { if err != nil && !errors.Is(err, sql.ErrNoRows) {
return err return err
@@ -577,18 +629,25 @@ func (s *Scheduler) reconcileAndReport(ctx context.Context, now time.Time) error
if err != nil { if err != nil {
return err return err
} }
orders, err := s.svc.Repo.ListOrders(ctx, s.svc.AccountIDHash, tradeDate.AddDate(0, 0, -1), tradeDate)
if err != nil {
return err
}
if err := s.applySizeReductionRule(ctx, tradeDate, true); err != nil { if err := s.applySizeReductionRule(ctx, tradeDate, true); err != nil {
return err return err
} }
if err := s.transitionTo(ctx, domain.StateReport); err != nil { if s.hasStateMachine() {
return err if err := s.transitionTo(ctx, domain.StateReport); err != nil {
return err
}
} }
msg := report.ComposeDaily(report.DailyInput{ msg := report.ComposeDaily(report.DailyInput{
Date: tradeDate, Date: tradeDate,
Mode: s.cfg.Mode, Mode: s.cfg.Mode,
Signals: signals, Signals: signals,
Positions: positionsList, Positions: positionsList,
RiskStatus: "ok", Orders: orders,
RiskStatus: riskStatus,
}) })
if err := s.svc.Notifier.Report(ctx, msg); err != nil { if err := s.svc.Notifier.Report(ctx, msg); err != nil {
return err return err
@@ -596,6 +655,9 @@ func (s *Scheduler) reconcileAndReport(ctx context.Context, now time.Time) error
if err := s.svc.Repo.MarkDailyReportSent(ctx, tradeDate, s.svc.AccountIDHash); err != nil { if err := s.svc.Repo.MarkDailyReportSent(ctx, tradeDate, s.svc.AccountIDHash); err != nil {
return err return err
} }
if !s.hasStateMachine() {
return nil
}
return s.transitionTo(ctx, domain.StateSleep) return s.transitionTo(ctx, domain.StateSleep)
} }
@@ -675,6 +737,7 @@ func (s *Scheduler) checkInfrastructure(ctx context.Context) error {
serverTime, err := s.svc.Gateway.GetServerTime(ctx) serverTime, err := s.svc.Gateway.GetServerTime(ctx)
if err != nil { if err != nil {
if s.cfg.Mode == domain.ModePaper { if s.cfg.Mode == domain.ModePaper {
s.infraFailedSince = time.Time{}
return nil return nil
} }
return s.recordInfrastructureFailure(fmt.Errorf("server_time_unavailable: %w", err)) return s.recordInfrastructureFailure(fmt.Errorf("server_time_unavailable: %w", err))
@@ -737,7 +800,96 @@ func (s Scheduler) cancelActiveOrders(ctx context.Context, side domain.Side, fal
return nil return nil
} }
func (s Scheduler) failOpenPositionsAtHardDeadline(ctx context.Context) error { func (s Scheduler) closeEntryWindow(ctx context.Context) error {
if err := s.cancelActiveOrders(ctx, domain.SideBuy, domain.OrderStatusCancelled, "entry_window_closed"); err != nil {
return err
}
return s.transitionTo(ctx, domain.StateHoldOvernight)
}
func (s *Scheduler) recordEntryFill(ctx context.Context, instrument domain.Instrument, order domain.Order) error {
pos, err := s.svc.Positions.OnEntryFill(ctx, s.svc.AccountIDHash, instrument, order)
if err != nil {
return err
}
_ = s.svc.Notifier.Info(ctx, fmt.Sprintf("entry fill %s lots=%d status=%s", order.InstrumentUID, order.FilledLots, pos.Status))
if err := s.handleCommission(ctx, order.InstrumentUID, order.Commission); err != nil {
return err
}
return s.reconcileAfterFill(ctx)
}
func (s *Scheduler) recordExitFill(ctx context.Context, pos domain.Position, order domain.Order) error {
_, err := s.recordExitFillWithPosition(ctx, pos, order)
return err
}
func (s *Scheduler) recordExitFillWithPosition(ctx context.Context, pos domain.Position, fill domain.Order) (domain.Position, error) {
updated, err := s.svc.Positions.OnExitFill(ctx, pos, fill)
if err != nil {
return domain.Position{}, err
}
_ = s.svc.Notifier.Info(ctx, fmt.Sprintf("exit fill %s lots=%d status=%s pnl=%s", fill.InstrumentUID, fill.FilledLots, updated.Status, updated.NetPnL.StringFixed(2)))
if err := s.handleCommission(ctx, fill.InstrumentUID, fill.Commission); err != nil {
return domain.Position{}, err
}
if err := s.reconcileAfterFill(ctx); err != nil {
return domain.Position{}, err
}
return updated, nil
}
func (s *Scheduler) handleCommission(ctx context.Context, instrumentUID string, commission decimal.Decimal) error {
if !risk.CommissionBreached(commission, s.cfg.RequireZeroCommission) {
return nil
}
reason := fmt.Sprintf("actual commission %s > 0", commission.StringFixed(2))
if s.cfg.QuarantineOnNonZero {
if err := s.svc.Repo.QuarantineInstrument(ctx, instrumentUID, reason); err != nil {
return err
}
}
return s.halt(ctx, "actual_commission_nonzero", reason, instrumentUID)
}
func (s *Scheduler) reconcileAfterFill(ctx context.Context) error {
if !s.cfg.Mode.AllowsBrokerOrders() {
return nil
}
return s.reconcileCritical(ctx, "reconciliation_after_fill_critical")
}
func (s *Scheduler) periodicReconcile(ctx context.Context) error {
if !s.cfg.Mode.AllowsBrokerOrders() {
return nil
}
now := s.nowUTC()
if !s.lastReconciledAt.IsZero() && now.Sub(s.lastReconciledAt) < s.cfg.ReconciliationInterval {
return nil
}
return s.reconcileCritical(ctx, "periodic_reconciliation_critical")
}
func (s *Scheduler) reconcileCritical(ctx context.Context, eventType string) error {
diffs, err := s.svc.Reconcile.Run(ctx)
if err != nil {
return err
}
s.lastReconciledAt = s.nowUTC()
for _, diff := range diffs {
if diff.Kind == "actual_commission_nonzero" && diff.InstrumentUID != "" && s.cfg.QuarantineOnNonZero {
if err := s.svc.Repo.QuarantineInstrument(ctx, diff.InstrumentUID, diff.Message); err != nil {
return err
}
}
}
if reconciliation.HasCritical(diffs) {
return s.halt(ctx, eventType, "critical reconciliation diff", "")
}
return nil
}
func (s *Scheduler) failOpenPositionsAtHardDeadline(ctx context.Context) error {
if err := s.cancelActiveOrders(ctx, domain.SideSell, domain.OrderStatusExpired, "hard_exit_deadline_cancel"); err != nil { if err := s.cancelActiveOrders(ctx, domain.SideSell, domain.OrderStatusExpired, "hard_exit_deadline_cancel"); err != nil {
return err return err
} }
@@ -763,6 +915,9 @@ func (s Scheduler) failOpenPositionsAtHardDeadline(ctx context.Context) error {
if len(failed) == 0 { if len(failed) == 0 {
return s.reconcileAndReport(ctx, s.nowUTC().In(s.cfg.Location)) return s.reconcileAndReport(ctx, s.nowUTC().In(s.cfg.Location))
} }
if err := s.sendDailyReport(ctx, s.nowUTC().In(s.cfg.Location), "hard_exit_deadline_missed"); err != nil {
s.logWarn("daily report failed after hard deadline", "err", err)
}
return s.svc.Risk.Halt(ctx, s.cfg.Mode, "hard_exit_deadline_missed", fmt.Sprintf("%d positions remain open after hard deadline", len(failed)), "") return s.svc.Risk.Halt(ctx, s.cfg.Mode, "hard_exit_deadline_missed", fmt.Sprintf("%d positions remain open after hard deadline", len(failed)), "")
} }
@@ -791,6 +946,22 @@ func repostAfter(now, deadline time.Time, attempts int, poll time.Duration) time
return after return after
} }
func (s Scheduler) maxOrderAttemptsPerTrade() int {
needed := s.cfg.MaxEntryOrderAttempts + s.cfg.MaxExitOrderAttempts
if needed <= 0 {
return 1
}
return needed
}
func isSizingSkipReason(reason string) bool {
return reason == "lots_below_one" || reason == "min_order_notional"
}
func (s Scheduler) hasStateMachine() bool {
return s.sm != (statemachine.System{})
}
func (s Scheduler) transitionSequence(ctx context.Context, states ...domain.SystemState) error { func (s Scheduler) transitionSequence(ctx context.Context, states ...domain.SystemState) error {
for _, state := range states { for _, state := range states {
if err := s.transitionTo(ctx, state); err != nil { if err := s.transitionTo(ctx, state); err != nil {
@@ -812,9 +983,6 @@ func (s Scheduler) transitionTo(ctx context.Context, to domain.SystemState) erro
return s.sm.Heartbeat(ctx, to) return s.sm.Heartbeat(ctx, to)
} }
if err := s.sm.Transition(ctx, from, to); err != nil { if err := s.sm.Transition(ctx, from, to); err != nil {
if errors.Is(err, statemachine.ErrIllegalTransition) {
return s.sm.Heartbeat(ctx, to)
}
return err return err
} }
return nil return nil
+50
View File
@@ -80,6 +80,9 @@ func TestReconcileAndReportIsIdempotentPerDate(t *testing.T) {
gateway := tinvest.NewFakeGateway() gateway := tinvest.NewFakeGateway()
notifier := &countNotifier{} notifier := &countNotifier{}
recon := reconciliation.New(repo, gateway, "account", "hash") recon := reconciliation.New(repo, gateway, "account", "hash")
if err := repo.SaveSystemState(ctx, domain.StateMonitorExitOrders, domain.ModePaper, false, "", "{}"); err != nil {
t.Fatal(err)
}
s := Scheduler{ s := Scheduler{
cfg: Config{Mode: domain.ModePaper, Location: time.UTC}, cfg: Config{Mode: domain.ModePaper, Location: time.UTC},
sm: statemachine.New(repo, domain.ModePaper), sm: statemachine.New(repo, domain.ModePaper),
@@ -168,6 +171,9 @@ func TestHardDeadlineMarksOpenPositionFailedAndHalts(t *testing.T) {
if notifier.alerts != 1 { if notifier.alerts != 1 {
t.Fatalf("alerts=%d, want 1", notifier.alerts) t.Fatalf("alerts=%d, want 1", notifier.alerts)
} }
if notifier.reports != 1 {
t.Fatalf("reports=%d, want daily report before HALT", notifier.reports)
}
} }
func TestHoldOvernightCancelsActiveBuyOrders(t *testing.T) { func TestHoldOvernightCancelsActiveBuyOrders(t *testing.T) {
@@ -195,6 +201,9 @@ func TestHoldOvernightCancelsActiveBuyOrders(t *testing.T) {
AccountIDHash: "hash", AccountIDHash: "hash",
}, },
} }
if err := repo.SaveSystemState(ctx, domain.StateMonitorEntryOrders, domain.ModePaper, false, "", "{}"); err != nil {
t.Fatal(err)
}
if err := s.holdOvernight(ctx); err != nil { if err := s.holdOvernight(ctx); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -207,6 +216,47 @@ func TestHoldOvernightCancelsActiveBuyOrders(t *testing.T) {
} }
} }
func TestNonZeroCommissionQuarantinesInstrumentAndHalts(t *testing.T) {
ctx := context.Background()
repo := testutil.NewMemoryRepository()
if err := repo.UpsertInstrument(ctx, domain.Instrument{
InstrumentUID: "uid",
Ticker: "TRUR",
Enabled: true,
}); err != nil {
t.Fatal(err)
}
notifier := &countNotifier{}
s := Scheduler{
cfg: Config{
Mode: domain.ModePaper,
RequireZeroCommission: true,
QuarantineOnNonZero: true,
},
svc: Services{
Repo: repo,
Risk: risk.NewManager(repo, risk.ManagerConfig{}),
Notifier: notifier,
},
}
if err := s.handleCommission(ctx, "uid", decimal.NewFromFloat(0.01)); err != nil {
t.Fatal(err)
}
if !repo.Halted || repo.State != domain.StateHalted {
t.Fatalf("system not halted: state=%s halted=%v", repo.State, repo.Halted)
}
instruments, err := repo.ListInstruments(ctx, true)
if err != nil {
t.Fatal(err)
}
if len(instruments) != 1 || !instruments[0].Quarantine {
t.Fatalf("instrument not quarantined: %+v", instruments)
}
if notifier.alerts != 1 {
t.Fatalf("alerts=%d, want 1", notifier.alerts)
}
}
func TestSizeReductionRuleCutsSizerAfterBadExpectedErrors(t *testing.T) { func TestSizeReductionRuleCutsSizerAfterBadExpectedErrors(t *testing.T) {
ctx := context.Background() ctx := context.Background()
repo := testutil.NewMemoryRepository() repo := testutil.NewMemoryRepository()
+24 -1
View File
@@ -251,7 +251,7 @@ func (g *RealGateway) GetPortfolio(ctx context.Context, accountID string) (domai
for _, position := range positions { for _, position := range positions {
holdings = append(holdings, domain.Holding{ holdings = append(holdings, domain.Holding{
InstrumentUID: position.GetInstrumentUid(), InstrumentUID: position.GetInstrumentUid(),
QuantityLots: money.QuotationToDecimal(position.GetQuantity()).IntPart(), QuantityLots: portfolioQuantityLots(position),
AveragePrice: money.MoneyValueToDecimal(position.GetAveragePositionPrice()), AveragePrice: money.MoneyValueToDecimal(position.GetAveragePositionPrice()),
MarketValue: money.MoneyValueToDecimal(position.GetCurrentPrice()).Mul(money.QuotationToDecimal(position.GetQuantity())), MarketValue: money.MoneyValueToDecimal(position.GetCurrentPrice()).Mul(money.QuotationToDecimal(position.GetQuantity())),
}) })
@@ -337,6 +337,29 @@ func rubMoneyValueToDecimal(value *pb.MoneyValue) (decimal.Decimal, error) {
return money.MoneyValueToDecimal(value), nil return money.MoneyValueToDecimal(value), nil
} }
func portfolioQuantityLots(position *pb.PortfolioPosition) int64 {
if position == nil {
return 0
}
if lots, ok := portfolioDeprecatedQuantityLots(position); ok {
return lots.IntPart()
}
return money.QuotationToDecimal(position.GetQuantity()).IntPart()
}
func portfolioDeprecatedQuantityLots(position *pb.PortfolioPosition) (decimal.Decimal, bool) {
message := position.ProtoReflect()
field := message.Descriptor().Fields().ByName("quantity_lots")
if field == nil || !message.Has(field) {
return decimal.Zero, false
}
quotation, ok := message.Get(field).Message().Interface().(*pb.Quotation)
if !ok || quotation == nil {
return decimal.Zero, false
}
return money.QuotationToDecimal(quotation), true
}
func serverTimeFromHeader(header map[string][]string) (time.Time, bool) { func serverTimeFromHeader(header map[string][]string) (time.Time, bool) {
for _, key := range []string{"date", "Date"} { for _, key := range []string{"date", "Date"} {
values := header[key] values := header[key]