Skip to content

Latest commit

 

History

15 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

TechnicalAnalysis

Testing whether chart-based technical analysis (TA) carries measurable signal in BTC, using hourly OHLCV data and machine learning.

Goal

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.

Thesis

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.

Idea

Pivots as the primitive

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.

Confirmation lag

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.

Strategies under test

Each strategy is built from confirmed pivots and emits numeric features per bar:

  1. Horizontal levels (support / resistance)
  2. Trendlines
  3. 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

Model

LightGBM, chosen for speed so we can iterate on feature design quickly and get an early read on whether the idea has merit.

Validation

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.

Project layout

.
├── 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

Setup

python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt

Data

Hourly 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.binance

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

Pivots

python -m src.features.pivots --check   # summary per tier, verified against brute force

pivot_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 zigzag

Horizontal levels (strategy 1)

python -m src.features.levels

build_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 count
  • res_dist_atr_{N} / sup_dist_atr_{N}: nearest level of tier at least N, for an obviousness-controlled comparison
  • n_levels_near: levels within near_band_atr of the close
  • break_dir, break_mag_atr, break_vol_ratio, break_touches, break_tier: set on bars where the close crossed a level since the previous close

Trendlines (strategy 2)

python -m src.features.trendlines

build_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 anchor
  • tl_sup_dist_atr_{N} / tl_res_dist_atr_{N}: nearest line of tier at least N
  • tl_n_sup, tl_n_res, tl_n_near: alive lines on each side and within one ATR
  • tl_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

Fibonacci (strategy 3)

python -m src.features.fibonacci

build_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 end
  • res_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 count
  • break_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.

Modeling harness

python -m src.model.harness --strategy levels --method zigzag --horizon 24

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

Tests

python -m pytest tests/

About

Testing whether chart TA carries predictable signal in BTC. Detects support/resistance, trendlines, and fibonacci levels algorithmically from OHLCV data, converts them to numeric features, and backtests the strategy.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages