Skip to content

Fix max_drawdown inventing drawdowns for price series - #541

Open
nyxst4ck wants to merge 1 commit into
ranaroussi:mainfrom
nyxst4ck:fix-drawdown-baseline-guess
Open

Fix max_drawdown inventing drawdowns for price series#541
nyxst4ck wants to merge 1 commit into
ranaroussi:mainfrom
nyxst4ck:fix-drawdown-baseline-guess

Conversation

@nyxst4ck

@nyxst4ck nyxst4ck commented Aug 5, 2026

Copy link
Copy Markdown

The bug (unreported)

qs.stats.max_drawdown() and to_drawdown_series() report drawdowns that never happened when given a real price series:

Input Reported True
S&P-level series [5000, 5100, 4900, 5200, 4800, 5050] −95.2% −7.69%
BTC-level series (60000s) −45.0% −11.29%
$50 stock [50, 55, 52, 60, 54, 58] −50.0% −10.0%
DataFrame with mixed-scale columns one column's baseline applied to all per-column

Root cause

_get_baseline_value() guesses the pre-series "starting equity" from the price level (>1000 → 1e5, >10 → 100, else 1.0). The phantom baseline point prepended by the #438 fix (so a day-one loss isn't hidden) then takes that guessed value — and when the guess lands above the first price, it becomes a peak the portfolio never reached, and every drawdown is measured from that fiction. The bug fires whenever the first value falls in (10, 100) or (1000, 100000) — most equity index levels, crypto, and mid-priced stocks. Outside those bands the guess sits below the series and is absorbed by the expanding max, which is why it went unnoticed.

The fix: stop guessing

  • Returns input: _prepare_prices() rebuilds prices as 1.0 × (1 + compsum), so the true baseline is exactly 1.0 — the max drawdown calculation is incorrect if the first return is a drawdown #438 day-one-loss behavior is preserved and now pinned by a test.
  • Price input: the first observation is the start of the record, so the phantom point takes that value and becomes a provable no-op.
  • DataFrames: baseline decided per column (columns can be on different scales, or mix returns and prices).

New shared _looks_like_returns() in utils.py replaces the two inline copies of the same predicate inside _prepare_prices() (each path's exact semantics preserved — Series used min() < 0, DataFrame columns min() <= 0), so the conversion and the baseline decision can't drift apart.

One deliberate behavior change to be transparent about

qs.utils.to_prices(returns) output (default base 1e5) piped into max_drawdown no longer counts a first-period loss measured from the 1e5 notional start — the base is unknowable from the series alone, and the alternative is continuing to corrupt genuine market price series. to_portfolio_value() already handles this honestly by prepending a real starting-balance row instead of guessing.

Validation

  • 4 new test functions (8 cases, one parametrized over 5 price scales) in the existing TestDrawdown class: 5 fail on main with the exact wrong values above, all green with the fix. The passing parametrizations (scale 1.0, 100.0) pin the boundary where the old guess happened to be harmless.
  • Full suite: main 125 passed → patched 133 passed (125 + 8 new), no failures either side (pandas 2.2.3 / numpy 2.2.6).
  • No-regression evidence: 11 returns-based metrics (ulcer, serenity, calmar, recovery_factor, cagr, sharpe, sortino, kelly, risk_of_ruin, drawdown-details row counts…) byte-identical before vs after for a 500-day random series and a series starting at −8%. All 14 internal call sites pass returns, so library-internal behavior is untouched.
  • ruff: 28 findings before, 28 after — nothing new.

Unrelated to #533 (weekly aggregation), which stays as a separate PR.

_get_baseline_value guessed the pre-series equity from the price level
(>1000 -> 1e5, >10 -> 100, else 1.0). When the guess landed above the
first price it became a peak the portfolio never reached: an S&P-level
series reported -95% instead of -7.7%, BTC-level -45% instead of -11%,
and a DataFrame applied one column's baseline to every other column.

Stop guessing. Returns rebuilt by _prepare_prices start from base 1.0,
so use exactly 1.0; a series that was already prices starts its record
at the first observation, so the phantom point takes that value and is
a no-op. The day-one-loss fix from ranaroussi#438 (returns input) is preserved
and pinned by tests.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant