Python¶
The fastmm package runs strategies written in Python inside the C++ engine: in backtests, in replays of their journals and, with fastmm-engine-live, against venues. It also backtests the C++ strategies. Install: pip install "fastmm-engine[hot]", plus the live extra for venues (Install).
What runs where¶
A strategy class uses one of two styles. Hot hooks (@fastmm.hot) are compiled with Numba and called by the engine thread without the GIL; slow methods beside them are plain Python on another thread and change the hooks' parameters. Plain hooks are fastmm.Strategy methods without @fastmm.hot, called by the engine thread with the GIL held.
| Style | Backtest (run_backtest) |
Replay (fastmm.replay) |
Live (run_live, python -m fastmm run) |
|---|---|---|---|
| hot hooks | yes | yes | yes |
slow methods beside hot hooks (on_start, on_stop, @fastmm.every) |
yes | no; the recorded parameter updates are applied | yes |
| plain hooks | yes | no | no |
First strategy¶
fastmm init my-mm writes a starter project: a config for the simulated market, a strategy with hot hooks, a backtest that runs it, a config that trades it live against the simulated exchange fastmm sim runs, and one that runs it on Binance Spot Demo Mode (Install).
Write it as hot hooks, the only style that trades live:
- Write hot hooks in Python: parameters, hooks and a backtest.
- Run slow methods beside hot hooks: a model in plain Python that publishes parameters.
- Run a Python strategy live: the simulated exchange, then a venue.
examples/python/strategies/basic_mm_hot.py ports the C++ basic_mm to hot hooks with the same outbound hash, and hot_slow_mm.py adds a slow model:
Reference: Python strategy API.
Running a backtest¶
import fastmm
cfg = fastmm.BacktestConfig.from_toml("configs/backtest-example.toml")
cfg.seed = 7
cfg.duration_s = 300
cfg.fill_model = "matching" # or "l2_queue"
cfg.set_param("half_spread_bps", 0.02) # str / int / float / bool
r = fastmm.run_backtest(cfg, data="synthetic")
print(r.summary_table())
r.stats()["net_pnl"], r.outbound_sha256
frames = r.to_pandas() # {"fills", "equity", "orders", "markouts"} DataFrames
from_toml raises ConfigError for an unknown key or section, with its line, and issues a UserWarning for each other warning, listed in cfg.warnings. BacktestConfig.single_instrument("BTCUSDT", tick="0.01", lot="0.00001") builds a config by hand. Other fields: strategy, params, engine_seed, start_ns, equity_bar_s, initial_capital, queue_conservatism, latency_fixed_us, latency_jitter_us, latency_md_us, latency_md_jitter_us, p_drop, maker_fee_bps, taker_fee_bps, markout_horizons_s, supports_replace, start_mid, limit_rate_per_s, market_rate_per_s, mid_step_rate_per_s, cancel_rate_per_order_s, max_param_age_ms, source, path, output_dir, journal_out, measure_wall_clock.
Data¶
data= |
source |
|---|---|
None |
the config's [backtest] source / path |
"synthetic" |
the seeded synthetic market |
"x.fmj" / "x.csv" (str or os.PathLike) |
journal / CSV (ts_ns,type,inst,side,price,qty,seq) |
"binance:BTCUSDT,2024-03-27" |
a registered source and its options; fastmm.data_sources() lists them (Market-data sources) |
dict of numpy arrays |
ArraySource, zero copy |
fastmm.data.binance.fetch(symbol, dates) and fastmm.data.tardis.fetch(exchange, symbol, dates) download public archives into the cache, python3 -m fastmm.data fetch does it from a shell, and fastmm.convert_data(spec, out, config) packs a source into an .fmj for faster repeated replays (Backtest on real BTCUSDT data).
Array columns: ts int64 (ns), type uint8 (0 snapshot level, 1 delta level, 2 trade, 3 book ticker), inst uint32, side int8 (0 bid/buy, 1 ask/sell), price and qty int64 (raw 1e-8) or float64, optional seq uint64. Dtypes must match exactly and arrays must be 1-D, C-contiguous, aligned and native-endian: a float32 column raises TypeError and a strided slice raises ValueError instead of being copied. fastmm.load_csv(path) reads a CSV into int64 columns (identical outbound hash to running the file by path).
The GIL is released while a C++ strategy or hot hooks run, so several runs can proceed in Python threads. A strategy with plain hooks holds the GIL for its whole run.
Results¶
r.fills, r.equity and r.orders are dicts of read-only numpy views over the C++ result vectors (no copy; the arrays keep the result alive). Prices, quantities, fees and PnL are raw int64 with a 1e-8 scale (fastmm.FIXED_SCALE); timestamps are int64 ns. to_pandas() converts to floats and datetime64[ns]. stats() returns the summary metrics; sharpe_annualized is NaN for runs shorter than 1 day and max_drawdown_pct is NaN without initial_capital ([backtest]). When the kill switch stopped the run, kill_reason names why (MaxLoss for [risk] max_loss) and kill_at_s when, in seconds of simulated time; both are None otherwise, and the summary table opens with a stopped line. engine_stats() and transport_stats() return the component counters; write_all(dir) writes the same CSV/JSON files as fastmm-backtest.
fastmm.write_report(r, "report.html") writes the run as one self-contained HTML page, and fastmm report <run-dir | journal.fmj> does the same from a shell (Run report).
markouts() returns one dict per horizon of cfg.markout_horizons_s with the buckets total, buy, sell, maker, taker and instrument, each holding markout, capture and adverse_selection in quote currency and in bps of notional, plus excluded_past_end and excluded_no_mid for the fills that could not be marked. fastmm.markout_frame(result) puts the same rows in a DataFrame, and stats() carries the PnL decomposition (spread_capture, mid_drift, fees_paid, rebates_received, decomposition_net, decomposition_residual) and the fill-quality diagnostics. r.fills gains best_bid, best_ask, queue_ahead (-1 when the fill model has no queue position) and one markout_mid_<ns>ns column per horizon, 0 where the fill could not be marked (Backtesting).
Sweeps¶
points = fastmm.sweep(cfg, {"half_spread_bps": [0.01, 0.02], "skew_bps_per_unit": [0, 0.01]},
data="synthetic", threads=0)
df = fastmm.sweep_frame(points) # one row per grid point
Points run on a C++ thread pool (GIL released), every worker with its own cursor over data; results come back in grid order with the first parameter varying slowest.
Walk-forward¶
The best point of a sweep is the luckiest one on that data. fastmm.walk_forward scores it on data it was not chosen on:
cfg = fastmm.BacktestConfig.from_toml("configs/backtest-example.toml") # fill_model l2_queue
cfg.duration_s = 240
cfg.maker_fee_bps = 0.0
grid = {"half_spread_bps": [0.005, 0.01, 0.02, 0.04], "skew_bps_per_unit": [0.0, 0.01]}
rep = fastmm.walk_forward(cfg, grid, folds=4, data="synthetic", metric="net_pnl")
print(rep["table"])
The event-time range of data is cut into folds equal consecutive slices and the whole grid runs on each slice, every run a fresh backtest from a flat engine. A slice starts on the book the full stream had at its first instant (earlier events are read to build it, not traded) and ends at the next slice. For fold i ≥ 1, the best point of fold i−1 by metric (net_pnl, realized_pnl, sharpe_bar or spread_captured_bps; ties go to the earlier grid point) is the choice, scored on fold i. The choice uses fold i−1 alone rather than every earlier fold: the grid already ran there, the rule works for metrics that do not add up across folds (a Sharpe ratio), and it is what re-running the sweep on the latest data before trading does.
walk-forward: 4 folds, 8 points, metric net_pnl, chosen on the previous fold
fold time_s chosen in_sample out_of_sample best hindsight
0 0.000-60.000 - - - #1 0.016073
1 60.000-120.000 #1 0.016073 0.139701 #2 0.184945
2 120.000-180.000 #2 0.184945 0.163094 #2 0.163094
3 180.000-240.000 #2 0.163094 0.0297004 #2 0.0297004
#1 half_spread_bps=0.005 skew_bps_per_unit=0.01
#2 half_spread_bps=0.01 skew_bps_per_unit=0.0
mean in-sample best 0.121371
mean out-of-sample 0.110832
mean hindsight best 0.125913
choice changes 1 of 2
chosen and best are grid points (#n in grid order, listed under the table): the point carried over from the previous fold and the one that scores best on this fold in hindsight. in_sample is the chosen point's score on the fold it was chosen on, out_of_sample its score on this fold, hindsight the best score on this fold. The summary averages folds 1 to K−1 and counts how often the choice changed between consecutive folds. The choice generalizes when the out-of-sample mean stays close to the hindsight mean and the choice rarely changes; an out-of-sample mean well below both, or a choice that changes on every fold, means the ranking fits noise.
folds=1 runs exactly fastmm.sweep. The result does not depend on threads. The synthetic market needs fill_model = "l2_queue" for more than one fold: the coupled matching market has no stream to cut. The dict also holds every fold's points and scores; the C++ entry point is bt::walk_forward in backtest/sweep.hpp.
Plain hooks¶
Subclass fastmm.Strategy, define the hooks you need and pass the class as strategy=. The class runs in backtests only (What runs where):
class Joiner(fastmm.Strategy):
qty = fastmm.Param(0.002, min=0.0, doc="size per side")
def on_book(self, ctx, inst, book):
if book.valid:
ctx.set_quotes(inst, [(book.best_bid[0], self.qty)], [(book.best_ask[0], self.qty)])
cfg.clear_params() # the example file configures basic_mm
r = fastmm.run_backtest(cfg, data="synthetic", strategy=Joiner, params={"qty": 0.001})
examples/python/strategies/basic_mm_exact.py is an integer port of the C++ BasicMM with the same outbound hash; skew_mm.py is a float market maker.
Other helpers¶
fastmm.strategies():{name: [{"name", "type", "default", "min", "max", "doc"}]}.fastmm.OrderBook(): L2 book (256 levels/side) withapply_snapshot(bids, asks),apply_delta(bids, asks)((n, 2)arrays or(price, qty)pairs, qty 0 deletes),best_bid(),best_ask(),mid(),spread(),microprice(),weighted_mid(levels),imbalance(levels),bids(n),asks(n).fastmm.features(data=..., horizons=[...]): book features and forward mid moves from a data source, as zero-copy numpy columns, andfastmm.evaluate_signal(table, values)for the information coefficient, bucket table and conditional touch markout of a signal (Judging a signal before writing a strategy).fastmm.inspect_journal(path): header and message counts of an.fmj.fastmm.open_store(path): the fills, orders, positions, PnL, funding payments and kill events a live session recorded, as DataFrames (Query what you traded). Needs pandas.
Logging¶
The C++ engine logs through an asynchronous logger that has no output until it is started. From Python, call fastmm.enable_logging(level="warn", path=None) to write records at level or above to a file (appended) or to stderr; warnings and errors are always mirrored to stderr as well. fastmm.disable_logging() flushes and stops it; interpreter exit does the same.
Development install, tests, the type stub and wheels: Python packages.