Great Trough Detector: exact snapshot, honest boundary

This notebook answers a narrow question: can the RecessionAlert Great Trough buy signal be replayed from the files already captured in this repository, without visiting the live site or guessing a hidden threshold? Yes for the supplied 2015–2026 signal snapshot. No for rebuilding the proprietary breadth formula or the chart-only 1987–2015 history.

In [1]:
import pandas as pd
import matplotlib.pyplot as plt
from IPython.display import display
from research.recessionalert.great_trough import (
    load_great_trough, fixture_summary, outcome_matrix, subperiod_outcomes, gap_report, check_causality
)

frame = load_great_trough()
summary = fixture_summary(frame)
pd.Series(summary, name='fixture').to_frame()
Out[1]:
fixture
workbook_sha256 sha256:fc2be5f1fc2a2e38949ae566f6e8cb4699a9556...
rows 2864
start 2015-03-26
end 2026-08-14
class_a_signals 27
class_b_signals 58
unique_signals 83
overlap_dates [2016-11-15, 2019-01-09]
raw_code_values {'buy_a': [0.0, 1.0, 2.0], 'buy_b': [0.0, 1.0,...

1. The disclosed rule and the executable source

The 2014 improvement is the canonical version. Class B arms below 27.4 for five trading days. Class A arms below 10 for ten trading days and cancels Class B. A buy occurs when breadth punches above 80 after at least four days below 80. The older 2012 pages used 26/27 → 87 within 14/15 days; those variants are recorded, not blended.

The workbook supplies calculated GTR/SIG, BUY-A, and BUY-B values. It does not supply formulas for those columns, and the page says the smoothing constant is proprietary. Positive marker codes therefore reproduce the supplied buys; codes 1 and 2 retain their raw values but get no invented confidence meaning.

In [2]:
rules = pd.DataFrame([
 {'class':'B', 'arm':'GTR < 27.4', 'window':'5 trading days', 'buy':'GTR > 80 after ≥4 days below 80'},
 {'class':'A', 'arm':'GTR < 10.0', 'window':'10 trading days', 'buy':'GTR > 80 after ≥4 days below 80'},
 {'class':'legacy only', 'arm':'GTR < 26 or 27', 'window':'14 or 15 trading days', 'buy':'GTR > 87'},
])
display(rules)
pd.DataFrame({
 'field':['GTR rows','Class A buys','Class B buys','Unique buy dates'],
 'value':[summary['rows'],summary['class_a_signals'],summary['class_b_signals'],summary['unique_signals']]
})
class arm window buy
0 B GTR < 27.4 5 trading days GTR > 80 after ≥4 days below 80
1 A GTR < 10.0 10 trading days GTR > 80 after ≥4 days below 80
2 legacy only GTR < 26 or 27 14 or 15 trading days GTR > 87
Out[2]:
field value
0 GTR rows 2864
1 Class A buys 27
2 Class B buys 58
3 Unique buy dates 83

2. What the snapshot looks like

The line is the supplied 0–100 breadth level. Triangles are workbook buy markers. They are sparse impulses, not an always-invested strategy and not a sell signal.

In [3]:
fig, ax = plt.subplots(figsize=(12, 5.5))
ax.plot(frame.index, frame['gtr'], color='#4c72b0', lw=1.0, label='GTR/SIG snapshot')
a = frame[frame['class_a_buy']]; b = frame[frame['class_b_buy']]
ax.scatter(a.index, a['gtr'], marker='^', s=58, color='#2a9d8f', label='Class A buy', zorder=4)
ax.scatter(b.index, b['gtr'], marker='^', s=38, color='#e76f51', label='Class B buy', zorder=3)
for y, label, color in [(10,'A arm 10','#2a9d8f'),(27.4,'B arm 27.4','#e9c46a'),(80,'buy punch 80','#6c757d')]:
    ax.axhline(y, ls='--', lw=.9, color=color, alpha=.8, label=label)
ax.set(title='Pinned Great Trough snapshot · 2015–2026', xlabel='Session', ylabel='Breadth level (0–100)', ylim=(-3,103))
ax.grid(alpha=.2); ax.legend(ncol=3, frameon=False); plt.show()
No description has been provided for this image

3. Causal outcome check — signal quality, not a portfolio

The page discusses 37, 83, 119, and 150 trading-day assessment windows. We enter one session after each marker, compare the S&P 500 close after each horizon, and keep the unconditional same-window baseline beside it. This is hit-rate evidence only: no exit overlay, sizing, costs, or catalog strategy is implied.

In [4]:
lag1 = outcome_matrix(frame, execution_lag=1)
rows=[]
for horizon, groups in lag1.items():
    for group in ('combined','class_a','class_b','unconditional'):
        row={'horizon':horizon,'group':group}; row.update(groups[group]); rows.append(row)
outcomes=pd.DataFrame(rows)
outcomes.style.format({'win_rate':'{:.1%}','false_signal_rate':'{:.1%}','mean_return':'{:.2%}','median_return':'{:.2%}'}, na_rep='—')
Out[4]:
  horizon group n win_rate false_signal_rate mean_return median_return
0 37d combined 83 66.3% 33.7% 1.11% 2.01%
1 37d class_a 27 66.7% 33.3% 2.22% 2.53%
2 37d class_b 58 67.2% 32.8% 0.75% 1.94%
3 37d unconditional 2826 70.0% 30.0% 1.86% 2.55%
4 83d combined 82 68.3% 31.7% 3.06% 4.01%
5 83d class_a 27 74.1% 25.9% 4.10% 5.69%
6 83d class_b 57 66.7% 33.3% 2.82% 3.96%
7 83d unconditional 2780 74.1% 25.9% 4.14% 5.09%
8 119d combined 81 74.1% 25.9% 4.99% 6.62%
9 119d class_a 27 70.4% 29.6% 5.43% 7.76%
10 119d class_b 56 76.8% 23.2% 5.04% 6.57%
11 119d unconditional 2744 76.2% 23.8% 5.92% 6.75%
12 150d combined 81 76.5% 23.5% 6.58% 9.16%
13 150d class_a 27 70.4% 29.6% 4.86% 7.23%
14 150d class_b 56 80.4% 19.6% 7.56% 9.56%
15 150d unconditional 2713 80.0% 20.0% 7.57% 8.44%
In [5]:
plot = outcomes[outcomes['group'].isin(['combined','unconditional'])].pivot(index='horizon',columns='group',values='win_rate')
ax=plot.plot.bar(figsize=(9,4.5),color=['#2a9d8f','#9aa0a6'])
ax.set(title='Positive S&P outcome rate after +1-bar entry',xlabel='Trading-day horizon',ylabel='Win rate',ylim=(0,1))
ax.grid(axis='y',alpha=.2); ax.legend(frameon=False); plt.tight_layout(); plt.show()
No description has been provided for this image

4. Timing stress and the missing-history decision

A second-bar entry checks that results are not dependent on same-close information. History extension was evaluated five ways: local proxy, synthetic fit, constituent rebuild, cached third-party material, and chart extraction. None can reproduce the exact pre-2015 signal without the proprietary smoothing rule and point-in-time NYSE universe. The nearest local proxy correlates only 0.53 with GTR. Longer would mean less truthful here.

In [6]:
lag2 = outcome_matrix(frame, execution_lag=2)
stress=[]
for h in lag1:
    for lag, matrix in [(1,lag1),(2,lag2)]:
        r=matrix[h]['combined']; stress.append({'horizon':h,'entry_lag':lag,**r})
display(pd.DataFrame(stress).style.format({'win_rate':'{:.1%}','false_signal_rate':'{:.1%}','mean_return':'{:.2%}','median_return':'{:.2%}'}))
split=[]
for period, groups in subperiod_outcomes(frame).items():
    for group in ('combined','unconditional'):
        split.append({'period':period,'group':group,**groups[group]})
display(pd.DataFrame(split).style.format({'win_rate':'{:.1%}','false_signal_rate':'{:.1%}','mean_return':'{:.2%}','median_return':'{:.2%}'}))
pd.DataFrame([
 {'route':'Local proxy','result':'reject','reason':'EMA(20) 13WK correlation 0.5316; different scale'},
 {'route':'Synthetic extension','result':'reject','reason':'would fit an expressly proprietary smoothing constant'},
 {'route':'Constituent rebuild','result':'reject','reason':'point-in-time NYSE common-stock universe is absent'},
 {'route':'Cached third party','result':'reject','reason':'narrative/charts only; no date ledger'},
 {'route':'Other / OCR','result':'reject','reason':'pixels cannot establish exact signal dates'},
])
  horizon entry_lag n win_rate false_signal_rate mean_return median_return
0 37d 1 83 66.3% 33.7% 1.11% 2.01%
1 37d 2 83 67.5% 32.5% 1.22% 2.34%
2 83d 1 82 68.3% 31.7% 3.06% 4.01%
3 83d 2 82 69.5% 30.5% 2.91% 4.27%
4 119d 1 81 74.1% 25.9% 4.99% 6.62%
5 119d 2 81 75.3% 24.7% 4.99% 6.47%
6 150d 1 81 76.5% 23.5% 6.58% 9.16%
7 150d 2 81 74.1% 25.9% 6.63% 9.11%
  period group n win_rate false_signal_rate mean_return median_return
0 2015_2019 combined 34 76.5% 23.5% 2.88% 3.91%
1 2015_2019 unconditional 1201 72.9% 27.1% 2.61% 3.69%
2 2020_2026 combined 48 62.5% 37.5% 3.20% 5.82%
3 2020_2026 unconditional 1579 75.0% 25.0% 5.30% 6.42%
Out[6]:
route result reason
0 Local proxy reject EMA(20) 13WK correlation 0.5316; different scale
1 Synthetic extension reject would fit an expressly proprietary smoothing c...
2 Constituent rebuild reject point-in-time NYSE common-stock universe is ab...
3 Cached third party reject narrative/charts only; no date ledger
4 Other / OCR reject pixels cannot establish exact signal dates

5. Reproducibility verdict

Fixture fidelity passes. Raw-formula reconstruction remains unavailable. The runner verifies every page and workbook byte, refuses altered fixtures, preserves the 83 unique signal dates, and moves return evaluation to the next session. Public outputs should show original summaries and derived markers—not cached page bodies or the raw subscriber workbook.

In [7]:
display(pd.Series(check_causality(frame), name='causality').to_frame())
pd.Series(gap_report(), name='fail-closed boundary').to_frame()
causality
ok True
summary {'workbook_sha256': 'sha256:fc2be5f1fc2a2e3894...
pages {'verified': True, 'pages': [{'verified': True...
execution signal close -> next session (+1 bar)
extra_lag +2 bars retains signal identity and usable counts
Out[7]:
fail-closed boundary
status fixture_signal_reproducible
executable_scope SHA-verified workbook GTR/SIG, BUY-A, and BUY-...
raw_formula_reconstruction fail_closed_unavailable
missing_disclosures [breadth smoothing constant, point-in-time NYS...
prohibited_inferences [do not infer a smoothing constant from the ca...
legacy_variants {'2012_project': 'arm below 27, buy above 87 w...