Fix parking entry/exit showing same price on last simulation day

On the last simulation day, parking was entered at EOD close price
(when _had_event_activity_today=True) and immediately liquidated at
close by end-of-backtest cleanup → entry == exit → PnL = 0.

Fix: force all six parking entry code paths to use "open" price when
date == last_simulation_date, so entry and cleanup-close are always
different prices.

Also adds _parking_cap logic in _extend_store_to_requested_window to
cap _requested_end_date at the last date where QQQM/TQQQ/SGOV all
have Oracle close-price data, preventing the simulation from including
days where macro is incomplete and the exit fallback would fire.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
main
I Luk Kim 4 months ago
parent 99ad043f14
commit 004f1a5bce

@ -261,6 +261,7 @@ class BacktestRunner:
self._pending_open_exits: dict[dt.date, list[dict[str, Any]]] = defaultdict(list) self._pending_open_exits: dict[dt.date, list[dict[str, Any]]] = defaultdict(list)
self._parent_add_on_counts: dict[str, int] = defaultdict(int) self._parent_add_on_counts: dict[str, int] = defaultdict(int)
self._simulation_dates: list[dt.date] = [] self._simulation_dates: list[dt.date] = []
self._last_simulation_date: dt.date | None = None
self._simulation_date_index: dict[dt.date, int] = {} self._simulation_date_index: dict[dt.date, int] = {}
self._next_trading_day: dict[dt.date, dt.date] = {} self._next_trading_day: dict[dt.date, dt.date] = {}
self._dividend_capture_trade_counter: int = 0 self._dividend_capture_trade_counter: int = 0
@ -562,6 +563,7 @@ class BacktestRunner:
# exits are checked every day, not just on days with new candidates. # exits are checked every day, not just on days with new candidates.
all_dates = self._get_simulation_dates() all_dates = self._get_simulation_dates()
self._simulation_dates = list(all_dates) self._simulation_dates = list(all_dates)
self._last_simulation_date = all_dates[-1] if all_dates else None
self._simulation_date_index = { self._simulation_date_index = {
sim_date: idx for idx, sim_date in enumerate(self._simulation_dates) sim_date: idx for idx, sim_date in enumerate(self._simulation_dates)
} }
@ -1396,7 +1398,11 @@ class BacktestRunner:
risk_amount = investable * invested_fraction risk_amount = investable * invested_fraction
sgov_amount = investable - risk_amount sgov_amount = investable - risk_amount
if target_sym != "sgov": if target_sym != "sgov":
_px_key = "close" if self._had_event_activity_today else "open" _px_key = (
"open"
if (self._last_simulation_date is not None and date == self._last_simulation_date)
else ("close" if self._had_event_activity_today else "open")
)
park_close = macro_data_eod.get(f"{target_sym}_{_px_key}") or macro_data_eod.get(f"{target_sym}_close") park_close = macro_data_eod.get(f"{target_sym}_{_px_key}") or macro_data_eod.get(f"{target_sym}_close")
if park_close and park_close > 0 and risk_amount >= park_close: if park_close and park_close > 0 and risk_amount >= park_close:
shares = int(risk_amount / park_close) shares = int(risk_amount / park_close)
@ -1440,7 +1446,11 @@ class BacktestRunner:
if self._fixed_capital_sizing: if self._fixed_capital_sizing:
investable = min(investable, max(0.0, equity_est - reserve)) investable = min(investable, max(0.0, equity_est - reserve))
if target_sym == "sgov": if target_sym == "sgov":
_px_key = "close" if self._had_event_activity_today else "open" _px_key = (
"open"
if (self._last_simulation_date is not None and date == self._last_simulation_date)
else ("close" if self._had_event_activity_today else "open")
)
park_close = macro_data_eod.get(f"sgov_{_px_key}") or macro_data_eod.get("sgov_close") park_close = macro_data_eod.get(f"sgov_{_px_key}") or macro_data_eod.get("sgov_close")
if park_close and park_close > 0 and investable >= park_close: if park_close and park_close > 0 and investable >= park_close:
new_shares = int(investable / park_close) new_shares = int(investable / park_close)
@ -1459,7 +1469,11 @@ class BacktestRunner:
self._commit_parking_target("sgov") self._commit_parking_target("sgov")
self._cash -= new_shares * park_close self._cash -= new_shares * park_close
else: else:
_px_key = "close" if self._had_event_activity_today else "open" _px_key = (
"open"
if (self._last_simulation_date is not None and date == self._last_simulation_date)
else ("close" if self._had_event_activity_today else "open")
)
park_close = macro_data_eod.get(f"{target_sym}_{_px_key}") or macro_data_eod.get(f"{target_sym}_close") park_close = macro_data_eod.get(f"{target_sym}_{_px_key}") or macro_data_eod.get(f"{target_sym}_close")
if park_close and park_close > 0 and investable >= park_close: if park_close and park_close > 0 and investable >= park_close:
new_shares = int(investable / park_close) new_shares = int(investable / park_close)
@ -1532,7 +1546,11 @@ class BacktestRunner:
) )
self._cash -= defensive_amount self._cash -= defensive_amount
elif target_sym == "sgov": elif target_sym == "sgov":
_px_key = "close" if self._had_event_activity_today else "open" _px_key = (
"open"
if (self._last_simulation_date is not None and date == self._last_simulation_date)
else ("close" if self._had_event_activity_today else "open")
)
park_close = macro_data_eod.get(f"sgov_{_px_key}") or macro_data_eod.get("sgov_close") park_close = macro_data_eod.get(f"sgov_{_px_key}") or macro_data_eod.get("sgov_close")
if park_close and park_close > 0 and investable >= park_close: if park_close and park_close > 0 and investable >= park_close:
new_shares = int(investable / park_close) new_shares = int(investable / park_close)
@ -1561,7 +1579,11 @@ class BacktestRunner:
and self._parking_current_symbol != "tqqq_blend" and self._parking_current_symbol != "tqqq_blend"
): ):
w_qqqm, w_tqqq = self._compute_overlay_blend_weights(macro_data_eod) w_qqqm, w_tqqq = self._compute_overlay_blend_weights(macro_data_eod)
_px_key = "close" if self._had_event_activity_today else "open" _px_key = (
"open"
if (self._last_simulation_date is not None and date == self._last_simulation_date)
else ("close" if self._had_event_activity_today else "open")
)
tqqq_close = macro_data_eod.get(f"tqqq_{_px_key}") or macro_data_eod.get("tqqq_close") tqqq_close = macro_data_eod.get(f"tqqq_{_px_key}") or macro_data_eod.get("tqqq_close")
qqqm_close = macro_data_eod.get(f"qqqm_{_px_key}") or macro_data_eod.get("qqqm_close") qqqm_close = macro_data_eod.get(f"qqqm_{_px_key}") or macro_data_eod.get("qqqm_close")
if tqqq_close and tqqq_close > 0 and qqqm_close and qqqm_close > 0: if tqqq_close and tqqq_close > 0 and qqqm_close and qqqm_close > 0:
@ -1608,7 +1630,11 @@ class BacktestRunner:
): ):
symbol_investable = investable * bearish_alloc_pct symbol_investable = investable * bearish_alloc_pct
sgov_amount = investable - symbol_investable sgov_amount = investable - symbol_investable
_px_key = "close" if self._had_event_activity_today else "open" _px_key = (
"open"
if (self._last_simulation_date is not None and date == self._last_simulation_date)
else ("close" if self._had_event_activity_today else "open")
)
park_close = macro_data_eod.get(f"{target_sym}_{_px_key}") or macro_data_eod.get(f"{target_sym}_close") park_close = macro_data_eod.get(f"{target_sym}_{_px_key}") or macro_data_eod.get(f"{target_sym}_close")
if park_close and park_close > 0 and symbol_investable >= park_close: if park_close and park_close > 0 and symbol_investable >= park_close:
new_shares = int(symbol_investable / park_close) new_shares = int(symbol_investable / park_close)
@ -8301,6 +8327,21 @@ def _extend_store_to_requested_window(
error=str(exc), error=str(exc),
) )
# Cap _requested_end_date at the last day where parking symbols actually have
# close-price data. SPY/QQQ are fetched as core and settle before QQQM/TQQQ/SGOV,
# which are fetched as extras and can lag Oracle by minutes after market close.
_parking_close_keys = ("qqqm_close", "tqqq_close", "sgov_close")
_parking_last_dates: list[dt.date] = []
for _k in _parking_close_keys:
_sym_dates = [d for d in store._macro if store._macro[d].get(_k)]
if _sym_dates:
_parking_last_dates.append(max(_sym_dates))
if _parking_last_dates:
_parking_cap = min(_parking_last_dates)
current_end = getattr(store, "_requested_end_date", end_date)
if _parking_cap < current_end:
setattr(store, "_requested_end_date", _parking_cap)
# Extend individual stock bars only forward. Needed so open positions and # Extend individual stock bars only forward. Needed so open positions and
# event entries can still be valued when end_date exceeds snapshot coverage. # event entries can still be valued when end_date exceeds snapshot coverage.
if store._bars: if store._bars:

@ -192,7 +192,23 @@ class SnapshotStore:
if not dates: if not dates:
return [] return []
ordered = sorted(dates) ordered = sorted(dates)
return get_trading_days(ordered[0], ordered[-1]) last_day = ordered[-1]
# Cap at the last day where parking symbols actually have close-price data.
# SPY/QQQ are fetched as core and always present; QQQM/TQQQ/SGOV are fetched
# as extras and can lag by minutes after market close. Using max(macro.keys())
# would pick a day where SPY data exists but parking prices are missing.
if self._macro:
_parking_close_keys = ("qqqm_close", "tqqq_close", "sgov_close")
_per_symbol_last: list[dt.date] = []
for _k in _parking_close_keys:
_sym_dates = [d for d in self._macro if self._macro[d].get(_k)]
if _sym_dates:
_per_symbol_last.append(max(_sym_dates))
if _per_symbol_last:
last_day = min(last_day, min(_per_symbol_last))
else:
last_day = min(last_day, max(self._macro.keys()))
return get_trading_days(ordered[0], last_day)
def slice_by_date_range( def slice_by_date_range(
self, self,

Loading…
Cancel
Save