2026-06-07 21:01:40 +00:00
|
|
|
package scheduler
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"testing"
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
"github.com/shopspring/decimal"
|
|
|
|
|
|
|
|
|
|
"overnight-trading-bot/internal/domain"
|
|
|
|
|
"overnight-trading-bot/internal/execution"
|
|
|
|
|
"overnight-trading-bot/internal/reconciliation"
|
|
|
|
|
"overnight-trading-bot/internal/risk"
|
|
|
|
|
"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 TestInfrastructureOutageRequiresThreshold(t *testing.T) {
|
|
|
|
|
gateway := tinvest.NewFakeGateway()
|
|
|
|
|
gateway.ServerTime = time.Now().UTC().Add(-10 * time.Second)
|
|
|
|
|
s := &Scheduler{
|
|
|
|
|
cfg: Config{
|
|
|
|
|
Mode: domain.ModeSandbox,
|
|
|
|
|
MaxClockDrift: 2 * time.Second,
|
|
|
|
|
APIOutageHalt: 180 * time.Second,
|
|
|
|
|
},
|
|
|
|
|
svc: Services{Gateway: gateway},
|
|
|
|
|
}
|
|
|
|
|
if err := s.checkInfrastructure(context.Background()); err != nil {
|
|
|
|
|
t.Fatalf("first infrastructure failure should be tolerated: %v", err)
|
|
|
|
|
}
|
|
|
|
|
s.infraFailedSince = time.Now().UTC().Add(-181 * time.Second)
|
|
|
|
|
if err := s.checkInfrastructure(context.Background()); err == nil {
|
|
|
|
|
t.Fatalf("expected outage after threshold")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestReconcileAndReportIsIdempotentPerDate(t *testing.T) {
|
|
|
|
|
ctx := context.Background()
|
|
|
|
|
repo := testutil.NewMemoryRepository()
|
|
|
|
|
gateway := tinvest.NewFakeGateway()
|
|
|
|
|
notifier := &countNotifier{}
|
|
|
|
|
recon := reconciliation.New(repo, gateway, "account", "hash")
|
2026-06-07 21:51:20 +00:00
|
|
|
if err := repo.SaveSystemState(ctx, domain.StateMonitorExitOrders, domain.ModePaper, false, "", "{}"); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
2026-06-07 21:01:40 +00:00
|
|
|
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 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)
|
|
|
|
|
}
|
2026-06-07 21:51:20 +00:00
|
|
|
if notifier.reports != 1 {
|
|
|
|
|
t.Fatalf("reports=%d, want daily report before HALT", notifier.reports)
|
|
|
|
|
}
|
2026-06-07 21:01:40 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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",
|
|
|
|
|
},
|
|
|
|
|
}
|
2026-06-07 21:51:20 +00:00
|
|
|
if err := repo.SaveSystemState(ctx, domain.StateMonitorEntryOrders, domain.ModePaper, false, "", "{}"); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
2026-06-07 21:01:40 +00:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-07 21:51:20 +00:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-07 21:01:40 +00:00
|
|
|
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 mustTOD(raw string) timeutil.TimeOfDay {
|
|
|
|
|
tod, err := timeutil.ParseTimeOfDay(raw)
|
|
|
|
|
if err != nil {
|
|
|
|
|
panic(err)
|
|
|
|
|
}
|
|
|
|
|
return tod
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 }
|