eleventh version
Deploy / Test, build and deploy (push) Failing after 2m15s

This commit is contained in:
2026-06-08 15:33:56 +00:00
parent 7626c1b831
commit e074eeedf2
22 changed files with 681 additions and 55 deletions
+123 -25
View File
@@ -32,6 +32,7 @@ import (
const (
sizeReductionWindowTrades = 20
sizeReductionFactor = 0.5
sizeReductionTriggerBps = -10
intervalVolumeLookbackDays = 20
)
@@ -899,30 +900,36 @@ func (s *Scheduler) applySizeReductionRule(ctx context.Context, tradeDate time.T
if err != nil {
return err
}
if !ok || count < sizeReductionWindowTrades || averageError.GreaterThanOrEqual(decimal.NewFromInt(-10)) {
if !ok || count < sizeReductionWindowTrades || averageError.GreaterThanOrEqual(decimal.NewFromInt(sizeReductionTriggerBps)) {
s.svc.Sizer = s.svc.Sizer.WithSizeFactor(decimal.NewFromInt(1))
return nil
}
factor := decimal.NewFromFloat(sizeReductionFactor)
s.svc.Sizer = s.svc.Sizer.WithSizeFactor(factor)
if !emitEvent {
return nil
if emitEvent {
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
}
}
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)
return s.handleLiveReadonlyAfterSizeReduction(ctx, tradeDate, averageError, count, factor, emitEvent)
}
func (s Scheduler) averageExpectedErrorBps(ctx context.Context, tradeDate time.Time, limit int) (decimal.Decimal, int, bool, error) {
return s.averageExpectedErrorBpsWindow(ctx, tradeDate, 0, limit)
}
func (s Scheduler) averageExpectedErrorBpsWindow(ctx context.Context, tradeDate time.Time, offset, limit int) (decimal.Decimal, int, bool, error) {
if limit <= 0 {
return decimal.Zero, 0, false, nil
}
if offset < 0 {
offset = 0
}
positionsList, err := s.svc.Repo.ListPositions(ctx, s.svc.AccountIDHash, tradeDate.AddDate(0, 0, -120), tradeDate)
if err != nil {
return decimal.Zero, 0, false, err
@@ -949,6 +956,10 @@ func (s Scheduler) averageExpectedErrorBps(ctx context.Context, tradeDate time.T
if sig.InstrumentUID != pos.InstrumentUID || sig.Decision != domain.DecisionEnter {
continue
}
if offset > 0 {
offset--
break
}
errorsBps = append(errorsBps, pos.RealizedEdgeBps.Sub(sig.NetEdgeBps))
break
}
@@ -1229,6 +1240,10 @@ func (s Scheduler) checkEntryInstrumentBeforeOrder(instrument domain.Instrument,
}
func (s Scheduler) preTradeCheck(ctx context.Context, now time.Time, instrumentUID string, portfolio domain.Portfolio, openPositions int, closingPosition bool, tradingStatus domain.TradingStatus, quoteReceivedAt time.Time) (risk.PreTradeResult, error) {
serverClockDrift, serverTimeUnavailable, err := s.preTradeClockDrift(ctx, now)
if err != nil {
return risk.PreTradeResult{}, err
}
metrics, err := s.riskMetrics(ctx, now, portfolio)
if err != nil {
if haltErr := s.halt(ctx, "database_unavailable", fmt.Sprintf("pre-trade risk metrics unavailable: %s", err), instrumentUID); haltErr != nil {
@@ -1241,19 +1256,22 @@ func (s Scheduler) preTradeCheck(ctx context.Context, now time.Time, instrumentU
return risk.PreTradeResult{}, err
}
result := s.svc.Risk.PreTradeCheck(risk.PreTradeInput{
Portfolio: portfolio,
OpenPositions: openPositions,
ClosingPosition: closingPosition,
DailyPnL: metrics.dailyPnL,
WeeklyPnL: metrics.weeklyPnL,
MonthlyDrawdownPct: metrics.monthlyDrawdownPct,
AvgSlippageBps10: metrics.avgSlippageBps10,
TradingStatus: tradingStatus,
QuoteReceivedAt: quoteReceivedAt,
Now: now.UTC(),
MarketClose: s.preTradeDeadlineOn(now, closingPosition),
UnknownBrokerOrder: unknownOrder,
UnknownBrokerHolding: unknownHolding,
Portfolio: portfolio,
OpenPositions: openPositions,
ClosingPosition: closingPosition,
DailyPnL: metrics.dailyPnL,
WeeklyPnL: metrics.weeklyPnL,
MonthlyDrawdownPct: metrics.monthlyDrawdownPct,
AvgSlippageBps10: metrics.avgSlippageBps10,
TradingStatus: tradingStatus,
QuoteReceivedAt: quoteReceivedAt,
Now: now.UTC(),
MarketClose: s.preTradeDeadlineOn(now, closingPosition),
ServerTimeUnavailable: serverTimeUnavailable,
ServerClockDrift: serverClockDrift,
MaxClockDrift: s.cfg.MaxClockDrift,
UnknownBrokerOrder: unknownOrder,
UnknownBrokerHolding: unknownHolding,
})
if !result.Allowed && isHardHaltPreTradeReason(result.Reason) {
if err := s.halt(ctx, result.Reason, fmt.Sprintf("pre-trade hard limit breached: %s", result.Reason), instrumentUID); err != nil {
@@ -1264,6 +1282,20 @@ func (s Scheduler) preTradeCheck(ctx context.Context, now time.Time, instrumentU
return result, nil
}
func (s Scheduler) preTradeClockDrift(ctx context.Context, now time.Time) (time.Duration, bool, error) {
if s.cfg.MaxClockDrift <= 0 || s.svc.Gateway == nil {
return 0, false, nil
}
serverTime, err := s.svc.Gateway.GetServerTime(ctx)
if err != nil {
if s.cfg.Mode == domain.ModePaper {
return 0, false, nil
}
return 0, true, nil
}
return timeutil.Drift(now.UTC(), serverTime), false, nil
}
func (s Scheduler) unknownBrokerState(ctx context.Context, portfolio domain.Portfolio) (bool, bool, error) {
if !s.cfg.Mode.AllowsBrokerOrders() {
return false, false, nil
@@ -1309,6 +1341,8 @@ func (s Scheduler) unknownBrokerState(ctx context.Context, portfolio domain.Port
func isHardHaltPreTradeReason(reason string) bool {
switch reason {
case "database_unavailable",
"server_time_unavailable",
"server_clock_drift_too_high",
"unknown_broker_order",
"unknown_broker_position",
"trading_status_unknown_before_order",
@@ -1392,6 +1426,70 @@ func (s Scheduler) preTradeDeadlineOn(now time.Time, closingPosition bool) time.
return s.marketCloseOn(now)
}
func (s *Scheduler) handleLiveReadonlyAfterSizeReduction(ctx context.Context, tradeDate time.Time, averageError decimal.Decimal, count int, factor decimal.Decimal, emitRecommendation bool) error {
if s.cfg.Mode != domain.ModeLiveTrade {
return nil
}
previousAverage, previousCount, previousOK, err := s.averageExpectedErrorBpsWindow(ctx, tradeDate, sizeReductionWindowTrades, sizeReductionWindowTrades)
if err != nil {
return err
}
if previousOK && previousCount == sizeReductionWindowTrades && previousAverage.LessThan(decimal.NewFromInt(sizeReductionTriggerBps)) {
return s.activateLiveReadonly(ctx, averageError, count, previousAverage, previousCount, factor)
}
if !emitRecommendation {
return nil
}
return s.recommendLiveReadonlyAfterSizeReduction(ctx, averageError, count, factor)
}
func (s *Scheduler) activateLiveReadonly(ctx context.Context, averageError decimal.Decimal, count int, previousAverage decimal.Decimal, previousCount int, factor decimal.Decimal) error {
if s.cfg.Mode != domain.ModeLiveTrade {
return nil
}
if s.svc.Repo == nil {
return nil
}
state, halted, reason, err := s.svc.Repo.GetSystemState(ctx)
if err != nil {
return err
}
if halted || state == domain.StateHalted {
return nil
}
message := fmt.Sprintf(
"average expected_error_bps stayed below %d for two consecutive %d-trade windows; switching to live_readonly",
sizeReductionTriggerBps,
sizeReductionWindowTrades,
)
s.cfg.Mode = domain.ModeLiveReadonly
if s.svc.Execution != nil {
s.svc.Execution.SetMode(domain.ModeLiveReadonly)
}
s.sm = statemachine.New(s.svc.Repo, domain.ModeLiveReadonly)
contextJSON := fmt.Sprintf(
`{"average_expected_error_bps":%q,"trades":%d,"previous_average_expected_error_bps":%q,"previous_trades":%d,"size_factor":%q,"mode":%q}`,
averageError.String(),
count,
previousAverage.String(),
previousCount,
factor.String(),
domain.ModeLiveReadonly,
)
if err := s.svc.Repo.SaveSystemState(ctx, state, domain.ModeLiveReadonly, false, reason, contextJSON); err != nil {
return err
}
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_activated",
Message: message,
ContextJSON: contextJSON,
})
}
func (s Scheduler) recommendLiveReadonlyAfterSizeReduction(ctx context.Context, averageError decimal.Decimal, count int, factor decimal.Decimal) error {
if s.cfg.Mode != domain.ModeLiveTrade {
return nil
+108
View File
@@ -564,6 +564,42 @@ func TestPreTradeDailyLossBreachHalts(t *testing.T) {
}
}
func TestPreTradeClockDriftBreachHalts(t *testing.T) {
ctx := context.Background()
repo := testutil.NewMemoryRepository()
now := time.Date(2026, 6, 8, 18, 20, 0, 0, time.UTC)
gateway := tinvest.NewFakeGateway()
gateway.ServerTime = now.Add(3 * time.Second)
notifier := &countNotifier{}
s := Scheduler{
cfg: Config{
Mode: domain.ModePaper,
Location: time.UTC,
MaxClockDrift: 2 * time.Second,
},
svc: Services{
Repo: repo,
Gateway: gateway,
Risk: risk.NewManager(repo, risk.ManagerConfig{}),
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: server_clock_drift_too_high" {
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()
@@ -812,6 +848,78 @@ func TestSizeReductionRuleRecommendsLiveReadonlyInLiveTrade(t *testing.T) {
}
}
func TestRepeatedSizeReductionRuleActivatesLiveReadonly(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*2; 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)
}
}
execEngine := execution.NewEngine(domain.ModeLiveTrade, "account", nil, repo)
s := Scheduler{
cfg: Config{Mode: domain.ModeLiveTrade},
sm: statemachine.New(repo, domain.ModeLiveTrade),
svc: Services{
Repo: repo,
AccountIDHash: "hash",
Notifier: notifier,
Execution: &execEngine,
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 repo.Mode != domain.ModeLiveReadonly || s.cfg.Mode != domain.ModeLiveReadonly {
t.Fatalf("modes repo=%s scheduler=%s, want live_readonly", repo.Mode, s.cfg.Mode)
}
if len(repo.RiskEvents) != 2 || repo.RiskEvents[1].EventType != "live_readonly_activated" || repo.RiskEvents[1].Severity != domain.SeverityAlert {
t.Fatalf("risk events=%+v, want live_readonly activation alert", repo.RiskEvents)
}
if notifier.alerts != 1 {
t.Fatalf("alerts=%d, want 1", notifier.alerts)
}
_, err := execEngine.PlaceLimit(ctx, domain.Order{
ClientOrderID: "order",
InstrumentUID: "uid",
TradeDate: tradeDate,
Side: domain.SideBuy,
OrderType: domain.OrderTypeLimit,
LimitPrice: decimal.NewFromInt(100),
QuantityLots: 1,
})
if !errors.Is(err, execution.ErrBrokerOrdersDisabled) {
t.Fatalf("PlaceLimit err=%v, want ErrBrokerOrdersDisabled after live_readonly activation", err)
}
}
func TestBatchSignalLimitsCapSlotsAndExposure(t *testing.T) {
s := Scheduler{
cfg: Config{MaxOpenPositions: 5},