Fix lookback entry bugs: current-price sizing + MHD expiration filter

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 <noreply@anthropic.com>
main
I Luk Kim 4 months ago
parent 3d3ac5cd0e
commit 426de9038d

@ -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,

@ -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)

@ -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

Loading…
Cancel
Save