Check detected chart patterns against the rate the market moves that way anyway, so you can see which of them carry any information at all.
A pattern detector tells you a head-and-shoulders formed. It may even tell you that across 2,795 historical occurrences it resolved downward 42.0% of the time. What it does not tell you is that an arbitrary US stock, with no pattern present, closed lower over the same 10-day window 42.0% of the time too.
The pattern contributed +0.0 percentage points.
honest-signals puts those two numbers side by side for a whole watchlist.
Ticker Pattern Dir Bars Hit rate Baseline Lift Lift 95% CI n Verdict
------- ------------------ ---- ---- -------- -------- ------- ----------- ------ ---------------
BTCUSDT double_bottom bull 1 37.1% 49.3% -12.2pp +/-8.9pp 143 TRAILS BASELINE
BTCUSDT double_top bear 1 60.4% 50.6% +9.8pp +/-8.4pp 149 BEATS BASELINE
SPY double_top bear 4 43.1% 42.0% +1.1pp +/-2.7pp 7,508 NO EDGE
SPY bearish_engulfing bear 2 41.3% 42.0% -0.7pp +/-2.6pp 12,842 NO EDGE
SPY bullish_engulfing bull 3 58.2% 57.8% +0.4pp +/-2.6pp 11,535 NO EDGE
SPY head_and_shoulders bear 1 42.0% 42.0% +0.0pp +/-3.1pp 2,795 NO EDGE
SPY doji neut 0 - - - - - NO DATA
Data snapshot: 2026-07-18.
Because that is wrong, and wrong in a direction that manufactures findings.
Markets drift. Over a 10-bar forward window on the daily US stock corpus, measured over 46,038 independent windows:
| share of windows | |
|---|---|
| closed higher | 57.84% |
| closed lower | 42.00% |
| closed exactly flat | 0.16% |
So every bearish pattern starts from 42%, and every bullish pattern starts from 57.8%, before the pattern has said anything at all. Judge them against 50% and you get:
- "bearish engulfing hits only 41.3% — a reliable inverse signal, fade it!" It is 0.7pp off the market average, comfortably inside the interval. It is nothing.
- "bullish engulfing hits 57.9% — an edge!" It is 0.06pp above baseline. Also nothing.
An earlier version of this tool made exactly that mistake and reported six patterns as
EDGE or INVERSE. Every one of them was the market's own drift, relabelled. That bug
is the reason this package exists in its current form.
lift = hit_rate - baseline
where baseline is the pattern-free rate for the same universe, timeframe, horizon
and bar definition, direction-matched (P(up) for a bullish pattern, P(down) for a
bearish one). A verdict is decided by whether an interval on that difference excludes
zero.
| Verdict | Meaning |
|---|---|
BEATS BASELINE |
The lift interval sits entirely above zero. The pattern added information. |
TRAILS BASELINE |
The lift interval sits entirely below zero. Doing nothing beat the pattern. |
NO EDGE |
The lift interval contains zero. Whatever the hit rate looks like, the pattern is indistinguishable from the market's own drift. |
NO BASELINE |
No baseline was available for that cell, so nothing could be measured. An open question, not a finding — and deliberately not resolved by falling back on 50%. |
NO DATA |
No backtested record for this pattern at all. |
Two intervals appear in this package and they are not interchangeable:
- The Wilson score interval still describes the hit rate itself, and is still the
right tool for that: it stays inside [0, 1] and behaves at small
n. It says nothing about edge. A tight Wilson interval around 41.3% that confidently excludes 50% is precisely the trap above. - The interval on the difference is what decides a verdict. It is wider, because the baseline is estimated too.
The difference interval comes in two forms. The independent-sample Wald half-width assumes every occurrence is its own draw, which pooled market data violates: a bearish engulfing on AAPL and one on MSFT on the same day are one market move observed twice. A cluster-robust half-width (sandwich estimator, clustered on the UTC calendar day) lets samples inside a day be arbitrarily correlated. On the 2026-07-18 tables it runs about 2.1x the Wald half-width at the median on daily stocks (range 1.4–2.7x), and about 1.1x on daily crypto, where occurrences are spread over far fewer symbols.
That is not a rounding detail. Across all 105 cells, the Wald interval would have called 14 of them findings; the cluster-robust interval calls 3. Eleven of those fourteen are artefacts of pretending same-day occurrences across tickers are independent draws.
Where a cluster-robust half-width is available, it decides the verdict. Where only the
Wald form is available, the interval is printed with a * and a footnote, because a
verdict resting on it is optimistic.
Across 105 (pattern, timeframe, confidence-band) cells measured on 2026-07-18 — 60 on US stocks & ETFs, 45 on major crypto pairs — three clear an uncorrected cluster-robust 95% interval, and all three are crypto:
| cell | tf | band | lift | interval | n | z | direction |
|---|---|---|---|---|---|---|---|
bullish_engulfing |
1h | 0.75–1.00 | -1.76pp | ±1.36pp | 8,897 | 2.53 | below baseline |
double_bottom |
1d | 0.50–0.75 | -12.25pp | ±8.91pp | 143 | 2.69 | below baseline |
double_top |
1d | 0.50–0.75 | +9.78pp | ±8.42pp | 149 | 2.28 | above baseline |
Two of the three are patterns that did worse than no pattern at all. double_top on 1d
in the 0.50–0.75 band is the one cell of the 105 that lands above its baseline, and
it rests on n=149.
- On US stocks & ETFs it is 0 of 60. There the strong claim holds without qualification: not one cell separates from its baseline, at any timeframe or confidence band. On major crypto pairs it is 3 of 45.
- Three is fewer than chance predicts. At α = 0.05 you expect about 5.3 false positives from 105 tests. We found three.
- None survives correction. A Bonferroni correction for 105 comparisons needs z > 3.49; the largest z anywhere in the sweep is 2.69. Zero cells clear it.
So the honest summary is neither "no pattern is ever significant" nor "not one of the 105 beat its baseline" — both would be as sloppy as the claim this package exists to fix, and the second one hides its own counter-example, which is exactly what we accuse the chart literature of. One cell did beat its baseline nominally and it is named above. The summary is the stricter one: we found fewer standouts than noise alone would have produced, and nothing survives correction for how many cells were looked at.
Install straight from the repository:
pip install "honest-signals[cli] @ git+https://github.com/MarvinRey7879/honest-signals"Or clone it first, which is what you want if you intend to read or change the code:
git clone https://github.com/MarvinRey7879/honest-signals
cd honest-signals
pip install -e ".[cli]"Python 3.10+. The only hard dependency is httpx; the cli extra adds rich for
coloured tables. Drop [cli] if you only want the library — the honest-signals
command needs it.
Not on PyPI yet. Once it is published,
pip install "honest-signals[cli]"will be the shorter route; until then the two commands above are the ones that work.
No account, no API key, no config file:
honest-signals check AAPL MSFT NVDA SPY BTCUSDThonest-signals check MSFT SPY NVDA, against the keyless endpoint with no key and no
signup, on 2026-07-18:
Watchlist signals - timeframe 1d, completed within 5 bars
Ticker Pattern Dir Bars Hit rate Baseline Lift Lift 95% CI n Verdict
------ ------------------ ---- ---- -------- -------- ------ ----------- ------ -------
SPY head_and_shoulders bear 2 44.0% 42.0% +2.0pp +/-3.4pp 2,152 NO EDGE
SPY double_top bear 0 43.1% 42.0% +1.1pp +/-2.7pp 7,508 NO EDGE
SPY double_top bear 2 43.1% 42.0% +1.1pp +/-2.7pp 7,508 NO EDGE
SPY double_bottom bull 5 56.9% 57.8% -0.9pp +/-2.8pp 6,692 NO EDGE
MSFT double_top bear 0 42.5% 42.0% +0.5pp +/-2.6pp 9,032 NO EDGE
NVDA bullish_engulfing bull 4 58.2% 57.8% +0.4pp +/-2.6pp 11,535 NO EDGE
MSFT bullish_engulfing bull 5 58.2% 57.8% +0.4pp +/-2.6pp 11,535 NO EDGE
NVDA double_bottom bull 4 57.3% 57.8% -0.5pp +/-2.8pp 5,818 NO EDGE
NVDA doji neut 0 - - - - - NO DATA
NVDA doji neut 2 - - - - - NO DATA
10 signals firing: 0 beat their baseline, 0 trail it, 8 show no edge over it, 2 could not be measured.
Read the Hit rate column against 50% and four of these look like tradeable inverse
signals. Read it against the Baseline column beside it and every one of them is the
market's own drift. That gap is the entire point of the tool.
Ten signals, nothing that separates from its baseline. That is the ordinary outcome, not a failure — see what the measurement actually found.
Not every cell can be judged. A response may carry a backtested hit rate but no
pattern-free baseline for it: an older deployment, a self-hosted or proxied endpoint, or
a (pattern, timeframe, confidence-band) combination the baseline sweep has not covered.
Those rows read NO BASELINE:
Ticker Pattern Dir Bars Hit rate Baseline Lift Lift 95% CI n Verdict
------- ------------------ ---- ---- -------- -------- ---- ----------- ------ -----------
MSFT double_top bear 0 42.4% - - - 9,046 NO BASELINE
SPY head_and_shoulders bear 2 44.1% - - - 2,151 NO BASELINE
NVDA bullish_engulfing bull 4 57.9% - - - 11,539 NO BASELINE
NVDA doji neut 0 - - - - - NO DATA
16 signals firing: 0 beat their baseline, 0 trail it, 0 show no edge over it, 16 could not be measured.
NO BASELINE: the response carried a backtest but no pattern-free baseline for that cell,
so the pattern's own contribution could not be measured. This is an open question, not a
finding -- and it is deliberately not answered by falling back on a 50% reference, which
would misread every pattern in a drifting market.
That is the correct output, not a degraded one. 42.4% is a real number and it is
shown; what is withheld is the judgement, because the input for it is missing.
If you have measured a baseline yourself on the same corpus, you can supply it:
from honest_signals import check_watchlist, Baseline
# Measured over the same universe, timeframe, horizon and bar definition as the
# hit rates. This package ships no baselines of its own -- one you did not measure
# yourself is how you get a confident wrong answer.
stock_1d = Baseline(tf="1d", horizon=10, n=46038, up=0.578413, down=0.419979, flat=0.001607)
report = check_watchlist(["SPY", "MSFT"], baselines={"1d": stock_1d})A caller-supplied baseline yields only the Wald interval, never the cluster-robust one,
so its verdicts are starred and should be read as optimistic. On a live run, that
difference alone is enough to flip hammer from NO EDGE to a spurious
TRAILS BASELINE.
from honest_signals import check_watchlist
report = check_watchlist(["AAPL", "MSFT", "NVDA"], timeframe="1d", within_bars=5)
print(f"{report.no_edge_share:.0%} of these signals show no edge over baseline")
print(f"{report.unmeasured_share:.0%} could not be measured at all")
for signal in report.ranked():
if signal.verdict.is_actionable:
print(
f"{signal.ticker} {signal.pattern}: {signal.hit_rate:.1%} vs a "
f"{signal.baseline:.1%} baseline -> {signal.lift_pp:+.1f}pp "
f"({signal.verdict.value})"
)Note is_actionable (the lift interval separates from zero) versus is_measured
(a lift could be computed at all). Conflating "we measured nothing" with "we could not
measure" is the same class of error as comparing to 50%.
The statistics are usable on their own, with no network involved:
from honest_signals import Baseline, compute_lift, wilson_interval, classify
stock_1d = Baseline(tf="1d", horizon=10, n=46038, up=0.578413, down=0.419979)
# The hit rate on its own looks damning against 50%...
print(wilson_interval(0.413, 12799)) # 41.3% [40.5%, 42.2%] n=12799
# ...and evaporates against the rate stocks fall at anyway.
lift = compute_lift("bearish", 0.413, 12799, stock_1d, clustered_ci95=0.026)
print(lift) # -0.7pp vs 42.0% baseline +/-2.6pp
print(classify(lift)) # Verdict.NO_EDGEFull objects: Signal, Report, PatternStats, Baseline, Lift, Interval,
Reading, Verdict. Everything is typed and to_dict()-able.
For anyone computing baselines from raw samples, the cluster-robust estimator is exported too:
from honest_signals import ClusterSample, clustered_diff_ci95, day_cluster
pattern = [ClusterSample(day_cluster(ts), hit) for ts, hit in pattern_outcomes]
baseline = [ClusterSample(day_cluster(ts), hit) for ts, hit in baseline_outcomes]
half_width = clustered_diff_ci95(pattern, baseline)honest-signals check AAPL MSFT NVDA # triage a watchlist
honest-signals check SPY --timeframe 1h --within 3
honest-signals check SPY --actionable # only what separates from baseline
honest-signals check BTCUSDT --json # machine-readable
honest-signals patterns AAPL MSFT NVDA SPY # per-pattern track record--within N keeps only patterns that completed within N bars of the latest candle,
defaulting to 5. A head-and-shoulders that resolved eighty bars ago is history, not a
signal.
The patterns subcommand collapses the watchlist into one row per pattern, weakest lift
first:
Pattern Dir Hit rate Baseline Lift Lift 95% CI n Firing Verdict
------------------ ---- -------- -------- ------ ----------- ------ ------ -------
double_bottom bull 56.9% 57.8% -0.9pp +/-2.8pp 6,692 2 NO EDGE
bearish_engulfing bear 41.3% 42.0% -0.7pp +/-2.6pp 12,842 2 NO EDGE
hammer bull 57.7% 57.8% -0.1pp +/-2.7pp 5,600 1 NO EDGE
head_and_shoulders bear 42.0% 42.0% +0.0pp +/-3.1pp 2,795 1 NO EDGE
bullish_engulfing bull 58.2% 57.8% +0.4pp +/-2.6pp 11,535 1 NO EDGE
double_top bear 43.1% 42.0% +1.1pp +/-2.7pp 7,508 2 NO EDGE
doji neut - - - - - 2 NO DATA
Pattern detection, the backtested base rates and the baselines come from patternfetch, which detects candlestick and chart patterns and reports, for each one, the fraction of non-overlapping historical occurrences that resolved in the pattern's stated direction over a fixed forward window, the sample size, and — where available — the direction-matched pattern-free rate for the same cell with a cluster-robust interval on the difference. Their methodology documents the corpus and the detection rules.
This package does four things on top of that:
- Anchors every judgement to the baseline. Never to 50%, and never silently.
- Recomputes the hit-rate interval. patternfetch reports a normal-approximation half-width; we compute a Wilson score interval, which stays inside [0, 1] and behaves better on the smaller samples that show up for rarer patterns and shorter timeframes.
- Classifies on the interval of the difference, preferring the cluster-robust half-width and flagging when only the Wald form was available.
- Filters and aggregates. Recency filtering, per-pattern rollups, watchlist-level summaries, and a rank that puts the largest separation from baseline first.
Requests go to patternfetch's keyless /v1/demo endpoint, which is rate-limited but
returns the full brief — the backtested evidence, the direction-matched baseline, the
cluster-robust interval and n — that is why this works with no signup, and why the
verdicts above are the same ones a keyed request would produce. If PATTERNFETCH_API_KEY is set, the client uses the keyed endpoints
instead, which raise the rate limit. A free key is available from their API
(POST /v1/keys with an email). Nothing in this package requires one.
from honest_signals import PatternfetchClient
client = PatternfetchClient(api_key="pf_...") # or set PATTERNFETCH_API_KEYexamples/01_single_ticker.py— every pattern on one symbol, with its baseline and lift spelled out in prose.examples/02_watchlist_triage.py— a watchlist reduced to the signals that separate from their baseline.examples/03_agent_tool.py— the same data as a tool-calling function plus JSON schema, for LLM agents. The baseline and the verdict travel with the signal, so a model cannot report the market's drift as a setup without contradicting its own input.notebooks/pattern_hit_rates.ipynb— hit rates plotted against their baselines, lift with error bars against the zero line, and what sample size does to an apparent edge. Outputs are committed so the charts render on GitHub without running anything.
Read these before using any of the numbers. Several are properties of the underlying measurement, not of this package, and none of them are fixable from here.
About the measurement
- Forward windows overlap. Occurrences are de-duplicated by requiring a minimum bar spacing within a ticker, and the cluster-robust interval absorbs same-day correlation across tickers. Neither separates two occurrences on different days whose 10-bar forward windows still overlap. The intervals are therefore a floor on the true uncertainty, not a ceiling.
- Survivorship in the ticker universe. The corpus is a fixed list of currently liquid, currently listed symbols. Companies that delisted, went to zero or were acquired are not in it. That biases every rate measured over it, and biases it upward.
- Yahoo caps hourly history at roughly 730 days. The 1h and 4h cells are measured over a much shorter window than the daily and weekly ones, and that window is one particular market regime. Their baselines and hit rates are not comparable in reliability to the daily ones.
- One horizon only. Everything is measured 10 bars forward. A pattern that resolves over 3 bars or 40 bars would not be detected as informative by this measurement, and its absence from the results is not evidence against it.
- Gross directional, no costs. Close-to-close sign only. No fees, no slippage, no stops, no position sizing, no overnight gaps, no borrow costs. A lift of +2pp does not survive contact with a real cost model.
- Hit rate and lift are not profit. A pattern can clear its baseline on frequency and still lose money if the losses are larger than the wins. There is no equity curve here and nothing in this package is a backtest of a strategy.
- The corpus is not the market. Rates are computed over a specific universe and a specific history. Regimes change; a rate measured over the last decade need not hold in the next one.
About using it
- Multiple comparisons. Scanning many symbols, patterns, timeframes and confidence bands will surface nominally separated cells by chance — roughly one in twenty at the 95% level. The package reports each cell's own verdict and does not correct across the cells you happen to look at. That correction is your job, and the "What the measurement actually found" section above is what it looks like when you do it.
- A base rate is a historical frequency, not a prediction. It says how often this shape has been followed by a move in a given direction across a fixed corpus. It says nothing about what the next occurrence will do.
NO BASELINEis notNO EDGE. The first means the question was not answered; the second means it was answered and the answer was zero. Treating them as the same thing reintroduces the error this package exists to prevent, from the other direction.- Not investment advice. This is a data tool. It is not financial, legal or tax advice, it is not personalised, and it is not a recommendation to buy, sell or hold anything. You can lose money. Do your own research.
git clone https://github.com/MarvinRey7879/honest-signals
cd honest-signals
pip install -e ".[cli,dev]"
pytestThe test suite mocks all HTTP through httpx.MockTransport and runs against recorded
API responses in tests/fixtures/, including one recorded before baselines shipped so
the missing-baseline path stays covered. It needs no network and no credentials.
Releasing to PyPI is documented in docs/PUBLISHING.md.
MIT. See LICENSE.
Market data, pattern base rates and baselines are provided by patternfetch under their own terms; this package is an independent client and is not affiliated with them.