Skip to content

feat(metrics): surface realized portfolio turnover in backtest metrics#478

Merged
warren618 merged 1 commit into
HKUDS:mainfrom
Robin1987China:portfolio-turnover-metrics
Jul 13, 2026
Merged

feat(metrics): surface realized portfolio turnover in backtest metrics#478
warren618 merged 1 commit into
HKUDS:mainfrom
Robin1987China:portfolio-turnover-metrics

Conversation

@Robin1987China

Copy link
Copy Markdown
Contributor

Summary

Portfolio Studio follow-up (Refs #456): surface realized portfolio turnover in the backtest metrics/report path, as requested after #466 merged.

After #466, the turnover_aware optimizer accumulates realized turnover on its own instance — but the engine's optimizer loader constructs that instance inside a lambda and keeps only the returned positions frame (engines/base.py:155, :132), so the realized_turnover list is discarded and never reaches metrics, the run card, or any artifact.

This wires turnover into the standard output by computing it generically from the executed position frame the engine already holds:

  • new pure helper calc_turnover_series(positions) → per-bar 0.5·Σ|wₜ − wₜ₋₁|
  • calc_metrics(...) gains an optional positions arg and emits avg_turnover / total_turnover
  • _empty_metrics gains the same zero-valued keys
  • the engine passes target_pos into the existing calc_metrics call site

Design note (please redirect if you'd prefer otherwise)

Your comment framed this as wiring "the optimizer's realized_turnover series". I implemented it as an engine-level metric computed from the position frame instead, because:

  1. it works for every strategy/optimizer (including the default 1/N and the four non-turnover-aware optimizers), not just turnover_aware;
  2. it avoids coupling the engine to optimizer internals / threading instances out of the loader lambda;
  3. it measures the executed (post-normalization) weight path, which is what actually drives transaction cost.

The trade-off: this figure is measured post-normalization, so it can differ slightly from the optimizer's own pre-normalization number. The optimizer's instance-level realized_turnover affordance is left fully intact. Happy to switch to threading the optimizer instance out instead if you prefer the literal approach.

Testing

  • ruff check — clean
  • pytest agent/tests/test_metrics.py — 44 passed (13 new turnover tests: full rotation = 1.0, constant weights = 0 after entry, entry-from-cash, NaN handling, total ≈ avg×bars, existing metrics unchanged with/without positions, zeros on empty/None)
  • full suite excl e2e — 5149 passed; the only 2 failures (test_factor_gate_fund_prefix, test_bottleneck_available) are pre-existing env failures on main, unrelated to this change
  • manual sanity: confirmed avg_turnover / total_turnover appear in the run card scalar metrics

Notes

  • Purely additive: no existing metric value changes, no new report surface, no new dependencies.
  • Off-path safe: absent/empty positions yield 0.0.

Refs #456

Compute realized portfolio turnover generically from the executed
target-position frame (0.5*sum|w_t - w_{t-1}| per bar) and expose it as
avg_turnover / total_turnover in the metrics dict, so turnover flows
through the existing run-card scalar-metrics path for any strategy.

Previously the turnover_aware optimizer accumulated turnover on its own
instance, but the engine's optimizer loader discards that instance after
extracting the positions frame, so turnover never reached metrics or the
run card. Deriving it from the position frame the engine already holds
makes the metric available for every optimizer (and the default 1/N),
not just turnover_aware, without coupling the engine to optimizer
internals.

- add pure helper calc_turnover_series(positions)
- calc_metrics gains an optional positions arg; additive, existing
  metric values unchanged, zeros on empty/degenerate runs
- _empty_metrics gains the new keys for shape consistency
- wire target_pos into the engine's calc_metrics call site

Refs HKUDS#456

Signed-off-by: Robin1987China <41602358+Robin1987China@users.noreply.github.com>
@warren618
warren618 merged commit 15a1a1f into HKUDS:main Jul 13, 2026
1 check passed
warren618 added a commit that referenced this pull request Jul 13, 2026
Covers issue #476 (all 10 audit findings), #377/#478/#480/#487/#479/#484.
Propagated to all 5 locale READMEs' News section (3 visible + moved
2026-07-10 into <details>, per convention).
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.

2 participants