Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,19 @@ All notable changes to this project are documented here. Format follows

## [Unreleased]

### Added (per-ticker fear and greed, 2026-09-08)
- Replay rows and the outcome-by-feature table gain a per-ticker fear-and-greed
reading: the equal-weight 0-100 composite of RSI 14, the MACD (12, 26, 9)
histogram's mid-rank percentile within the trailing 250 bars and Bollinger
%B (20, 2) that the TradingView community indicators of that name share,
read at the scan day and at the pattern's last low, with the three
components alongside. Tested over the eleven yearly point-in-time runs
(tuning page): the greedier the stock at its breakout the lower the mean R,
from +0.45 in the neutral zone to +0.12 in extreme greed, bases formed in
fear paid about twice what bases formed in greed did, and a breakout bar
closing above its upper Bollinger band ran +0.04 R on 453 signals with the
upper-half bucket ahead in 10 of 11 years. No rule uses it yet.

### Added (backtest features from the second external review, 2026-09-08)
- Replay rows and the outcome-by-feature table gain the pattern's depth in
ATR, the breakout bar's close within its range and whether it cleared the
Expand Down
19 changes: 19 additions & 0 deletions docs/wiki/03-Configuration-and-Tuning.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,25 @@ A second external review proposed a converging-wedge check for Wolfe (already th
| Reward:risk floor of 2.0 | rows at or below 1.5 ran +0.20 R with a 54 % hit rate over ten years (#100) | rejected |
| Inhibit alerts in a SPY bear regime or above VIX 25 | bear regime +0.59 R on 226, VIX above 25 +0.50 R in 8 of 8 years (#101) | contradicted |

### Per-ticker fear and greed, tested (2026-09-08)

A trader's suggestion: read a per-ticker fear-and-greed indicator, as the TradingView community scripts do, to tell whether a stock is being bought or sold too hard. The replay's version, `fear_greed` in `tools/backtest.py`, is the equal-weight 0-100 composite of RSI 14, the MACD histogram's percentile within the trailing year and Bollinger %B that those scripts share, read at the scan day and at the pattern's last low, and replayed over the same eleven years (runs 34267945406 to 34268343900). The direction the trader expected is what the data shows, in both readings:

| Reading | Zone | N traded | Hit rate | Mean R | Positive years |
|---|---|---|---|---|---|
| at the scan day | fear, 20-40 | 73 | 23 % | +0.61 | 6 of 11 |
| at the scan day | neutral, 40-60 | 174 | 40 % | +0.45 | 10 of 11 |
| at the scan day | greed, 60-80 | 957 | 33 % | +0.20 | 10 of 11 |
| at the scan day | extreme greed, above 80 | 427 | 42 % | +0.12 | 8 of 11 |
| at the pattern's last low | extreme fear, below 20 | 112 | 35 % | +0.30 | 7 of 11 |
| at the pattern's last low | fear | 352 | 34 % | +0.28 | 8 of 11 |
| at the pattern's last low | neutral | 616 | 39 % | +0.24 | 10 of 11 |
| at the pattern's last low | greed | 534 | 33 % | +0.15 | 8 of 11 |

A confirmed breakout is almost never fearful by construction (one row below 20 in ten years), so at the scan day the scale runs from fear to extreme greed, and the greedier the stock at its breakout, the lower the mean R; the higher hit rate of the greediest bucket does not compensate, because its wins are smaller. Bases that formed in fear paid about twice what bases formed in greed did. Of the components, the stretch measures carry the effect and momentum does not: RSI 30-50 at the breakout ran +0.55 R on 230 signals against +0.18 for 50-70 and +0.12 above 70, the lower bucket ahead in 8 of 11 years; Bollinger %B in the lower half ran +0.53 on 185, the upper half +0.26 on 991 and a close above the upper band +0.04 on 453, the upper half ahead of the above-band bucket in 10 of 11 years (the exception, 2017, a tie at +0.44 against +0.45); the MACD percentile ran +0.18 to +0.26 across its buckets with the strongest momentum slightly best.

The one candidate rule this produces is the Bollinger stretch: a breakout bar that closes above its upper band. Leaving those 453 signals out would keep 1179 signals at +0.30 R against 1632 at +0.22, at a cost of 16 R of the ten-year total of 367. Under the protocol it remains a hypothesis: the gate has to be replayed as a rule on its own, so that its effect on the drawdown and per pattern is measured, and the reading belongs in the report as information first.

### Market context (2026-09-08)

The report header and every backtest row carry the SPY regime (close and SMA50 against the SMA200), the VIX and the breadth of the universe, and the backtest summaries add a per-regime slice with VIX and breadth among the feature buckets. Over the ten years on the point-in-time index (the section above), signals scanned in a SPY bear regime ran +0.59 R on 226 against +0.16 on 1190 in bull regimes, positive in five of the six years with bear sessions, and signals scanned at a VIX above 25 ran +0.50 R in eight of eight years; a VIX below 15, negative in the two-year window, ran +0.21 R pooled. Nothing gates on the context; #101 tracks how the report should present it.
Expand Down
2 changes: 1 addition & 1 deletion docs/wiki/04-Testing-and-Contributing.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ Work on a feature branch and open a PR to `main`; the `tests` workflow must pass

The bootstrap resamples scan **months**, not trades: signals arrive in clusters (33 in one month and 2 in another over 2024-26), so treating them as independent draws would make the interval far too narrow. An interval is only printed with at least two months and five trades.

Two more tables follow each summary. **Excursions by horizon** gives, overall and per pattern, the median MFE and MAE within the first 5, 10, 20, 40 and 60 bars after the fill, in percent and in ATR, and the share of signals that had reached the target, hit the stop or done neither by then (medians, because a few runaway winners dominate the means). **Outcome by feature** buckets the traded signals by features measured at the scan day from the history the scan saw: breakout volume ratio and its z-score against the prior 20 bars, close vs SMA200, SMA50 vs SMA200, the SMA200's change over 40 bars, the distance from the SMA200 in ATR, the stop distance in ATR, the pattern's depth in ATR, risk %, reward:risk, the bars from the pattern's last anchor to the breakout, the breakout age when first reported, the breakout bar's close within its range and whether it cleared the prior bar's high, the VIX and the breadth, and for cups the handle's volume against the cup's and the handle's volume slope. Bucket edges are fixed (`FEATURE_BUCKETS` in `tools/backtest.py`) so two replays, or the two windows of a split, compare bucket by bucket; each row shows N, hit rate, mean and median R and the month-block interval. Nothing is added to `signals.json` or the report: the features exist to be tested, and a feature earns a rule only if its buckets separate outcomes by more than their intervals on both windows of a split.
Two more tables follow each summary. **Excursions by horizon** gives, overall and per pattern, the median MFE and MAE within the first 5, 10, 20, 40 and 60 bars after the fill, in percent and in ATR, and the share of signals that had reached the target, hit the stop or done neither by then (medians, because a few runaway winners dominate the means). **Outcome by feature** buckets the traded signals by features measured at the scan day from the history the scan saw: breakout volume ratio and its z-score against the prior 20 bars, close vs SMA200, SMA50 vs SMA200, the SMA200's change over 40 bars, the distance from the SMA200 in ATR, the stop distance in ATR, the pattern's depth in ATR, risk %, reward:risk, the bars from the pattern's last anchor to the breakout, the breakout age when first reported, the breakout bar's close within its range and whether it cleared the prior bar's high, the VIX and the breadth, for cups the handle's volume against the cup's and the handle's volume slope, and a per-ticker fear-and-greed reading, the equal-weight 0-100 composite of RSI 14, the MACD histogram's percentile within the trailing year and Bollinger %B that the TradingView community indicators of that name share, at the scan day and at the pattern's last low, with its components. Bucket edges are fixed (`FEATURE_BUCKETS` in `tools/backtest.py`) so two replays, or the two windows of a split, compare bucket by bucket; each row shows N, hit rate, mean and median R and the month-block interval. Nothing is added to `signals.json` or the report: the features exist to be tested, and a feature earns a rule only if its buckets separate outcomes by more than their intervals on both windows of a split.

**Out-of-sample check.** `--split YYYY-MM-DD` prints every table three times: all sessions, the sessions before the date, and the sessions from it. A rule chosen on one window is judged on the other; how the windows are used is the protocol in [Configuration and Tuning](03-Configuration-and-Tuning.md). `--bars 500` makes each scan see only its last 500 bars, which is what the nightly's `2y` download gives it, so a `--period 5y --days 500` replay reproduces two years of nightly runs rather than scans with ever-longer histories. Caveats: today's constituents only (survivorship bias), the last `horizon` sessions are still open, and both replayed years to 2026-09 were mostly bull markets.

Expand Down
35 changes: 35 additions & 0 deletions test_backtest.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,41 @@ def test_row_features_match_hand_computation(mini_universe):
assert g["break_close_pos"] is not None and g["volume_z"] is not None # the last bar still is a bar


def test_fear_greed_components_and_score():
rising = bt.fear_greed(100 * 1.01 ** np.arange(300)) # accelerating rise: everything stretched up
assert rising["rsi"] == 100.0 and rising["macd_pct"] > 90 and rising["bb_pctb"] > 0.5 and rising["score"] > 80
falling = bt.fear_greed(100 * 0.99 ** np.arange(300))
assert falling["rsi"] == 0.0 and falling["macd_pct"] < 10 and falling["bb_pctb"] < 0.5 and falling["score"] < 20
flat = bt.fear_greed(np.full(300, 50.0)) # never moved: neutral, and no bands
assert flat == {"rsi": 50.0, "macd_pct": 50.0, "bb_pctb": None, "score": 50.0}
short = bt.fear_greed(np.arange(10, dtype=float)) # too short for every component
assert short == {"rsi": None, "macd_pct": None, "bb_pctb": None, "score": None}
# RSI by hand on a 15-bar series: gains 1 on ten bars, losses 1 on four -> avg gain 10/14, avg loss 4/14 -> RS 2.5.
steps = np.array([1, 1, 1, -1, 1, 1, -1, 1, 1, 1, -1, 1, 1, -1], dtype=float)
assert bt._rsi(np.concatenate([[100.0], 100 + np.cumsum(steps)])) == round(100 - 100 / 3.5, 2)
# %B is the close's position between the bands: outside them beyond 0 or 1.
spike = np.concatenate([np.full(19, 100.0), [110.0]])
fg = bt.fear_greed(np.concatenate([np.full(30, 100.0), spike]))
assert fg["bb_pctb"] > 1.0 and fg["score"] is not None


def test_row_features_fear_greed_at_scan_day_and_base(mini_universe):
cup = mini_universe["CUP"]
(s,) = scan.detect_cup_and_handle(cup, "CUP")
f = bt.row_features(cup, s, float(scan.atr(cup).iloc[-1]))
close = cup["Close"].to_numpy()
fg = bt.fear_greed(close)
assert (f["fg_score"], f["fg_rsi"], f["fg_macd_pct"], f["fg_bb_pctb"]) == (
fg["score"], fg["rsi"], fg["macd_pct"], fg["bb_pctb"])
anchor = cup.index.get_loc(pd.Timestamp(re.search(r"handle low (\S+)", s.notes)[1]))
assert f["fg_base"] == bt.fear_greed(close[:anchor + 1])["score"]
assert 0 <= f["fg_base"] < f["fg_score"] <= 100 # the base is fearful, the breakout greedy
rows = bt.walk_forward(mini_universe, days=5, horizon=10)
assert all(r["fg_score"] is not None and r["fg_base"] is not None for r in rows)
md = bt.render(rows, bt.report_sections(rows, 5, 10), 5, 10)
assert "| fear and greed at the scan day |" in md and "| fear and greed at the pattern's last low |" in md


def test_row_features_per_pattern(mini_universe):
ihs, ww = mini_universe["IHS"], mini_universe["WW"]
(si,) = scan.detect_inverse_hs(ihs, "IHS")
Expand Down
Loading
Loading