first version
This commit is contained in:
@@ -0,0 +1,287 @@
|
||||
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")
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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 := 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 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 }
|
||||
Reference in New Issue
Block a user