-
Notifications
You must be signed in to change notification settings - Fork 0
03 Configuration and Tuning
All thresholds are module-level constants at the top of scan.py. Two of them (MIN_SCORE, MAX_BREAKOUT_AGE) can also be set per run from the CLI; everything else is edited in the file so the rules stay auditable in one place. There are no environment variables and no API keys.
Since 2026-09-05 the pattern rules follow the engine specification (the spec profile, the module defaults). The rules in force before that date are kept as the legacy profile in RULE_PROFILES, so the two can be compared on the same data:
python scan.py --profile legacy # scan with the old rulesgh workflow run backtest.yml -f profile=spec # replays spec, and legacy alongside itapply_profile(name) switches the constants at run time; signals.json records the profile in meta.profile. A value of None switches an optional rule off. In the tables below, spec is the default and legacy the alternative.
A third profile, tuned, is the spec with the four rules changed that the 2026-09-05 rule ablation showed were removing good signals: volume confirmation off (back to a score bonus), IHS side-duration symmetry off, Wolfe leg rhythm loosened to ±45 %, cup rollback cap at the spec's own 61.8 % maximum. Everything else in it is the spec. The Wolfe rhythm value was chosen on a three-point replay: ±30 % kept 8 Wolfe signals a year at +0.37 R, ±45 % 28 at +0.13 R, ±60 % 43 at +0.02 R; ±45 % lifts the whole tuned profile to 177 signals at +0.34 R.
| Profile | What it is | Confirmed signals | Hit rate | Mean R | +5 % first | IHS | Wolfe | Cup |
|---|---|---|---|---|---|---|---|---|
| legacy | rules until 2026-09-05 | 354 | 33 % | +0.06 | 64 % | 87 at +0.20 R | 142 at −0.11 R | 125 at +0.16 R |
| spec | the engine specification as written | 22 | 53 % | +0.14 | 76 % | 10 at 0.00 R | 8 at +0.37 R | 4 at +0.03 R |
| tuned | spec with the four relaxations above (Wolfe rhythm ±45 %) | 177 | 50 % | +0.34 | 72 % | 129 at +0.40 R | 28 at +0.13 R | 20 at +0.23 R |
Year-long walk-forward replay, 250 sessions to 2026-09-04, horizon 60 bars, next-open fills, intraday stops (backtest workflow runs 33968691768, 33973275310 and 33985398488). Read with the usual caveats: today's constituents only, one year, one regime.
The nightly scan runs --profile tuned, chosen on this evidence on 2026-09-05; the default in code stays spec. Change the flag in .github/workflows/daily-scan.yml to switch. The cup entry stays at the handle peak: a rim-B entry replayed at 19 cup signals and +0.17 R against 20 and +0.23 R. The grid also shows a close-based stop (exit on the first close at or below the stop) lifting tuned's mean R to +0.35 at a 52 % hit rate; the report's stop level is unchanged, that is an execution choice.
| Flag | Default | Effect |
|---|---|---|
--tickers A,B,C |
full S&P 500 | scan only these symbols (upper-cased) |
--csv path |
pinned GitHub dataset | local constituents CSV; Symbol column or first column |
--period |
2y |
yfinance period; 2y gives about 500 daily bars, enough for SMA200 plus a 250-bar cup |
--min-score |
60 | overrides MIN_SCORE for the run and is echoed in meta.min_score
|
--max-age |
3 | overrides MAX_BREAKOUT_AGE; the effective per-pattern limits are in meta.max_breakout_age_by_pattern
|
--profile |
spec |
rule profile, spec or legacy; echoed in meta.profile
|
--out-dir |
output |
destination for signals.json and report.md
|
-v |
off | DEBUG logging, including per-symbol last-bar detail |
| Constant | Default | What it controls | Loosen → | Tighten → |
|---|---|---|---|---|
PIVOT_ORDER |
5 | bars on each side needed to call a swing high/low | fewer bars: more (noisier) pivots, patterns visible sooner | fewer, cleaner pivots; H&S/Wolfe seen later; BREAKOUT_AGE_LAG follows it automatically |
ATR_LEN |
14 | ATR window used for stops and the head/overshoot tests | ||
MIN_SCORE |
60 | minimum quality score reported | more marginal setups | only the cleanest geometry |
MAX_BREAKOUT_AGE |
3 | max bars since the confirming close (Cup); +PIVOT_ORDER for H&S and Wolfe |
older breakouts reported | only fresh breakouts |
BREAKOUT_AGE_LAG |
Cup 0, H&S 5, Wolfe 5 | extra age tolerated because the last pivot lags | ||
MAX_RUNAWAY |
0.05 | close more than this above the trigger = chasing, dropped | ||
WATCH_PROXIMITY |
0.05 | setups whose close is within this of the trigger → watchlist (was 0.03 until 2026-09-05; at 3 % a normal down day cleared most of the list) | longer watchlist | shorter watchlist |
LAST_BAR_MIN_FRACTION |
0.5 | share of symbols that must have a complete bar for it to be meta.last_bar
|
||
FILL_CLOSE_MIN_AGE |
1 h | how old the last trade must be to count as the closing print | ||
CONSTITUENTS_COMMIT |
2026-08-20 hash | pinned upstream commit of the constituent CSV |
| Constant | spec | legacy | Meaning |
|---|---|---|---|
VOLUME_AVG_LEN |
20 | 50 | bars in the average that the breakout-bar volume is compared with |
VOLUME_CONFIRM |
cup 1.4, H&S 1.3, Wolfe none | none | a breakout close is CONFIRMED only with at least this volume ratio; otherwise the row stays on the watchlist with the note "breakout without volume". A ratio ≥ 1.3 always adds +5 to the score. |
MAX_RISK_PCT |
cup 12, H&S 15, Wolfe 15 | 15 for all | reject setups whose stop is further than this below the entry |
| Constant | Default | Meaning | Tuning note |
|---|---|---|---|
TREND_VETO_REVERSALS |
spec False, legacy True
|
reject H&S and Wolfe setups when strong_downtrend holds |
the spec expects reversals in down-trends; the backtest's profile comparison shows what the veto costs |
TREND_STRONG_DOWN |
0.90 | close below this fraction of a falling SMA200 = strong down-trend | only used by the veto and the trend description |
TREND_SLOPE_LOOKBACK |
40 | bars back used to decide whether the SMA200 is falling | shorter reacts faster to a roll-over, longer ignores wobble |
TREND_STRONG_DOWN_SMA50 |
0.85 | the same test against SMA50 when fewer than 200 bars exist, or when the SMA200's slope cannot be judged yet (200–239 bars) | |
CUP_REQUIRE_CLOSE_ABOVE_SMA200 |
spec False, legacy True
|
the cup's legacy gate | under spec the cup's trend filter is the SMA50/advance test below |
| Constant | spec | legacy | Meaning | Tuning note |
|---|---|---|---|---|
CUP_MIN_LEN / CUP_MAX_LEN
|
20 / 300 | 30 / 250 | rim-to-rim width in bars | spec: minimum 20, typically 35–300 |
CUP_MIN_DEPTH / CUP_MAX_DEPTH
|
0.12 / 0.50 | same | depth as a fraction of the left rim | the spec is silent on a minimum; the score peaks at 25 % |
CUP_MAX_RETRACE |
0.50 | off | the cup decline may not exceed this share of the preceding advance | the spec's "not more than 50 % (61.8 % absolute max)"; the rule that limits depth relative to the run-up |
CUP_ADVANCE_LOOKBACK |
250 | – | window before rim A in which the preceding advance's low is sought | the spec does not define the window; a year lets a gradual advance count |
CUP_RIM_TOL_OF_DEPTH |
0.15 | off | right rim within this share of the cup depth of the left rim | stricter than 5 % of price for shallow cups, looser for deep ones |
CUP_RIM_TOL |
0.05 | 0.05 | right rim within 5 % of the left rim's price; used only when CUP_RIM_TOL_OF_DEPTH is off |
|
CUP_BOTTOM_ZONE |
(0.25, 0.75) | (0.20, 0.80) | the lowest low must sit in this part of the span | |
CUP_PRIOR_ADVANCE / CUP_PRIOR_LOOKBACK
|
0.20 / 60 | 0.25 / 120 | required rise from the look-back low into the left rim | |
CUP_TREND_SMA_OR |
True |
False |
SMA50 > SMA200 satisfies the trend filter on its own | easy to satisfy after any cup, so under spec the rollback rule does most of the work |
CUP_MIN_ROUNDNESS |
0.70 | 0.60 | R² of the convex quadratic fit of cup lows | rejects ragged bases; on its own it would pass a clean V (R² ≈ 0.93) |
CUP_MAX_V_ADVANTAGE |
0.0 | 0.0 | how much the best two-legged V fit's R² may exceed the parabola's | 0 = the U must explain the lows at least as well as a V; ~0.07 would re-admit clean Vs |
HANDLE_MIN_LEN / HANDLE_MAX_LEN
|
5 / 25 | 5 / 40 | handle length in bars | below 5 bars a close above the running high is treated as the handle still forming |
HANDLE_MAX_LEN_OF_CUP |
1.0 | off | handle bars ≤ cup bars × this | spec: the handle never outlasts the cup |
HANDLE_MAX_DEPTH |
0.12 | same | handle pull-back vs. the right rim | O'Neil's 12 % |
HANDLE_MAX_FRACTION_OF_CUP |
0.50 | same | handle depth vs. cup depth | |
CUP_TARGET_BASE |
right_rim |
left_rim |
the measured move is bottom → this level, added to the entry (trigger = Investopedia's breakout-level measure) |
|
CUP_TRIGGER |
handle_high |
same | breakout level: the handle peak (O'Neil's buy point) or rim_b, the higher of the handle peak and rim B, so the close must also clear the rim |
a later, more conservative entry; compare with gh workflow run backtest.yml -f overrides="CUP_TRIGGER=rim_b"
|
Not configurable: the handle low must stay in the upper half of the cup.
| Constant | spec | legacy | Meaning | Tuning note |
|---|---|---|---|---|
IHS_MIN_LEN / IHS_MAX_LEN
|
20 / 200 | same | shoulder-to-shoulder width | |
IHS_MIN_HEAD_ATR |
1.0 | same | head at least this many ATR below both shoulders | the scale-free "is there really a head" test; the spec only asks for strictly lower |
IHS_SHOULDER_SYM_OF_HEIGHT |
0.30 | off | shoulder gap as a share of the head height (neckline at the head bar minus head) | |
IHS_SHOULDER_SYM |
off | 0.50 | shoulder gap as a share of the shallower shoulder depth | |
IHS_SIDE_SYM_TOL |
0.40 | off | the durations LS→N1 and N2→RS within this of each other | |
IHS_TIME_SYM |
2.5 | same | max ratio of left-half to right-half duration (sanity bound) | |
IHS_MAX_NECK_SLOPE |
0.15 | same | neckline change over the width, as a fraction of the head price | the spec's angle limits are scale-dependent; this is the usable form |
IHS_PRIOR_DECLINE_OF_HEIGHT |
1.0 | off | required decline into LS, in head heights | |
IHS_PRIOR_DECLINE |
off | 0.10 | required decline into LS as a share of the 60-bar high | |
IHS_TREND_SMA_OR |
True |
False |
SMA50 < SMA200 satisfies the trend filter on its own | |
IHS_TARGET_AT_HEAD |
True |
False |
measured move uses the neckline at the head bar (spec) rather than at the break bar | identical for a flat neckline |
| Constant | spec | legacy | Meaning | Tuning note |
|---|---|---|---|---|
WW_MIN_LEN / WW_MAX_LEN
|
15 / 200 | same | point-1 to point-5 width | |
WW_SWEET_ZONE |
True |
False |
point 5 must be below line 1-3 and above the line through point 3 parallel to 2-4 | the classic Wolfe rule |
WW_MAX_OVERSHOOT_ATR |
2.0 | 2.0 | legacy band: max undercut of line 1-3 by point 5 (also scales the score's overshoot term) | with the sweet zone on, only the score uses it |
WW_TIME_SYM_TOL |
0.30 (tuned 0.45) | off | legs 1→2, 2→3, 3→4 each within this of their mean | replay: 0.30 → 8 signals at +0.37 R, 0.45 → 28 at +0.13, 0.60 → 43 at +0.02 |
WW_MAX_BARS_SINCE_P5 |
25 | same | point 5 must be within the last 25 bars | |
WW_MAX_ETA_BARS |
250 | same | lines 1-3 and 2-4 must meet within this many bars after point 5 for a target to be reported | guards against near-parallel lines projecting absurd targets |
WW_MAX_TARGET_GAIN |
1.0 | same | no target when line 1-4 at the ETA is more than +100 % above the entry | a year of replay produced +590 % and +120 % "targets" |
- Trend filter — spec: the cup needs SMA50 > SMA200 or a 20 % rise into rim A; H&S needs SMA50 < SMA200 or a one-head-height decline into LS. Legacy: cups needed an up-trend and reversal patterns were vetoed in a strong down-trend.
- Geometry — the width/depth/symmetry/slope/sweet-zone/rhythm rules above. Each is a hard reject.
- Rollback (cup) — the decline may not exceed half the preceding advance.
- Roundness (cup only) — R² ≥ 0.70 and the parabola must fit at least as well as a V.
- Confirmation state — stale (> age limit) and runaway (> 5 % above) breakouts are dropped; setups more than 5 % below the trigger are dropped.
- Volume — a breakout close without ≥ 1.4× (cup) or ≥ 1.3× (H&S) average volume is watch-listed, not confirmed.
-
Risk — stop above entry or risk above
MAX_RISK_PCTis dropped. -
Score — everything surviving with a score below
MIN_SCOREis dropped. -
De-duplication — only the best-scoring signal per
(ticker, pattern, status)is kept, so overlapping pivot combinations never inflate the count.
The random-walk sweeps in test_scan.py::test_random_walk_false_positive_rate and test_patterns.py::test_fat_tailed_noise_false_positive_rate print the share of 200 synthetic 500-bar series on which any detector fires (spec profile: 4 and 3 of 200, all watchlist; hard limit 5 %):
python -m pytest test_scan.py -k random_walk -q -sFor sensitivity, run the negative-control mutations (test_patterns.py -k violations) after loosening a threshold: each mutation names the rule it violates, so a newly passing mutation tells you which rule you have effectively removed.
python -m pytest test_patterns.py -k "violations or controls" -qFor real-data effect, run the backtest (see Testing and Contributing): it replays the active profile and the other one on the same year of prices, and re-scores the signals under stop and target variants. Any constant can be overridden for one replay without a code change: gh workflow run backtest.yml -f overrides="CUP_TRIGGER=rim_b WW_TIME_SYM_TOL=0.45" (locally --set KEY=VALUE). Change one constant, re-run, and only then change the default.
📖 Documentation
💬 Community
🔗 Links