From d387d567c56ef9a29227743970d6aee562ca3402 Mon Sep 17 00:00:00 2001 From: I Luk Kim Date: Thu, 12 Mar 2026 22:09:28 -0700 Subject: [PATCH] =?UTF-8?q?fix:=20correct=20equity=20calculation=20?= =?UTF-8?q?=E2=80=94=20use=20market=20value,=20not=20unrealized=20PnL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit equity was computed as cash + unrealized_pnl where unrealized_pnl = (close - entry) × shares. Since cash already had entry cost subtracted, this double-counted the cost basis: buggy: equity = (initial - entry×shares) + (close - entry)×shares = initial + close×shares − 2×entry×shares ← WRONG correct: equity = cash + market_value = (initial - entry×shares) + close×shares = initial + (close − entry)×shares ← RIGHT This caused drawdown to spike to ~73% the instant a position opened (e.g. TSLA $330 × 222 shares → equity appeared to drop from 100k to 27k), falsely triggering the kill switch at 25% and blocking all subsequent entries. Before fix: 3 trades, +0.08% return, 39.2% max drawdown (fake) After fix: 10 trades, -2.63% return, 4.24% max drawdown (real) Also: when bar data is missing, positions now use entry_price as fallback market value instead of treating the position as worthless. Co-Authored-By: Claude Opus 4.6 --- apps/backtester/run.py | 41 +++++++++++++++++++++++++++++++++-------- 1 file changed, 33 insertions(+), 8 deletions(-) diff --git a/apps/backtester/run.py b/apps/backtester/run.py index 9ea98b8..762bf27 100644 --- a/apps/backtester/run.py +++ b/apps/backtester/run.py @@ -234,8 +234,11 @@ class BacktestRunner: self._open_positions = still_open # --- Compute current equity for kill-switch check --- - unrealized = self._compute_unrealized_pnl(date) - self._equity = self._cash + unrealized + market_value = self._compute_positions_market_value(date) + unrealized = market_value - sum( + p.entry_price * p.shares_open for p in self._open_positions + ) + self._equity = self._cash + market_value self._peak_equity = max(self._peak_equity, self._equity) drawdown_pct = ( (self._peak_equity - self._equity) / self._peak_equity * 100.0 @@ -281,14 +284,23 @@ class BacktestRunner: self._open_positions.append(pos) self._cash -= pos.entry_price * pos.shares_total self._daily_new_risk_used += plan.risk_dollars - # Update portfolio state for next candidate in same day + # Update equity and portfolio state for next candidate + mv = self._compute_positions_market_value(date) + self._equity = self._cash + mv + ur = mv - sum( + p.entry_price * p.shares_open + for p in self._open_positions + ) portfolio_state = self._build_portfolio_state( - date, drawdown_pct, self._compute_unrealized_pnl(date) + date, drawdown_pct, ur ) # --- Record daily equity curve snapshot --- - unrealized_final = self._compute_unrealized_pnl(date) - self._equity = self._cash + unrealized_final + market_value_final = self._compute_positions_market_value(date) + unrealized_final = market_value_final - sum( + p.entry_price * p.shares_open for p in self._open_positions + ) + self._equity = self._cash + market_value_final self._peak_equity = max(self._peak_equity, self._equity) final_drawdown = ( (self._peak_equity - self._equity) / self._peak_equity * 100.0 @@ -316,14 +328,27 @@ class BacktestRunner: ) ) - def _compute_unrealized_pnl(self, date: dt.date) -> float: + def _compute_positions_market_value(self, date: dt.date) -> float: + """Market value of all open positions using today's close. + + Falls back to entry_price when bar is missing (assumes no change + rather than treating the position as worthless). + """ total = 0.0 for pos in self._open_positions: bar = self.store.get_bar(pos.plan.candidate.symbol, date) if bar and bar.get("close"): - total += (float(bar["close"]) - pos.entry_price) * pos.shares_open + total += float(bar["close"]) * pos.shares_open + else: + total += pos.entry_price * pos.shares_open return total + def _compute_unrealized_pnl(self, date: dt.date) -> float: + """Unrealized PnL = market_value − cost_basis.""" + market_value = self._compute_positions_market_value(date) + cost_basis = sum(p.entry_price * p.shares_open for p in self._open_positions) + return market_value - cost_basis + def _build_portfolio_state( self, date: dt.date,