Tinkerings

Predicting Cryptocurrency Movements with Machine Learning

Date Published

## Situation


I wanted to predict short-term price movements for cryptocurrencies — Monero, Ethereum, Bitcoin — and equities like the S&P 500. The goal was not to build a trading system, but to see whether a machine learning model could extract a real signal from historical price data, and whether that signal could survive an honest evaluation.


## Task


Build a set of predictive models across multiple asset classes, using best practices for time-series evaluation: avoid lookahead bias, use walk-forward validation rather than a single holdout, and separate model confidence from trade execution.


## Action


**1. Shift-before-roll in feature construction.**

When building rolling features from the target, I shifted the series before applying the rolling window: `sp500.shift(1).rolling(horizon).sum()["Target"]`. This prevents the current day's target from appearing in its own feature — the most common lookahead bug in financial feature engineering.


**2. Wilder's RSI with the correct smoothing constant.**

The RSI calculation uses `u.ewm(com=period-1, adjust=False).mean()` — the Wilder smoothing (α = 1/period). Most implementations use a plain rolling mean, which drifts from the standard definition.


**3. Signal shift in the backtest.**

`Strategy Returns = Market Returns * signal_pred.shift(1)`. You cannot earn today's return on today's signal. One line, and it separates a real backtest from a fantasy.


**4. Probability threshold rather than argmax.**

`preds[preds >= .6] = 1`. The model is allowed to abstain when it is not confident. Most people take the highest-probability class and trade every bar; this version stays flat unless the signal is strong.


**5. Neutralising small moves in the target.**

`y[y.between(-.004, .004)] = 0`. Rather than labelling every move as up or down, moves smaller than 0.4% are treated as noise. That is the difference between training on structure and training on noise.


**6. Consistent NaN masking across X and y.**

`isnan = np.isnan(y); X = X[~isnan]; y = y[~isnan]`. The same mask is applied to both arrays. Easy to get wrong, and it keeps the design matrix aligned with the target.


**7. Early stopping with a real evaluation set.**

`early_stopping_rounds=50` with an explicit `eval_set`. Correct XGBoost API usage. Combined with `max_depth=3`, `learning_rate=0.01`, and `n_estimators=1000`, this is the textbook slow-learner configuration for time series — a deliberate choice, not a default.


**8. Defensive copying in walk-forward slices.**

`.copy()` on the train and test slices inside the loop. Prevents `SettingWithCopyWarning` and keeps the source frame untouched.


**9. Time-based trailing stop.**

`if current_time - trade.entry_time > pd.Timedelta('2 days'): trade.sl = max(trade.sl, low)`. A real trading primitive: tighten the stop on trades that have been open too long. Rarely seen in hobby code.


**10. Minimal-surface inheritance for walk-forward retraining.**

`MLWalkForwardStrategy` overrides only `next()`, retraining every 20 bars and otherwise calling `super().next()`. Clean inheritance with a single, well-defined point of variation.


**11. Position check before ordering.**

`if forecast == 1 and not self.position.is_long`. Prevents stacking duplicate longs. A small guard that reflects how the strategy actually operates.


**12. Error analysis by date.**

`groupby('date')['error'].mean().sort_values(ascending=False).head(10)`. The diagnostic asks *which days* the model failed worst, not just what the aggregate RMSE was. That is a diagnostic habit, not a metric reflex.


**13. Pre-allocated forecast indicator.**

`np.repeat(np.nan, len(self.data))`, filled in as bars progress. The plot shows forecast only where it exists. Library-idiomatic and visually honest.


**14. Full ranked GridSearchCV output.**

Rank, mean, standard deviation, and parameters printed for every combination. That is the full picture, not just `best_params_`.


**15. `price_delta` as a class constant.**

No magic number buried in the logic. The 0.4% threshold is declared once, at the top, and referenced by name.


## Result


The models produced a working predictive pipeline across equities and cryptocurrencies, with an evaluation methodology that avoids the most common traps in financial machine learning: lookahead bias, single-split validation, and treating classification accuracy as a trading objective. The walk-forward backtest generated a trajectory of performance across regimes rather than a single score, and the strategy layer demonstrated that signal generation and trade execution are separate concerns. The code is a deliberate body of work, not a collection of tutorials — and the specific decisions above are the ones that make it hold together.