Issue 1341 — RecessionAlert leading U.S.-stock indicator

Offline provenance, rule, and evidence-gap validation. The canonical crawl database is opened read-only; no live-site request, raw media publication, credentials, or private account data is used.

In [1]:
from datetime import datetime
from pathlib import Path
import hashlib
import json
import sqlite3

root = Path.cwd()
while root != root.parent and not (root / 'data' / 'recessionalert.sqlite').exists():
    root = root.parent
spec = json.loads((root / 'research/findings/specs/recessionalert-leading-us-stocks.json').read_text(encoding='utf-8'))
manifest = json.loads((root / 'research/artifacts/recessionalert/manifest.json').read_text(encoding='utf-8'))
inventory = (root / 'research/findings/recessionalert_inventory.md').read_text(encoding='utf-8')
url = 'https://recessionalert.com/a-leading-indicator-for-u-s-stocks/'
page_sha = 'sha256:e5a324ca2890226d7f62553757130605a5335c6b98bd88e4a67d22b2ca76ea01'
manifest_page = next(item for item in manifest if item.get('redacted_url') == url)
db_path = root / 'data/recessionalert.sqlite'
connection = sqlite3.connect(f'file:{db_path}?mode=ro', uri=True)
row = connection.execute('SELECT redacted_url, fetched_at, status, content_type, sha256, content_length, body FROM pages WHERE url=?', (url,)).fetchone()
history = connection.execute('SELECT fetched_at, status, kind, sha256 FROM crawl_log WHERE url=? ORDER BY fetched_at', (url,)).fetchall()
target_asset_count = connection.execute('SELECT COUNT(*) FROM assets WHERE page_url=? OR redacted_url=? OR url LIKE ?', (url, url, '%StocksLEI.gif%')).fetchone()[0]
all_asset_count = connection.execute('SELECT COUNT(*) FROM assets').fetchone()[0]
try:
    connection.execute('CREATE TABLE qm_1341_should_fail (id INTEGER)')
    raise AssertionError('read-only connection allowed DDL')
except sqlite3.OperationalError:
    pass
finally:
    connection.close()
redacted_url, fetched_at, status, content_type, digest, content_length, body = row
assert redacted_url == url
assert manifest_page['sha256'] == page_sha == digest == 'sha256:' + hashlib.sha256(body).hexdigest()
assert spec['source']['page']['sha256'] == page_sha
assert 'sha256:e5a324ca2' in inventory
assert content_length == len(body) == 83175
assert fetched_at == '2026-08-22T23:48:28Z'
assert history == [
    ('2026-08-22T23:36:04Z', 200, 'page', 'sha256:da85cd5c2915e4250431662982135fb48f477a87a6998d68b9c5ce27652f9d2a'),
    (fetched_at, 200, 'page', page_sha),
]
assert target_asset_count == 0
assert all_asset_count == 6
text = body.decode('utf-8', errors='replace')
for phrase in (
    'net percentage of 39 OECD countries with rising leading economic indices',
    'net percentage of 39 central banks that are easing rates',
    '7 and 10 months lead respectively',
    '0.57 r-square to NYSE',
    'buying opportunity',
    'StocksLEI.gif',
):
    assert phrase in text, phrase
assert spec['direction'] == 'buy_only_qualitative'
assert spec['output']['signal'] is None and spec['output']['playable'] is False
assert spec['fixture_expectations']['machine_readable_series_pinned'] is False
assert spec['fixture_expectations']['late_input_rejected'] is True
assert spec['fixture_expectations']['unavailable_state_preserved'] is True
assert spec['fixture_expectations']['registry_changed'] is False
article = datetime.fromisoformat('2024-05-01T15:35:21-04:00')
snapshot = datetime.fromisoformat(fetched_at.replace('Z', '+00:00'))
assert (snapshot - article).days == 843
print({
    'page_sha256': digest,
    'bytes': content_length,
    'fetched_at': fetched_at,
    'article_timestamp': spec['source']['page']['article_timestamp_disclosed'],
    'crawl_vintages': len(history),
    'canonical_assets': all_asset_count,
    'target_asset_rows': target_asset_count,
    'series_observations': 0,
    'source_claimed_leads_months': [7, 10],
    'source_claimed_r_squared': 0.57,
    'playable': spec['output']['playable'],
    'publication_lag': spec['publication_lag']['release_lag_status'],
    'disposition': spec['disposition'],
})
{'page_sha256': 'sha256:e5a324ca2890226d7f62553757130605a5335c6b98bd88e4a67d22b2ca76ea01', 'bytes': 83175, 'fetched_at': '2026-08-22T23:48:28Z', 'article_timestamp': '2024-05-01T15:35:21-0400', 'crawl_vintages': 2, 'canonical_assets': 6, 'target_asset_rows': 0, 'series_observations': 0, 'source_claimed_leads_months': [7, 10], 'source_claimed_r_squared': 0.57, 'playable': False, 'publication_lag': 'unknown', 'disposition': 'insufficient_evidence'}

What the source discloses

The May 1, 2024 article names two international breadth measures: the net percentage of 39 OECD countries with rising leading economic indices (LEIs), and the net percentage of 39 central banks easing rates. It reports 7- and 10-month maximum leads to the NYSE annual percentage change, respectively, and says aggregating the two produces a leading indicator with a 0.57 R-squared to NYSE. The indicators are said to appear in the monthly Global Economic Report and the CRB tab of Monthly charts. The article adds that dips are supposed to be a buying opportunity.

Those statements identify a qualitative buy-side context, not a playable series. The page does not provide observations, exact netting or aggregate formulas, weights, thresholds, state transitions, missing-data handling, or a sell/hold/exit rule.

Causal conclusion

insufficient_evidence: the article timestamp is an information boundary only. Its approximately 843-day interval to the archival crawl is snapshot delay, not economic publication lag. Any future overlay requires documented point-in-time input availability and at least one available-bar execution lag; same-close/lookahead is unsupported. Unknown inputs remain unavailable. No signal history, equity curve, proxy, or registry entry is emitted.