From 426de9038d430732a005982e0e07d74fad52c14e Mon Sep 17 00:00:00 2001 From: I Luk Kim Date: Sat, 11 Apr 2026 00:45:29 -0700 Subject: [PATCH] Fix lookback entry bugs: current-price sizing + MHD expiration filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug #2 (paper trader): lookback entries sized using historical entry_price_est but filled at current market price, causing cash overdraft. Fix: override entry_price_est with get_latest_bars() close before entering _process_entries. Bug #3 (paper trader + backtester): paper trader was missing the per-candidate MHD expiration check that the backtester already had. Also adds lookback_min_remaining_days (default 3) to reject candidates with too little holding time remaining — prevents entering a position the day before forced exit. Co-Authored-By: Claude Sonnet 4.6 --- apps/backtester/run.py | 10 +++++++ apps/paper_trader/engine.py | 59 +++++++++++++++++++++++++++++++++++++ libs/backtest/domain.py | 2 ++ 3 files changed, 71 insertions(+) diff --git a/apps/backtester/run.py b/apps/backtester/run.py index 7a389bd..025d3a1 100644 --- a/apps/backtester/run.py +++ b/apps/backtester/run.py @@ -1842,6 +1842,11 @@ class BacktestRunner: if elapsed >= candidate_mhd: continue # would have timed out by now + remaining = candidate_mhd - elapsed + min_remaining = self.config.execution.lookback_min_remaining_days + if min_remaining is not None and remaining < min_remaining: + continue # too little holding time left to be worthwhile + candidate.features["is_lookback_entry"] = True candidate.features["lookback_days_elapsed"] = elapsed candidate.features["lookback_original_execution_date"] = ( @@ -7845,6 +7850,11 @@ class BacktestRunner: and unrealized_r > engine_cfg.rotation_max_unrealized_r ): continue + if ( + engine_cfg.rotation_min_unrealized_r is not None + and unrealized_r >= engine_cfg.rotation_min_unrealized_r + ): + continue trade = simulate_rotation_exit( position=position, diff --git a/apps/paper_trader/engine.py b/apps/paper_trader/engine.py index 2976842..77222ba 100644 --- a/apps/paper_trader/engine.py +++ b/apps/paper_trader/engine.py @@ -1825,6 +1825,24 @@ class PaperTradingEngine: lookback_rows = await self._detector.get_candidates_for_lookback( today, self._lookback_start_date(today), self._config ) + # Override entry_price_est with current market price for lookback rows. + # Sizing was designed for the historical reaction-day price; filling at + # today's market price without updating entry_price_est causes share counts + # to be based on a stale price, which can result in the account going into + # negative cash (cost = shares × current_price > shares × hist_price). + if lookback_rows: + lb_symbols = list({str(r.get("symbol", "")).upper() for r in lookback_rows if r.get("symbol")}) + try: + latest_bars = self._broker.get_latest_bars(lb_symbols) + for row in lookback_rows: + sym = str(row.get("symbol", "")).upper() + bar = latest_bars.get(sym) + if bar is not None: + row["lookback_original_entry_price_est"] = row.get("entry_price_est") + row["entry_price_est"] = bar.close + except Exception as e: + logger.warning("lookback_price_override_failed", error=str(e)) + if lookback_rows: macro_data_lb = await self._fetch_macro(today) lookback_entries, lookback_rejected = await self._process_entries( @@ -2322,6 +2340,42 @@ class PaperTradingEngine: ecfg = next((e for e in engines if e.engine_id == eid), None) candidate_batches.append((ecfg, acs)) + # ============================================================ + # Lookback MHD expiration filter (mirrors BacktestRunner run.py:1832-1843). + # Reject lookback candidates whose holding window has expired or whose + # remaining holding period is too short to be worthwhile. + # ============================================================ + min_remaining = self._config.execution.lookback_min_remaining_days + if min_remaining is not None or True: # always run expiry check + from libs.backtest.execution import build_effective_execution_config + filtered_batches: list[tuple[Any, list[Any]]] = [] + for ecfg, batch in candidate_batches: + filtered: list[Any] = [] + for candidate in batch: + if not candidate.features.get("is_lookback_entry", False): + filtered.append(candidate) + continue + elapsed = int(candidate.features.get("lookback_days_elapsed", 0)) + eff_exec = build_effective_execution_config(candidate, self._config) + candidate_mhd = eff_exec.max_holding_days + if elapsed >= candidate_mhd: + logger.info( + "lookback_skip_expired", + symbol=candidate.symbol, elapsed=elapsed, mhd=candidate_mhd, + ) + continue + remaining = candidate_mhd - elapsed + if min_remaining is not None and remaining < min_remaining: + logger.info( + "lookback_skip_insufficient_remaining", + symbol=candidate.symbol, elapsed=elapsed, mhd=candidate_mhd, + remaining=remaining, min_required=min_remaining, + ) + continue + filtered.append(candidate) + filtered_batches.append((ecfg, filtered)) + candidate_batches = filtered_batches + # ============================================================ # Phase 2: Cross-Engine Global Score Ranking # Apply strategy_engine_selection_mode before allocation. @@ -3187,6 +3241,11 @@ class PaperTradingEngine: and unrealized_r > engine_cfg.rotation_max_unrealized_r ): continue + if ( + engine_cfg.rotation_min_unrealized_r is not None + and unrealized_r >= engine_cfg.rotation_min_unrealized_r + ): + continue try: self._broker.close_position(sym) diff --git a/libs/backtest/domain.py b/libs/backtest/domain.py index 245546a..771f37b 100644 --- a/libs/backtest/domain.py +++ b/libs/backtest/domain.py @@ -1852,6 +1852,7 @@ class ExecutionConfig(BaseModel): trailing_warmup_days: int = 0 # days after entry before trailing activates max_holding_days: int = 10 lookback_entry_enabled: bool = False # enter positions for pre-start events still within holding window + lookback_min_remaining_days: int | None = 3 # min holding days remaining for a lookback entry to be allowed no_follow_through_exit: bool = False # exit at D+1 close if close < entry price early_failure_close_below_entry_and_reaction_close: bool = False early_failure_no_progress_days: int | None = None @@ -1942,6 +1943,7 @@ class StrategyEngineConfig(BaseModel): rotation_fitness_threshold: float = 0.20 rotation_min_candidate_score: float = 0.40 rotation_max_unrealized_r: float | None = None + rotation_min_unrealized_r: float | None = None trailing_model_override: str | None = None trailing_warmup_days_override: int | None = None use_reaction_day_low_stop_override: bool | None = None