Skip to content

Latest commit

 

History

3 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

honest-signals

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.

Why not just compare to 50%?

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.

The measure: lift

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.

What the measurement actually found

Across 105 (pattern, timeframe, confidence-band) cells measured on 2026-07-18 — 60 on US stocks & ETFs, 45 on major crypto pairsthree 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

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.

30 seconds

No account, no API key, no config file:

honest-signals check AAPL MSFT NVDA SPY BTCUSDT

A real run

honest-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.

When a response carries no baseline

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.

Library

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_EDGE

Full 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)

CLI

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

How it works

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:

  1. Anchors every judgement to the baseline. Never to 50%, and never silently.
  2. 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.
  3. Classifies on the interval of the difference, preferring the cluster-robust half-width and flagging when only the Wald form was available.
  4. 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_KEY

Examples

  • examples/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.

Limitations

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 BASELINE is not NO 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.

Development

git clone https://github.com/MarvinRey7879/honest-signals
cd honest-signals
pip install -e ".[cli,dev]"
pytest

The 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.

License

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.

About

Check detected chart patterns against the rate the market moves that way anyway, so you can see which signals carry any information at all. Lift over a pattern-free baseline with cluster-robust intervals -- never against 50%.

Topics

Resources

Stars

2 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages