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: