This commit is contained in:
+123
-25
@@ -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
|
||||
|
||||
@@ -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},
|
||||
|
||||
Reference in New Issue
Block a user