From bb7674a708475b762af9ef7b831852a50643992c Mon Sep 17 00:00:00 2001 From: momentum dev Date: Wed, 24 Jun 2026 00:08:43 -0400 Subject: [PATCH] Entry-path unification (step 2): liquidity sizing on the auto path Adds the fast path's volume-based share cap to the live auto path: cap shares to liquidity_max_volume_pct * cum_volume so a thin cheap name can't be oversized into heavy slippage (the VTAK-style stop that filled far past its level on 2026-06-23). Threads cum_volume through the setup dict + ready-signal snapshot; applies max_shares in request_approvals_for_ready_signals, gated by TRADING_UNIFIED_ENTRY. Fail-open: no cum_volume -> no cap. Protective only (can only shrink a position). Tests: thin name capped to liq*cum_volume when on, risk-sized when off. 67 path tests green. Remaining staged (supervised): the full single _place_entry chokepoint + fill-model unification + halt-status plumbing. Co-Authored-By: Claude Opus 4.8 --- storage/projections.py | 1 + strategy/evaluation/setup_evaluator.py | 1 + tests/unit/test_trading_execution.py | 25 ++++++++++++++++++++++++- trading_execution.py | 10 ++++++++++ 4 files changed, 36 insertions(+), 1 deletion(-) diff --git a/storage/projections.py b/storage/projections.py index 1d3be70..de2816d 100644 --- a/storage/projections.py +++ b/storage/projections.py @@ -230,6 +230,7 @@ def query_ready_signals_snapshot(store, session_id: str | None = None) -> list[d "vwap": signal_data.get("vwap"), "above_vwap": signal_data.get("above_vwap"), "day_open": signal_data.get("day_open"), + "cum_volume": signal_data.get("cum_volume"), "timestamp": event.get("timestamp"), } return sorted( diff --git a/strategy/evaluation/setup_evaluator.py b/strategy/evaluation/setup_evaluator.py index 9d4f567..f631bad 100644 --- a/strategy/evaluation/setup_evaluator.py +++ b/strategy/evaluation/setup_evaluator.py @@ -162,6 +162,7 @@ def evaluate_setup( if levels.vwap is not None else None), "above_vwap": bool(above_vwap), "day_open": round(float(open_price), 4) if open_price > 0 else None, + "cum_volume": float(bars["volume"].sum()), } ) diff --git a/tests/unit/test_trading_execution.py b/tests/unit/test_trading_execution.py index 04be950..0269aa2 100644 --- a/tests/unit/test_trading_execution.py +++ b/tests/unit/test_trading_execution.py @@ -67,7 +67,7 @@ class Clock: def emit_signal(store, symbol="GOOD", entry=14.0, stop=13.45, - above_vwap=None, vwap=None, day_open=None): + above_vwap=None, vwap=None, day_open=None, cum_volume=None): signal_data = { "entry_price": entry, "stop_loss_price": stop, @@ -79,6 +79,8 @@ def emit_signal(store, symbol="GOOD", entry=14.0, stop=13.45, round(entry - 1.0, 2) if above_vwap else round(entry + 1.0, 2)) if day_open is not None: signal_data["day_open"] = day_open + if cum_volume is not None: + signal_data["cum_volume"] = cum_volume store.emit( SignalReadyEvent( timestamp=T0, @@ -289,6 +291,27 @@ def test_unified_entry_allows_normal_day(store): assert len(make_service(store, unified_entry=True).request_approvals_for_ready_signals()) == 1 +def test_liquidity_cap_sizes_down_thin_name_on_auto_path(store): + # thin name (cum_volume 10k) -> shares capped to liq*cum_volume, not risk-sized + emit_signal(store, symbol="THIN", entry=2.0, stop=1.9, above_vwap=True, + day_open=1.95, cum_volume=10_000) + svc = make_service(store, unified_entry=True, liquidity_max_volume_pct=0.01, + max_concurrent_positions=10) + svc.request_approvals_for_ready_signals() + q = query_approval_queue(store)[0]["execution_request"]["quantity"] + assert q <= int(0.01 * 10_000) # liquidity-capped at 100 shares + + +def test_liquidity_cap_off_when_unified_disabled(store): + emit_signal(store, symbol="THIN", entry=2.0, stop=1.9, above_vwap=True, + day_open=1.95, cum_volume=10_000) + svc = make_service(store, unified_entry=False, liquidity_max_volume_pct=0.01, + max_concurrent_positions=10) + svc.request_approvals_for_ready_signals() + q = query_approval_queue(store)[0]["execution_request"]["quantity"] + assert q > int(0.01 * 10_000) # not capped -> risk-based size is larger + + def test_no_duplicate_requests_for_same_symbol(store): emit_signal(store) service = make_service(store) diff --git a/trading_execution.py b/trading_execution.py index 2ff8a26..714edae 100644 --- a/trading_execution.py +++ b/trading_execution.py @@ -719,6 +719,15 @@ class TradingExecutionService: # remaining gross-notional budget (so the book can't pile into ~100%). pos_cap = min(eq * self.settings.max_position_pct, self._remaining_gross_budget(eq)) + # Liquidity cap (unification): on the auto path too, cap shares to a + # fraction of the name's cumulative volume so a thin cheap name can't be + # oversized into heavy slippage (the VTAK-style stop that filled far past + # its level). Fail-open: no cum_volume in the signal -> no cap. + liq = self.settings.liquidity_max_volume_pct + cum_vol = signal.get("cum_volume") or 0.0 + max_shares = (int(liq * float(cum_vol)) + if (self.settings.unified_entry and liq > 0 and cum_vol > 0) + else None) sizing = calculate_position_size( float(entry), float(stop), @@ -728,6 +737,7 @@ class TradingExecutionService: default_equity=self.settings.default_equity, ), max_position_value=pos_cap, + max_shares=max_shares, max_risk_dollars=self.settings.max_risk_dollars, ) if sizing.position_size <= 0: