fix: correct equity calculation — use market value, not unrealized PnL

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 <noreply@anthropic.com>
main
I Luk Kim 5 months ago
parent 867d70afae
commit d387d567c5

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

Loading…
Cancel
Save