Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 98 additions & 0 deletions docs/user-guide/empyrical-migration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
# Migrating Selected Empyrical Metrics

Diagnostic covers the portfolio metrics listed below, but it does not replace
every Empyrical workflow. Move one calculation at a time, retain its input
cadence and missing-value policy, and compare the result on your own returns.
For Pyfolio returns analysis or Alphalens factor analysis, use the
[separate migration guide](migration.md).

If portfolio metrics are one part of a larger research workflow, Diagnostic
also documents [purged cross-validation and multiple-trial Sharpe
assessment](cross-validation.md). That capability is a reason to evaluate it,
not evidence that every Empyrical function has an equivalent.

## Run the comparison

From a checkout of this repository, run the published packages on one
deterministic returns series with Python 3.12:

```bash
uv run --no-project --python 3.12 \
--with 'ml4t-diagnostic==0.1.8' \
--with 'empyrical-reloaded==0.5.12' \
--with 'pytz==2026.5' \
python examples/legacy_migration_comparison.py --task empyrical
```

The script at `examples/legacy_migration_comparison.py` calls real Empyrical
functions and Diagnostic `PortfolioAnalysis` on the same 252 daily decimal
returns. It checks complete-data agreement, then replaces one return with
NaN. These are observed results for versions 0.5.12 and 0.1.8, not a general
equivalence guarantee.
The separate `pytz` pin supplies a runtime import used by Empyrical 0.5.12
but absent from that release's dependency metadata.

<div class="migration-table" markdown="1">

| Empyrical call | Diagnostic result from `PortfolioAnalysis(...).compute_summary_stats()` | Complete-input result in both |
|---|---|---:|
| `annual_return(..., annualization=252)` | `.annual_return` | 0.079730 |
| `annual_volatility(..., annualization=252)` | `.annual_volatility` | 0.150155 |
| `sharpe_ratio(..., annualization=252)` | `.sharpe_ratio` | 0.585568 |
| `sortino_ratio(..., annualization=252)` | `.sortino_ratio` | 0.890397 |
| `max_drawdown(...)` | `.max_drawdown` | -0.110216 |
| `calmar_ratio(..., annualization=252)` | `.calmar_ratio` | 0.723394 |
| `omega_ratio(...)` | `.omega_ratio` | 1.098490 |
| `tail_ratio(...)` | `.tail_ratio` | 1.218861 |
| `value_at_risk(..., cutoff=0.05)` | `.var_95` | -0.013656 |
| `conditional_value_at_risk(..., cutoff=0.05)` | `.cvar_95` | -0.017801 |

</div>

Both drawdown and the observed 5% VaR/CVaR are **negative return values**, not
positive loss magnitudes. Empyrical uses a left-tail `cutoff=0.05` while
Diagnostic labels the same selection as 95% confidence. Check the sign before
feeding either result into a risk limit.

## Preserve cadence, risk-free units, and missing-value behavior

Empyrical defaults to `period="daily"` (252 periods per year). Diagnostic
defaults to `periods_per_year=252`. For monthly returns, pass
`period="monthly"` to Empyrical and `periods_per_year=12` to Diagnostic.
The script's separate twelve-month input produced annual return `0.037051`
in both. Neither API can infer the intended annualization from an unlabelled
NumPy return array.

Both Sharpe defaults use zero risk-free rate. For nonzero rates, Empyrical's
`risk_free` argument is a **per-period return**; Diagnostic's `risk_free`
argument is an **annual rate**. The script converted a 2% annual rate to
`(1 + 0.02) ** (1 / 252) - 1` before calling Empyrical and passed `0.02`
to Diagnostic. Both produced Sharpe `0.453682`. Passing `0.02` directly to
both functions would not compare the same assumption.

With one NaN at return index 10, nine of the ten listed metric pairs still
matched on this input. Empyrical `value_at_risk` returned `nan` because it
uses a percentile that includes NaN; Diagnostic `.var_95` returned
`-0.013664` using a NaN-aware percentile. Both annual-return values were
`0.071988`; the input still had 252 periods and 251 valid returns. Choose a
missing-value policy before migration rather than relying on this one example.

## Metrics that need separate review

Benchmark-relative `alpha` and `beta` are available from Diagnostic when you
pass aligned benchmark returns. On the [Pyfolio comparison](migration.md#pyfolio-portfolio-and-performance-analysis),
beta matched at `0.795601`, while annualized alpha was `0.100369` in Pyfolio's
Empyrical calculation and `0.095663` in Diagnostic. Empyrical compounds the
mean periodic alpha; Diagnostic multiplies the fitted daily intercept by 252.
Choose the annualization definition before replacing alpha in a report.

The similarly named stability statistics are not equivalent. On the complete
input, Empyrical `stability_of_timeseries` returned `0.252783` and Diagnostic
`.stability` returned `0.247302`. Empyrical fits cumulative log returns;
Diagnostic fits cumulative simple wealth. Do not relabel one as the other.

This assessment does not cover Empyrical's `gpd_risk_estimates`,
`perf_attrib`, date aggregation, or the `roll_*` function family as drop-in
replacements. Diagnostic has other portfolio and factor analyses, but their
inputs and result definitions must be checked separately. Keep Empyrical for
those tasks until a specific migration is verified.
67 changes: 66 additions & 1 deletion docs/user-guide/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,36 @@
Diagnostic is not a drop-in replacement for either package. Choose a task below,
convert its input shape, and compare result definitions before comparing numbers.
The examples use synthetic data and run with the base `ml4t-diagnostic` package.
For selected performance metrics, see the separate
[Empyrical migration assessment](empyrical-migration.md).

One reason to evaluate Diagnostic beyond these matching values is its
[purged cross-validation workflow](cross-validation.md), which combines
chronological folds with multiple-trial Sharpe assessment. For portfolio
reporting, its [backtest bridge](backtest-tearsheets.md) also accepts a
completed `ml4t-backtest` result. These are separate capabilities, not
claims that the legacy tear sheets or factor panels are reproduced.

## Run the shared-input comparison

The package-only examples below show each Diagnostic input shape. To compare the
published legacy and Diagnostic APIs on the same deterministic inputs, run this
script from a checkout of this repository on Python 3.12:

```bash
uv run --no-project --python 3.12 \
--with 'ml4t-diagnostic==0.1.8' \
--with 'pyfolio-reloaded==0.9.9' \
--with 'alphalens-reloaded==0.4.6' \
--with 'empyrical-reloaded==0.5.12' \
python examples/legacy_migration_comparison.py --task all
```

The comparison script at `examples/legacy_migration_comparison.py` uses the
published packages, not the source checkout. It asserts the shared
Sharpe, drawdown, and IC results and prints other measured values. The figures
below are from that command with the stated versions; they are not performance
benchmarks or a promise that every legacy output matches.

## Alphalens factor analysis

Expand Down Expand Up @@ -67,9 +97,19 @@ print(f"One-day IC: {signal.ic['1D']:.3f}")
The assertion establishes the expected direction on these synthetic data;
it is not a significance test for a real factor. Use the
[signal quickstart](../getting-started/quickstart.md) to interpret IC and spread.
In the shared-input comparison, 600 factor rows across 50 business dates and
12 assets use one-day forward returns from the same wide price table. Both calls
use five quantiles, no outlier filter, and ungrouped Spearman IC. Alphalens
kept all 600 rows; its mean IC and Diagnostic's `SignalResult.ic["1D"]` were
both `0.318881`. Diagnostic also returned a top-minus-bottom quantile spread
of `0.004636`. The agreement is for this ungrouped input and definition.

Alphalens also supports grouped and group-adjusted IC, configurable
zero-aware bucketing, and its own complete factor tear sheet. Those options
are not implied by a Diagnostic signal result.
are not implied by a Diagnostic signal result. In particular, do not interpret
Diagnostic's default global quantiles as Alphalens `group_adjust=True` or
`binning_by_group=True`. Keep the legacy path when those panels or conventions
are required.

Factor *construction* is a separate task. If needed,
[ML4T Engineer's feature guide](https://github.com/ml4t/engineer/blob/145ccafd2ca36dc61f976cdbeaaf147af21e7c2e/docs/user-guide/features.md)
Expand Down Expand Up @@ -122,6 +162,31 @@ print(f"Portfolio Sharpe: {summary.sharpe_ratio:.2f}")

Use the [portfolio workflow](workflows.md) and
[backtest report guide](backtest-tearsheets.md) for the supported output checks.
The shared-input comparison passes 252 aligned business-day returns and benchmark
returns to Pyfolio `perf_stats()` and Diagnostic `PortfolioAnalysis`, with zero
risk-free rate and 252 periods per year. Return, volatility, drawdown, and
alpha values use decimal-return units; Sharpe ratio and beta are dimensionless:

<div class="migration-table" markdown="1">

| Metric | Pyfolio 0.9.9 | Diagnostic 0.1.8 |
|---|---:|---:|
| Annual return | 0.079730 | 0.079730 |
| Annual volatility | 0.150155 | 0.150155 |
| Sharpe ratio | 0.585568 | 0.585568 |
| Maximum drawdown | -0.110216 | -0.110216 |
| Alpha | 0.100369 | 0.095663 |
| Beta | 0.795601 | 0.795601 |

</div>

The alpha difference is expected under the two implementations: Pyfolio's
Empyrical calculation compounds mean periodic alpha, while Diagnostic
multiplies its fitted daily intercept by 252. Do not compare annualized alpha
without choosing a convention. The example has no positions or transactions;
Pyfolio's optional Zipline position multipliers were unavailable and did not
affect these returns-based values.

Pyfolio's full sheet can additionally include return cones, significant
events, round trips reconstructed from fills, capacity analysis using
market data, and optional factor-attribution panels. Diagnostic has related
Expand Down
Loading
Loading