| Overall Statistics |
|
Total Orders 0 Average Win 0% Average Loss 0% Compounding Annual Return 0% Drawdown 0% Expectancy 0 Start Equity 100000 End Equity 100000 Net Profit 0% Sharpe Ratio 0 Sortino Ratio 0 Probabilistic Sharpe Ratio 0% Loss Rate 0% Win Rate 0% Profit-Loss Ratio 0 Alpha 0 Beta 0 Annual Standard Deviation 0 Annual Variance 0 Information Ratio -0.713 Tracking Error 0.141 Treynor Ratio 0 Total Fees $0.00 Estimated Strategy Capacity $0 Lowest Capacity Asset Portfolio Turnover 0% Drawdown Recovery 0 |
from AlgorithmImports import *
from Portfolio.RiskParityPortfolioOptimizer import RiskParityPortfolioOptimizer
class RiskParityBugDemoAlgorithm(QCAlgorithm):
"""
Demonstrates two bugs in RiskParityPortfolioOptimizer:
Bug 1 — Bounds are silently ignored during optimization:
Newton-CG doesn't support bounds, so maximum_weight is never
enforced during the solve. The constraint only applies as a
post-hoc np.clip(), which is too late to affect the solution.
Bug 2 — Clipping after normalization breaks sum-to-1:
weights = np.clip(solver["x"] / sum, min_w, max_w)
If any weight is clipped, the portfolio no longer sums to 1.0
and the risk parity property is silently violated.
"""
MAX_WEIGHT = 0.20 # Tight cap
def initialize(self):
self.set_start_date(self.end_date - timedelta(5 * 365))
self.set_cash(100_000)
self.settings.rebalance_portfolio_on_security_changes = False
tickers = ["IEF", "TLT", "SPY", "EFA", "EEM", "JPXN", "XLK"]
self._securities = [self.add_equity(t, Resolution.DAILY) for t in tickers]
# Build a ticker -> Symbol map for set_holdings calls later
self._symbol_by_ticker = {s.symbol.value: s.symbol for s in self._securities}
# Positional args only — keyword names are not exposed in the stubs
self._constrained_optimizer = RiskParityPortfolioOptimizer(0.0, self.MAX_WEIGHT)
self._unconstrained_optimizer = RiskParityPortfolioOptimizer(1e-5, 1.0)
self.set_warm_up(timedelta(252))
def on_warmup_finished(self):
time_rule = self.time_rules.at(8, 0)
self.schedule.on(self.date_rules.month_start("SPY"), time_rule, self._rebalance)
if self.live_mode:
self._rebalance()
else:
self.schedule.on(self.date_rules.today, time_rule, self._rebalance)
def _rebalance(self):
history = self.history(self._securities, 253, Resolution.DAILY)
if history.empty:
return
returns = history["close"].unstack(level=0).pct_change().dropna()
if returns.empty or returns.shape[1] < 2:
return
# columns are ticker strings after unstack; NOT Symbol objects
tickers = list(returns.columns)
unconstrained = self._unconstrained_optimizer.optimize(returns)
constrained = self._constrained_optimizer.optimize(returns)
weight_sum = float(np.sum(constrained))
any_violation = any(float(w) > self.MAX_WEIGHT + 1e-6 for w in constrained)
# Bug 1: did any weight exceed the declared bound?
self.plot("Bug1 - Bound Violations", "bound_violated", int(any_violation))
self.plot("Bug1 - Bound Violations", "max_weight_cap", self.MAX_WEIGHT)
# Bug 2: constrained weights must sum to 1.0
self.plot("Bug2 - Weight Sum", "constrained_sum", weight_sum)
self.plot("Bug2 - Weight Sum", "expected_sum_1_0", 1.0)
for i, ticker in enumerate(tickers):
c_w = float(constrained[i])
u_w = float(unconstrained[i])
# ticker is already a plain string — no .value needed
self.plot("Weights - Constrained", ticker, c_w)
self.plot("Weights - Unconstrained", ticker, u_w)
if c_w > self.MAX_WEIGHT + 1e-6:
self.log(
f"[BUG 1] {self.time:%Y-%m-%d}: {ticker} weight={c_w:.4f} "
f"exceeds max_weight={self.MAX_WEIGHT} — Newton-CG ignored the bound."
)
if abs(weight_sum - 1.0) > 1e-4:
self.log(
f"[BUG 2] {self.time:%Y-%m-%d}: constrained weights sum to "
f"{weight_sum:.6f}, not 1.0 — post-clip normalization is broken."
)
# Trade on unconstrained weights (known-good baseline)
for i, ticker in enumerate(tickers):
symbol = self._symbol_by_ticker.get(ticker)
if symbol:
self.set_holdings(symbol, float(unconstrained[i]))