A crypto research framework and the live trading system that came out of it. One strategy survived the validation stack and has been placing real orders on Binance mainnet since 2026-08-24, with capped capital, under a daily cron — with a public read-only dashboard showing every cycle it runs: trade-lab.gistrec.cloud.
The stack is built around layered honesty: IS Sharpe → OOS Sharpe → Deflated Sharpe at a pinned trial budget, each shown separately and never collapsed into a headline. The deployed config's honest DSR under the project's own conservative deflator is 0.037 ≈ 0 — the deploy case rests on a parameter plateau plus the forward test now running, not on a flattering number. See RESULTS.md.
Four layers, each with its own README:
| Layer | What it does | Where |
|---|---|---|
| Research | fetch → strategies → backtest → walk-forward → DSR → ensemble | this README |
| Forward harness | daily paper clock, frozen-config gate, vintage store, look-ahead detector, behavioural fingerprint monitor | paper_trading/README.md |
| Execution | live order placement on Binance: signal → allocator → delta plan → idempotent orders → journal | src/trade_lab/execution/README.md |
| Monitoring | read-only Streamlit dashboard + health endpoints + alerting | src/trade_lab/monitoring/README.md |
data (ccxt / Coin Metrics)
└─> basket index ──> signal (TSMOM ladder + SMA200 gate)
└─> allocator (drifted weights × equity)
└─> delta plan (min-notional, reserve cap)
└─> orders (deterministic clientOrderId)
└─> journal (JSONL, fsynced)
├─> dashboard + /healthz
└─> execution-vs-sim tracking
Real-money placement is gated by three separate environment flags and a rollout ladder that was walked step by step (testnet pipeline validation → mainnet read-only observation → capped smoke test → daily live cron). It is not a flag-flip.
What lives in this repo today:
-
Candle fetching from any ccxt-supported exchange, plus Coin Metrics community fetcher for historical market-cap and survivability data.
-
Long-only, vectorized backtests with symmetric fee + slippage costs (B&H benchmark gets billed the same entry round as strategies — see
findings/buy_and_hold_cost_symmetry.md). -
Eight bundled long-only strategies including TSMOM, P/MA ratio ensemble, Donchian trend, and SMA crossover — plus a
VolatilityTargetWrapperdecorator that bolts vol-targeting onto any of them. -
Cross-sectional momentum (
xsmom) with a curated point-in-time universe that explicitly includes LUNA / FTT / UST / etc. so survivorship bias can't quietly inflate results. -
Walk-forward runner with warmup-feed, optional purge gap, and per-fold + concatenated-OOS Deflated Sharpe Ratio (Bailey & López de Prado 2014).
PROJECT_NUM_TRIALS = 500is pinned in code with a census comment — it does not move retroactively. Convention: every DSR in this README is the minimal 1/sqrt(T) one, with a single stated exception — the deployed config's headline 0.037 at the top of this file is the conservative deflator. The two are never the same number; read "DSR convention" below before comparing any of them. -
A 21-sleeve equal-weight portfolio runner (3 strategies × 7 assets, per-asset vol-target picks, dynamic 1/N_active with rebalance-on- universe-change costing) that aggregates everything above into a single deployable artifact.
-
A live execution layer (
src/trade_lab/execution/) that turns the signal into real orders: drifted-weight allocator, min-notional delta planner, deterministicclientOrderIdidempotency, order-state reconstruction, wait-for-ack, and an fsynced JSONL journal. Built on ccxt, but deliberately Binance-only at runtime:VERIFIED_EXCHANGESgatesBroker.connect, because the idempotency parameters are venue-specific and an unverified venue must not be reached by accident. The exchange is the single source of truth — balances are never cached across cycles. -
A read-only monitoring dashboard (
src/trade_lab/monitoring/) plus health endpoints and Prometheus metrics for alerting. It never writes, never touches the exchange, and holds no credentials.
An earlier revision of this README said "paper / live execution is intentionally out of scope". That stopped being true on 2026-08-24.
python3 -m venv .venv
source .venv/bin/activate # the commands below assume this
scripts/install_runtime.sh .venv dev # constrained installscripts/install_runtime.sh is the single tracked install procedure: it
applies constraints.txt, so the versions you develop against are the
versions CI tests and the server trades on. Every deployment path — the
SSH helper, an ansible role, a hand-rebuilt box — goes through it.
Dependencies are declared with lower bounds only, so an unconstrained
pip install -e . resolves whatever shipped that day. On 2026-08-30
that had produced three simultaneous ccxt versions: one in the dev
venv where tests ran, another on the box placing real orders, a third
from a fresh resolve. Updating the pins is a deliberate commit that CI
runs the suite against before any deploy — see the header of
constraints.txt for the regeneration command.
Copy .env.example to .env and tweak defaults if desired.
The optional Streamlit dashboard has its own extras group:
pip install -e ".[dashboard]"# 1. Fetch a year of BTC/USDT 1h candles from Binance.
trade-lab fetch --since 2024-01-01
# 2. Backtest SMA crossover (fast=20, slow=100) against those candles.
trade-lab backtest --strategy sma_crossThe first command writes data/binance_BTC_USDT_1h.parquet, the second loads
it, prints metrics, and saves the equity curve to
outputs/sma_cross_BTC_USDT_1h.png.
trade-lab fetch \
--exchange binance \
--symbol BTC/USDT \
--timeframe 1h \
--since 2024-01-01 \
--until 2025-01-01Defaults: --exchange binance --symbol BTC/USDT --timeframe 1h. Output goes to
<TRADE_LAB_DATA_DIR>/<exchange>_<symbol>_<timeframe>.parquet. The fetcher
paginates automatically, deduplicates timestamps, and validates that the
resulting frame has columns open, high, low, close, volume indexed by a UTC
timestamp (the schema the rest of the toolkit expects). Pass --output PATH
to write somewhere else.
trade-lab backtest \
--strategy sma_cross \
--symbol BTC/USDT \
--timeframe 1h \
--initial-cash 10000 \
--fee-rate 0.001 \
--slippage 0.0005 \
--param fast_period=20 \
--param slow_period=100You can also point at a Parquet file directly:
trade-lab backtest --strategy sma_cross --input data/binance_BTC_USDT_1h.parquetRestrict the backtest to a sub-window with --start-date and --end-date
(both inclusive at the day level — --end-date 2024-06-30 keeps every bar
through 2024-06-30 23:59). This is how you carve train vs test splits
without refetching:
# Train window
trade-lab backtest --strategy sma_cross \
--start-date 2023-01-01 --end-date 2023-12-31 \
--param fast_period=20 --param slow_period=100
# Test window
trade-lab backtest --strategy sma_cross \
--start-date 2024-01-01 --end-date 2024-06-30 \
--param fast_period=20 --param slow_period=100The actual tested window is echoed in the report's Period: line along with
the bar count, so you can confirm the slice landed where you expected.
trade-lab yearly evaluates a fixed set of strategies on each
calendar year and lays out a comparison alongside buy-and-hold. No
parameter sweep, no tuning per year. Bundled strategies:
buy_and_hold, regime_only_200, regime_only_300,
sma_cross_20_100, regime_sma_cross_20_100_200, golden_cross_50_200.
trade-lab yearly --symbol BTC/USDT --timeframe 1d --output-csv outputs/yearly.csvReal 9-year aggregate on BTC/USDT 1d — full table and commentary in docs/results/yearly_btc.md.
trade-lab multi-asset runs the same fixed-parameter yearly validation
across several symbols. The point is to test whether behaviour you saw
on BTC generalizes — or whether you got lucky on one ticker.
trade-lab multi-asset --symbols BTC/USDT,ETH/USDT,BNB/USDT,SOL/USDT \
--timeframe 1d --output-csv outputs/multi_asset.csvThree CSVs are produced: per-(asset, year, strategy) detail, per-(asset, strategy) aggregate, and an across-asset summary. Real 4-asset / 34-year results in docs/results/multi_asset.md.
trade-lab walk-forward cuts the dataset into rolling train / test
windows (default: 2-year train, 1-year test, step 1 year), runs a
sweep on each train slice, picks the best (strategy, params)
candidate from train only, and evaluates that pair on the immediately
following test slice.
trade-lab walk-forward --symbol BTC/USDT --timeframe 1d \
--strategies sma_cross,regime_sma_cross \
--fast-periods 5,10,20,30 --slow-periods 50,100,150,200 \
--regime-periods 100,200,300 \
--objective total_return \
--train-years 2 --test-years 1 --step-years 1 \
--output-csv outputs/walk_forward.csv--strategies picks which SMA-family candidates enter the train sweep.
--objective is total_return (default) or return_div_drawdown for
risk-adjusted picks. Real 7-window BTC/USDT 1d run, plus what the
parameter instability tells us, in
docs/results/walk_forward_btc.md.
trade-lab sweep grid-searches SMA crossover parameters across a date range
and writes one row per combination to CSV. Invalid combos
(fast_period >= slow_period) are skipped automatically.
trade-lab sweep \
--strategy sma_cross \
--symbol BTC/USDT \
--timeframe 1h \
--fast-periods 5,10,20,30 \
--slow-periods 50,100,150,200 \
--start-date 2024-01-01 \
--end-date 2024-06-30 \
--output-csv outputs/sweep.csvThe console prints a sorted table (highest total return on top) and saves the full result with raw numeric values to the CSV. This is research only — picking "the best" parameters from a sweep is a notorious way to overfit. Use it to compare neighborhoods and to look for stable regions, not to choose live settings.
A local-only dashboard for poking at a single backtest visually:
pip install -e ".[dashboard]"
streamlit run src/trade_lab/dashboard/app.pySidebar controls: candle file path, strategy + parameters
(sma_cross or rsi), initial cash / fee / slippage / position size,
and an optional date range.
The main panel has metric cards at the top (with a "vs B&H" delta on total return), context-aware warnings when the strategy underperforms buy & hold, has a worse drawdown than buy & hold, or trades too often, followed by five tabs:
- Overview — run summary + metrics CSV download.
- Price & Trades — Plotly price chart with buy / sell markers on execution candles.
- Equity — strategy equity vs buy-and-hold + equity CSV download.
- Drawdown — underwater chart.
- Trades — trade list with a CSV download.
The dashboard reuses the regular backtest engine, strategies, and metrics — there is no duplicate logic. Candles are cached by file path; the backtest itself runs fresh on every interaction since parameters change too often to memoize safely. The dashboard is local-only by design: no auth, no execution, no live trading.
Typical workflow:
trade-lab fetch --since 2024-01-01 # pull candles once
streamlit run src/trade_lab/dashboard/app.py # explore parametersSample output:
Strategy: sma_cross
Symbol/timeframe: BTC/USDT 1h
Period: 2024-01-01 00:00 to 2024-01-15 23:00
Bars: 400
Initial cash: $10,000.00
Strategy
Final equity: $13,533.00
Total return: 35.33%
Max drawdown: 3.70%
Number of trades: 1
Win rate: 100.00%
Average trade: 35.33%
Fees paid: $10.00
Buy & hold
Final equity: $15,026.00
Total return: 50.26%
Max drawdown: 4.81%
Plot saved to outputs/sma_cross_BTC_USDT_1h.png
The plot shows the strategy equity curve and the buy-and-hold equity curve on
the same axes, with a strategy-only drawdown panel underneath. It is saved
under outputs/ by default; pass --save-plot PATH to override,
--show-plot to also display it interactively, or --no-plot to skip
plotting entirely.
Pass --show-trades to add a price panel above the equity panel with buy and
sell markers:
trade-lab backtest --strategy sma_cross --show-tradesMarkers are placed on the execution candle (the bar where the engine
actually holds the position), not the signal candle. This matters for
look-ahead checks: signal at bar N is shifted into a position at bar N+1,
so the green ▲ should sit on the bar after the cross — never on the bar that
produced the cross itself. If you ever see a marker on a candle whose close
caused the signal, that's the smell of look-ahead bias.
Note: Trade.entry_time in the engine's result records the signal bar (the
close where the decision was made); the marker derived from
execution_bars(positions) records the bar after — they're intentionally
one bar apart.
Pass --trades-csv PATH to dump the trade list:
trade-lab backtest --strategy sma_cross --trades-csv outputs/trades.csvThe CSV has one row per completed trade with these columns:
| Column | Meaning |
|---|---|
entry_time |
Timestamp of the execution candle (one bar after the signal candle). |
entry_price |
close[entry_bar] * (1 + slippage_rate) — the slippage-adjusted price the strategy effectively paid. |
exit_time |
Timestamp of the execution candle for the exit. |
exit_price |
close[exit_bar] * (1 - slippage_rate) — what the strategy effectively received on the sell. |
gross_return_pct |
Raw close-to-close return between execution bars (no fees, no slippage). |
net_return_pct |
Net return from the strategy equity curve. Includes fees AND slippage. |
fees_paid |
Dollar exchange fees for the round trip. Excludes slippage (it's already in entry/exit prices). |
holding_period |
Number of bars the position was actually held. |
pnl |
Dollar P&L (equity_at_exit - equity_just_before_entry). |
Sample row from a real run:
entry_time,entry_price,exit_time,exit_price,gross_return_pct,net_return_pct,fees_paid,holding_period,pnl
2024-01-02 06:00:00+00:00,97.744,2024-01-03 04:00:00+00:00,100.864,0.03295,0.01211,20.14,22,121.12
Open positions at the end of the window are excluded from the CSV (the
CLI prints "excluded N open position(s)" so you know). For programmatic use
that needs them, call trades_to_dataframe(result, candles, include_open=True)
— that adds an is_open column.
| Metric | What it means |
|---|---|
| Initial cash | Starting equity. Fed by --initial-cash (env: TRADE_LAB_INITIAL_CAPITAL). |
| Final equity | Equity at the last bar after fees and slippage. |
| Total return | (final_equity / initial_cash) - 1, expressed as a percentage. Net of fees and slippage. |
| Max drawdown | Worst peak-to-trough decline of the equity curve, reported as a positive percentage. |
| Number of trades | Round-trip long trades. An open trade at the end of the window is closed at the last bar. |
| Win rate | Share of trades with a positive net P/L. |
| Average trade | Mean net return per trade (compounded fees/slippage included). |
| Fees paid | Cumulative dollar fees paid across all rebalances. Slippage is not included here. |
The Buy & hold block reports the same starting cash parked in the asset at the first bar and held to the last bar (no fees). It produces three figures — final equity, total return, and max drawdown — so you can see whether the strategy actually beat just holding the asset, and how much of the peak equity it gave back relative to a passive position.
The CLI also saves an equity curve plot under outputs/ showing strategy and
buy-and-hold equity on the same axes, with a strategy-only underwater panel
underneath — useful for eyeballing whether outperformance is real or whether
the strategy is just along for the ride.
- Long-only, single asset, no leverage.
- Signals generated on bar
Nexecute against barN+1— signals are shifted by one bar before computing returns, so a strategy cannot peek at the close it just observed. - Fees and slippage are charged on every change in exposure, proportional to the size of the change.
position_size(default1.0) scales exposure:0.5means we deploy half of equity per long trade.
Every backtest carries two explicit per-side costs:
- Fee (
--fee-rate, default0.001= 0.1%) — what the exchange charges. Charged on every buy and every sell. - Slippage (
--slippage, default0.0005= 0.05%) — the gap between the close price and the price you actually trade at. Buys fill atclose * (1 + slippage_rate)(you pay more), sells atclose * (1 - slippage_rate)(you receive less).
The report makes the round trip explicit:
Cost model
Buy cost (fee + slip): 0.15%
Sell cost (fee + slip): 0.15%
Round-trip cost: 0.30%
A trade has to clear the round-trip cost just to break even, so 0.30% per trade is the floor for "real" profitability with the defaults.
Exchanges typically charge two different fee tiers:
- Maker fees apply when you add liquidity to the order book (limit orders that rest before being filled). They're lower — sometimes zero or even negative as a rebate.
- Taker fees apply when you remove liquidity (market orders, or limit orders that match immediately). They're higher.
trade-lab models market orders at the close of the execution bar (the
simplest realistic model for a vectorized backtest). Market orders pay the
taker fee, so set --fee-rate to the taker tier of your venue. Binance
spot's standard taker fee is 0.10% at the time of writing — hence the default.
Even a single round-trip trade is two transactions. The exchange charges on each side; slippage hurts you on each side too. If you only charged costs on the entry (or only on the exit), the equity curve would look about half a percent more optimistic than reality per round-trip — enough to make a marginal strategy look profitable when it isn't.
trade-lab backtest --strategy sma_cross --fee-rate 0.001 --slippage 0.0005from pathlib import Path
from trade_lab.backtest.engine import run_backtest
from trade_lab.backtest.metrics import compute_metrics
from trade_lab.data.storage import load_candles
from trade_lab.strategies.sma_cross import SMACrossStrategy
candles = load_candles(Path("data"), "binance", "BTC/USDT", "1h")
result = run_backtest(
candles=candles,
strategy=SMACrossStrategy(fast_period=20, slow_period=100),
initial_capital=10_000,
fee_rate=0.001,
slippage_rate=0.0005,
)
print(compute_metrics(result))- Create
src/trade_lab/strategies/my_strategy.py. - Subclass
Strategyand implementgenerate_signals(self, candles). It must return a0/1target-positionpd.Seriesaligned withcandles. - Register the class in
STRATEGIESinsrc/trade_lab/cli.pyto make it selectable via--strategy my_strategy.
Minimal example:
import pandas as pd
from trade_lab.strategies.base import Strategy
class BuyAndHold(Strategy):
name = "buy_and_hold"
def generate_signals(self, candles: pd.DataFrame) -> pd.Series:
return pd.Series(1, index=candles.index, dtype=int)The engine handles look-ahead protection, fees, slippage, and trade extraction — your strategy only needs to express the target position.
src/trade_lab/
config.py Environment-driven configuration + the pinned
PRODUCTION_CONFIG and its canonical hash.
data/ ccxt fetcher, parquet storage, Coin Metrics
community client, curated coin registry, PIT
universe builder.
strategies/ Strategy base class, 8 bundled strategies,
VolatilityTargetWrapper decorator.
backtest/ engine, metrics, plotting, reports,
sweep, compare, multi_asset, yearly,
cross_sectional (XSMOM), dsr (Deflated
Sharpe), walk_forward + walk_forward_v2
(generic), ensemble (portfolio runner),
market_index (the deployed basket).
paper_trading/ Forward harness: daily paper clock with a
frozen-config gate, hash-addressed vintage
store, look-ahead detector, behavioural
fingerprint + its live monitor, and the
execution-vs-simulation tracking layer.
execution/ LIVE order path: config (three-flag mainnet
gate), broker (ccxt), signal, allocator,
delta, orders (deterministic clientOrderId),
order_state, journal, live_cycle, dry_run,
db_mirror.
monitoring/ Read-only Streamlit dashboard + data_source.
No writes, no exchange, no credentials.
dashboard/ Local backtest inspector (research tool).
risk/ Position sizing helpers.
cli.py argparse entry point.
ops/ pm2 ecosystems, health apps, netdata alarm
definitions, static assets.
pytest~1400 tests across the backtest engine, strategies, walk-forward, DSR,
the execution path (mocked exchange — never the live API), the forward
harness, and the monitoring layer. CI runs the suite on a Python
3.11 / 3.12 / 3.13 matrix with --cov-fail-under=70, plus a ruff
lint job.
Execution tests include deliberately defensive stubs — fakes that omit dangerous methods so an accidental live call raises immediately rather than reaching an exchange.
Each bundled strategy has a dedicated doc with exact rules, defaults, example CLI invocations, and any per-strategy results worth pinning:
sma_cross— simple fast-vs-slow SMA crossover; the trend-following baseline.regime_sma_cross— crossover gated by a long-term regime SMA.regime_only— pure long-or-flat regime filter, no crossover.donchian_trend— Donchian breakout ensemble + SMA filter + volatility targeting.tsmom— multi-lookback time-series momentum (Moskowitz et al. 2012, Liu & Tsyvinski 2021).pma_ratio— price-to-MA ratio ensemble (Detzel et al. 2021).cross_sectional_momentum— multi-asset top-N rotation (Liu, Tsyvinski, Wu 2022). Lives inbacktest/cross_sectional.pybecause it spans the panel.rsi— RSI mean reversion (contrast baseline, not a recommendation).
Plus a generic decorator:
VolatilityTargetWrapper— wraps any of the above with a Moreira-Muir styletarget_vol / realized_volposition scaler. Use deliberately: seefindings/vol_targeting_regime_gate.mdfor the per-asset Sharpe/Calmar tradeoffs (vol-targeting helps ETH and most alts on Sharpe, hurts BTC on Calmar — decision is per-asset, not universal).
For a side-by-side comparison of the trend strategies above on the BTC/ETH/BNB/SOL panel, see Strategy comparison.
For the layered out-of-sample validation done after that comparison —
PIT cross-sectional universe, walk-forward of priority-5 strategies,
volatility-targeting cross-asset, Deflated Sharpe in walk-forward,
and the buy-and-hold cost-symmetry audit — see the docs/results/
and findings/ directories:
- PIT XSMOM universe — survivorship-bias delta on cross-sectional momentum (Sharpe 1.40 → 0.93 once delisted pairs are included point-in-time).
- Walk-forward priority-5 — IS→OOS Sharpe shrinkage 0.55-0.82 across SMA / TSMOM / PMA.
- Vol-targeting wrapper — asset-conditional Sharpe / Calmar tradeoff on BTC vs ETH.
- DSR in walk-forward — 0/21 strategy×asset cells survive DSR > 0.5 at project N=500.
- Vol-targeting × regime gate (findings) — 7-asset confirmation: per-asset vol-target decision required.
- B&H cost symmetry (findings) — bug audit: B&H was getting a ~0.15% free head-start, now fixed.
- Multi-asset ensemble portfolio (findings) —
21-sleeve equal-weight portfolio Sharpe +1.13 / DSR 0.425, below
best single sleeve (
pma_medium × BNBat DSR 0.564). Diversification cuts max DD in half but does not lift statistical confidence.
Adding a new strategy is a five-minute job — subclass Strategy,
implement generate_signals(candles), register the class in
STRATEGIES in src/trade_lab/cli.py. See
Adding a new strategy below.
Every DSR figure on this page is a minimal 1/sqrt(T)-convention
number at PROJECT_NUM_TRIALS = 500 unless it is explicitly marked
conservative — the deployed config's 0.037 below is the one such
figure. Under the minimal convention the trial-pool dispersion is set
to the sampling noise of a single Sharpe estimate; that is the
convention this project's deploy gate ("DSR > 0.5, cluster-stable") is
defined on.
The linked docs/results/ writeups are NOT uniformly that convention —
each states its own. In particular the per-fold train DSR of
dsr_in_walk_forward.md is a third one: num_trials = len(grid) with
sharpe_std_dev = the observed spread of that fold's own grid, which
answers "is this fold's winner robust among its siblings?" rather than
"does it survive the project's whole search budget?". Check the
convention in the writeup before comparing its numbers with anything
here.
Since the 2026-08-25 owner decision the project reports the deployed
config on a conservative deflator instead — the pinned assumption
sd_trial_sharpes ≈ 0.7 annualized, 0.7/sqrt(365) ≈ 0.0366
per-period — under which the deployed TSMOM (28, 60) basket config
scores DSR 0.037 ≈ 0, and every other figure on this page would
read ≈ 0 as well. Nothing was re-run and no threshold was restated;
only the deployed config has ever been recomputed conservatively, so
treat the numbers above as gate-convention figures and never mix the
two. Full statement: RESULTS.md and
findings/dsr_convention_2026_08.md.
Completed in earlier rounds (see docs/results/ and findings/):
- Multi-asset portfolios and rebalancing —
backtest/ensemble.pywith dynamic equal-weight + rebalance-on-universe-change costing. - Walk-forward + parameter optimization —
backtest/walk_forward_v2.pywith per-fold DSR and concatenated-OOS DSR. - Survivorship-free cross-sectional universe —
data/universe.pywith Coin Metrics community fetcher and curated delisting registry. - Symmetric cost model for benchmarks —
engine.buy_and_hold_with_costs. - Paper trading via a
Brokerinterface —execution/broker.py, ccxt-backed, validated on Binance testnet. - Forward harness with a frozen-config gate, vintage store and look-ahead
detector —
paper_trading/. - Live execution —
execution/live_cycle.py, placing real orders on Binance mainnet daily since 2026-08-24 behind a three-flag gate. - Monitoring, health endpoints and alerting —
monitoring/,ops/.
Open:
- Basket composition is an ex-post choice: the survivorship /
composition axis is still untested (
findings/validation_universe_bias.md). - Inverse-vol and risk-parity allocators as alternatives to equal-weight.
- Higher-frequency timeframes (4h, 1h) to widen the OOS sample. Tests with 1d data sit at "marginal" DSR for the best single sleeve only; more bars per asset should help.
- Short selling and leverage are deliberately out of scope for the spot-only research mandate.
