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
        lookback_years = 10
        self._var = Variance(lookback_years * 12)
        # Add a universe that runs selection at the start of each month.
        date_rule = self.date_rules.month_start("SPY")
        self.universe_settings.schedule.on(date_rule)
        self._universe = self.add_universe(self._select_assets)
        # Add a Scheduled Event to rebalance the portfolio each month.
        self.schedule.on(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 ASG, beta, and target weights of each asset.
        self.plot('ASG', 'Value', self._asg.get(month))
        self.plot('Regression', 'Beta', beta)
        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"