RecessionAlert SP-500 seasonal trends — exploratory proxy study

This notebook is an offline, inferred/proxy experiment. The checked-in RecessionAlert page (SHA256 sha256:7e34ccc16114249e7652c0bf1080f6df0f14b49e48d1009f05f9b8a33fb369cd) discloses four seasonal cycle families, ranked monthly characteristics, and short/long/2x text, but no source-linked signal history, scale conversion, publication clock, or complete portfolio rule.

We use only checked-in Yahoo snapshots from data/market.sqlite: ^GSPC as the longest direct SP500 price proxy (1927-12-30 onward) and actual SPY for the requested overlay (1993-01-29 onward). Scores use prior observations only; lag 1 is causal, while lag 0 is a research-only sensitivity.

In [1]:
import math
import sys
from pathlib import Path
import pandas as pd
import matplotlib.pyplot as plt
for candidate in [Path.cwd(), *Path.cwd().resolve().parents]:
    if (candidate / 'research').is_dir():
        sys.path.insert(0, str(candidate))
        break
from research.research_campaign.recessionalert_sp500_seasonal import (
    SOURCE_SHA256, SOURCE_URL, evaluate_seasonal_spec, load_proxy_panel, proxy_specs
)

panel = load_proxy_panel()
driver = panel['GSPC']
spy = panel['SPY'].dropna().astype(float).sort_index()
print({'source_sha256': SOURCE_SHA256, 'source_url': SOURCE_URL, 'driver_span': [str(driver.index.min().date()), str(driver.index.max().date())], 'overlay_span': [str(spy.index.min().date()), str(spy.index.max().date())], 'driver_rows': len(driver), 'overlay_rows': len(spy)})
{'source_sha256': 'sha256:7e34ccc16114249e7652c0bf1080f6df0f14b49e48d1009f05f9b8a33fb369cd', 'source_url': 'https://recessionalert.com/analyzing-sp-500-seasonal-trends/', 'driver_span': ['1927-12-30', '2026-07-24'], 'overlay_span': ['1993-01-29', '2026-07-24'], 'driver_rows': 24757, 'overlay_rows': 8428}

Four labeled variants

The variants below are not official RecessionAlert settings. They make the missing choices explicit: expanding month-of-year best guess, documented-inspired 12/24/36/48 cycle composite, nearby threshold/lag/unlevered sensitivity, and a short-cycle long-short sensitivity.

In [2]:
rows = []
evaluated = []
benchmark = spy.pct_change().fillna(0.0)
for spec in proxy_specs():
    out = evaluate_seasonal_spec(spec, driver, spy)
    evaluated.append((spec, out))
    returns = out['strategy_returns'].reindex(spy.index).fillna(0.0)
    equity = (1.0 + returns).cumprod()
    years = (returns.index[-1] - returns.index[0]).days / 365.25
    rows.append({
        'variant': spec['id'],
        'family': spec['semantics']['family'],
        'lag_bars': spec['semantics']['lag_bars'],
        'overlay': spec['parameters']['overlay']['default'],
        'scores': int(out['monthly_score'].notna().sum()),
        'end_equity': float(equity.iloc[-1]),
        'cagr': float(equity.iloc[-1] ** (1.0 / years) - 1.0),
        'max_drawdown': float((equity / equity.cummax() - 1.0).min()),
    })
summary = pd.DataFrame(rows)
summary
Out[2]:
variant family lag_bars overlay scores end_equity cagr max_drawdown
0 recessionalert-sp500-seasonal-expanding_month-... expanding_month 1 cash 1147 9.245426 0.068685 -0.700346
1 recessionalert-sp500-seasonal-four_cycle-lbexp... four_cycle 1 cash 1147 9.619328 0.069952 -0.715269
2 recessionalert-sp500-seasonal-four_cycle-lbexp... four_cycle 2 unlevered 1147 7.834203 0.063412 -0.526437
3 recessionalert-sp500-seasonal-short_cycle-lb12... short_cycle 1 short 1147 3.216959 0.035514 -0.757862
In [3]:
fig, axes = plt.subplots(2, 1, figsize=(12, 8), sharex=True)
for spec, out in evaluated:
    label = spec['semantics']['family'] + ' lag' + str(spec['semantics']['lag_bars'])
    axes[0].plot(out['monthly_score'].index, out['monthly_score'], label=label, linewidth=1.0)
    axes[1].plot(out['strategy_equity'].index, out['strategy_equity'], label=label, linewidth=1.0)
axes[0].axhline(37, color='black', linestyle='--', linewidth=0.7, label='source-like 37')
axes[0].axhline(74, color='black', linestyle=':', linewidth=0.7, label='source-like 74')
axes[0].set_ylabel('Inferred score (0–100)')
axes[0].set_title('Inferred/proxy seasonal score; not native RecessionAlert data')
axes[0].legend(loc='upper left', fontsize=8, ncol=2)
axes[1].plot(spy.index, (1.0 + benchmark).cumprod(), color='black', linewidth=1.5, label='SPY benchmark')
axes[1].set_ylabel('Growth of $1')
axes[1].set_title('Causal daily SPY overlays; lag 1 is primary')
axes[1].legend(loc='upper left', fontsize=8)
fig.tight_layout()
plt.show()
No description has been provided for this image

Interpretation

All four variants are retained even where they underperform. The full-window proxy results trail SPY CAGR, vary by regime, and depend on inferred scale and execution choices. The source-fidelity disposition remains separate: no native indicator registry admission is justified. Raw cached HTML, media, workbook bytes, credentials, and private account data are not included.