# region imports
from AlgorithmImports import *
# endregion
class LazyPricesStrategy(QCAlgorithm):
_vector_by_symbol: Dict[Symbol, np.ndarray] = {}
_similarity_by_symbol: Dict[Symbol, float] = {}
def initialize(self) -> None:
self.set_start_date(2015, 1, 1)
self.set_end_date(2026, 6, 1)
self.set_cash(1_000_000)
self.settings.seed_initial_prices = True
self.universe_settings.resolution = Resolution.DAILY
self._min_price = 5
self._pool_size = 1000
self._quantile = 0.2
# Mid- and small-cap style boxes: the Lazy Prices anomaly's habitat, away from mega-caps.
self._style_boxes = [
StyleBox.MID_VALUE, StyleBox.MID_CORE, StyleBox.MID_GROWTH,
StyleBox.SMALL_VALUE, StyleBox.SMALL_CORE, StyleBox.SMALL_GROWTH
]
self._metric_n = 0
self._metric_sum = np.zeros(10)
self._metric_sumsq = np.zeros(10)
# The fundamental universe picks the tradeable book; the Brain universe only feeds scores.
self._universe = self.add_universe(self._fundamental_filter)
self.add_universe(BrainCompanyFilingLanguageMetricsUniverseAll, self._update_signals)
def on_warmup_finished(self) -> None:
# Daily data fills at the prior close, so anchor the quarterly rebalance to 8 AM.
time_rule = self.time_rules.at(8, 0)
self.schedule.on(self.date_rules.quarter_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 _fundamental_filter(self, fundamental: List[Fundamental]) -> List[Symbol]:
# Keep scored, tradeable mid/small-cap names; the most liquid form the cross-section.
eligible = [
f for f in fundamental
if f.has_fundamental_data and f.symbol in self._similarity_by_symbol and
f.price > self._min_price and f.asset_classification.style_box in self._style_boxes
]
return [f.symbol for f in sorted(eligible, key=lambda f: f.dollar_volume)[-self._pool_size:]]
def _update_signals(self, filings: List[BrainCompanyFilingLanguageMetricsUniverseAll]) -> List[Symbol]:
for filing in filings:
report = filing.report_sentiment
if report is None:
continue
fields = [
report.sentiment, report.uncertainty, report.litigious, report.constraining,
report.interesting, report.readability, report.lexical_richness,
report.lexical_density, report.specific_density, report.mean_sentence_length
]
if any(f is None for f in fields):
continue
vector = np.array([float(f) for f in fields])
previous = self._vector_by_symbol.get(filing.symbol)
# An identical vector is the same filing carried forward, so skip it before scoring.
if previous is not None and np.array_equal(previous, vector):
continue
self._vector_by_symbol[filing.symbol] = vector
self._metric_n += 1
self._metric_sum += vector
self._metric_sumsq += vector * vector
# The first filing for a symbol has no prior to compare, so store it and score the next.
if previous is None:
continue
# Standardize each metric by its own scale so big-magnitude fields can't dominate.
mean = self._metric_sum / self._metric_n
std = np.sqrt(np.maximum(self._metric_sumsq / self._metric_n - mean * mean, 1e-12))
self._similarity_by_symbol[filing.symbol] = 1.0 / (1.0 + float(np.linalg.norm((vector - previous) / std)))
return []
def _rebalance(self) -> None:
selected = [s for s in self._universe.selected if s in self._similarity_by_symbol and self.securities[s].price]
n = int(len(selected) * self._quantile)
if n < 1:
return
# Long the top similarity quintile (lazy), short the bottom quintile (biggest rewriters).
ranked = sorted(selected, key=self._similarity_by_symbol.get)
weight = 0.5 / n
targets = [PortfolioTarget(s, -weight) for s in ranked[:n]] + [PortfolioTarget(s, weight) for s in ranked[-n:]]
self.set_holdings(targets, True)