Skip to content

Repository files navigation

Candlestick Classifier

Gradient boosting classifier that flags good long-entry points for BTC/USDT, trained directly on OHLCV technical indicators — no rendered candlestick images involved. Exits are handled by a rule-based ATR bracket rather than a second classifier.

This replaces an earlier approach that rendered candlestick charts as images and fine-tuned a VGG16 CNN on them. Feeding a gradient boosting model the underlying indicators directly is simpler, faster to train, and avoids the leakage risk of randomly splitting overlapping chart images into train/validation sets.

Approach

  1. Data: hourly OHLCV data downloaded from cryptodatadownload.com, defaulting to Gemini BTC/USD rather than Binance. Binance's hourly export is missing ~2% of hours (visible as jumps in the price chart); of the exchanges checked, Gemini has by far the fewest gaps while also covering the longest, most current history:

    Exchange History Missing hours
    Binance BTCUSDT 2017-present (8.9y) ~2.0%
    Poloniex BTCUSDT 2015-present (11.4y) ~0.25%
    Bitstamp BTCUSD 2018-present (8.1y) ~0.29%
    Bitfinex BTCUSD 2018-2025 (7.4y) ~0.02%
    Gemini BTCUSD 2015-present (10.7y) ~0.01%
    FTX BTCUSD 2019-2022 only ~0.01% (but exchange is defunct, data stops in 2022)

    data.py also drops any row with a non-positive open/high/low/close, which filters out a one-off zero-price bootstrap artifact on Gemini's very first ever candle.

  2. Features: backward-looking technical indicators only, all normalized (percentages/ratios/z-scores) so they stay comparable across BTC's very different historical price regimes — SMA/EMA deviation, RSI, MACD, stochastic oscillator, rate of change, Bollinger Bands, ATR, ADX (trend strength), OBV and VWAP deviation (volume-based), and higher-timeframe (4h/1D) trend context reindexed back onto the hourly rows without leaking a still-forming higher-timeframe candle.

  3. Label: binary buy / not-buy via the triple-barrier method — a row is buy if price moves a symmetric, ATR-scaled distance up before it moves the same distance down within a bounded horizon. The barriers are deliberately symmetric: an asymmetric reward:risk ratio sounds appealing but mechanically biases the label itself, since the closer barrier gets touched by short-term noise far more often regardless of the true trend — an earlier version used a 2:1 ratio and ended up with 2x more sell than buy labels even through BTC's multi-year uptrend, producing thousands of noisy trades that wiped out the backtested account.

  4. Validation: walk-forward folds (expanding training window, consecutive out-of-sample test periods with an embargo gap) instead of one chronological split, so performance is checked across different market regimes (bull/bear/sideways) rather than one possibly-lucky period.

  5. Model: HistGradientBoostingClassifier (scikit-learn), predicting entries only.

  6. Exit: handled by a rule, not a model. A second classifier trying to predict sell performed far worse than the entry model (precision ~3-5%, near-random) — spotting an entry and timing an exit are different problems, and exits are the kind of thing risk management rules already do well. Positions are closed by whichever comes first of an ATR-scaled take-profit, stop-loss, or a maximum holding period.

  7. Backtest: a long-only simulation (next-bar-open entries, fees, bracket exits) run over the concatenated out-of-sample predictions from every walk-forward fold, giving one continuous equity curve that never used a model trained on future data, compared against simply buying and holding. An optional ADX regime filter can gate entries to only take signals when a trend is confirmed — it's off by default because it empirically made results worse (or at best mixed) here, contrary to the a priori assumption that it would help.

  8. Threshold tuning: model.predict(...) argmax isn't necessarily the best trade-off for a trading signal. A precision-recall curve over model.predict_proba(...) makes the "fewer, higher-conviction trades vs. more, noisier trades" trade-off explicit and tunable.

  9. Hyperparameters: min_samples_leaf raised from scikit-learn's default of 20, since the highly imbalanced buy class (~3.6% of rows) otherwise lets the model split on patterns backed by very few real examples. A broader sweep against the walk-forward backtest showed results were wildly non-monotonic for nearby values -- a sign of fitting this particular historical sample rather than a real effect -- so a moderate, round-number setting was picked deliberately over the single highest-scoring value found (this held on both the Binance and Gemini data).

Project layout

candlestick_classifier/   # library code
  data.py                 # download & cache OHLCV data
  features.py             # technical indicator feature engineering
  labeling.py             # triple-barrier buy/not-buy label generation
  modeling.py             # walk-forward splits, training, evaluation
  backtesting.py          # long-only bracket-exit strategy simulation and performance summary
notebooks/
  train_classifier.ipynb  # end-to-end pipeline: data -> features -> labels -> walk-forward -> backtest -> threshold tuning
data/raw/                 # local CSV cache (gitignored)

Setup

poetry install
poetry run jupyter notebook notebooks/train_classifier.ipynb

All parameters (data URL, indicator windows, barrier width/horizon, execution bracket, ADX filter, number of walk-forward folds, trading fee) are set in a single configuration cell at the top of the notebook — nothing is hardcoded further down in the pipeline.

Honest expectations

Out-of-sample across all walk-forward folds, on Gemini's longer, cleaner history (2015-present, spanning the 2017-18 bubble/crash that the earlier Binance-based run didn't cover): +189% total return over 415 trades (win rate 39%, Sharpe 0.57, max drawdown -48%), versus +1174% for simply buying and holding over the same ~11-year period. The strategy has a real, positive, risk-adjusted edge but does not beat buy-and-hold in absolute terms over the full period -- and on the most recent fold alone (2024-10 to 2026-06, a rough patch for BTC), both the strategy and buy-and-hold were slightly negative (-5.8% vs -2.9%).

The execution bracket (15x/5x ATR, 96h max hold) and min_samples_leaf (75) were re-tuned by sweeping the walk-forward backtest against this data, replacing values previously tuned on Binance's data -- those collapsed from +215% to +18% total return when carried over unchanged to Gemini's longer history, which is the expected consequence of tuning against one specific historical sample rather than evidence the new values are "more correct": the same overfitting risk, just demonstrated by swapping the sample instead of the parameter. A follow-up check that swept min_samples_leaf and learning_rate together found an even higher-scoring combination (+448%), deliberately not used here since searching two hyperparameters against the same backtest simultaneously has even more room to fit noise than tuning one at a time.

Two "obviously right" ideas were tested and empirically rejected rather than assumed: an ADX regime filter for entries (should filter out choppy markets — instead made results worse or at best mixed at every threshold tried, on both datasets) and pruning to the top permutation-importance features (should reduce noise — instead cut backtest return substantially on the Binance-based run). Both are documented in the notebook. Treat this as a methodologically sound base (correct labeling, leakage-free validation, realistic backtesting, on the highest-quality data available) to keep iterating on, not a finished trading system.

About

Here my TradingBot for crypto is unveiled. For any suggestions or hints, please keep me informed

Topics

Resources

Stars

0 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages