← All work

Personal project · code private, write-up public · 2025 — 2026

TradingBot

A day-trading bot for the Dow Jones CFD running live against MetaTrader 5. The trading rule is deliberately simple. The work is in the model that decides which breakouts are worth taking, and in the discipline that stops that model from fooling itself.

Validatedwalk-forward across 5 expanding windows · bootstrapped lift over baseline
  • Python
  • XGBoost
  • LightGBM
  • scikit-learn
  • Optuna
  • SHAP
  • pandas
  • NumPy
  • PyArrow
  • MetaTrader 5 API
  • pydantic
  • CustomTkinter
  • pytest

The problem, stated as classification

A raw opening-range breakout on US30 is barely profitable. Backtested honestly with a realistic ECN spread it sits near break-even, around a 35% win rate at two-to-one. There is an edge in there, buried in noise.

So the task is not predicting the market. It is: given everything observable at the moment a breakout fires, estimate the probability that this specific trade reaches +2R before −1R, and take only the trades whose calibrated probability clears a learned threshold. Framed that way it becomes measurable — the model is judged on calibration and expected R per trade, not on accuracy, and the decision to deploy comes from out-of-sample expectancy rather than a good-looking backtest.

Features that could have been known at the time

The training set comes from replaying the strategy bar by bar across eight years of five-minute data and labelling each trade by what actually happened. Every trade is described by 67 features, all computed causally from information available at entry: the geometry of the setup, multi-timeframe context joined by an as-of merge against the last closed bar on each timeframe, and session and calendar position.

Features that looked predictive but carried no real information — raw price levels, CFD tick volume — were dropped after analysis rather than kept because they improved a number.

Closing the ways this silently breaks

Most machine learning applied to trading fails because the evaluation leaks, not because the model is weak. The pipeline is built around that:

Time-ordered splits, never random
— train, calibration and test are contiguous in time, so the model is always tested on its own future.
Purged cross-validation with a gap and an embargo
so a trade whose daily-timeframe lookback overlaps the next segment cannot leak its label across the boundary.
The threshold is chosen on calibration and reported on test
. Picking the operating point on the same data you report is the most common way a backtest gets inflated.
A feature-parity test as the deploy gate
— a golden-vector test asserts the live feature builder produces the same vectors as the training pipeline, to float tolerance. It caught a real look-ahead bug where the two disagreed.

Making the probabilities mean something

The filter is an ensemble of gradient-boosted trees, XGBoost and LightGBM, each tuned by Optuna over 200 trials optimising the Brier score rather than accuracy — because the strategy depends on the probabilities being honest, not merely well-ranked. Raw scores then pass through isotonic regression fitted on the held-out calibration set, so a 0.6 really does mean roughly a 60% win rate, and that claim is checked with reliability diagrams and log loss.

SHAP values rank what the model is leaning on, and it is retrained on the top 70% of features to confirm the edge is not resting on a few fragile inputs. Risk of ruin is modelled separately: bootstrap intervals on win rate and average R, and a Monte-Carlo trade shuffle to estimate the drawdown distribution and the chance of breaching a prop-firm limit at a given risk per trade.

The test that killed the better-looking model

A filter that merely takes fewer trades can look good by luck. So the pipeline compares the average R of ML-selected trades against taking every rule-filtered signal, and bootstraps the difference over 5,000 resamples. The model ships only if the lower bound of that interval stays above zero.

That test rejected a variant I wanted to keep. A one-to-one configuration produced a much prettier 63% win rate — but its confidence interval for expectancy included zero and one walk-forward fold went negative. The two-to-one configuration, with a worse-looking win rate, held up in every fold. The prettier number lost.

Live, and the brakes on it

At the New York open the bot builds the fifteen-minute range, takes the first five-minute close beyond it, filters weak setups by rule, then scores the survivor and trades only above threshold. Stop at the volume-profile point of control, target at 2R, one re-entry allowed after a stop, flat by the end of the session.

Risk is fixed at 1% per trade, halved after two consecutive losses, behind a daily-loss breaker and a maximum-drawdown breaker. Breaker state is persisted, so a restart in the middle of a bad session does not hand the bot a clean slate it has not earned.