diff --git a/apps/backtester/run.py b/apps/backtester/run.py index b59e52a..1e352a4 100644 --- a/apps/backtester/run.py +++ b/apps/backtester/run.py @@ -261,6 +261,7 @@ class BacktestRunner: self._pending_open_exits: dict[dt.date, list[dict[str, Any]]] = defaultdict(list) self._parent_add_on_counts: dict[str, int] = defaultdict(int) self._simulation_dates: list[dt.date] = [] + self._last_simulation_date: dt.date | None = None self._simulation_date_index: dict[dt.date, int] = {} self._next_trading_day: dict[dt.date, dt.date] = {} 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. all_dates = self._get_simulation_dates() self._simulation_dates = list(all_dates) + self._last_simulation_date = all_dates[-1] if all_dates else None self._simulation_date_index = { sim_date: idx for idx, sim_date in enumerate(self._simulation_dates) } @@ -1396,7 +1398,11 @@ class BacktestRunner: risk_amount = investable * invested_fraction sgov_amount = investable - risk_amount 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") if park_close and park_close > 0 and risk_amount >= park_close: shares = int(risk_amount / park_close) @@ -1440,7 +1446,11 @@ class BacktestRunner: if self._fixed_capital_sizing: investable = min(investable, max(0.0, equity_est - reserve)) 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") if park_close and park_close > 0 and investable >= park_close: new_shares = int(investable / park_close) @@ -1459,7 +1469,11 @@ class BacktestRunner: self._commit_parking_target("sgov") self._cash -= new_shares * park_close 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") if park_close and park_close > 0 and investable >= park_close: new_shares = int(investable / park_close) @@ -1532,7 +1546,11 @@ class BacktestRunner: ) self._cash -= defensive_amount 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") if park_close and park_close > 0 and investable >= park_close: new_shares = int(investable / park_close) @@ -1561,7 +1579,11 @@ class BacktestRunner: and self._parking_current_symbol != "tqqq_blend" ): 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") 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: @@ -1608,7 +1630,11 @@ class BacktestRunner: ): symbol_investable = investable * bearish_alloc_pct 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") if park_close and park_close > 0 and symbol_investable >= park_close: new_shares = int(symbol_investable / park_close) @@ -8301,6 +8327,21 @@ def _extend_store_to_requested_window( 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 # event entries can still be valued when end_date exceeds snapshot coverage. if store._bars: diff --git a/libs/backtest/snapshot_store.py b/libs/backtest/snapshot_store.py index f15bd68..6a79e1b 100644 --- a/libs/backtest/snapshot_store.py +++ b/libs/backtest/snapshot_store.py @@ -192,7 +192,23 @@ class SnapshotStore: if not dates: return [] 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( self,