fix(analytics): alias per-symbol return columns to stop portfolio metrics collapse #84
No reviewers
Labels
No labels
bug
documentation
duplicate
enhancement
wontfix
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
gertjan/bodega!84
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "fix/portfolio-returns-column-collision"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
_build_portfolio_returns(src/analytics/portfolio.py) gave every per-symbolweighted-return column the same literal name (
"weighted_return") beforeouter-joining the per-symbol frames together. That collision produced two
distinct silent failure modes depending on portfolio size:
"weighted_return_right"on join. The finalpl.sum_horizontal(["weighted_return"])then only ever summed the firstsymbol's column — the second symbol's returns were silently dropped and
portfolio metrics (Sharpe, drawdown, VaR, alpha/beta, total/annualised
return) collapsed to a single-symbol result with no error or warning.
raises
polars.exceptions.DuplicateErrorinstead of silently renaming.PortfolioAnalytics.compute's broadexcept Exceptionswallowed thaterror and returned all-
Nonemetrics withnum_bars=0— again with novisible error, just an empty-looking analytics response.
Both failure modes affect
GET /portfolios/{pid}/analytics(
src/api/routes/portfolios.py:666-766) and the simulation service(
src/api/services/simulation_service.py:209-214), for any portfolio holding2+ symbols.
Fix
(
f"weighted_return__{symbol}") instead of reusing a shared literal name,and collect the column names.
how="full", coalesce=True(also incidentally silences thepolars 1.x deprecation warning for
how="outer"— confirmed accepted bythis repo's pinned polars 1.41.2).
pl.sum_horizontal([pl.col(c).fill_null(0.0) for c in weighted_cols])so a symbol's missing bar on a given date contributes 0 to that date's
portfolio return instead of nulling out the row or being silently ignored.
PortfolioAnalytics.compute's broadexcept Exceptionto logwith
exc_info=True(was previously only interpolating%sinto themessage), so a future regression in this class of bug surfaces in logs
instead of quietly returning empty metrics.
Tests
Added
tests/unit/test_analytics_portfolio.py(5 new tests), eachindependently hand-computed (no dependency on the buggy code under test for
the expected values):
test_two_symbols_second_symbol_contribution_included— N=2, asserts thesecond symbol's weighted return is present in the summed series (fails
against the old code: it returns the first symbol's series unchanged).
test_three_symbols_no_exception_and_correct_values— N=3, asserts noexception and correct per-date portfolio_return (fails against the old
code with
DuplicateError).test_five_symbols_no_exception_and_correct_values— N=5, same shape.test_three_symbols_full_compute_does_not_raise_and_returns_metrics—integration-level check that
PortfolioAnalytics.computedoesn't swallowthe join error into all-None metrics for N=3.
test_mixed_non_overlapping_dates_missing_contribution_is_zero— twosymbols with disjoint bar-date ranges; asserts each date's return equals
only the symbol that has a bar that day (missing symbol contributes 0, not
null, and the row isn't dropped).
Verified all 5 new tests fail against the pre-fix code exactly as described
above (
git stashthe fix, rerun — 5 failed: silent-drop assertion for N=2,DuplicateErrorfor N=3/5,num_bars == 0for the compute() integrationtest, missing-contribution assertion for the mixed-dates case), then pass
after the fix.
Full suite:
ruff check src/,mypy src/(207 files, no issues), andpytest -m "not integration"(1067 passed, 0 failed) all green.Co-Authored-By: Claude Fable 5 noreply@anthropic.com