Testing whether chart-based technical analysis (TA) carries measurable signal in BTC, using hourly OHLCV data and machine learning.
Test TA strategies on historical BTC OHLCV data. Rather than asking "does TA predict direction?", we ask whether price reacts at TA-derived levels, and whether that reaction scales with how obvious the level is.
TA is self-fulfilling. The more a level on a chart is watched, the more significance it carries, because more participants place orders around it. If that is true, the thing to measure is not directional prediction but reaction rate at levels, scaling with obviousness. A flat response across obviousness tiers means the "signal" is noise, regardless of how the aggregate statistics look.
Prior work in this direction:
- Lo, Mamaysky & Wang (2000), Foundations of Technical Analysis — formalises chart patterns via kernel regression and tests whether they carry information.
- Osler (2000, 2003) on FX round numbers — documents the self-fulfilling mechanism directly in order flow: stop-loss and take-profit orders cluster at obvious levels.
We detect swing points algorithmically. A bar is a pivot high if its high exceeds the highs of the N bars on either side; a pivot low is defined symmetrically. We run at three scales:
| N | Tier |
|---|---|
| 5 | minor |
| 20 | intermediate |
| 50 | major |
The tier is our proxy for obviousness: a major pivot is visible on every chart, a minor one only on short timeframes.
A pivot at time t is not knowable until t + N. All pivots are timestamped at confirmation, not occurrence, so no feature can see into the future.
Each strategy is built from confirmed pivots and emits numeric features per bar:
- Horizontal levels (support / resistance)
- Trendlines
- Fibonacci retracements / extensions from OHLC swings
Features include:
- Distance to nearest support / resistance, in ATR units
- Touch count on that level
- Trendline slope
- Breakout decisiveness (close-through magnitude, volume confirmation)
- Position within the current Fibonacci range
- Confluence count across timeframes / tiers
LightGBM, chosen for speed so we can iterate on feature design quickly and get an early read on whether the idea has merit.
A random train/test split is invalid here: adjacent windows share ~99% of their inputs, so the model would just memorise. We use purged walk-forward validation with an embargo of at least the prediction horizon between train and test folds.
.
├── data/ # SQLite store (gitignored)
│ └── technical_analysis.sqlite
├── src/
│ ├── api/
│ │ └── binance.py # BTC hourly OHLCV fetch + SQLite cache
│ └── features/
│ ├── indicators.py # ATR and other causal helpers
│ ├── pivots.py # N-bar pivot detection, stamped at confirmation
│ ├── levels.py # strategy 1: horizontal S/R levels and their features
│ ├── trendlines.py # strategy 2: trendlines through same-kind pivots
│ └── fibonacci.py # strategy 3: Fibonacci ratios on the active swing per tier
│ └── model/
│ ├── features.py # feature matrix: three strategies + causal context
│ ├── events.py # approach events and bounce / break labels
│ ├── walkforward.py # purged walk-forward splits with embargo
│ └── harness.py # LightGBM runs, obviousness ablation, report
├── tests/
│ ├── test_pivots.py
│ ├── test_levels.py
│ ├── test_trendlines.py
│ └── test_fibonacci.py
├── requirements.txt
└── README.md
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txtHourly BTC/USDT OHLCV from the Binance public klines API, going back 10 years (Binance spot BTCUSDT history starts August 2017, so in practice the series starts there).
python -m src.api.binanceThe first run fetches from Binance and writes to data/technical_analysis.sqlite, table
prices. Later calls (or load_prices() from Python) read from the database and only
hit the API to fill gaps or extend the series to the present.
python -m src.features.pivots --check # summary per tier, verified against brute forcepivot_table(df, method=...) returns one row per (swing, scale) with both the occurrence
bar and the confirmation bar. Three detectors share that table:
| method | scale parameter n |
tiers (minor / intermediate / major) | confirmed at |
|---|---|---|---|
nbar |
N bars each side | 5 / 20 / 50 | idx + N |
zigzag |
reversal threshold, in ATRs | 2 / 4 / 8 | first bar that retraces n ATRs from the extreme |
kernel |
Gaussian bandwidth, in bars | 3 / 8 / 20 | t + 1 + 3n, where t is the smoothed extremum |
zigzag is the classic ATR-scaled ZigZag: it self-adjusts, so quiet regimes yield pivots
from small swings and volatile regimes only from large ones, and its pivots strictly
alternate high / low. kernel is Nadaraya-Watson smoothing after Lo, Mamaysky & Wang
(2000) made causal: the smoothed close at t needs bars up to t + 3n, so a smoothed
extremum is only stamped once that bar has closed, and the pivot is the real high (low)
within one bandwidth of it.
known_pivots(piv, t) gives the swings a chart-watcher could see at the close of bar t,
with each swing's tier as it was known then; because the tiers confirm at different
times, a swing's tier upgrades over time. pivot_events(df, piv) is the wide,
time-aligned view stamped at confirmation, which is the form the feature builders consume.
python -m src.features.pivots --method zigzagpython -m src.features.levelsbuild_level_features(df, piv) replays the bars in order. When a pivot is confirmed its
price either joins an existing level within merge_tol_atr ATRs (one more touch) or
opens a new one; a later confirmation of the same swing at a larger N upgrades the
level's tier without adding a touch. Support versus resistance is decided per bar by
which side of the close the level sits on, and every close through a level is counted
as a break rather than deleting it.
Levels expire because a chart only shows a window: each swing stays visible for
lookback[N] bars after it occurred, with N its largest confirmed tier. The defaults
are one month for minor, six months for intermediate and two years for major swings,
assigned by rank so they apply to any pivot method. Without this, nine years of swings
blanket the price range and every bar sits within half an ATR of some level.
Only strong levels are reported. A single confirmed pivot is a point, not a level, so the
book keeps every cluster but the features and the level table only report clusters with
at least min_touches members (default 2). Weak clusters stay invisible until they earn
a second touch. Trendlines take the same knob with a default of 3: every line has two
anchors, and the classic rule is that a third touch confirms it, so only confirmed lines
are reported while two-anchor lines are tracked until they earn that touch.
Per-bar features, all distances in ATR units:
res_*/sup_*: nearest level above / below the close with its distance, touch count, tier, number of distinct tiers (cross-timeframe confluence), age and break countres_dist_atr_{N}/sup_dist_atr_{N}: nearest level of tier at least N, for an obviousness-controlled comparisonn_levels_near: levels withinnear_band_atrof the closebreak_dir,break_mag_atr,break_vol_ratio,break_touches,break_tier: set on bars where the close crossed a level since the previous close
python -m src.features.trendlinesbuild_trendline_features(df, piv) draws a line through two confirmed pivots of the
same kind: two lows make a support line, two highs a resistance line. A line is only
drawn if the chord between its anchors is clean, meaning no bar low dips below a support
chord (no bar high pokes above a resistance chord) within touch_tol_atr. Later pivots
landing within that tolerance of the projected line add touches; a swing confirmed at a
larger N upgrades the line's tier without adding a touch.
A line dies the first time a close finishes beyond it. Chart-watchers erase a broken
trendline, so unlike horizontal levels there is no role reversal; the break is recorded in
the tl_break_* features on that bar. A line also dies quietly once it drifts more than
max_dist_atr from price, since a steep line running away from price is off the screen
and can never be closed through. Lines and their candidate anchors share the per-tier
lookback used by horizontal levels.
Per-bar features, prefixed tl_, with distances in ATR and slopes in ATR per bar:
tl_sup_*/tl_res_*: nearest support line below / resistance line above the close with distance, slope, touches, tier (smaller of the two anchor tiers), distinct tiers, bars since last touch and bars since the first anchortl_sup_dist_atr_{N}/tl_res_dist_atr_{N}: nearest line of tier at least Ntl_n_sup,tl_n_res,tl_n_near: alive lines on each side and within one ATRtl_break_dir,tl_break_mag_atr,tl_break_vol_ratio,tl_break_touches,tl_break_tier,tl_break_slope_atr: set on bars where a close finished through a line
python -m src.features.fibonaccibuild_fibonacci_features(df, piv) keeps one active swing per tier: from the latest
confirmed tier-N pivot low to the latest confirmed tier-N pivot high, with whichever came
later as the swing's end. A tier-N swing is redrawn whenever a new tier-N pivot confirms,
so the minor swing changes every few bars while the major one persists for weeks. That is
the obviousness axis: nobody draws fibs on every minor leg, everyone draws them on the
major one.
Ratio levels are measured as retracement from the swing end back toward its start, so 0
is the end, 1 the start, 0.618 the golden retracement, and negative ratios are extensions
beyond the end. The set is -0.618, -0.272, 0, 0.236, 0.382, 0.5, 0.618, 0.786, 1, 1.272,
1.618. Confirmed pivots of any tier landing within touch_tol_atr of a level after the
swing's end count as touches; counters reset when the swing changes.
Per-bar features for each tier, prefixed fib_{N}_:
dir,retrace,range_atr,swing_bars,age_bars: swing direction, the close in retracement units, swing size in ATR, bars between the endpoints, bars since the endres_dist_atr,res_ratio,res_touches/sup_dist_atr,sup_ratio,sup_touches: nearest ratio level above / at-or-below the close, its ratio and touch countbreak_dir,break_ratio,break_mag_atr,break_vol_ratio: set on bars where the close crossed a ratio level since the previous close
Across tiers, fib_n_near counts ratio levels within one ATR of the close.
python -m src.model.harness --strategy levels --method zigzag --horizon 24The harness answers the thesis question directly: does reaction at a level scale with how obvious the level is?
Events. The unit of analysis is an approach: the first bar at which the close comes
within near_atr (0.5) ATRs of the nearest level above or below. Each approach is
labelled by a race over the next horizon (24) bars: a bounce if the close first
moves react_atr (1.0) ATRs away from the level, a break if it first moves
break_atr (1.0) ATRs toward and through it, undecided otherwise. Both distances are
measured from the event bar's close, so the barriers are symmetric and a random walk
bounces half the time; any excess is reaction. (--barrier level measures them from
the level's price instead, which reads more literally off a chart but biases the base
rate toward "bounce" because the bounce barrier is then the nearer one.) Undecided
events are dropped and their share reported. The same event builder serves horizontal
levels, trendlines and Fibonacci ratios by renaming each strategy's side-specific
columns to generic ones.
Purged walk-forward. Test blocks of test_days (180) run forward in time from
min_train_days (365). Training uses only events whose whole label horizon ends before
the block starts minus an embargo, so the purge is structural and the embargo (default
= horizon, never smaller) adds a gap against serial correlation. Early stopping uses an
embargoed tail of the training set, never the test block.
The test. Per fold, two LightGBM classifiers: one on geometry and context features only (distance to the level, distance to the level on the other side, slope, recent returns, volatility ...), and one that also sees the obviousness features (touches, tier, cross-tier confluence, age, breaks, nearby-level count). The out-of-sample log-loss gain from adding obviousness is the ablation. Alongside it, a direct table of out-of-sample bounce rate by touches bin, by tier and by confluence, with Wilson intervals and a Cochran-Armitage trend test. A flat table and a zero ablation gain mean the levels are noise, whatever the aggregate accuracy looks like.
Outputs (fold metrics, obviousness table, feature importance, labelled events with
out-of-sample predictions, summary JSON) are written to data/results/.
python -m pytest tests/