| Overall Statistics |
|
Total Orders 3400 Average Win 0.23% Average Loss -0.20% Compounding Annual Return 21.632% Drawdown 21.900% Expectancy 0.299 Start Equity 100000 End Equity 266029.11 Net Profit 166.029% Sharpe Ratio 0.806 Sortino Ratio 0.835 Probabilistic Sharpe Ratio 47.761% Loss Rate 40% Win Rate 60% Profit-Loss Ratio 1.16 Alpha 0.099 Beta 0.47 Annual Standard Deviation 0.155 Annual Variance 0.024 Information Ratio 0.44 Tracking Error 0.159 Treynor Ratio 0.266 Total Fees $3692.81 Estimated Strategy Capacity $4600000.00 Lowest Capacity Asset BCM R735QTJ8XC9X Portfolio Turnover 8.77% Drawdown Recovery 793 |
from AlgorithmImports import *
from datetime import timedelta
class QualityMomentumStrategy(QCAlgorithm):
"""
Biweekly quality-momentum with 200-day SMA regime and Sharpe/Vol composite.
Universe: top-200 liquid large-caps with positive PE.
Ranking: Sharpe / returns-STD composite (double-penalizes volatility).
Regime: SPY price above 200-day SMA to invest; below to cash.
Sizing: rank-weighted across top-25 stocks.
Execution: biweekly rebalance Monday 31 min after open.
"""
COARSE_SIZE = 500
FINE_SIZE = 200
MIN_PRICE = 15.0
MIN_DOLLAR_VOL = 20e6
MIN_MARKET_CAP = 20e9
SHARPE_PERIOD = 126
TOP_N = 25
VOL_PERIOD = 21
REGIME_PERIOD = 200
MAX_GROSS = 2.0
MAX_SINGLE = 0.20
def initialize(self):
self.set_start_date(self.end_date - timedelta(5 * 365))
self.set_cash(100_000)
self.settings.minimum_order_margin_portfolio_percentage = 0
self.settings.automatic_indicator_warm_up = True
self.settings.seed_initial_prices = True
self.set_warm_up(timedelta(45))
self._spy = self.add_equity("SPY")
self._spy_sma = self.sma(
self._spy, self.REGIME_PERIOD, Resolution.DAILY
)
self.universe_settings.schedule.on(
self.date_rules.month_start(self._spy)
)
self._universe = self.add_universe(self._fundamental_filter)
def on_warmup_finished(self):
# Add a Scheduled Event to rebalance the portfolio every 2 weeks.
time_rule = self.time_rules.after_market_open(self._spy, 31)
self.schedule.on(self.date_rules.week_start(self._spy), time_rule, self._weekly_check)
# Rebalance today too.
if self.live_mode:
self._weekly_check()
else:
self.schedule.on(self.date_rules.today, time_rule, lambda: self._weekly_check(True))
# ── universe ─────────────────────────────────────────────
def _fundamental_filter(self, fundamental):
pool = [
f for f in fundamental
if (f.has_fundamental_data and
f.price > self.MIN_PRICE and
f.dollar_volume > self.MIN_DOLLAR_VOL and
f.market_cap >= self.MIN_MARKET_CAP and
f.valuation_ratios.pe_ratio > 0)
]
pool = sorted(pool, key=lambda f: f.dollar_volume)[-self.COARSE_SIZE:]
pool = sorted(pool, key=lambda f: f.market_cap)[-self.FINE_SIZE:]
return [f.symbol for f in pool]
# ── security lifecycle ───────────────────────────────────
def on_securities_changed(self, changes):
for sec in changes.added_securities:
if sec == self._spy:
continue
sec.sharpe = self.sr(
sec, self.SHARPE_PERIOD, 0.0, Resolution.DAILY
)
sec.volatility = IndicatorExtensions.of(
StandardDeviation(self.VOL_PERIOD),
self.roc(sec, 1, Resolution.DAILY),
)
# ── biweekly rebalance (Monday open) ─────────────────────
def _weekly_check(self, skip_week_check=False):
if self.is_warming_up or not self._spy_sma.is_ready:
return
if self._spy.price < self._spy_sma.current.value:
self.liquidate(tag="bear_regime")
return
if not skip_week_check and self.time.isocalendar()[1] % 2 != 0:
return
securities = [self.securities[sym] for sym in self._universe.selected]
candidates = [
s for s in securities
if (s != self._spy and
s.sharpe.is_ready and
s.sharpe.current.value > 0 and
s.volatility.is_ready and
s.volatility.current.value > 0)
]
if not candidates:
return
# rank by Sharpe / Vol composite (double-penalize volatility)
top = sorted(
candidates,
key=lambda s: s.sharpe.current.value / s.volatility.current.value
)[-self.TOP_N:]
# rank-weighted
rank_weights = {security: i+1 for i, security in enumerate(top)}
total_rank = sum(rank_weights.values())
weights = {}
for security, rw in rank_weights.items():
raw = (rw / total_rank) * self.MAX_GROSS
weights[security] = min(raw, self.MAX_SINGLE)
total_w = sum(weights.values())
if total_w > self.MAX_GROSS:
scale = self.MAX_GROSS / total_w
weights = {s: w * scale for s, w in weights.items()}
targets = [
PortfolioTarget(security, weight)
for security, weight in weights.items()
]
self.set_holdings(targets, True)