tenth version
Deploy / Test, build and deploy (push) Successful in 1m46s

This commit is contained in:
2026-06-08 14:58:56 +00:00
parent 20cc8506ad
commit 7626c1b831
17 changed files with 917 additions and 27 deletions
+49 -6
View File
@@ -192,6 +192,8 @@ func (s *Scheduler) Step(ctx context.Context) error {
}
func (s Scheduler) phase(now time.Time) domain.SystemState {
// WAIT_ENTRY_WINDOW is a persisted checkpoint after signal generation; wall-clock
// phases move directly from GENERATE_SIGNALS to PLACE_ENTRY_ORDERS.
tod := sinceMidnight(now)
exitWindowStart := s.cfg.ExitWindowStart.Duration
if s.cfg.ExitNotBefore.Duration > exitWindowStart {
@@ -351,6 +353,7 @@ func (s Scheduler) applyBatchSignalLimits(portfolio domain.Portfolio, existingEx
}
}
selectedCount := remainingSlots
reservedCash := decimal.Zero
for rank, index := range enterIndexes {
candidate := &generated[index]
if rank >= remainingSlots {
@@ -360,7 +363,7 @@ func (s Scheduler) applyBatchSignalLimits(portfolio domain.Portfolio, existingEx
candidate.Signal.RejectReason = signal.ReasonMaxPositions
continue
}
sized, sizingErr := s.sizeSignal(portfolio, candidate.Instrument, candidate.Feature, candidate.Book, selectedCount, existingExposure, decimal.Zero)
sized, sizingErr := s.sizeSignal(portfolio, candidate.Instrument, candidate.Feature, candidate.Book, selectedCount, existingExposure, reservedCash)
switch {
case sizingErr != nil:
candidate.Signal.Decision = domain.DecisionReject
@@ -374,6 +377,7 @@ func (s Scheduler) applyBatchSignalLimits(portfolio domain.Portfolio, existingEx
default:
candidate.Signal.TargetLots = sized.Lots
candidate.Signal.TargetNotional = sized.TargetNotional
reservedCash = reservedCash.Add(sized.TargetNotional)
}
}
}
@@ -582,7 +586,8 @@ func (s *Scheduler) monitorEntryOrders(ctx context.Context, now time.Time) error
if err != nil {
return err
}
if monitored.FilledLots > order.FilledLots || monitored.Commission.GreaterThan(order.Commission) {
previousFill := execution.AggregatedOrderFill(order)
if monitored.FilledLots > previousFill.FilledLots || monitored.Commission.GreaterThan(previousFill.Commission) {
fill := entryFillDelta(order, monitored)
if fill.FilledLots <= 0 && fill.Commission.IsZero() {
continue
@@ -758,7 +763,8 @@ func (s *Scheduler) monitorExitOrders(ctx context.Context, now time.Time) error
if err != nil {
return err
}
if monitored.FilledLots > order.FilledLots || monitored.Commission.GreaterThan(order.Commission) {
previousFill := execution.AggregatedOrderFill(order)
if monitored.FilledLots > previousFill.FilledLots || monitored.Commission.GreaterThan(previousFill.Commission) {
fill := exitFillDelta(order, monitored)
if fill.FilledLots <= 0 && fill.Commission.IsZero() {
continue
@@ -902,12 +908,15 @@ func (s *Scheduler) applySizeReductionRule(ctx context.Context, tradeDate time.T
if !emitEvent {
return nil
}
return s.svc.Repo.InsertRiskEvent(ctx, domain.RiskEvent{
if err := s.svc.Repo.InsertRiskEvent(ctx, domain.RiskEvent{
Severity: domain.SeverityWarn,
EventType: "size_reduction_rule_triggered",
Message: fmt.Sprintf("average expected_error_bps over %d trades is %s; sizing factor set to %s", count, averageError.StringFixed(2), factor.String()),
ContextJSON: fmt.Sprintf(`{"average_expected_error_bps":%q,"trades":%d,"size_factor":%q}`, averageError.String(), count, factor.String()),
})
}); err != nil {
return err
}
return s.recommendLiveReadonlyAfterSizeReduction(ctx, averageError, count, factor)
}
func (s Scheduler) averageExpectedErrorBps(ctx context.Context, tradeDate time.Time, limit int) (decimal.Decimal, int, bool, error) {
@@ -1242,7 +1251,7 @@ func (s Scheduler) preTradeCheck(ctx context.Context, now time.Time, instrumentU
TradingStatus: tradingStatus,
QuoteReceivedAt: quoteReceivedAt,
Now: now.UTC(),
MarketClose: s.marketCloseOn(now),
MarketClose: s.preTradeDeadlineOn(now, closingPosition),
UnknownBrokerOrder: unknownOrder,
UnknownBrokerHolding: unknownHolding,
})
@@ -1373,6 +1382,38 @@ func (s Scheduler) marketCloseOn(now time.Time) time.Time {
return s.cfg.MarketClose.On(now, s.cfg.Location).UTC()
}
func (s Scheduler) preTradeDeadlineOn(now time.Time, closingPosition bool) time.Time {
if closingPosition && s.cfg.HardExitDeadline.Duration > 0 {
return s.cfg.HardExitDeadline.On(now, s.cfg.Location).UTC()
}
if !closingPosition && s.cfg.NoNewEntryAfter.Duration > 0 {
return s.cfg.NoNewEntryAfter.On(now, s.cfg.Location).UTC()
}
return s.marketCloseOn(now)
}
func (s Scheduler) recommendLiveReadonlyAfterSizeReduction(ctx context.Context, averageError decimal.Decimal, count int, factor decimal.Decimal) error {
if s.cfg.Mode != domain.ModeLiveTrade {
return nil
}
message := fmt.Sprintf("size reduction remains active after %d trades; consider switching to live_readonly until expected error recovers", count)
if s.svc.Notifier != nil {
_ = s.svc.Notifier.Alert(ctx, message)
}
return s.svc.Repo.InsertRiskEvent(ctx, domain.RiskEvent{
Severity: domain.SeverityAlert,
EventType: "live_readonly_recommended",
Message: message,
ContextJSON: fmt.Sprintf(
`{"average_expected_error_bps":%q,"trades":%d,"size_factor":%q,"recommended_mode":%q}`,
averageError.String(),
count,
factor.String(),
domain.ModeLiveReadonly,
),
})
}
func (s Scheduler) recordPreTradeReject(ctx context.Context, instrumentUID, message, contextJSON string) error {
return s.svc.Repo.InsertRiskEvent(ctx, domain.RiskEvent{
Severity: domain.SeverityWarn,
@@ -1510,6 +1551,7 @@ func (s Scheduler) logWarn(msg string, args ...any) {
}
func entryFillDelta(previous, current domain.Order) domain.Order {
previous = execution.AggregatedOrderFill(previous)
fill := current
fill.FilledLots = current.FilledLots - previous.FilledLots
if fill.FilledLots < 0 {
@@ -1532,6 +1574,7 @@ func entryFillDelta(previous, current domain.Order) domain.Order {
}
func exitFillDelta(previous, current domain.Order) domain.Order {
previous = execution.AggregatedOrderFill(previous)
fill := current
fill.FilledLots = current.FilledLots - previous.FilledLots
if fill.FilledLots < 0 {
+308
View File
@@ -267,6 +267,34 @@ func TestEntryFillDeltaUsesOnlyNewlyExecutedLots(t *testing.T) {
}
}
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()
@@ -421,6 +449,81 @@ func TestEntryInstrumentPreTradeRejectsQuarantineAndCommission(t *testing.T) {
}
}
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()
@@ -461,6 +564,46 @@ func TestPreTradeDailyLossBreachHalts(t *testing.T) {
}
}
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()
@@ -555,6 +698,120 @@ func TestSizeReductionRuleCutsSizerAfterBadExpectedErrors(t *testing.T) {
}
}
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},
@@ -609,6 +866,57 @@ func TestBatchSignalLimitsCapSlotsAndExposure(t *testing.T) {
}
}
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()