Files
overnight-trading-bot/internal/scheduler/scheduler_test.go
T

338 lines
10 KiB
Go
Raw Normal View History

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 }