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
4 changes: 3 additions & 1 deletion src/portfolio.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,9 @@ def select_portfolio(
return selected

if weighting in {"inverse_vol", "inverse-vol", "inv_vol"} and "vol20" in selected.columns:
inverse_vol = 1.0 / selected["vol20"].clip(lower=0.05)
# Compute normalized weights in float64 so downcast research inputs
# cannot make a fully invested portfolio round above the strict guard.
inverse_vol = 1.0 / selected["vol20"].astype(np.float64).clip(lower=0.05)
selected["target_weight"] = inverse_vol / inverse_vol.sum()
else:
selected["target_weight"] = 1.0 / len(selected)
Expand Down
15 changes: 15 additions & 0 deletions tests/test_backtest_accounting.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
import pandas as pd

from src.backtest import run_single_backtest
from src.portfolio import select_portfolio
from src.utils import clean_numeric_frame


class BacktestAccountingTests(unittest.TestCase):
Expand Down Expand Up @@ -121,6 +123,19 @@ def test_inverse_vol_skips_top_score_with_missing_volatility(self) -> None:

self.assertEqual(result.trades["symbol"].tolist(), ["B"])

def test_inverse_vol_float32_weights_remain_fully_invested(self) -> None:
panel = self.panel(("A", "B"))
panel["final_score"] = np.tile([2.0, 1.0], len(self.dates))
panel["vol20"] = np.tile([0.13621539, 0.6013158], len(self.dates))
panel = clean_numeric_frame(panel)
config = {"strategy": {**self.config["strategy"], "top_n": 2, "weighting": "inverse_vol"}}

selected = select_portfolio(panel.xs(self.dates[0], level="date"), "final_score", 2, "inverse_vol")
self.assertEqual(selected["target_weight"].dtype, np.dtype("float64"))
self.assertLessEqual(float(selected["target_weight"].sum()), 1.0 + 1e-12)
result = run_single_backtest(panel, "final_score", config)
self.assertFalse(result.returns.empty)

def test_nonfinite_eligible_scores_are_not_cash(self) -> None:
for bad_score in (np.inf, -np.inf):
with self.subTest(bad_score=bad_score):
Expand Down