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_RECONCILIATION_WINDOW_HOURS=72
RISK_RECONCILIATION_SKEW_SEC=10
RISK_COMMISSION_TOLERANCE_RUB=0.01
RISK_CASH_USAGE_BUFFER=0.95
RISK_RISK_BUDGET_PER_INSTRUMENT_PCT=0.005
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_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_APP_NAME` | имя приложения | `overnight-trading-bot` | строка | Передаётся в SDK как имя клиента. Меняет идентификацию приложения на стороне 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_RECONCILIATION_WINDOW_HOURS` | целое число часов | `72` | должно быть `> 0` | Глубина сверки последних заявок и операций брокера. Больше - больше история сверки, но тяжелее запросы. |
| `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_RISK_BUDGET_PER_INSTRUMENT_PCT` | доля equity | `0.005` | рекомендуется `> 0` | Риск-бюджет на инструмент, используется вместе с оценкой неблагоприятного overnight-движения. Больше - крупнее позиции при прочих равных. |
| `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_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` | Политика учёта бесплатных заявок: счётчик увеличивается при отправке заявки. Другие значения запрещены валидацией. |
### 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`).
`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
+32 -26
View File
@@ -114,8 +114,9 @@ func Run(ctx context.Context, opts Options) error {
}
accountIDHash := accountHash(cfg.TInvest.AccountID)
recon := reconciliation.New(repo, gateway, cfg.TInvest.AccountID, accountIDHash).
WithWindow(time.Duration(cfg.Risk.ReconciliationWindowHours) * time.Hour).
WithInFlightGrace(time.Duration(cfg.Risk.ReconciliationSkewSec) * time.Second)
WithWindow(time.Duration(cfg.Risk.ReconciliationWindowHours)*time.Hour).
WithInFlightGrace(time.Duration(cfg.Risk.ReconciliationSkewSec)*time.Second).
WithCommissionPolicy(cfg.Commission.RequireZeroCommission, cfg.Commission.QuarantineOnNonZero, cfg.Risk.CommissionToleranceRUB)
diffs, err := recon.Run(ctx)
if err != nil {
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)
recon := reconciliation.New(repo, gateway, cfg.TInvest.AccountID, accountIDHash).
WithWindow(time.Duration(cfg.Risk.ReconciliationWindowHours) * time.Hour).
WithInFlightGrace(time.Duration(cfg.Risk.ReconciliationSkewSec) * time.Second)
WithWindow(time.Duration(cfg.Risk.ReconciliationWindowHours)*time.Hour).
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)
if _, err := sm.Recover(ctx, recon); err != nil {
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,
End: cfg.Execution.ExitWindowEnd,
},
Location: cfg.Location,
IntervalVolumeLookback: 20,
Location: cfg.Location,
})
signalEngine := signalengine.New(signalengine.Config{
MinTStat60: cfg.Strategy.MinTStat60,
@@ -255,27 +258,30 @@ 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,
ExitWindowStart: cfg.Execution.ExitWindowStart,
ExitWindowEnd: cfg.Execution.ExitWindowEnd,
HardExitDeadline: cfg.Execution.HardExitDeadline,
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,
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,
ExitWindowStart: cfg.Execution.ExitWindowStart,
ExitWindowEnd: cfg.Execution.ExitWindowEnd,
HardExitDeadline: cfg.Execution.HardExitDeadline,
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,
ReconciliationInterval: 5 * time.Minute,
}, services)
}
+51 -13
View File
@@ -45,6 +45,13 @@ type Config struct {
AssumedTickBps decimal.Decimal
Lot int64
UseMinuteModel bool
EntryWindow TimeWindow
ExitWindow TimeWindow
}
type TimeWindow struct {
Start time.Duration
End time.Duration
}
type Trade struct {
@@ -142,6 +149,12 @@ func (cfg Config) withDefaults() Config {
if cfg.Lot == 0 {
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
}
@@ -153,8 +166,12 @@ func (e Engine) RunWithMinuteCandles(candlesByInstrument map[string][]domain.Can
prepared := prepareCandles(candlesByInstrument)
preparedMinutes := prepareCandles(minuteCandlesByInstrument)
candidatesByExitDate := make(map[string][]candidate)
tradingDateSet := make(map[string]struct{})
for instrumentUID, candles := range prepared {
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)
if err != nil {
return Result{}, err
@@ -164,8 +181,8 @@ func (e Engine) RunWithMinuteCandles(candlesByInstrument map[string][]domain.Can
}
}
}
dates := make([]string, 0, len(candidatesByExitDate))
for date := range candidatesByExitDate {
dates := make([]string, 0, len(tradingDateSet))
for date := range tradingDateSet {
dates = append(dates, date)
}
sort.Strings(dates)
@@ -239,15 +256,17 @@ func (e Engine) RunWithMinuteCandles(candlesByInstrument map[string][]domain.Can
CapacityRUB: capacity,
})
}
if !dayPnL.IsZero() {
equity = equity.Add(dayPnL)
cash = equity
points = append(points, Point{
Date: date,
Equity: equity,
Return: dayPnL.Div(dayStartEquity),
})
equity = equity.Add(dayPnL)
cash = equity
dayReturn := decimal.Zero
if dayStartEquity.IsPositive() {
dayReturn = dayPnL.Div(dayStartEquity)
}
points = append(points, Point{
Date: date,
Equity: equity,
Return: dayReturn,
})
}
sort.Slice(trades, func(i, j int) bool {
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 {
return 0, decimal.Zero, false
}
entryLots, entryCapacity := e.fillableMinuteLots(minutes, c.entry.TradeDate, c.buy, domain.SideBuy)
exitLots, exitCapacity := e.fillableMinuteLots(minutes, c.exit.TradeDate, c.sell, domain.SideSell)
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, e.cfg.ExitWindow)
lots := min(requestedLots, entryLots)
lots = min(lots, exitLots)
if lots <= 0 {
@@ -276,7 +295,7 @@ func (e Engine) minuteExecution(c candidate, minutes []domain.Candle, requestedL
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 {
return 0, decimal.Zero
}
@@ -289,6 +308,9 @@ func (e Engine) fillableMinuteLots(minutes []domain.Candle, date time.Time, limi
if !sameDate(candle.TradeDate, date) {
continue
}
if !window.Contains(candle.TradeDate) {
continue
}
reachable := side == domain.SideBuy && candle.Low.LessThanOrEqual(limitPrice)
reachable = reachable || side == domain.SideSell && candle.High.GreaterThanOrEqual(limitPrice)
if !reachable {
@@ -300,6 +322,22 @@ func (e Engine) fillableMinuteLots(minutes []domain.Candle, date time.Time, limi
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 {
instrumentUID string
entry domain.Candle
+1
View File
@@ -51,6 +51,7 @@ func TestMinuteExecutionRequiresReachableLimitAndParticipation(t *testing.T) {
}
minutes := []domain.Candle{
{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)},
}
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"`
ReconciliationWindowHours int `env:"RECONCILIATION_WINDOW_HOURS" envDefault:"72"`
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"`
RiskBudgetPerInstrumentPct decimal.Decimal `env:"RISK_BUDGET_PER_INSTRUMENT_PCT" envDefault:"0.005"`
MinOrderNotionalRUB decimal.Decimal `env:"MIN_ORDER_NOTIONAL_RUB" envDefault:"1000"`
@@ -198,6 +199,9 @@ func (c *Config) Validate() error {
if c.Risk.ReconciliationSkewSec < 0 {
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" {
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 == "" {
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 {
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/repository"
"overnight-trading-bot/internal/risk"
)
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
}
}
if e.mode == domain.ModePaper {
return e.placePaperLimit(ctx, order)
}
if !e.mode.AllowsBrokerOrders() {
order.Status = domain.OrderStatusNew
if e.store != nil {
@@ -159,6 +163,28 @@ func (e *Engine) PlaceLimit(ctx context.Context, order domain.Order) (domain.Ord
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) {
orders, err := e.store.ListOrders(ctx, order.AccountIDHash, order.TradeDate, order.TradeDate)
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) {
if err := e.ensureRepostBudget(ctx, order, cfg.Instrument); err != nil {
return domain.Order{}, err
}
if err := e.Cancel(ctx, order); err != nil {
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 {
if e.maxQuoteAge <= 0 {
return nil
}
receivedAt := book.ReceivedAt
if receivedAt.IsZero() {
receivedAt = book.Time
if book.ReceivedAt.IsZero() {
return fmt.Errorf("quote received timestamp is missing")
}
if receivedAt.IsZero() {
return fmt.Errorf("quote timestamp is missing")
}
age := time.Since(receivedAt)
age := time.Since(book.ReceivedAt)
if 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"
"encoding/hex"
"fmt"
"regexp"
"strconv"
"strings"
"time"
@@ -14,7 +14,7 @@ import (
"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) {
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 {
base := fmt.Sprintf("%s|%s|%s|%d", tradeDate.Format("20060102"), instrumentUID, side, attempt)
sum := sha256.Sum256([]byte(base))
suffix := hex.EncodeToString(sum[:])[:8]
cleanUID := nonIDChar.ReplaceAllString(instrumentUID, "_")
if len(cleanUID) > 24 {
cleanUID = cleanUID[:24]
suffix := hex.EncodeToString(sum[:])
sideToken := "b"
if side == domain.SideSell {
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) {
date := time.Date(2024, 1, 2, 0, 0, 0, 0, time.UTC)
a := ClientOrderID(date, "uid", domain.SideBuy, 1)
b := ClientOrderID(date, "uid", domain.SideBuy, 1)
c := ClientOrderID(date, "uid", domain.SideBuy, 2)
longUID := "a-realistic-instrument-uid-that-is-much-longer-than-the-order-id-limit"
a := ClientOrderID(date, longUID, domain.SideBuy, 1)
b := ClientOrderID(date, longUID, domain.SideBuy, 1)
c := ClientOrderID(date, longUID, domain.SideBuy, 2)
if a != b || a == 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) {
ctx := context.Background()
engine := NewEngine(domain.ModeSandbox, "account", tinvest.NewFakeGateway(), testutil.NewMemoryRepository())
+45 -3
View File
@@ -3,6 +3,7 @@ package features
import (
"context"
"fmt"
"sort"
"time"
"github.com/shopspring/decimal"
@@ -12,6 +13,8 @@ import (
"overnight-trading-bot/internal/timeutil"
)
const defaultIntervalVolumeLookback = 20
type PipelineConfig struct {
RollingShort int
RollingLong int
@@ -22,6 +25,7 @@ type PipelineConfig struct {
CommissionRoundtripBps decimal.Decimal
EntryWindow timeutil.Window
ExitWindow timeutil.Window
IntervalVolumeLookback int
Location *time.Location
}
@@ -44,7 +48,7 @@ func (p Pipeline) Recompute(ctx context.Context, instrument domain.Instrument, t
if err != nil {
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 {
return domain.FeatureSet{}, err
}
@@ -66,13 +70,17 @@ func (p Pipeline) intervalVolume(ctx context.Context, instrument domain.Instrume
if loc == nil {
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()
candles, err := p.repo.ListMinuteCandles(ctx, instrument.InstrumentUID, from, to)
if err != nil {
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) {
@@ -146,3 +154,37 @@ func IntervalVolume(candles []domain.Candle, lot int64) decimal.Decimal {
}
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"
"overnight-trading-bot/internal/domain"
"overnight-trading-bot/internal/timeutil"
)
func TestComputeExpectedCostIncludesCommissionAndSlippage(t *testing.T) {
@@ -55,3 +56,28 @@ func TestIntervalVolume(t *testing.T) {
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 {
return err
}
resp, err := http.DefaultClient.Do(req)
client := http.Client{Timeout: 5 * time.Second}
resp, err := client.Do(req)
if err != nil {
return err
}
+13 -16
View File
@@ -3,7 +3,6 @@ package instruments
import (
"context"
"fmt"
"strings"
"overnight-trading-bot/internal/domain"
"overnight-trading-bot/internal/repository"
@@ -25,21 +24,19 @@ func (r Registry) SyncMetadata(ctx context.Context) error {
return err
}
for _, instrument := range instruments {
if strings.HasPrefix(instrument.InstrumentUID, "PENDING:") || !instrument.MetadataValid() {
remote, err := r.gateway.GetInstrument(ctx, instrument.Ticker, instrument.ClassCode)
if err != nil {
return fmt.Errorf("sync %s: %w", instrument.Ticker, err)
}
remote.Enabled = instrument.Enabled && remote.Enabled
remote.FundType = instrument.FundType
remote.ExpectedCommissionBpsPerSide = instrument.ExpectedCommissionBpsPerSide
remote.FreeOrderLimitPerDay = instrument.FreeOrderLimitPerDay
remote.Quarantine = instrument.Quarantine
remote.QuarantineReason = instrument.QuarantineReason
remote.ExcludeReason = instrument.ExcludeReason
if err := r.repo.ReplaceInstrument(ctx, instrument.InstrumentUID, remote); err != nil {
return fmt.Errorf("replace synced instrument %s: %w", instrument.Ticker, err)
}
remote, err := r.gateway.GetInstrument(ctx, instrument.Ticker, instrument.ClassCode)
if err != nil {
return fmt.Errorf("sync %s: %w", instrument.Ticker, err)
}
remote.Enabled = instrument.Enabled && remote.Enabled
remote.FundType = instrument.FundType
remote.ExpectedCommissionBpsPerSide = instrument.ExpectedCommissionBpsPerSide
remote.FreeOrderLimitPerDay = instrument.FreeOrderLimitPerDay
remote.Quarantine = instrument.Quarantine
remote.QuarantineReason = instrument.QuarantineReason
remote.ExcludeReason = instrument.ExcludeReason
if err := r.repo.ReplaceInstrument(ctx, instrument.InstrumentUID, remote); err != nil {
return fmt.Errorf("replace synced instrument %s: %w", instrument.Ticker, err)
}
}
return nil
+2 -2
View File
@@ -56,10 +56,10 @@ func (l Loader) LatestQuote(ctx context.Context, instrumentUID string, depth int
if err != nil {
return domain.OrderBook{}, err
}
age := time.Since(book.ReceivedAt)
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 {
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) {
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
}
bot, err := tgbotapi.NewBotAPI(cfg.BotToken)
+53 -9
View File
@@ -16,16 +16,26 @@ import (
)
type Engine struct {
repo repository.Repository
gateway tinvest.Gateway
accountID string
accountIDHash string
window time.Duration
inFlightGrace time.Duration
repo repository.Repository
gateway tinvest.Gateway
accountID string
accountIDHash string
window time.Duration
inFlightGrace time.Duration
commissionTolerance decimal.Decimal
requireZeroCommission bool
quarantineOnNonZero bool
}
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 {
@@ -42,6 +52,15 @@ func (e Engine) WithInFlightGrace(grace time.Duration) Engine {
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) {
localOrders, err := e.repo.ListActiveOrders(ctx, e.accountIDHash)
if err != nil {
@@ -138,7 +157,17 @@ func (e Engine) Run(ctx context.Context) ([]domain.ReconciliationDiff, error) {
if err != nil {
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)
if err := e.repo.InsertReconciliation(ctx, now, string(raw), len(diffs) > 0); err != nil {
return nil, err
@@ -163,7 +192,14 @@ func HasCritical(diffs []domain.ReconciliationDiff) bool {
}
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
if commissionTolerance.IsNegative() {
commissionTolerance = decimal.Zero
}
localCommissionByInstrument := make(map[string]decimal.Decimal)
localTraded := make(map[string]bool)
for _, order := range orders {
@@ -192,7 +228,15 @@ func compareOperations(orders []domain.Order, operations []domain.Operation) []d
for instrumentUID := range instruments {
localCommission := localCommissionByInstrument[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{
Kind: "commission_mismatch",
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) {
ctx := context.Background()
repo := testutil.NewMemoryRepository()
+119 -1
View File
@@ -1,7 +1,9 @@
package report
import (
"encoding/json"
"fmt"
"sort"
"strings"
"time"
@@ -15,6 +17,7 @@ type DailyInput struct {
Mode domain.Mode
Signals []domain.Signal
Positions []domain.Position
Orders []domain.Order
AverageSpreadBps decimal.Decimal
AverageSlipBps decimal.Decimal
RiskStatus string
@@ -28,19 +31,134 @@ func ComposeDaily(input DailyInput) string {
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)
}
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
net := decimal.Zero
commission := decimal.Zero
expectedByInstrument := expectedEdgeByInstrument(input.Signals)
for _, pos := range input.Positions {
gross = gross.Add(pos.GrossPnL)
net = net.Add(pos.NetPnL)
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, "Net PnL: %s\n", net.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))
writeExecutionErrors(&b, input.Orders)
fmt.Fprintf(&b, "Risk: %s", input.RiskStatus)
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 (
sizeReductionWindowTrades = 20
sizeReductionFactor = 0.5
sizeReductionWindowTrades = 20
sizeReductionFactor = 0.5
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
ExitWindowStart timeutil.TimeOfDay
ExitWindowEnd timeutil.TimeOfDay
HardExitDeadline 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
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
ExitWindowStart timeutil.TimeOfDay
ExitWindowEnd timeutil.TimeOfDay
HardExitDeadline 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
ReconciliationInterval time.Duration
}
type Services struct {
@@ -84,6 +88,7 @@ type Scheduler struct {
svc Services
infraFailedSince time.Time
lastReconciledAt time.Time
}
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 {
cfg.Location = time.UTC
}
if cfg.ReconciliationInterval <= 0 {
cfg.ReconciliationInterval = 5 * time.Minute
}
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 {
return err
}
minuteFrom := s.cfg.EntryWindowStart.On(tradeDate, s.cfg.Location)
minuteTo := s.cfg.ExitWindowEnd.On(tradeDate.AddDate(0, 0, 1), s.cfg.Location)
minuteFrom := s.cfg.EntryWindowStart.On(tradeDate.AddDate(0, 0, -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)
}
@@ -222,7 +230,7 @@ func (s Scheduler) generateInstrumentSignal(ctx context.Context, now, tradeDate
if err != nil {
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
sig := s.svc.Signals.Evaluate(signal.Candidate{
Instrument: instrument,
@@ -234,6 +242,7 @@ func (s Scheduler) generateInstrumentSignal(ctx context.Context, now, tradeDate
ExtraContext: map[string]any{
"free_orders_remaining": remaining,
"quote_time": book.Time.Format(time.RFC3339),
"spread_bps": spread.SpreadBps.String(),
},
})
if sig.Decision == domain.DecisionEnter {
@@ -244,6 +253,9 @@ func (s Scheduler) generateInstrumentSignal(ctx context.Context, now, tradeDate
sig.RejectReason = sizingErr.Error()
case sized.Lots <= 0:
sig.Decision = domain.DecisionReject
if isSizingSkipReason(sized.Reason) {
sig.Decision = domain.DecisionSkip
}
sig.RejectReason = sized.Reason
default:
sig.TargetLots = sized.Lots
@@ -288,11 +300,15 @@ func (s Scheduler) sizeSignal(_ context.Context, portfolio domain.Portfolio, ins
}), 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 {
return err
}
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)
if err != nil {
return err
@@ -317,6 +333,21 @@ func (s Scheduler) placeEntryOrders(ctx context.Context, now time.Time) error {
if !ok {
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)
if err != nil {
return err
@@ -354,12 +385,17 @@ func (s Scheduler) placeEntryOrders(ctx context.Context, now time.Time) error {
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))
if placed.FilledLots > 0 {
if err := s.recordEntryFill(ctx, instrument, placed); err != nil {
return err
}
}
existing = append(existing, placed)
}
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 {
return err
}
@@ -372,6 +408,9 @@ func (s Scheduler) monitorEntryOrders(ctx context.Context, now time.Time) error
return err
}
deadline := s.cfg.NoNewEntryAfter.On(now, s.cfg.Location).UTC()
if !s.nowUTC().Before(deadline) {
return s.closeEntryWindow(ctx)
}
for _, order := range orders {
if order.Side != domain.SideBuy || order.BrokerOrderID == "" {
continue
@@ -395,18 +434,13 @@ func (s Scheduler) monitorEntryOrders(ctx context.Context, now time.Time) error
return err
}
if monitored.FilledLots > order.FilledLots || monitored.Commission.GreaterThan(order.Commission) {
pos, err := s.svc.Positions.OnEntryFill(ctx, s.svc.AccountIDHash, instrument, monitored)
if err != nil {
if err := s.recordEntryFill(ctx, instrument, monitored); err != nil {
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 err := s.cancelActiveOrders(ctx, domain.SideBuy, domain.OrderStatusCancelled, "entry_window_closed"); err != nil {
return err
}
return s.transitionTo(ctx, domain.StateHoldOvernight)
return s.closeEntryWindow(ctx)
}
return nil
}
@@ -415,14 +449,14 @@ func (s Scheduler) waitExit(ctx context.Context, _ time.Time) error {
return s.transitionTo(ctx, domain.StateWaitExitWindow)
}
func (s Scheduler) holdOvernight(ctx context.Context) error {
if err := s.cancelActiveOrders(ctx, domain.SideBuy, domain.OrderStatusCancelled, "entry_window_closed"); err != nil {
func (s *Scheduler) holdOvernight(ctx context.Context) error {
if err := s.closeEntryWindow(ctx); err != nil {
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 {
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) {
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
if err := s.svc.Repo.UpsertPosition(ctx, pos); err != nil {
return err
@@ -483,7 +524,7 @@ func (s Scheduler) placeExitOrders(ctx context.Context, now time.Time) error {
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 {
return err
}
@@ -535,12 +576,11 @@ func (s Scheduler) monitorExitOrders(ctx context.Context, now time.Time) error {
if !ok {
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 {
return err
}
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 {
@@ -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 {
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)
sent, err := s.svc.Repo.WasDailyReportSent(ctx, tradeDate, s.svc.AccountIDHash)
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"))
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)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return err
@@ -577,18 +629,25 @@ func (s *Scheduler) reconcileAndReport(ctx context.Context, now time.Time) error
if err != nil {
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 {
return err
}
if err := s.transitionTo(ctx, domain.StateReport); err != nil {
return err
if s.hasStateMachine() {
if err := s.transitionTo(ctx, domain.StateReport); err != nil {
return err
}
}
msg := report.ComposeDaily(report.DailyInput{
Date: tradeDate,
Mode: s.cfg.Mode,
Signals: signals,
Positions: positionsList,
RiskStatus: "ok",
Orders: orders,
RiskStatus: riskStatus,
})
if err := s.svc.Notifier.Report(ctx, msg); err != nil {
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 {
return err
}
if !s.hasStateMachine() {
return nil
}
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)
if err != nil {
if s.cfg.Mode == domain.ModePaper {
s.infraFailedSince = time.Time{}
return nil
}
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
}
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 {
return err
}
@@ -763,6 +915,9 @@ func (s Scheduler) failOpenPositionsAtHardDeadline(ctx context.Context) error {
if len(failed) == 0 {
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)), "")
}
@@ -791,6 +946,22 @@ func repostAfter(now, deadline time.Time, attempts int, poll time.Duration) time
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 {
for _, state := range states {
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)
}
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 nil
+50
View File
@@ -80,6 +80,9 @@ func TestReconcileAndReportIsIdempotentPerDate(t *testing.T) {
gateway := tinvest.NewFakeGateway()
notifier := &countNotifier{}
recon := reconciliation.New(repo, gateway, "account", "hash")
if err := repo.SaveSystemState(ctx, domain.StateMonitorExitOrders, domain.ModePaper, false, "", "{}"); err != nil {
t.Fatal(err)
}
s := Scheduler{
cfg: Config{Mode: domain.ModePaper, Location: time.UTC},
sm: statemachine.New(repo, domain.ModePaper),
@@ -168,6 +171,9 @@ func TestHardDeadlineMarksOpenPositionFailedAndHalts(t *testing.T) {
if notifier.alerts != 1 {
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) {
@@ -195,6 +201,9 @@ func TestHoldOvernightCancelsActiveBuyOrders(t *testing.T) {
AccountIDHash: "hash",
},
}
if err := repo.SaveSystemState(ctx, domain.StateMonitorEntryOrders, domain.ModePaper, false, "", "{}"); err != nil {
t.Fatal(err)
}
if err := s.holdOvernight(ctx); err != nil {
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) {
ctx := context.Background()
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 {
holdings = append(holdings, domain.Holding{
InstrumentUID: position.GetInstrumentUid(),
QuantityLots: money.QuotationToDecimal(position.GetQuantity()).IntPart(),
QuantityLots: portfolioQuantityLots(position),
AveragePrice: money.MoneyValueToDecimal(position.GetAveragePositionPrice()),
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
}
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) {
for _, key := range []string{"date", "Date"} {
values := header[key]