Skip to content
2025 · PYTHON · CCXT · PANDAS · PANDAS-TA · PYDANTIC

Daytrade

Momentum breakout bot with composable filters and risk controls

Problem

Systematic crypto day-trading is easy to implement badly and expensive to run badly. Most open-source bots I looked at chased a strategy (“I found the magic indicator combination”) and ignored the surrounding engineering: position sizing that doesn’t exceed the account, a kill switch that reads unrealized losses and not just closed-trade P&L, state that survives a mid-write crash without leaving phantom positions, and a backtest that can’t cheat by seeing tomorrow’s close. I wanted to build Daytrade to flip that ratio. The strategy was the replaceable component; the surrounding apparatus was the product. The non-obvious constraint was code-path identity between live trading and backtest: if the paper loop and the walk-forward backtest use different indicator math or different entry logic, a bug in either surface hides from the other, and the whole promotion pipeline becomes aspirational rather than binding.

Approach & Architecture

I built Daytrade as a single-process Python application composed through constructor injection at TradingBot startup. Each component implemented an ABC from interfaces.py (BaseStrategy, BaseBroker, BaseExchange, BaseFilter), so the orchestrator depended on interfaces, not implementations. The main loop polls every 20 seconds, pulls 5-minute OHLCV candles via DataFeed → ExchangeClient (a CCXT Gemini wrapper with 3-retry network handling), and dispatches through BreakoutStrategy → FilterChain → PositionSizer → PaperBroker.

The breakout strategy fires on a newly closed candle when close >= yesterday_high * (1 + BREAKOUT_BUFFER) and volume >= VOL_MULT * mean(prior 20 candles). Yesterday’s high is cached per UTC day; the 20-bar volume baseline uses .shift(1).rolling(20).mean() so the current bar is never in its own average. Exits are pre-computed at entry with TP_MULT=2.0 giving a 2R target. The same function body runs in the backtest via check_signal_on_df, sharing indicator math and entry logic, so any drift between live and backtest paths breaks a test.

Signal filtering ran through a composable FilterChain: TrendFilter rejected counter-trend breakouts when EMA-50 < EMA-200 and RSI(14) < 50, ChopFilter rejected ranging markets when ADX(14) < 20, and LiquidityFilter rejected wide bid-ask spreads. All three ran on every signal (no short-circuit), so every rejection got logged with its reason. Cheapest-first ordering put the cache-local reads (trend, chop) before the exchange round-trip (liquidity). NaN indicator values defaulted to pass so a fresh symbol with a half-warm ADX didn’t silently block entries.

Risk management was layered. PositionSizer computed qty = (equity * RISK_PER_TRADE) / stop_distance and clamped against get_available_capital(), which returned max(0, equity - allocated_capital). This closed a real over-allocation bug (AUDIT-02) where three concurrent positions could silently fund a fourth the account couldn’t support. DailyGuard read PaperBroker.get_equity_with_unrealized, walked every open position at the latest candle close, applied mark-to-market P&L, and compared against the -3% daily threshold. TradeCounter hard-capped trades per symbol per UTC day with a 3-consecutive-stop-out lockout.

State persistence used portalocker.Lock + .tmp + os.replace (atomic on POSIX and Windows) so state/bot_state.json could never be half-written. Reads retried 3 times on contention and logged CRITICAL if all failed. The same pattern wrote backtest promotion artifacts.

Tradeoffs

Composable filters added startup config complexity but made A/B-testing a config change rather than a redeploy. Conservative fills (assuming the stop filled first when both stop and take-profit were touched in the same candle) systematically underestimated paper performance, which was what I wanted. False confidence before live is strictly more dangerous than honest underperformance. os.replace atomicity added one fsync per state-changing event but eliminated half-written-state bugs. 5-minute candles reduced noise versus 1-minute at the cost of delayed entries; the filter chain was tuned assuming 5m.

Outcome

Daytrade ran as a single-process Python application polling 5-minute candles every 20 seconds across a configurable USD-pair universe on Gemini’s sandbox. The codebase sits at around 300 passing tests across unit, integration, slow, and live markers (strictly enforced via --strict-markers), with golden Parquet + CSV fixtures in tests/fixtures/ so indicator-math drift shows up as a diff. The walk-forward pipeline in backtest/ produced 180/60/30 day train/validation/test windows with a 200-bar pre-warm buffer, Optuna TPE optimization with plateau-centroid parameter picking and jitter-robustness checks, Monte Carlo permutation of returns, and a 7-gate promotion evaluator (Sharpe, max-drawdown, positive-weeks on both OOS and hold-out slices, plus a jitter pass-rate floor). The gate emits an atomic PROMOTION.json bound to git SHA, parameter hash, and SHA-256-per-symbol Parquet content hashes. The current shipped artifact at data/backtests/breakout/.../PROMOTION.json shows verdict: "FAILED" for the breakout strategy at Gemini’s 1.20% taker / 0.60% maker base-tier fees: five of seven gates failed. That is the pipeline doing exactly what it was built to do.

Learnings

The hardest lesson was that the backtest’s job is to refuse promotion, not to rubber-stamp it. My first instinct was to tune the gate thresholds until the breakout strategy passed. The correct move was the opposite: lock the gate floors with Pydantic ge/le validators so they couldn’t be relaxed through .env editing, and let a failing strategy fail visibly in the artifact. The deeper lesson was that hard-coded safety floors are worth their maintenance cost. Security-through-configuration only works when the configuration has teeth. A close second was atomic state persistence: my first version wrote directly to the state file, and one mid-write crash produced a corrupted JSON that fed a phantom position into the next startup. The temp-file-then-os.replace pattern with portalocker is three lines of extra code and eliminates a class of bugs I could not reason my way around.