| Overall Statistics |
|
Total Orders 100 Average Win 3.04% Average Loss -0.34% Compounding Annual Return 20.481% Drawdown 24.300% Expectancy 5.192 Start Equity 100000 End Equity 255901.00 Net Profit 155.901% Sharpe Ratio 0.753 Sortino Ratio 0.753 Probabilistic Sharpe Ratio 19.069% Loss Rate 38% Win Rate 62% Profit-Loss Ratio 8.95 Alpha 0.062 Beta 0.747 Annual Standard Deviation 0.14 Annual Variance 0.02 Information Ratio 0.472 Tracking Error 0.099 Treynor Ratio 0.142 Total Fees $146.80 Estimated Strategy Capacity $11000000000.00 Lowest Capacity Asset BIL TT1EBZ21QWKL Portfolio Turnover 0.90% Drawdown Recovery 322 |
from AlgorithmImports import *
class AggregateSalesGrowthRotationAlgorithm(QCAlgorithm):
def initialize(self) -> None:
self.set_start_date(2021, 7, 1)
self.set_end_date(2026, 7, 15)
self.set_cash(100_000)
# Add the SPY and BIL ETFs to trade.
self._spy = self.add_equity("SPY", Resolution.DAILY, leverage=3)
self._bil = self.add_equity("BIL", Resolution.DAILY, leverage=3)
# Add some members we'll need to make trading decisions.
self._firm_data = {}
self._asg = pd.Series()
self._market_return = RateOfChange(1)
self._excess_returns = pd.Series()
self._gamma = 3.0
lookback_years = 10
self._var = Variance(lookback_years * 12)
# Add a universe that runs selection at the start of each month.
self._date_rule = self.date_rules.month_start("SPY")
self.universe_settings.schedule.on(self._date_rule)
self._universe = self.add_universe(self._select_assets)
# Add a Scheduled Event to rebalance the portfolio each month.
self.schedule.on(self._date_rule, self.time_rules.at(8, 0), self._rebalance)
# Add a warm-up period to prime the factors and labels.
self.set_warm_up(timedelta((lookback_years+1)*365))
def _select_assets(self, fundamentals: List[Fundamental]) -> List[Symbol]:
# Get revenue growth and market cap of stocks no in the Financial Services and Real Estate sectors.
self._firm_data = {
f.symbol: (f.operation_ratios.revenue_growth.one_year, f.market_cap)
for f in fundamentals
if (f.company_reference.country_id == "USA" and
f.security_reference.is_primary_share and
f.security_reference.security_type == "ST00000001" and # Common stock
f.asset_classification.morningstar_sector_code not in (MorningstarSectorCode.FINANCIAL_SERVICES, MorningstarSectorCode.REAL_ESTATE))
}
return []
def _rebalance(self) -> None:
# Get the month that just ended.
month = pd.Period(self.time, freq="M") - 1
# Update the excess return history.
if self._market_return.update(self.time, self._spy.price):
excess_return = self._market_return.current.value - self.risk_free_interest_rate_model.get_interest_rate(self.time) / 12
self._excess_returns[month] = excess_return
self._var.update(self.time, excess_return)
# Calculate the market-cap-weighted ASG, winsorised at the 1st/99th percentiles.
firms = pd.DataFrame.from_dict(self._firm_data, orient="index", columns=["growth", "market_cap"]).dropna(subset=["growth"])
growth = firms["growth"].clip(*firms["growth"].quantile([0.01, 0.99]))
caps = firms["market_cap"]
usable = caps.notna() & (caps > 0)
if usable.any():
self._asg[month] = np.average(growth[usable], weights=caps[usable])
# If we're still warming up, do nothing.
if self.is_warming_up:
return
# Regress this month's excess return on last month's ASG.
X, y = self._asg.shift(1, freq="M").align(self._excess_returns, join="inner")
alpha, beta = np.polynomial.polynomial.polyfit(X, y, 1)
# Forecast this month's excess return.
forecast_r = alpha + beta * self._asg.get(month)
# Calculate the target exposure to SPY using Merton's closed-form mean-variance solution.
w_star = np.clip(forecast_r / (self._gamma * self._var.current.value), 0, 1.5)
# Plot the target weights of each asset.
self.plot('Weights', 'SPY', w_star)
self.plot('Weights', 'BIL', 1.0 - w_star)
# Place trades to rebalance the portfolio.
self.set_holdings([PortfolioTarget(self._spy, w_star), PortfolioTarget(self._bil, 1 - w_star)])SPEC = "# Cash Equities — Accountancy #3: Aggregate Sales Growth (spec v2)\n\n**Deutsche Bank \"Academic Insights\" (AI October 2025), item #76, p.272.**\n**Underlying paper:** Garfinkel, Hribar & Hsiao (2024), *\"Aggregate Sales Growth and Stock Market Returns\"* — SSRN 5066654.\n*(v2, 2026-07-17: supersedes the original hand-off md — drops the blanket 4-month reporting lag in favor of the data feed's point-in-time availability, and makes the cross-section, pairing, and seeding requirements explicit.)*\n\nImplement the **full method** below. Do not build a reduced version of the signal.\n\n---\n\n## Structured brief (for the QC Assistants)\n\n**Concept.** Aggregate sales growth (ASG) — the market-cap-weighted average one-year sales growth across US **non-financial** public firms — negatively predicts next-month market excess returns (a cash-flow channel: high ASG precedes negative aggregate earnings surprises). Use the monthly ASG reading to time a market-vs-T-bill allocation.\n\n**Investment universe.** Traded legs: the **S&P 500 ETF** (market) and a **short-T-bill ETF** (cash leg). The ASG signal itself is computed from the cross-section of US non-financial common stocks (not traded).\n\n**Signal.** Each month: (1) compute ASG over the **current month's** eligible cross-section; (2) forecast next month's market excess return from an **expanding-window OLS** of next-month excess return on ASG.\n\n**Portfolio construction & rebalance.** Equity weight `w* = (1/γ)·(R̂ / σ̂²)` with `γ = 3`, **constrained to `0 ≤ w* ≤ 1.5`** (no shorting the market; up to 150% long). Put `1 − w*` in the T-bill ETF — at `w* > 1` the T-bill position is **negative**, the financing leg (borrowing at the T-bill rate). **Monthly** rebalance.\n\n---\n\n## Implementation specifics from the source paper\n\n**1. Firm-level one-year sales growth.** For firm `i` in month `t`:\n`SALES_GROWTH_{i,t} = (SALES_{i,t} − SALES_{i,t−12}) / SALES_{i,t−12}`\non **trailing-annual net sales/revenue** — the most recently **available** filing as of month *t* versus the filing ~12 months prior.\n\n**2. Data availability (replaces the paper's 4-month lag — disclosed).** The paper lags Compustat items 4 months after fiscal period end as a look-ahead guard, because Compustat lacks availability dates. The platform's fundamental feed is **point-in-time** (values reach the algorithm when they were actually published), which satisfies the guard exactly and matches live trading. **Use each firm's most recently available fundamentals as of month t — no additional blanket lag.**\n\n**3. ASG (eq. 1).** `ASG_t = Σ_i [SALES_GROWTH_{i,t} × MCAP_{i,t}] / Σ_i MCAP_{i,t}` over the eligible cross-section, where:\n- **The cross-section is the CURRENT month's:** firms passing the screens **in month t**. Firms that delist or drop out of the feed leave the aggregate that month — never carry a firm's stale values forward.\n- **`MCAP_{i,t}` is month-t market cap** (market data, known in real time — never lagged or cached from an old filing).\n- Screens: US common stocks (primary share class of US-incorporated operating companies), **excluding financial firms** (SIC 6000–6999 or the equivalent sector classification).\n- **Winsorize firm-level sales growth at the 1st/99th percentiles each month** (computed over that month's cross-section) before aggregating.\n\n**4. Return-forecast regression (expanding window).** At the end of month `t`, estimate by OLS on all available pairs through `t`:\n`R̂_{t+1} = α̂_t + β̂_t · ASG_t`\n- **Pairs are keyed by calendar month:** each pair is (ASG of month τ, realized excess return of month τ+1). Never pair by list position; if a month's ASG is unavailable, form **no pair** for it and make **no forecast** from a stale reading (that month's equity weight is 0).\n- **Excess returns are simple** (not log): market total return minus the **one-month risk-free rate** — the *rate*, not a T-bill ETF's price return.\n\n**5. Mean-variance weight (eqs. 5–6).**\n`w*_t = (1/γ) · (R̂_{t+1} / σ̂²_{t+1})`, `γ = 3`\n- `σ̂²_{t+1}` = variance of monthly market **excess returns** over a **10-year (120-month) rolling window**. No variance floor or other unspecified modifications.\n- Constrain `0 ≤ w*_t ≤ 1.5`; put `1 − w*_t` in the T-bill ETF (negative when levered).\n\n**6. Seeding (required — no in-sample warm-up stretch).** At the first live month, both estimators must already be fully populated:\n- the **120-month variance window**, seeded from pre-start market history;\n- the **regression**, seeded by reconstructing the pre-start monthly ASG series (and its paired next-month excess returns) as far back as the fundamentals data allows. Do not let live allocations be driven by a regression with fewer than **24 monthly pairs** (park at equity weight 0 until then; this floor is a deployment convention, flagged).\n\n**7. Correction vs the DB blurb (unchanged from v1).** The DB summary says the strategy \"can go long and short equities.\" The paper does **not** short — `w ∈ [0, 1.5]`. Follow the paper.\n\n---\n"