package scheduler import ( "context" "errors" "testing" "time" "github.com/shopspring/decimal" "overnight-trading-bot/internal/domain" "overnight-trading-bot/internal/execution" "overnight-trading-bot/internal/marketdata" "overnight-trading-bot/internal/position" "overnight-trading-bot/internal/reconciliation" "overnight-trading-bot/internal/risk" signalengine "overnight-trading-bot/internal/signal" "overnight-trading-bot/internal/statemachine" "overnight-trading-bot/internal/testutil" "overnight-trading-bot/internal/timeutil" "overnight-trading-bot/internal/tinvest" ) func TestPhaseUsesMoscowWindows(t *testing.T) { loc := time.FixedZone("MSK", 3*60*60) s := Scheduler{cfg: Config{ Location: loc, EntrySignalTime: mustTOD("18:10:00"), EntryWindowStart: mustTOD("18:20:00"), NoNewEntryAfter: mustTOD("18:38:30"), ExitWatchStart: mustTOD("09:50:00"), ExitWindowStart: mustTOD("10:05:00"), ExitWindowEnd: mustTOD("10:25:00"), HardExitDeadline: mustTOD("10:45:00"), }} tests := []struct { at string want domain.SystemState }{ {"2026-06-06T09:55:00+03:00", domain.StateWaitExitWindow}, {"2026-06-06T10:10:00+03:00", domain.StatePlaceExitOrders}, {"2026-06-06T10:30:00+03:00", domain.StateMonitorExitOrders}, {"2026-06-06T11:00:00+03:00", domain.StateReconcile}, {"2026-06-06T18:15:00+03:00", domain.StateGenerateSignals}, {"2026-06-06T18:25:00+03:00", domain.StatePlaceEntryOrders}, {"2026-06-06T19:00:00+03:00", domain.StateHoldOvernight}, } for _, tt := range tests { t.Run(tt.at, func(t *testing.T) { at, err := time.Parse(time.RFC3339, tt.at) if err != nil { t.Fatal(err) } if got := s.phase(at.In(loc)); got != tt.want { t.Fatalf("phase=%s, want %s", got, tt.want) } }) } } func TestPhaseHonorsExitNotBeforeWhenWindowStartsEarlier(t *testing.T) { loc := time.FixedZone("MSK", 3*60*60) s := Scheduler{cfg: Config{ Location: loc, EntrySignalTime: mustTOD("18:10:00"), ExitWatchStart: mustTOD("09:50:00"), ExitNotBefore: mustTOD("10:03:00"), ExitWindowStart: mustTOD("10:00:00"), ExitWindowEnd: mustTOD("10:25:00"), HardExitDeadline: mustTOD("10:45:00"), }} at, err := time.Parse(time.RFC3339, "2026-06-06T10:01:00+03:00") if err != nil { t.Fatal(err) } if got := s.phase(at.In(loc)); got != domain.StateWaitExitWindow { t.Fatalf("phase before ExitNotBefore=%s, want WAIT_EXIT_WINDOW", got) } at, err = time.Parse(time.RFC3339, "2026-06-06T10:04:00+03:00") if err != nil { t.Fatal(err) } if got := s.phase(at.In(loc)); got != domain.StatePlaceExitOrders { t.Fatalf("phase after ExitNotBefore=%s, want PLACE_EXIT_ORDERS", got) } } 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, Risk: risk.NewManager(repo, risk.ManagerConfig{}), Notifier: notifier, }, } if err := s.checkInfrastructure(context.Background()); !errors.Is(err, statemachine.ErrSystemHalted) { t.Fatalf("err=%v, want immediate halt on clock drift", err) } 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) } } func TestReconcileAndReportIsIdempotentPerDate(t *testing.T) { ctx := context.Background() repo := testutil.NewMemoryRepository() 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), svc: Services{ Repo: repo, Gateway: gateway, Reconcile: recon, Notifier: notifier, Risk: risk.NewManager(repo, risk.ManagerConfig{}), AccountID: "account", AccountIDHash: "hash", }, } now := time.Date(2026, 6, 7, 12, 0, 0, 0, time.UTC) if err := s.reconcileAndReport(ctx, now); err != nil { t.Fatal(err) } if err := s.reconcileAndReport(ctx, now); err != nil { t.Fatal(err) } if notifier.reports != 1 { t.Fatalf("reports sent=%d, want 1", notifier.reports) } } func TestExitFillDeltaUsesOnlyNewlyExecutedLots(t *testing.T) { previous := domain.Order{ FilledLots: 2, AvgFillPrice: decimal.NewFromInt(100), Commission: decimal.NewFromFloat(0.50), } current := domain.Order{ FilledLots: 4, AvgFillPrice: decimal.NewFromInt(110), Commission: decimal.NewFromFloat(1.25), } fill := exitFillDelta(previous, current) if fill.FilledLots != 2 { t.Fatalf("delta filled lots=%d, want 2", fill.FilledLots) } if !fill.AvgFillPrice.Equal(decimal.NewFromInt(120)) { t.Fatalf("delta avg fill price=%s, want 120", fill.AvgFillPrice) } if !fill.Commission.Equal(decimal.NewFromFloat(0.75)) { t.Fatalf("delta commission=%s, want 0.75", fill.Commission) } } func TestEntryFillDeltaUsesOnlyNewlyExecutedLots(t *testing.T) { previous := domain.Order{ QuantityLots: 10, FilledLots: 4, AvgFillPrice: decimal.NewFromInt(100), Commission: decimal.NewFromFloat(0.40), InstrumentUID: "uid", } current := domain.Order{ QuantityLots: 10, FilledLots: 10, AvgFillPrice: decimal.NewFromInt(106), Commission: decimal.NewFromFloat(1.00), InstrumentUID: "uid", } fill := entryFillDelta(previous, current) if fill.FilledLots != 6 { t.Fatalf("delta filled lots=%d, want 6", fill.FilledLots) } if fill.QuantityLots != 6 { t.Fatalf("delta quantity lots=%d, want 6 remaining target", fill.QuantityLots) } if !fill.AvgFillPrice.Equal(decimal.NewFromInt(110)) { t.Fatalf("delta avg fill price=%s, want 110", fill.AvgFillPrice) } if !fill.Commission.Equal(decimal.NewFromFloat(0.60)) { t.Fatalf("delta commission=%s, want 0.60", fill.Commission) } } func TestEntryFillDeltaUsesStoredMonitorAggregate(t *testing.T) { previous := domain.Order{ QuantityLots: 3, FilledLots: 0, AvgFillPrice: decimal.Zero, Commission: decimal.Zero, InstrumentUID: "uid", RawStateJSON: `{"local":{"monitor_aggregate":{"quantity_lots":5,"filled_lots":2,"avg_fill_price":"100","commission":"0.40"}}}`, } current := domain.Order{ QuantityLots: 5, FilledLots: 4, AvgFillPrice: decimal.NewFromInt(110), Commission: decimal.NewFromInt(1), InstrumentUID: "uid", } fill := entryFillDelta(previous, current) if fill.FilledLots != 2 { t.Fatalf("delta filled lots=%d, want 2 after stored aggregate", fill.FilledLots) } if !fill.AvgFillPrice.Equal(decimal.NewFromInt(120)) { t.Fatalf("delta avg fill price=%s, want 120", fill.AvgFillPrice) } if !fill.Commission.Equal(decimal.RequireFromString("0.60")) { t.Fatalf("delta commission=%s, want 0.60", fill.Commission) } } func TestHardDeadlineMarksOpenPositionFailedAndHalts(t *testing.T) { ctx := context.Background() repo := testutil.NewMemoryRepository() openDate := time.Date(2026, 6, 6, 0, 0, 0, 0, time.UTC) if err := repo.UpsertPosition(ctx, domain.Position{ AccountIDHash: "hash", InstrumentUID: "uid", OpenTradeDate: openDate, Lots: 1, Lot: 1, Status: domain.PositionHoldingOvernight, }); err != nil { t.Fatal(err) } notifier := &countNotifier{} s := Scheduler{ cfg: Config{Mode: domain.ModePaper, Location: time.UTC}, svc: Services{ Repo: repo, Risk: risk.NewManager(repo, risk.ManagerConfig{}), Notifier: notifier, AccountIDHash: "hash", }, } if err := s.failOpenPositionsAtHardDeadline(ctx); 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) } positions, err := repo.ListOpenPositions(ctx, "hash") if err != nil { t.Fatal(err) } if len(positions) != 1 || positions[0].Status != domain.PositionExitFailed { t.Fatalf("positions=%+v, want EXIT_FAILED", positions) } 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) { ctx := context.Background() repo := testutil.NewMemoryRepository() tradeDate := time.Date(2026, 6, 6, 0, 0, 0, 0, time.UTC) if err := repo.UpsertOrder(ctx, domain.Order{ ClientOrderID: "buy", AccountIDHash: "hash", InstrumentUID: "uid", TradeDate: tradeDate, Side: domain.SideBuy, OrderType: domain.OrderTypeLimit, QuantityLots: 1, Status: domain.OrderStatusNew, }); err != nil { t.Fatal(err) } s := Scheduler{ cfg: Config{Mode: domain.ModePaper, Location: time.UTC}, sm: statemachine.New(repo, domain.ModePaper), svc: Services{ Repo: repo, Execution: &execution.Engine{}, 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) } orders, err := repo.ListOrders(ctx, "hash", tradeDate, tradeDate) if err != nil { t.Fatal(err) } if len(orders) != 1 || orders[0].Status != domain.OrderStatusCancelled { t.Fatalf("orders=%+v, want CANCELLED", orders) } } 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 TestEntryInstrumentPreTradeRejectsQuarantineAndCommission(t *testing.T) { s := Scheduler{cfg: Config{RequireZeroCommission: true}} err := s.checkEntryInstrumentBeforeOrder(domain.Instrument{ InstrumentUID: "uid", Ticker: "TRUR", Enabled: true, Quarantine: true, Lot: 1, MinPriceIncrement: decimal.NewFromInt(1), Currency: "RUB", }, domain.TradingStatusNormal) if err == nil { t.Fatal("expected quarantine rejection") } err = s.checkEntryInstrumentBeforeOrder(domain.Instrument{ InstrumentUID: "uid", Ticker: "TRUR", Enabled: true, Lot: 1, MinPriceIncrement: decimal.NewFromInt(1), Currency: "RUB", ExpectedCommissionBpsPerSide: decimal.NewFromInt(1), }, domain.TradingStatusNormal) if err == nil || err.Error() != signalengine.ReasonCommission { t.Fatalf("err=%v, want commission rejection", err) } } func TestPlaceExitAllowsQuarantinedInstrumentForOpenPosition(t *testing.T) { ctx := context.Background() repo := testutil.NewMemoryRepository() openDate := time.Date(2026, 6, 6, 0, 0, 0, 0, time.UTC) exitDate := openDate.AddDate(0, 0, 1) instrument := domain.Instrument{ InstrumentUID: "uid", Ticker: "TRUR", ClassCode: "TQTF", Enabled: true, Quarantine: true, QuarantineReason: "actual commission nonzero", Lot: 1, MinPriceIncrement: decimal.RequireFromString("0.01"), Currency: "RUB", FreeOrderLimitPerDay: -1, } if err := repo.UpsertInstrument(ctx, instrument); err != nil { t.Fatal(err) } if err := repo.UpsertPosition(ctx, domain.Position{ AccountIDHash: "hash", InstrumentUID: "uid", OpenTradeDate: openDate, Lots: 2, Lot: 1, AvgBuyPrice: decimal.NewFromInt(100), Status: domain.PositionHoldingOvernight, }); err != nil { t.Fatal(err) } gateway := tinvest.NewFakeGateway() gateway.OrderBooks["uid"] = domain.OrderBook{ InstrumentUID: "uid", Bids: []domain.OrderBookLevel{{Price: decimal.NewFromInt(100), QuantityLots: 10}}, Asks: []domain.OrderBookLevel{{Price: decimal.RequireFromString("100.10"), QuantityLots: 10}}, ReceivedAt: time.Now().UTC(), } execEngine := execution.NewEngine(domain.ModePaper, "account", gateway, repo) s := Scheduler{ cfg: Config{ Mode: domain.ModePaper, Location: time.UTC, HardExitDeadline: mustTOD("23:00:00"), }, sm: statemachine.New(repo, domain.ModePaper), svc: Services{ Repo: repo, Gateway: gateway, MarketData: marketdata.NewLoader(repo, gateway), Signals: signalengine.New(signalengine.Config{}), FreeOrders: risk.NewFreeOrderBudget(repo), Risk: risk.NewManager(repo, risk.ManagerConfig{}), Execution: &execEngine, Positions: position.NewManager(repo), Notifier: &countNotifier{}, AccountID: "account", AccountIDHash: "hash", }, } if err := repo.SaveSystemState(ctx, domain.StateWaitExitWindow, domain.ModePaper, false, "", "{}"); err != nil { t.Fatal(err) } if err := s.placeExitOrders(ctx, exitDate.Add(10*time.Hour)); err != nil { t.Fatal(err) } orders, err := repo.ListOrders(ctx, "hash", exitDate, exitDate) if err != nil { t.Fatal(err) } if len(orders) != 1 || orders[0].Side != domain.SideSell { t.Fatalf("orders=%+v, want sell order for quarantined open position", orders) } } func TestPreTradeDailyLossBreachHalts(t *testing.T) { ctx := context.Background() repo := testutil.NewMemoryRepository() now := time.Date(2026, 6, 8, 18, 20, 0, 0, time.UTC) closedAt := now.Add(-time.Hour) if err := repo.UpsertPosition(ctx, domain.Position{ AccountIDHash: "hash", InstrumentUID: "uid", OpenTradeDate: tradingDate(now), Status: domain.PositionExitFilled, NetPnL: decimal.NewFromInt(-200), ClosedAt: &closedAt, }); err != nil { t.Fatal(err) } notifier := &countNotifier{} s := Scheduler{ cfg: Config{Mode: domain.ModePaper, Location: time.UTC}, svc: Services{ Repo: repo, Risk: risk.NewManager(repo, risk.ManagerConfig{MaxDailyLossPct: decimal.RequireFromString("0.01")}), Notifier: notifier, AccountIDHash: "hash", }, } _, err := s.preTradeCheck(ctx, now, "uid", domain.Portfolio{ Equity: decimal.NewFromInt(10000), Cash: decimal.NewFromInt(10000), }, 0, false, domain.TradingStatusNormal, now) if !errors.Is(err, statemachine.ErrSystemHalted) { t.Fatalf("err=%v, want ErrSystemHalted", err) } if !repo.Halted || repo.HaltReason != "pre-trade hard limit breached: max_daily_loss" { t.Fatalf("halted=%v reason=%q", repo.Halted, repo.HaltReason) } if notifier.alerts != 1 { t.Fatalf("alerts=%d, want 1", notifier.alerts) } } func TestPreTradeUsesPhaseDeadlineForMinTimeToClose(t *testing.T) { ctx := context.Background() repo := testutil.NewMemoryRepository() now := time.Date(2026, 6, 8, 18, 37, 45, 0, time.UTC) s := Scheduler{ cfg: Config{ Mode: domain.ModePaper, Location: time.UTC, NoNewEntryAfter: mustTOD("18:38:30"), HardExitDeadline: mustTOD("18:40:00"), MarketClose: mustTOD("23:00:00"), }, svc: Services{ Repo: repo, Risk: risk.NewManager(repo, risk.ManagerConfig{MinTimeToClose: 90 * time.Second}), AccountIDHash: "hash", }, } entry, err := s.preTradeCheck(ctx, now, "uid", domain.Portfolio{ Equity: decimal.NewFromInt(10000), Cash: decimal.NewFromInt(10000), }, 0, false, domain.TradingStatusNormal, now) if err != nil { t.Fatal(err) } if entry.Allowed || entry.Reason != "min_time_to_close_sec" { t.Fatalf("entry result=%+v, want min_time_to_close_sec reject before NoNewEntryAfter", entry) } exit, err := s.preTradeCheck(ctx, now, "uid", domain.Portfolio{ Equity: decimal.NewFromInt(10000), Cash: decimal.NewFromInt(10000), }, 1, true, domain.TradingStatusNormal, now) if err != nil { t.Fatal(err) } if !exit.Allowed { t.Fatalf("exit result=%+v, want allowed before HardExitDeadline", exit) } } func TestStepSendsMissedDailyReportAfterEntrySignalTime(t *testing.T) { ctx := context.Background() repo := testutil.NewMemoryRepository() notifier := &countNotifier{} now := time.Date(2026, 6, 8, 18, 15, 0, 0, time.UTC) s := Scheduler{ clock: fixedClock{now: now}, cfg: Config{ Mode: domain.ModePaper, Location: time.UTC, EntrySignalTime: mustTOD("18:10:00"), }, sm: statemachine.New(repo, domain.ModePaper), svc: Services{ Repo: repo, Notifier: notifier, AccountIDHash: "hash", }, } if err := s.Step(ctx); err != nil { t.Fatal(err) } if notifier.reports != 1 { t.Fatalf("reports=%d, want catch-up report", notifier.reports) } sent, err := repo.WasDailyReportSent(ctx, now, "hash") if err != nil { t.Fatal(err) } if !sent { t.Fatal("daily report was not marked as sent") } } func TestSizeReductionRuleCutsSizerAfterBadExpectedErrors(t *testing.T) { ctx := context.Background() repo := testutil.NewMemoryRepository() tradeDate := time.Date(2026, 6, 30, 0, 0, 0, 0, time.UTC) for i := 0; i < sizeReductionWindowTrades; i++ { date := tradeDate.AddDate(0, 0, -i) if err := repo.UpsertSignal(ctx, domain.Signal{ TradeDate: date, InstrumentUID: "uid", Decision: domain.DecisionEnter, NetEdgeBps: decimal.NewFromInt(20), }); err != nil { t.Fatal(err) } if err := repo.UpsertPosition(ctx, domain.Position{ AccountIDHash: "hash", InstrumentUID: "uid", OpenTradeDate: date, Lot: 1, Status: domain.PositionExitFilled, RealizedEdgeBps: decimal.Zero, UpdatedAt: date.Add(time.Hour), }); err != nil { t.Fatal(err) } } s := Scheduler{ svc: Services{ Repo: repo, AccountIDHash: "hash", Sizer: risk.NewSizer(risk.SizingConfig{ MaxPositionPct: decimal.NewFromInt(1), MaxTotalExposurePct: decimal.NewFromInt(1), MaxParticipationRate: decimal.NewFromInt(1), CashUsageBuffer: decimal.NewFromInt(1), RiskBudgetPerInstrumentPct: decimal.NewFromInt(1), MinOrderNotionalRUB: decimal.NewFromInt(1), }), }, } if err := s.applySizeReductionRule(ctx, tradeDate, true); err != nil { t.Fatal(err) } sized := s.svc.Sizer.Size(risk.SizingInput{ Portfolio: domain.Portfolio{Equity: decimal.NewFromInt(10_000), Cash: decimal.NewFromInt(10_000)}, SelectedInstruments: 1, LimitPrice: decimal.NewFromInt(100), Lot: 1, EntryIntervalVolume: decimal.NewFromInt(10_000), ExitIntervalVolume: decimal.NewFromInt(10_000), Q05OvernightAbs: decimal.NewFromInt(1), }) if sized.Lots != 50 { t.Fatalf("lots=%d, want reduced 50", sized.Lots) } if len(repo.RiskEvents) != 1 || repo.RiskEvents[0].EventType != "size_reduction_rule_triggered" { t.Fatalf("risk events=%+v", repo.RiskEvents) } } func TestSizeReductionRuleBoundaryMinusTenDoesNotCut(t *testing.T) { ctx := context.Background() repo := testutil.NewMemoryRepository() tradeDate := time.Date(2026, 6, 30, 0, 0, 0, 0, time.UTC) for i := 0; i < sizeReductionWindowTrades; i++ { date := tradeDate.AddDate(0, 0, -i) if err := repo.UpsertSignal(ctx, domain.Signal{ TradeDate: date, InstrumentUID: "uid", Decision: domain.DecisionEnter, NetEdgeBps: decimal.NewFromInt(20), }); err != nil { t.Fatal(err) } if err := repo.UpsertPosition(ctx, domain.Position{ AccountIDHash: "hash", InstrumentUID: "uid", OpenTradeDate: date, Lot: 1, Status: domain.PositionExitFilled, RealizedEdgeBps: decimal.NewFromInt(10), UpdatedAt: date.Add(time.Hour), }); err != nil { t.Fatal(err) } } s := Scheduler{ svc: Services{ Repo: repo, AccountIDHash: "hash", Sizer: risk.NewSizer(risk.SizingConfig{ MaxPositionPct: decimal.NewFromInt(1), MaxTotalExposurePct: decimal.NewFromInt(1), MaxParticipationRate: decimal.NewFromInt(1), CashUsageBuffer: decimal.NewFromInt(1), RiskBudgetPerInstrumentPct: decimal.NewFromInt(1), MinOrderNotionalRUB: decimal.NewFromInt(1), }), }, } if err := s.applySizeReductionRule(ctx, tradeDate, true); err != nil { t.Fatal(err) } sized := s.svc.Sizer.Size(risk.SizingInput{ Portfolio: domain.Portfolio{Equity: decimal.NewFromInt(10_000), Cash: decimal.NewFromInt(10_000)}, SelectedInstruments: 1, LimitPrice: decimal.NewFromInt(100), Lot: 1, EntryIntervalVolume: decimal.NewFromInt(10_000), ExitIntervalVolume: decimal.NewFromInt(10_000), Q05OvernightAbs: decimal.NewFromInt(1), }) if sized.Lots != 100 { t.Fatalf("lots=%d, want unreduced 100 at -10.00 bps boundary", sized.Lots) } if len(repo.RiskEvents) != 0 { t.Fatalf("risk events=%+v, want none at boundary", repo.RiskEvents) } } func TestSizeReductionRuleRecommendsLiveReadonlyInLiveTrade(t *testing.T) { ctx := context.Background() repo := testutil.NewMemoryRepository() notifier := &countNotifier{} tradeDate := time.Date(2026, 6, 30, 0, 0, 0, 0, time.UTC) for i := 0; i < sizeReductionWindowTrades; i++ { date := tradeDate.AddDate(0, 0, -i) if err := repo.UpsertSignal(ctx, domain.Signal{ TradeDate: date, InstrumentUID: "uid", Decision: domain.DecisionEnter, NetEdgeBps: decimal.NewFromInt(20), }); err != nil { t.Fatal(err) } if err := repo.UpsertPosition(ctx, domain.Position{ AccountIDHash: "hash", InstrumentUID: "uid", OpenTradeDate: date, Lot: 1, Status: domain.PositionExitFilled, RealizedEdgeBps: decimal.Zero, UpdatedAt: date.Add(time.Hour), }); err != nil { t.Fatal(err) } } s := Scheduler{ cfg: Config{Mode: domain.ModeLiveTrade}, svc: Services{ Repo: repo, AccountIDHash: "hash", Notifier: notifier, Sizer: risk.NewSizer(risk.SizingConfig{ MaxPositionPct: decimal.NewFromInt(1), MaxTotalExposurePct: decimal.NewFromInt(1), MaxParticipationRate: decimal.NewFromInt(1), CashUsageBuffer: decimal.NewFromInt(1), RiskBudgetPerInstrumentPct: decimal.NewFromInt(1), MinOrderNotionalRUB: decimal.NewFromInt(1), }), }, } if err := s.applySizeReductionRule(ctx, tradeDate, true); err != nil { t.Fatal(err) } if len(repo.RiskEvents) != 2 || repo.RiskEvents[1].EventType != "live_readonly_recommended" || repo.RiskEvents[1].Severity != domain.SeverityAlert { t.Fatalf("risk events=%+v, want live_readonly recommendation alert", repo.RiskEvents) } if notifier.alerts != 1 { t.Fatalf("alerts=%d, want 1", notifier.alerts) } } func TestBatchSignalLimitsCapSlotsAndExposure(t *testing.T) { s := Scheduler{ cfg: Config{MaxOpenPositions: 5}, svc: Services{Sizer: risk.NewSizer(risk.SizingConfig{ MaxPositionPct: decimal.NewFromInt(1), MaxTotalExposurePct: decimal.RequireFromString("0.50"), MaxParticipationRate: decimal.NewFromInt(1), CashUsageBuffer: decimal.NewFromInt(1), RiskBudgetPerInstrumentPct: decimal.NewFromInt(1), MinOrderNotionalRUB: decimal.NewFromInt(1), })}, } book := domain.OrderBook{ Bids: []domain.OrderBookLevel{{Price: decimal.NewFromInt(99), QuantityLots: 10}}, Asks: []domain.OrderBookLevel{{Price: decimal.NewFromInt(101), QuantityLots: 10}}, } generated := make([]signalCandidate, 0, 9) for i := 0; i < 9; i++ { uid := string(rune('a' + i)) generated = append(generated, signalCandidate{ Signal: domain.Signal{ InstrumentUID: uid, Decision: domain.DecisionEnter, Score: decimal.NewFromInt(int64(100 - i)), }, Instrument: domain.Instrument{InstrumentUID: uid, Lot: 1, MinPriceIncrement: decimal.NewFromInt(1)}, Feature: domain.FeatureSet{ EntryIntervalVolume: decimal.NewFromInt(1_000_000), ExitIntervalVolume: decimal.NewFromInt(1_000_000), SigmaOn60: decimal.NewFromInt(1), }, Book: book, }) } s.applyBatchSignalLimits(domain.Portfolio{Equity: decimal.NewFromInt(100_000), Cash: decimal.NewFromInt(100_000)}, decimal.Zero, 0, generated) enters := 0 total := decimal.Zero for _, candidate := range generated { if candidate.Signal.Decision == domain.DecisionEnter { enters++ total = total.Add(candidate.Signal.TargetNotional) } } if enters != 5 { t.Fatalf("enter signals=%d, want 5", enters) } if total.GreaterThan(decimal.NewFromInt(50_000)) { t.Fatalf("total target notional=%s exceeds 50%% exposure", total) } if generated[5].Signal.RejectReason != signalengine.ReasonMaxPositions { t.Fatalf("sixth signal reason=%q, want max positions", generated[5].Signal.RejectReason) } } func TestBatchSignalLimitsReserveCashAcrossCandidates(t *testing.T) { s := Scheduler{ cfg: Config{MaxOpenPositions: 5}, svc: Services{Sizer: risk.NewSizer(risk.SizingConfig{ MaxPositionPct: decimal.NewFromInt(1), MaxTotalExposurePct: decimal.NewFromInt(1), MaxParticipationRate: decimal.NewFromInt(1), CashUsageBuffer: decimal.NewFromInt(1), RiskBudgetPerInstrumentPct: decimal.NewFromInt(1), MinOrderNotionalRUB: decimal.NewFromInt(1), })}, } book := domain.OrderBook{ Bids: []domain.OrderBookLevel{{Price: decimal.NewFromInt(100), QuantityLots: 10}}, Asks: []domain.OrderBookLevel{{Price: decimal.NewFromInt(102), QuantityLots: 10}}, } generated := make([]signalCandidate, 0, 5) for i := 0; i < 5; i++ { uid := string(rune('a' + i)) generated = append(generated, signalCandidate{ Signal: domain.Signal{ InstrumentUID: uid, Decision: domain.DecisionEnter, Score: decimal.NewFromInt(int64(100 - i)), }, Instrument: domain.Instrument{InstrumentUID: uid, Lot: 1, MinPriceIncrement: decimal.NewFromInt(1)}, Feature: domain.FeatureSet{ EntryIntervalVolume: decimal.NewFromInt(1_000_000), ExitIntervalVolume: decimal.NewFromInt(1_000_000), Q05On60Abs: decimal.NewFromInt(1), }, Book: book, }) } s.applyBatchSignalLimits(domain.Portfolio{Equity: decimal.NewFromInt(100_000), Cash: decimal.NewFromInt(30_000)}, decimal.Zero, 0, generated) enters := 0 total := decimal.Zero for _, candidate := range generated { if candidate.Signal.Decision == domain.DecisionEnter { enters++ total = total.Add(candidate.Signal.TargetNotional) } } if enters != 2 { t.Fatalf("enter signals=%d, want only candidates that fit reserved cash", enters) } if total.GreaterThan(decimal.NewFromInt(30_000)) { t.Fatalf("total target notional=%s exceeds cash", total) } } func TestPlaceEntryRejectsWideSpreadBeforeOrder(t *testing.T) { ctx := context.Background() repo := testutil.NewMemoryRepository() tradeDate := time.Date(2026, 6, 6, 0, 0, 0, 0, time.UTC) instrument := domain.Instrument{ InstrumentUID: "uid", Ticker: "TRUR", ClassCode: "TQTF", Enabled: true, Lot: 1, MinPriceIncrement: decimal.RequireFromString("0.01"), Currency: "RUB", FreeOrderLimitPerDay: -1, } if err := repo.UpsertInstrument(ctx, instrument); err != nil { t.Fatal(err) } if err := repo.UpsertSignal(ctx, domain.Signal{ TradeDate: tradeDate, InstrumentUID: "uid", Decision: domain.DecisionEnter, Score: decimal.NewFromInt(10), TargetLots: 1, }); err != nil { t.Fatal(err) } if err := repo.UpsertFeature(ctx, domain.FeatureSet{ InstrumentUID: "uid", TradeDate: tradeDate, EntryIntervalVolume: decimal.NewFromInt(1_000_000), ExitIntervalVolume: decimal.NewFromInt(1_000_000), SigmaOn60: decimal.NewFromInt(1), }); err != nil { t.Fatal(err) } gateway := tinvest.NewFakeGateway() gateway.OrderBooks["uid"] = domain.OrderBook{ InstrumentUID: "uid", Bids: []domain.OrderBookLevel{{Price: decimal.NewFromInt(90), QuantityLots: 10}}, Asks: []domain.OrderBookLevel{{Price: decimal.NewFromInt(110), QuantityLots: 10}}, ReceivedAt: time.Now().UTC(), } execEngine := execution.NewEngine(domain.ModePaper, "account", gateway, repo) now := tradeDate.Add(18 * time.Hour) s := Scheduler{ clock: fixedClock{now: now}, cfg: Config{ Mode: domain.ModePaper, Location: time.UTC, NoNewEntryAfter: mustTOD("23:00:00"), MaxQuoteAge: time.Minute, MarketClose: mustTOD("23:30:00"), MaxOpenPositions: 5, }, sm: statemachine.New(repo, domain.ModePaper), svc: Services{ Repo: repo, Gateway: gateway, MarketData: marketdata.NewLoader(repo, gateway), Signals: signalengine.New(signalengine.Config{MaxSpreadBpsDefault: decimal.NewFromInt(20)}), Sizer: risk.NewSizer(testSizingConfig()), FreeOrders: risk.NewFreeOrderBudget(repo), Risk: risk.NewManager(repo, risk.ManagerConfig{MaxOpenPositions: 5}), Execution: &execEngine, Positions: position.NewManager(repo), Notifier: &countNotifier{}, AccountID: "account", AccountIDHash: "hash", }, } if err := repo.SaveSystemState(ctx, domain.StateWaitEntryWindow, domain.ModePaper, false, "", "{}"); err != nil { t.Fatal(err) } if err := s.placeEntryOrders(ctx, now); err != nil { t.Fatal(err) } orders, err := repo.ListOrders(ctx, "hash", tradeDate, tradeDate) if err != nil { t.Fatal(err) } if len(orders) != 0 { t.Fatalf("orders=%+v, want no order on wide spread", orders) } if len(repo.RiskEvents) != 1 || repo.RiskEvents[0].EventType != "pre_trade_reject" { t.Fatalf("risk events=%+v", repo.RiskEvents) } } func TestPlaceExitUsesCurrentTradeDateForOrderAndFreeCounter(t *testing.T) { ctx := context.Background() repo := testutil.NewMemoryRepository() openDate := time.Date(2026, 6, 6, 0, 0, 0, 0, time.UTC) exitDate := openDate.AddDate(0, 0, 1) instrument := domain.Instrument{ InstrumentUID: "uid", Ticker: "TRUR", ClassCode: "TQTF", Enabled: true, Lot: 1, MinPriceIncrement: decimal.RequireFromString("0.01"), Currency: "RUB", FreeOrderLimitPerDay: 10, } if err := repo.UpsertInstrument(ctx, instrument); err != nil { t.Fatal(err) } if err := repo.UpsertPosition(ctx, domain.Position{ AccountIDHash: "hash", InstrumentUID: "uid", OpenTradeDate: openDate, Lots: 2, Lot: 1, AvgBuyPrice: decimal.NewFromInt(100), Status: domain.PositionHoldingOvernight, }); err != nil { t.Fatal(err) } gateway := tinvest.NewFakeGateway() gateway.OrderBooks["uid"] = domain.OrderBook{ InstrumentUID: "uid", Bids: []domain.OrderBookLevel{{Price: decimal.NewFromInt(100), QuantityLots: 10}}, Asks: []domain.OrderBookLevel{{Price: decimal.RequireFromString("100.10"), QuantityLots: 10}}, ReceivedAt: time.Now().UTC(), } execEngine := execution.NewEngine(domain.ModePaper, "account", gateway, repo) s := Scheduler{ cfg: Config{ Mode: domain.ModePaper, Location: time.UTC, HardExitDeadline: mustTOD("23:00:00"), MaxQuoteAge: time.Minute, MarketClose: mustTOD("23:30:00"), }, sm: statemachine.New(repo, domain.ModePaper), svc: Services{ Repo: repo, Gateway: gateway, MarketData: marketdata.NewLoader(repo, gateway), Signals: signalengine.New(signalengine.Config{MaxSpreadBpsDefault: decimal.NewFromInt(20)}), FreeOrders: risk.NewFreeOrderBudget(repo), Risk: risk.NewManager(repo, risk.ManagerConfig{}), Execution: &execEngine, Positions: position.NewManager(repo), Reconcile: reconciliation.New(repo, gateway, "account", "hash"), Notifier: &countNotifier{}, AccountID: "account", AccountIDHash: "hash", }, } if err := repo.SaveSystemState(ctx, domain.StateWaitExitWindow, domain.ModePaper, false, "", "{}"); err != nil { t.Fatal(err) } if err := s.placeExitOrders(ctx, exitDate.Add(10*time.Hour)); err != nil { t.Fatal(err) } orders, err := repo.ListOrders(ctx, "hash", exitDate, exitDate) if err != nil { t.Fatal(err) } if len(orders) != 1 || !sameTradingDate(orders[0].TradeDate, exitDate) { t.Fatalf("orders=%+v, want one exit order on current date", orders) } sentToday, err := repo.GetFreeOrdersSent(ctx, exitDate, "uid") if err != nil { t.Fatal(err) } sentOpenDate, err := repo.GetFreeOrdersSent(ctx, openDate, "uid") if err != nil { t.Fatal(err) } if sentToday != 1 || sentOpenDate != 0 { t.Fatalf("free counters today=%d openDate=%d, want 1/0", sentToday, sentOpenDate) } } func TestGracefulShutdownCancelsActiveOrders(t *testing.T) { ctx := context.Background() repo := testutil.NewMemoryRepository() gateway := tinvest.NewFakeGateway() tradeDate := time.Date(2026, 6, 6, 0, 0, 0, 0, time.UTC) order := domain.Order{ ClientOrderID: "shutdown-order", BrokerOrderID: "broker-shutdown-order", AccountIDHash: "hash", InstrumentUID: "uid", TradeDate: tradeDate, Side: domain.SideBuy, OrderType: domain.OrderTypeLimit, LimitPrice: decimal.NewFromInt(100), QuantityLots: 1, Status: domain.OrderStatusSent, RawStateJSON: "{}", } if err := repo.UpsertOrder(ctx, order); err != nil { t.Fatal(err) } gateway.Orders[order.BrokerOrderID] = order execEngine := execution.NewEngine(domain.ModeSandbox, "account", gateway, repo) s := Scheduler{ cfg: Config{Mode: domain.ModeSandbox}, svc: Services{ Repo: repo, Execution: &execEngine, AccountIDHash: "hash", }, } if err := s.GracefulShutdown(ctx); err != nil { t.Fatal(err) } orders, err := repo.ListOrders(ctx, "hash", tradeDate, tradeDate) if err != nil { t.Fatal(err) } if len(orders) != 1 || orders[0].Status != domain.OrderStatusCancelled { t.Fatalf("orders=%+v, want cancelled", orders) } } func mustTOD(raw string) timeutil.TimeOfDay { tod, err := timeutil.ParseTimeOfDay(raw) if err != nil { panic(err) } return tod } func testSizingConfig() risk.SizingConfig { return risk.SizingConfig{ MaxPositionPct: decimal.NewFromInt(1), MaxTotalExposurePct: decimal.NewFromInt(1), MaxParticipationRate: decimal.NewFromInt(1), CashUsageBuffer: decimal.NewFromInt(1), RiskBudgetPerInstrumentPct: decimal.NewFromInt(1), MinOrderNotionalRUB: decimal.NewFromInt(1), } } type fixedClock struct { now time.Time } func (c fixedClock) Now() time.Time { return c.now } func (fixedClock) Sleep(<-chan struct{}, time.Duration) bool { return true } type countNotifier struct { reports int alerts int } func (n *countNotifier) Info(context.Context, string) error { return nil } func (n *countNotifier) Warn(context.Context, string) error { return nil } func (n *countNotifier) Alert(context.Context, string) error { n.alerts++; return nil } func (n *countNotifier) Report(context.Context, string) error { n.reports++; return nil } func (n *countNotifier) Close() error { return nil }