NUMERICAL AUDIT · 1 NOVEMBER 2026
pyfolio-reloaded: the drawdown table ignores the starting capital as a peak
When a return series starts with a loss, pyfolio-reloaded's worst-drawdown table and underwater plot understate the drawdown and date the recovery too early, contradicting Max drawdown in the same tear sheet.
Xamit Kadirbekov, Daniyal Kadirbekov · AI-assisted investigation and writing
Checked 1 November 2026: issue #113 and PR #114 are open, with no review yet; not merged or released. Local regression checks and limitations documented.
Upstream report #114 · Upstream issue #113
Authors: Xamit Kadirbekov and Daniyal Kadirbekov (GERO). Published 1 November 2026.
Status: Checked 1 November 2026: issue #113 and PR #114 are open, with no review yet; not merged or released. Local regression checks and limitations documented.
When a return series starts with a loss, pyfolio-reloaded's worst-drawdown table and underwater plot understate the drawdown and date the recovery too early, contradicting Max drawdown in the same tear sheet.
Published proposal
- stefan-jansen/pyfolio-reloaded issue #113 — problem report, OPEN.
- PR #114 (closes #113) — head commit
1b019754d7780ac99d8fe9d948e06bf41a170829, OPEN at submission on 1 November 2026.
What the functions do
pyfolio-reloaded is the maintained fork of Quantopian's pyfolio, used to produce performance "tear sheets" for trading strategies and portfolios. A drawdown is the fall of capital from its previous peak. The tear sheet shows a "Max drawdown" statistic (from empyrical), a "Worst drawdown periods" table (gen_drawdown_table, get_top_drawdowns) and an underwater plot (plot_drawdown_underwater). The first peak is the starting capital, 1.0 before the first day.
The discrepancy
The table and the plot compute the running peak from the cumulative returns only, which start at the end of day one (0.9 after a −10% day, not 1.0). The first day's loss falls out of every drawdown, and "recovery" means getting back to 0.9 rather than to 1.0.
import pandas as pd
from pyfolio import timeseries as ts
idx = pd.bdate_range("2024-01-01", periods=6)
r = pd.Series([-0.10, -0.10, 0.05, 0.05, 0.10, 0.02], index=idx)
ts.gen_drawdown_table(r, top=1)
# Net drawdown in % Peak date Valley date Recovery date
# 10.0 2024-01-01 2024-01-02 2024-01-05
ts.perf_stats(r)["Max drawdown"] # -0.19
| pyfolio table | From the starting capital | |
|---|---|---|
| Depth | 10% | 19% (1.0 to 0.81) |
| Peak | 2024-01-01 | the starting capital, before 2024-01-01 |
| Recovery | 2024-01-05 (0.9823: above 0.9, below 1.0) | 2024-01-08 (1.002) |
The equity curve from 1.0 is 0.90, 0.81, 0.8505, 0.893, 0.9823, 1.002. The repository's own test case (prices 10 → 9 → 7.5) shows the same with the common .pct_change().dropna() returns: the table gives 16.67%, while the drawdown from the start and empyrical.max_drawdown give 25%. Without .dropna() the first element is NaN, empyrical treats it as zero, and the existing test passes, which is why it does not catch the case.
In a fuzz test of 400 random 120-day series, 27 disagree with max_drawdown; among those, the depth is understated by a median of 0.54 and at most 1.8 percentage points. Recovery dates can move further, depending on the path.
Reference and why this is not a convention
The reference is the direct calculation from the definition and empyrical.max_drawdown, a separate package that starts from 1.0. The same tear sheet reports both numbers for the same concept, so the report contradicts itself. The same root cause was reported for the max-drawdown statistic in quantopian/pyfolio#179 (2016) and fixed by moving that statistic to empyrical; the table and the underwater plot kept the old behaviour. We present this as an unfixed remainder of a known issue, not as a new class of error.
Fix and tests
- A shared helper
_underwatercomputes the drawdown from max(1, running peak); the table functions and the underwater plot use it. Series whose first return is non-negative are unaffected. - Base
01ca107,pytest tests/test_timeseries.py: 2 failed, 22 passed; the failures are the newtest_drawdown_from_first_return_recovers_at_starting_valueand a regression through the publicgen_drawdown_table. With the fix: 24 passed. Full local suite: 51 passed against 48 on the base (3 new tests). - Fuzz comparison of
gen_drawdown_tablewithempyrical.max_drawdown: 27 of 400 disagreeing before, 0 after.
Upstream status
As of 1 November 2026, issue #113 and PR #114 are open, with no review or comment. Nothing has been merged or released.
Limits
The depth error is limited to the first day's loss, usually one to two percentage points; recovery dates and durations are affected more. Returns produced by zipline usually have zero on day one, in which case nothing changes. tox across Python versions and the documentation build were not run. Forks were not checked. We do not claim any measured investment loss.
The example in the issue was re-computed in a separate session on 1 November before submission. No reproduction archive is attached to this page yet.
Attribution and license
AI tools (Claude) helped find the cases, build the reference calculations and draft the patches and tests; the examples were re-run independently before submission. Xamit Kadirbekov and Daniyal Kadirbekov are the report authors and reviewed the submitted diff; upstream software remains credited to its developers. Report: CC BY 4.0. Upstream patches retain their upstream licenses.