From 7e602b8b30340289b260f8dd3f60ebb210e723ac Mon Sep 17 00:00:00 2001 From: momentum dev Date: Wed, 24 Jun 2026 07:08:07 -0400 Subject: [PATCH] Entry-path unification (step 3): selectable fill model on the auto path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The last real divergence between the two entry paths was the fill model. Make it a flag (TRADING_ENTRY_FILL_MODEL), default 'resting' (zero behaviour change): - resting — rest a buy-limit AT the breakout level (today's behaviour; fills only on a pullback, misses runners that gap up and never return). - marketable — limit a hair above the trigger (entry * (1+trigger_slippage_pct)), fills as price breaks UP through the level (the fast path's runner-catching behaviour). The limit caps the fill, so a halt-resume gap-through can't be chased. R is still measured from the level, not the limit. Operator chose ship-both-default-resting: flip to 'marketable' to A/B with eyes on the first session. Boot log shows "fill=". With gates already unified (steps 1-2: anti-chase, liquidity) the two paths now run the same gate set and the fill model is selectable — the substantive unification is done. The remaining single-_place_entry structural merge is cosmetic (gates are shared) and deliberately not forced through. Tests: resting rests at the level, marketable lifts above the trigger. 69 green. Co-Authored-By: Claude Opus 4.8 --- run_live_paper.py | 1 + tests/unit/test_trading_execution.py | 16 ++++++++++++++++ trading_execution.py | 21 ++++++++++++++++++++- 3 files changed, 37 insertions(+), 1 deletion(-) diff --git a/run_live_paper.py b/run_live_paper.py index 76b0e47..202a2a2 100644 --- a/run_live_paper.py +++ b/run_live_paper.py @@ -807,6 +807,7 @@ def main(argv: list[str] | None = None) -> int: print( f"[boot] entry gates: VWAP {'ENFORCED' if _es.require_above_vwap else 'shadow-only'}" f", anti-chase {'ON (auto path)' if _es.unified_entry else 'fast-path only'}" + f", fill={_es.entry_fill_model}" f", daily-cap {_es.max_fresh_entries_per_day or 'off'}" f", risk-cap ${_es.max_risk_dollars:.0f}" ) diff --git a/tests/unit/test_trading_execution.py b/tests/unit/test_trading_execution.py index 0269aa2..1021ae6 100644 --- a/tests/unit/test_trading_execution.py +++ b/tests/unit/test_trading_execution.py @@ -312,6 +312,22 @@ def test_liquidity_cap_off_when_unified_disabled(store): assert q > int(0.01 * 10_000) # not capped -> risk-based size is larger +def test_fill_model_resting_rests_at_level(store): + emit_signal(store, symbol="REST", entry=14.0, stop=13.45, above_vwap=True) + make_service(store).request_approvals_for_ready_signals() # default resting + req = query_approval_queue(store)[0]["execution_request"] + assert req["entry_price"] == 14.0 # rests AT the level + + +def test_fill_model_marketable_lifts_above_trigger(store): + emit_signal(store, symbol="MKT", entry=14.0, stop=13.45, above_vwap=True) + make_service(store, entry_fill_model="marketable").request_approvals_for_ready_signals() + req = query_approval_queue(store)[0]["execution_request"] + assert req["entry_price"] == round(14.0 * 1.004, 2) # a hair above (slippage 0.4%) + assert req["entry_price"] > 14.0 + assert req["stop_loss_price"] == 13.45 # R still measured from the level + + 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 714edae..dee87bc 100644 --- a/trading_execution.py +++ b/trading_execution.py @@ -91,6 +91,15 @@ class ExecutionSettings: # the fast trigger path, leaving live auto entries unprotected (see the # consolidation plan). Fail-open on missing data. False = pre-unification. unified_entry: bool = False + # Auto-path FILL MODEL (the last real divergence from the fast path): + # "resting" — rest a buy-limit AT the breakout level; fills only on a + # pullback to it (today's behaviour; misses runners that gap + # up and never return). + # "marketable" — limit a hair above the trigger (entry * (1+slippage)); fills + # as price breaks UP through the level (what the fast path + # does), catching runners. The limit caps the fill price, so a + # halt-resume gap-through can't be chased. + entry_fill_model: str = "resting" max_daily_loss_pct: float = 0.03 # on a daily-loss breach, also flatten open positions + cancel unfilled entries flatten_on_breach: bool = True @@ -231,6 +240,7 @@ class ExecutionSettings: max_fresh_entries_per_day=int(values.get("TRADING_MAX_FRESH_ENTRIES_PER_DAY", "0")), require_above_vwap=flag("TRADING_REQUIRE_ABOVE_VWAP", "1"), unified_entry=flag("TRADING_UNIFIED_ENTRY", "1"), + entry_fill_model=values.get("TRADING_ENTRY_FILL_MODEL", "resting"), liquidity_max_volume_pct=float( values.get("TRADING_LIQUIDITY_MAX_VOLUME_PCT", "0.0") ), @@ -746,11 +756,20 @@ class TradingExecutionService: entry_f = float(entry) stop_f = float(stop) risk_per_share = entry_f - stop_f + # FILL MODEL (unification): rest a limit AT the level (default), or place + # a marketable limit a hair above the trigger so it fills as price breaks + # UP through it (the fast path's runner-catching behaviour). The limit + # caps the fill price either way, so a gap-through can't be chased. R + # (stop/target) is measured from the level (entry_f), not the limit. + limit_price = ( + round(entry_f * (1.0 + self.settings.trigger_slippage_pct), 2) + if self.settings.entry_fill_model == "marketable" else entry_f + ) request = ExecutionRequest( symbol=symbol, side="buy", quantity=sizing.position_size, - entry_price=entry_f, + entry_price=limit_price, stop_loss_price=stop_f, take_profit_price=round( entry_f + self.settings.reward_multiple * risk_per_share, 2