eighth version

This commit is contained in:
2026-06-08 11:55:36 +00:00
parent ebea17b411
commit e8b7d8e27c
15 changed files with 431 additions and 42 deletions
+8
View File
@@ -92,3 +92,11 @@ BT_USE_MINUTE_MODEL=false
BT_OUTPUT_DIR=./backtest_out
LIVE_TRADE_ACK=
LIVE_READONLY_DAYS=0
LIVE_PAPER_DAYS=0
LIVE_SANDBOX_DAYS=0
LIVE_COMMISSION_WHITELIST_CHECKED=false
LIVE_TELEGRAM_TESTED=false
LIVE_KILL_SWITCH_TESTED=false
LIVE_SERVER_TIME_CHECKED=false
LIVE_SMALL_CAPITAL=false
+9 -1
View File
@@ -10,7 +10,7 @@ make test
APP_MODE=backtest go run ./cmd/bot
```
Для daemon-режимов (`paper`, `sandbox`, `live_readonly`, `live_trade`) нужен `DB_DSN` MariaDB/MySQL. `live_trade` дополнительно требует `LIVE_TRADE_ACK=I_ACCEPT_RISK`.
Для daemon-режимов (`paper`, `sandbox`, `live_readonly`, `live_trade`) нужен `DB_DSN` MariaDB/MySQL. `live_trade` дополнительно требует `LIVE_TRADE_ACK=I_ACCEPT_RISK` и выполненные pre-flight условия из секции `LIVE`.
## Environment Variables
@@ -168,6 +168,14 @@ APP_MODE=backtest go run ./cmd/bot
| Переменная | Что указывать | Дефолт | Границы/валидация | За что отвечает и что меняется |
| --- | --- | --- | --- | --- |
| `LIVE_TRADE_ACK` | ровно `I_ACCEPT_RISK` | пусто | обязателен только для `APP_MODE=live_trade` | Ручное подтверждение риска для режима реальной торговли. Без него `live_trade` не стартует. |
| `LIVE_READONLY_DAYS` | целое число торговых дней | `0` | для `live_trade` должно быть `>= 20` | Подтверждает накопленный период работы в `live_readonly` перед реальной торговлей. |
| `LIVE_PAPER_DAYS` | целое число торговых дней | `0` | для `live_trade` должно быть `>= 20` | Подтверждает период `paper`-прогона с bid/ask моделью. |
| `LIVE_SANDBOX_DAYS` | целое число торговых дней | `0` | для `live_trade` должно быть `>= 10` | Подтверждает период sandbox без критических ошибок. |
| `LIVE_COMMISSION_WHITELIST_CHECKED` | `true` или `false` | `false` | для `live_trade` должно быть `true` | Ручное подтверждение актуальных комиссий и whitelist инструментов. |
| `LIVE_TELEGRAM_TESTED` | `true` или `false` | `false` | для `live_trade` должно быть `true` | Подтверждает тест доставки Telegram-уведомлений. |
| `LIVE_KILL_SWITCH_TESTED` | `true` или `false` | `false` | для `live_trade` должно быть `true` | Подтверждает тест ручного halt/unhalt сценария. |
| `LIVE_SERVER_TIME_CHECKED` | `true` или `false` | `false` | для `live_trade` должно быть `true` | Подтверждает проверку server-time/drift в sandbox. |
| `LIVE_SMALL_CAPITAL` | `true` или `false` | `false` | для `live_trade` должно быть `true` | Подтверждает запуск реальной торговли с малым стартовым капиталом. |
## Commands
+28 -12
View File
@@ -212,9 +212,6 @@ func (e Engine) RunWithMinuteCandles(candlesByInstrument map[string][]domain.Can
return Result{}, err
}
if ok {
if capacity := e.windowCapacity(candidate, preparedMinutes[instrumentUID]); capacity.IsPositive() {
candidate.capacity = capacity
}
candidatesByExitDate[candidate.exit.TradeDate.Format("2006-01-02")] = append(candidatesByExitDate[candidate.exit.TradeDate.Format("2006-01-02")], candidate)
}
}
@@ -251,20 +248,29 @@ func (e Engine) RunWithMinuteCandles(candlesByInstrument map[string][]domain.Can
dayStartEquity := equity
dayPnL := decimal.Zero
for _, c := range dayCandidates {
entryIntervalVolume, exitIntervalVolume := e.windowVolumes(c, preparedMinutes[c.instrumentUID])
capacity := decimal.Zero
if entryIntervalVolume.IsPositive() && exitIntervalVolume.IsPositive() {
capacity = money.Min(entryIntervalVolume, exitIntervalVolume).Mul(e.cfg.MaxParticipationRate)
} else if e.cfg.UseMinuteModel {
continue
} else {
entryIntervalVolume = e.unconstrainedIntervalVolume(equity)
exitIntervalVolume = entryIntervalVolume
}
sized := sizer.Size(risk.SizingInput{
Portfolio: domain.Portfolio{Equity: equity, Cash: cash},
SelectedInstruments: len(dayCandidates),
LimitPrice: c.buy,
Lot: c.lot,
EntryIntervalVolume: c.adv,
ExitIntervalVolume: c.adv,
EntryIntervalVolume: entryIntervalVolume,
ExitIntervalVolume: exitIntervalVolume,
Q05OvernightAbs: c.q05Abs,
})
if sized.Lots <= 0 {
continue
}
lots := sized.Lots
capacity := c.capacity
if e.cfg.UseMinuteModel {
executedLots, minuteCapacity, ok := e.minuteExecution(c, preparedMinutes[c.instrumentUID], sized.Lots)
if !ok {
@@ -365,22 +371,34 @@ func (e Engine) fillableMinuteLots(minutes []domain.Candle, date time.Time, limi
}
func (e Engine) windowCapacity(c candidate, minutes []domain.Candle) decimal.Decimal {
if len(minutes) == 0 {
entryVolume, exitVolume := e.windowVolumes(c, minutes)
if !entryVolume.IsPositive() || !exitVolume.IsPositive() {
return decimal.Zero
}
return money.Min(entryVolume, exitVolume).Mul(e.cfg.MaxParticipationRate)
}
func (e Engine) windowVolumes(c candidate, minutes []domain.Candle) (decimal.Decimal, decimal.Decimal) {
if len(minutes) == 0 {
return decimal.Zero, decimal.Zero
}
lot := c.lot
if lot <= 0 {
lot = e.lotFor(c.instrumentUID)
}
if lot <= 0 {
return decimal.Zero
return decimal.Zero, decimal.Zero
}
entryVolume := e.windowNotional(minutes, c.entry.TradeDate, e.cfg.EntryWindow, lot)
exitVolume := e.windowNotional(minutes, c.exit.TradeDate, e.cfg.ExitWindow, lot)
if !entryVolume.IsPositive() || !exitVolume.IsPositive() {
return entryVolume, exitVolume
}
func (e Engine) unconstrainedIntervalVolume(equity decimal.Decimal) decimal.Decimal {
if !equity.IsPositive() || !e.cfg.MaxParticipationRate.IsPositive() {
return decimal.Zero
}
return money.Min(entryVolume, exitVolume).Mul(e.cfg.MaxParticipationRate)
return equity.Div(e.cfg.MaxParticipationRate).Mul(decimal.NewFromInt(10))
}
func (e Engine) windowNotional(minutes []domain.Candle, date time.Time, window TimeWindow, lot int64) decimal.Decimal {
@@ -421,7 +439,6 @@ type candidate struct {
adv decimal.Decimal
q05Abs decimal.Decimal
overnightGap decimal.Decimal
capacity decimal.Decimal
lot int64
}
@@ -507,7 +524,6 @@ func (e Engine) evaluateCandidate(instrumentUID string, candles []domain.Candle,
adv: adv,
q05Abs: q05Abs,
overnightGap: gap,
capacity: adv.Mul(e.cfg.MaxParticipationRate),
lot: lot,
}, true, nil
}
+21
View File
@@ -128,6 +128,27 @@ func TestWindowCapacityUsesMinuteEntryAndExitWindows(t *testing.T) {
}
}
func TestBacktestWithoutMinuteDataDoesNotReportADVAsCapacity(t *testing.T) {
engine := New(Config{
RollingShort: 2,
RollingLong: 2,
MinTStat60: decimal.NewFromInt(-1),
MinWinRate60: decimal.NewFromFloat(0.1),
MinNetEdgeBps: decimal.NewFromInt(-1000),
MinADVRUB: decimal.NewFromInt(1),
})
result, err := engine.Run(map[string][]domain.Candle{"uid": candidateCandles("uid")})
if err != nil {
t.Fatal(err)
}
if len(result.Trades) == 0 {
t.Fatal("expected daily-only minimal backtest trade")
}
if !result.Trades[0].CapacityRUB.IsZero() {
t.Fatalf("capacity=%s, want zero when minute windows are unavailable", result.Trades[0].CapacityRUB)
}
}
func TestLoadCandlesCSVWithMetadata(t *testing.T) {
raw := strings.NewReader(`instrument_uid,trade_date,open,high,low,close,volume_lots,lot,min_price_increment
uid,2024-01-02,100,101,99,100,10,10,0.05
+50 -3
View File
@@ -15,6 +15,12 @@ import (
const liveTradeAck = "I_ACCEPT_RISK"
const maxQuoteDepth = 50
const (
minLiveReadonlyDays = 20
minPaperDays = 20
minSandboxDays = 10
)
type Config struct {
App AppConfig `envPrefix:"APP_"`
TInvest TInvestConfig `envPrefix:"TINVEST_"`
@@ -147,7 +153,15 @@ type BacktestConfig struct {
}
type LiveConfig struct {
TradeAck string `env:"TRADE_ACK"`
TradeAck string `env:"TRADE_ACK"`
ReadonlyDays int `env:"READONLY_DAYS" envDefault:"0"`
PaperDays int `env:"PAPER_DAYS" envDefault:"0"`
SandboxDays int `env:"SANDBOX_DAYS" envDefault:"0"`
CommissionWhitelistChecked bool `env:"COMMISSION_WHITELIST_CHECKED" envDefault:"false"`
TelegramTested bool `env:"TELEGRAM_TESTED" envDefault:"false"`
KillSwitchTested bool `env:"KILL_SWITCH_TESTED" envDefault:"false"`
ServerTimeChecked bool `env:"SERVER_TIME_CHECKED" envDefault:"false"`
SmallCapital bool `env:"SMALL_CAPITAL" envDefault:"false"`
}
func Load() (Config, error) {
@@ -236,8 +250,41 @@ func (c *Config) Validate() error {
if c.TInvest.UseSandbox && c.App.Mode != domain.ModeSandbox {
return errors.New("TINVEST_USE_SANDBOX=true is only valid with APP_MODE=sandbox")
}
if c.App.Mode == domain.ModeLiveTrade && c.Live.TradeAck != liveTradeAck {
return fmt.Errorf("LIVE_TRADE_ACK=%s is required for APP_MODE=live_trade", liveTradeAck)
if c.App.Mode == domain.ModeLiveTrade {
if c.Live.TradeAck != liveTradeAck {
return fmt.Errorf("LIVE_TRADE_ACK=%s is required for APP_MODE=live_trade", liveTradeAck)
}
if err := c.validateLiveTradePreconditions(); err != nil {
return err
}
}
return nil
}
func (c Config) validateLiveTradePreconditions() error {
if c.Live.ReadonlyDays < minLiveReadonlyDays {
return fmt.Errorf("LIVE_READONLY_DAYS must be >= %d for APP_MODE=live_trade", minLiveReadonlyDays)
}
if c.Live.PaperDays < minPaperDays {
return fmt.Errorf("LIVE_PAPER_DAYS must be >= %d for APP_MODE=live_trade", minPaperDays)
}
if c.Live.SandboxDays < minSandboxDays {
return fmt.Errorf("LIVE_SANDBOX_DAYS must be >= %d for APP_MODE=live_trade", minSandboxDays)
}
if !c.Live.CommissionWhitelistChecked {
return errors.New("LIVE_COMMISSION_WHITELIST_CHECKED=true is required for APP_MODE=live_trade")
}
if !c.Live.TelegramTested {
return errors.New("LIVE_TELEGRAM_TESTED=true is required for APP_MODE=live_trade")
}
if !c.Live.KillSwitchTested {
return errors.New("LIVE_KILL_SWITCH_TESTED=true is required for APP_MODE=live_trade")
}
if !c.Live.ServerTimeChecked {
return errors.New("LIVE_SERVER_TIME_CHECKED=true is required for APP_MODE=live_trade")
}
if !c.Live.SmallCapital {
return errors.New("LIVE_SMALL_CAPITAL=true is required for APP_MODE=live_trade")
}
return nil
}
+31
View File
@@ -27,6 +27,23 @@ func TestValidateAllowsCancelCountsFreeOrderPolicy(t *testing.T) {
}
}
func TestValidateLiveTradeRequiresPreconditions(t *testing.T) {
cfg := minimalBrokerConfig(domain.ModeLiveTrade)
cfg.Live.TradeAck = liveTradeAck
err := cfg.Validate()
if err == nil || !strings.Contains(err.Error(), "LIVE_READONLY_DAYS") {
t.Fatalf("Validate err=%v, want live_trade readonly precondition", err)
}
}
func TestValidateLiveTradeAcceptsAllPreconditions(t *testing.T) {
cfg := minimalBrokerConfig(domain.ModeLiveTrade)
cfg.Live = validLiveTradeConfig()
if err := cfg.Validate(); err != nil {
t.Fatalf("Validate live_trade preconditions: %v", err)
}
}
func minimalBrokerConfig(mode domain.Mode) Config {
return Config{
App: AppConfig{
@@ -64,6 +81,20 @@ func minimalBrokerConfig(mode domain.Mode) Config {
}
}
func validLiveTradeConfig() LiveConfig {
return LiveConfig{
TradeAck: liveTradeAck,
ReadonlyDays: minLiveReadonlyDays,
PaperDays: minPaperDays,
SandboxDays: minSandboxDays,
CommissionWhitelistChecked: true,
TelegramTested: true,
KillSwitchTested: true,
ServerTimeChecked: true,
SmallCapital: true,
}
}
func mustTOD(raw string) timeutil.TimeOfDay {
tod, err := timeutil.ParseTimeOfDay(raw)
if err != nil {
+14 -10
View File
@@ -149,6 +149,9 @@ func (e *Engine) placeLimit(ctx context.Context, order domain.Order, freeOrderLi
lock := e.lockFor(order.InstrumentUID)
lock.Lock()
defer lock.Unlock()
if e.mode != domain.ModePaper && !e.mode.AllowsBrokerOrders() {
return order, ErrBrokerOrdersDisabled
}
if e.store != nil {
existing, err := e.findExisting(ctx, order)
if err != nil {
@@ -161,13 +164,6 @@ func (e *Engine) placeLimit(ctx context.Context, order domain.Order, freeOrderLi
if e.mode == domain.ModePaper {
return e.placePaperLimit(ctx, order, freeOrderLimit)
}
if !e.mode.AllowsBrokerOrders() {
order.Status = domain.OrderStatusNew
if e.store != nil {
return order, e.store.UpsertOrder(ctx, order)
}
return order, ErrBrokerOrdersDisabled
}
if e.gateway == nil {
return domain.Order{}, errors.New("gateway is nil")
}
@@ -569,16 +565,24 @@ func (e *Engine) checkQuoteFresh(book domain.OrderBook) error {
if e.maxQuoteAge <= 0 {
return nil
}
if book.ReceivedAt.IsZero() {
return fmt.Errorf("quote received timestamp is missing")
quoteTs := quoteTimestamp(book)
if quoteTs.IsZero() {
return fmt.Errorf("quote timestamp is missing")
}
age := e.nowUTC().Sub(book.ReceivedAt)
age := e.nowUTC().Sub(quoteTs)
if age > e.maxQuoteAge {
return fmt.Errorf("quote age %s exceeds %s", age, e.maxQuoteAge)
}
return nil
}
func quoteTimestamp(book domain.OrderBook) time.Time {
if !book.Time.IsZero() {
return book.Time.UTC()
}
return book.ReceivedAt.UTC()
}
func (e *Engine) lockFor(instrumentUID string) *sync.Mutex {
value, _ := e.mu.LoadOrStore(instrumentUID, &sync.Mutex{})
lock, ok := value.(*sync.Mutex)
+46
View File
@@ -243,6 +243,52 @@ func TestPlaceEntryRejectsStaleQuote(t *testing.T) {
}
}
func TestPlaceEntryRejectsStaleExchangeQuoteTime(t *testing.T) {
ctx := context.Background()
now := time.Date(2026, 6, 8, 18, 20, 0, 0, time.UTC)
engine := NewEngine(domain.ModeSandbox, "account", tinvest.NewFakeGateway(), testutil.NewMemoryRepository())
engine.SetClock(&fixedClock{now: now})
engine.SetMaxQuoteAge(time.Second)
_, err := engine.PlaceEntry(ctx, "hash", domain.Instrument{
InstrumentUID: "uid",
Lot: 1,
MinPriceIncrement: decimal.NewFromInt(1),
}, now, 1, domain.OrderBook{
InstrumentUID: "uid",
Time: now.Add(-2 * time.Second),
ReceivedAt: now,
Bids: []domain.OrderBookLevel{{Price: decimal.NewFromInt(99), QuantityLots: 10}},
Asks: []domain.OrderBookLevel{{Price: decimal.NewFromInt(101), QuantityLots: 10}},
}, 1, 1)
if err == nil {
t.Fatal("expected stale exchange quote timestamp error")
}
}
func TestLiveReadonlyDoesNotPersistLocalOrder(t *testing.T) {
ctx := context.Background()
repo := testutil.NewMemoryRepository()
engine := NewEngine(domain.ModeLiveReadonly, "account", tinvest.NewFakeGateway(), repo)
_, err := engine.PlaceLimit(ctx, domain.Order{
ClientOrderID: "readonly-order",
AccountIDHash: "hash",
InstrumentUID: "uid",
TradeDate: time.Date(2026, 6, 8, 0, 0, 0, 0, time.UTC),
Side: domain.SideBuy,
OrderType: domain.OrderTypeLimit,
LimitPrice: decimal.NewFromInt(100),
QuantityLots: 1,
Status: domain.OrderStatusNew,
AttemptNo: 1,
})
if !errors.Is(err, ErrBrokerOrdersDisabled) {
t.Fatalf("PlaceLimit err=%v, want ErrBrokerOrdersDisabled", err)
}
if len(repo.Orders) != 0 {
t.Fatalf("readonly mode persisted orders: %+v", repo.Orders)
}
}
func TestMonitorUntilRepostsAndExpiresAtDeadline(t *testing.T) {
ctx := context.Background()
repo := testutil.NewMemoryRepository()
+11 -3
View File
@@ -86,16 +86,24 @@ func (l Loader) LatestQuote(ctx context.Context, instrumentUID string, depth int
if err != nil {
return domain.OrderBook{}, err
}
if book.ReceivedAt.IsZero() {
return domain.OrderBook{}, fmt.Errorf("quote received timestamp is missing")
quoteTs := quoteTimestamp(book)
if quoteTs.IsZero() {
return domain.OrderBook{}, fmt.Errorf("quote timestamp is missing")
}
age := l.nowUTC().Sub(book.ReceivedAt)
age := l.nowUTC().Sub(quoteTs)
if maxAge > 0 && age > maxAge {
return domain.OrderBook{}, fmt.Errorf("quote age %s exceeds %s", age, maxAge)
}
return book, nil
}
func quoteTimestamp(book domain.OrderBook) time.Time {
if !book.Time.IsZero() {
return book.Time.UTC()
}
return book.ReceivedAt.UTC()
}
func (l Loader) nowUTC() time.Time {
if l.clock == nil {
return time.Now().UTC()
+43
View File
@@ -0,0 +1,43 @@
package marketdata
import (
"context"
"strings"
"testing"
"time"
"github.com/shopspring/decimal"
"overnight-trading-bot/internal/domain"
"overnight-trading-bot/internal/tinvest"
)
type fixedClock struct {
now time.Time
}
func (c fixedClock) Now() time.Time {
return c.now
}
func (c fixedClock) Sleep(<-chan struct{}, time.Duration) bool {
return true
}
func TestLatestQuoteUsesExchangeTimestampForFreshness(t *testing.T) {
now := time.Date(2026, 6, 8, 18, 20, 0, 0, time.UTC)
gateway := tinvest.NewFakeGateway()
gateway.OrderBooks["uid"] = domain.OrderBook{
InstrumentUID: "uid",
Time: now.Add(-2 * time.Second),
ReceivedAt: now,
Bids: []domain.OrderBookLevel{{Price: decimal.NewFromInt(99), QuantityLots: 10}},
Asks: []domain.OrderBookLevel{{Price: decimal.NewFromInt(101), QuantityLots: 10}},
}
loader := NewLoader(nil, gateway)
loader.SetClock(fixedClock{now: now})
_, err := loader.LatestQuote(context.Background(), "uid", 20, time.Second)
if err == nil || !strings.Contains(err.Error(), "quote age") {
t.Fatalf("LatestQuote err=%v, want stale exchange timestamp rejection", err)
}
}
+1 -1
View File
@@ -356,7 +356,7 @@ INSERT INTO candles_minute (
:instrument_uid, :trade_date, :open, :high, :low, :close, :volume_lots, :source, :loaded_at
) ON DUPLICATE KEY UPDATE
open=VALUES(open), high=VALUES(high), low=VALUES(low), close=VALUES(close),
volume_lots=VALUES(volume_lots), source=VALUES(source), loaded_at=VALUES(loaded_at)`, candleRowFromDomain(candle))
volume_lots=VALUES(volume_lots), source=VALUES(source), loaded_at=VALUES(loaded_at)`, minuteCandleRowFromDomain(candle))
if err != nil {
return err
}
+14
View File
@@ -35,6 +35,20 @@ func candleRowFromDomain(candle domain.Candle) candleRow {
}
}
func minuteCandleRowFromDomain(candle domain.Candle) candleRow {
return candleRow{
InstrumentUID: candle.InstrumentUID,
TradeDate: candle.TradeDate.UTC(),
Open: candle.Open,
High: candle.High,
Low: candle.Low,
Close: candle.Close,
VolumeLots: candle.VolumeLots,
Source: candle.Source,
LoadedAt: candle.LoadedAt,
}
}
func (r candleRow) domain() domain.Candle {
return domain.Candle{
InstrumentUID: r.InstrumentUID,
+27
View File
@@ -0,0 +1,27 @@
package mysql
import (
"testing"
"time"
"github.com/shopspring/decimal"
"overnight-trading-bot/internal/domain"
)
func TestMinuteCandleRowPreservesTimestamp(t *testing.T) {
ts := time.Date(2026, 6, 8, 15, 25, 30, 123000000, time.UTC)
row := minuteCandleRowFromDomain(domain.Candle{
InstrumentUID: "uid",
TradeDate: ts,
Open: decimal.NewFromInt(1),
})
if !row.TradeDate.Equal(ts) {
t.Fatalf("minute timestamp=%s, want %s", row.TradeDate, ts)
}
daily := candleRowFromDomain(domain.Candle{InstrumentUID: "uid", TradeDate: ts})
if daily.TradeDate.Equal(ts) || daily.TradeDate.Hour() != 0 || daily.TradeDate.Minute() != 0 {
t.Fatalf("daily timestamp was not truncated to date: %s", daily.TradeDate)
}
}
+46 -5
View File
@@ -153,18 +153,34 @@ func (s *Scheduler) Step(ctx context.Context) error {
return nil
}
phase := s.phase(now)
current, halted, reason, err := s.svc.Repo.GetSystemState(ctx)
if err != nil {
return err
}
if halted || current == domain.StateHalted {
return fmt.Errorf("%w: %s", statemachine.ErrSystemHalted, reason)
}
switch phase {
case domain.StateWaitExitWindow:
return s.waitExit(ctx, now)
case domain.StatePlaceExitOrders:
if current == domain.StateMonitorExitOrders {
return s.monitorExitOrders(ctx, now)
}
return s.placeExitOrders(ctx, now)
case domain.StateMonitorExitOrders:
return s.monitorExitOrders(ctx, now)
case domain.StateReconcile:
return s.failOpenPositionsAtHardDeadline(ctx)
case domain.StateGenerateSignals:
if signalPhaseAlreadyPrepared(current) {
return s.sm.Heartbeat(ctx, current)
}
return s.prepareSignals(ctx, now)
case domain.StatePlaceEntryOrders:
if current == domain.StateMonitorEntryOrders {
return s.monitorEntryOrders(ctx, now)
}
return s.placeEntryOrders(ctx, now)
case domain.StateMonitorEntryOrders:
return s.monitorEntryOrders(ctx, now)
@@ -492,7 +508,7 @@ func (s *Scheduler) placeEntryOrders(ctx context.Context, now time.Time) error {
}
continue
}
pre, err := s.preTradeCheck(ctx, now, sig.InstrumentUID, portfolio, projectedOpenPositions, false, tradingStatus, book.ReceivedAt)
pre, err := s.preTradeCheck(ctx, now, sig.InstrumentUID, portfolio, projectedOpenPositions, false, tradingStatus, quoteTimestamp(book))
if err != nil {
return err
}
@@ -664,7 +680,7 @@ func (s *Scheduler) placeExitOrders(ctx context.Context, now time.Time) error {
if err != nil {
return err
}
pre, err := s.preTradeCheck(ctx, now, pos.InstrumentUID, portfolio, len(positionsList), true, tradingStatus, book.ReceivedAt)
pre, err := s.preTradeCheck(ctx, now, pos.InstrumentUID, portfolio, len(positionsList), true, tradingStatus, quoteTimestamp(book))
if err != nil {
return err
}
@@ -954,7 +970,11 @@ func (s *Scheduler) checkInfrastructure(ctx context.Context) error {
}
drift := timeutil.Drift(s.nowUTC(), serverTime)
if drift > s.cfg.MaxClockDrift {
return s.recordInfrastructureFailure(ctx, fmt.Errorf("server_clock_drift_too_high: %s > %s", drift, s.cfg.MaxClockDrift))
reason := fmt.Sprintf("server_clock_drift_too_high: %s > %s", drift, s.cfg.MaxClockDrift)
if err := s.halt(ctx, "server_clock_drift_too_high", reason, ""); err != nil {
return err
}
return fmt.Errorf("%w: %s", statemachine.ErrSystemHalted, reason)
}
s.infraFailedSince = time.Time{}
return nil
@@ -1177,7 +1197,7 @@ func (s Scheduler) repostPreTradeCheck(ctx context.Context, now time.Time, order
if err != nil {
return err
}
pre, err := s.preTradeCheck(ctx, now, order.InstrumentUID, portfolio, len(openPositions), order.Side == domain.SideSell, tradingStatus, book.ReceivedAt)
pre, err := s.preTradeCheck(ctx, now, order.InstrumentUID, portfolio, len(openPositions), order.Side == domain.SideSell, tradingStatus, quoteTimestamp(book))
if err != nil {
return err
}
@@ -1457,6 +1477,25 @@ func isSizingSkipReason(reason string) bool {
return reason == "lots_below_one" || reason == "min_order_notional"
}
func signalPhaseAlreadyPrepared(state domain.SystemState) bool {
switch state {
case domain.StateWaitEntryWindow,
domain.StatePlaceEntryOrders,
domain.StateMonitorEntryOrders,
domain.StateHoldOvernight:
return true
default:
return false
}
}
func quoteTimestamp(book domain.OrderBook) time.Time {
if !book.Time.IsZero() {
return book.Time.UTC()
}
return book.ReceivedAt.UTC()
}
func (s Scheduler) hasStateMachine() bool {
return s.sm != (statemachine.System{})
}
@@ -1488,7 +1527,9 @@ func (s Scheduler) transitionTo(ctx context.Context, to domain.SystemState) erro
}
func (s Scheduler) halt(ctx context.Context, eventType, reason, instrumentUID string) error {
_ = s.svc.Notifier.Alert(ctx, fmt.Sprintf("%s: %s", eventType, reason))
if s.svc.Notifier != nil {
_ = s.svc.Notifier.Alert(ctx, fmt.Sprintf("%s: %s", eventType, reason))
}
return s.svc.Risk.Halt(ctx, s.cfg.Mode, eventType, reason, instrumentUID)
}
+82 -7
View File
@@ -85,23 +85,98 @@ func TestPhaseHonorsExitNotBeforeWhenWindowStartsEarlier(t *testing.T) {
}
}
func TestInfrastructureOutageRequiresThreshold(t *testing.T) {
func TestClockDriftHardLimitHaltsImmediately(t *testing.T) {
gateway := tinvest.NewFakeGateway()
gateway.ServerTime = time.Now().UTC().Add(-10 * time.Second)
repo := testutil.NewMemoryRepository()
notifier := &countNotifier{}
s := &Scheduler{
cfg: Config{
Mode: domain.ModeSandbox,
MaxClockDrift: 2 * time.Second,
APIOutageHalt: 180 * time.Second,
},
svc: Services{Gateway: gateway},
svc: Services{
Gateway: gateway,
Risk: risk.NewManager(repo, risk.ManagerConfig{}),
Notifier: notifier,
},
}
if err := s.checkInfrastructure(context.Background()); err != nil {
t.Fatalf("first infrastructure failure should be tolerated: %v", err)
if err := s.checkInfrastructure(context.Background()); !errors.Is(err, statemachine.ErrSystemHalted) {
t.Fatalf("err=%v, want immediate halt on clock drift", err)
}
s.infraFailedSince = time.Now().UTC().Add(-181 * time.Second)
if err := s.checkInfrastructure(context.Background()); err == nil {
t.Fatalf("expected outage after threshold")
if !repo.Halted || repo.HaltReason == "" {
t.Fatalf("system was not halted: state=%s halted=%v reason=%q", repo.State, repo.Halted, repo.HaltReason)
}
if notifier.alerts != 1 {
t.Fatalf("alerts=%d, want 1", notifier.alerts)
}
}
func TestStepIsIdempotentAfterSignalPreparation(t *testing.T) {
ctx := context.Background()
repo := testutil.NewMemoryRepository()
now := time.Date(2026, 6, 8, 18, 15, 0, 0, time.UTC)
if err := repo.SaveSystemState(ctx, domain.StateWaitEntryWindow, domain.ModePaper, false, "", "{}"); err != nil {
t.Fatal(err)
}
s := Scheduler{
clock: fixedClock{now: now},
cfg: Config{
Mode: domain.ModePaper,
Location: time.UTC,
EntrySignalTime: mustTOD("18:10:00"),
EntryWindowStart: mustTOD("18:20:00"),
},
sm: statemachine.New(repo, domain.ModePaper),
svc: Services{
Repo: repo,
Notifier: &countNotifier{},
AccountIDHash: "hash",
},
}
if err := s.Step(ctx); err != nil {
t.Fatal(err)
}
state, halted, _, err := repo.GetSystemState(ctx)
if err != nil {
t.Fatal(err)
}
if halted || state != domain.StateWaitEntryWindow {
t.Fatalf("state=%s halted=%v, want WAIT_ENTRY_WINDOW without rollback", state, halted)
}
}
func TestStepMonitorsEntryOrdersOnRepeatedEntryWindowTick(t *testing.T) {
ctx := context.Background()
repo := testutil.NewMemoryRepository()
now := time.Date(2026, 6, 8, 18, 25, 0, 0, time.UTC)
if err := repo.SaveSystemState(ctx, domain.StateMonitorEntryOrders, domain.ModePaper, false, "", "{}"); err != nil {
t.Fatal(err)
}
s := Scheduler{
clock: fixedClock{now: now},
cfg: Config{
Mode: domain.ModePaper,
Location: time.UTC,
EntryWindowStart: mustTOD("18:20:00"),
NoNewEntryAfter: mustTOD("18:38:30"),
},
sm: statemachine.New(repo, domain.ModePaper),
svc: Services{
Repo: repo,
AccountIDHash: "hash",
},
}
if err := s.Step(ctx); err != nil {
t.Fatal(err)
}
state, halted, _, err := repo.GetSystemState(ctx)
if err != nil {
t.Fatal(err)
}
if halted || state != domain.StateMonitorEntryOrders {
t.Fatalf("state=%s halted=%v, want MONITOR_ENTRY_ORDERS", state, halted)
}
}