← Back to portfolio

cryptobot

An orchestrated crypto-trading framework on Binance, built as a sandbox for strategy ideas and architecture experiments. Not a commercial bot, not an attempt to print money — a research project about how to structure a system that trades, with real backtesting numbers behind every strategy.

Duration
~4 months
Commits
~78
Strategies
16
Backtest trades
2,000+
Role
Solo — personal project, no collaborators
Duration
Oct 2021 – Feb 2022 · ~78 commits
Status
Not production. Kept as a sandbox for strategy and architecture experimentation.

The brief

Most crypto bots are one-file scripts — fetch candle, check indicator, place order. They work for a weekend and then fall apart the moment you want to try a second strategy, run a backtest, or survive a process restart without losing the current position.

I wanted to see what a slightly-more-serious version looked like: an execution framework decoupled from the strategies that feed it, a backtesting harness driven by real historical data, persistent state so a crash didn’t cost me an open position, and enough instrumentation that I could quantify whether any of the strategies I was trying actually did anything. The trading results were a secondary outcome — the architecture was the point.

Architecture

The core is an Orchestrator that holds the runtime state and a reference to a pluggable Strategy. The orchestrator knows nothing about specific indicators — it only knows when to ask the strategy for a decision (on a closed candle, to consider entry) and when to ask if a trade should exit (on every price tick, against stop-loss / take-profit / trailing rules).

That separation keeps strategy code free of order-book mechanics, wallet state, and persistence concerns. A strategy is a subclass of SuperStrategy with four entry points: check_entry, open_position, exit_limits, and check_exit. Everything else — order placement, fee accounting, wallet updates, history tracking, Telegram notifications — lives in the orchestrator and the state layer.

The same orchestrator runs against a live Binance exchange or a BacktestExchange implementation that replays historical candles from Redis. Paper-trading mode sits in the middle. One code path, three execution modes.

Runtime architecture: Exchange → Orchestrator → Strategy / State / PersistenceLive BinanceWebSocket candles + ticksBacktestExchangeRedis-backed replayPaperBinanceSimulated fills, live feedOrchestratorfind_new_trades · position_checkStrategy (pluggable)check_entry · open_positionexit_limits · check_exitStatewallet · open positionsMAX_COINS guardPersistLayercrash-recovery snapshotsEXCHANGE SOURCESCORESTRATEGY · STATE · PERSISTENCE
Runtime layout. Exchange events feed into the Orchestrator, which consults a pluggable Strategy for entry and exit decisions and mutates the State layer (wallet + open positions). Every mutation flows through the Persistence layer so a restart hydrates back to a consistent snapshot. The same orchestrator core runs against a live Binance feed or a Redis-backed BacktestExchange.

The strategy layer

Sixteen strategy files, all inheriting from the same interface:

stupid_rsi_strategy
macd_strategy
macd_trail_strategy
macd_win_strategy
macd_adx_strategy
macd_adx_fractal_strategy
macd_sar_trail_strategy
macd_fractal_exit_strategy
macd_backed_volume_strategy
only_sar_strategy
sar_strategy
sar_adx_strategy
sar_atr_strategy
sar_fractal_strategy
spike_sar_strategy

Most of them are small variations on the same idea: combine an entry signal (MACD cross, RSI oversold, SAR flip, spike detection) with a different exit discipline (fixed TP/SL, trailing stop, Parabolic SAR, Fractal-based). The point wasn’t to find the one true signal — it was to make swapping one in or out trivial, and to quantify how each variation actually behaved under the same historical window.

Backtesting

Historical OHLCV candles are pre-loaded into Redis per contract. The BacktestExchange implementation replays them one at a time, triggering the same orchestrator callbacks a live WebSocket would: candle close to check entry, price ticks to check exit. The wallet, the persistence layer, and the state machine don’t know the difference.

At the end of each run the bot dumps a stats blob per strategy: total trades, profitable vs losing, win/loss ratio, average winner, average looser, max win, max loss, total fees, fees ratio, net profit, absolute net profit, average holding time. Thirteen measures per run. That’s what made it possible to compare strategies objectively instead of cherry-picking chart screenshots.

Actual backtest numbers

Two strategies, same $1,000 starting balance, same historical window. The numbers are straight out of the repo’s README.md:

Stupid RSI (baseline)
Total trades
2,042
Win rate
29.8%
Win / loss ratio
0.42
Net profit (after fees)
$164.89
Fees ratio
4.82%
Avg holding time
~70 hours
MACD Swing Low from Lows
Total trades
246
Win rate
52.4%
Win / loss ratio
1.10
Net profit (after fees)
$450.05
Fees ratio
0.89%
Avg holding time
~1,089 hours

Honest read on the numbers

The 45% net on the MACD-swing variant looks great until you read the fine print: 246 trades across four months is not a statistically meaningful sample, a single bull-market window is not a regime test, and the holding-time average sits in months — meaning most of that PnL is just being long crypto during an up-period. The baseline RSI strategy does something more honest: 2,042 trades, 4.8% fees ratio, and a 16% net that is almost entirely funded by the average winner being 3× the average loser, not by a higher win rate.

That’s the actual lesson I took from the project. The edge these strategies “found” was thinner than the fee structure in every case once you controlled for regime. None of this is news — it’s what anyone who has seriously backtested retail strategies concludes — but doing it yourself on your own framework with your own numbers is a different kind of knowing.

State and crash-recovery

Running a multi-pair bot live means the process will die — network hiccup, exchange timeout, Binance rate-limit, server reboot. The wallet state, the open positions, and the stats history all live in a PersistLayer so that on restart the orchestrator can hydrate: load open positions, reattach them to the in-memory state, reload the wallet’s running stats, and keep going without double-entering or forgetting a position it was already watching.

Max-coin exposure is enforced by the state (MAX_COINS) before a strategy is even asked for an entry signal — you can’t open a twenty-first position, no matter how bullish the indicator looks. Telegram notifications fire on fills, exits, wallet rollups, and uncaught exceptions via knockknock.

Looking back

Four years on, the architectural decisions still look right. Orchestrator / Strategy / State / Persistence is the separation I’d reach for again. Redis-as-backtest-store is overkill for the data volume (a directory of parquet files would be simpler), and some of the strategy files duplicate more than they should — a cleaner base class with composable exit rules would have cut the file count in half.

What I’d do differently now: treat fees and slippage as first-class in the backtester, test across regimes (not a single rising window), and resist the temptation to add the seventeenth strategy when the framework is already telling you the edge isn’t there.

Stack

Python 3.8+python-binanceRedispytestpandasTelegram Bot APIknockknockboto3