Backtest Costs — pmlab.core.costs¶
A backtest that fills at the quoted price reports an edge the broker cannot
reproduce. CostModel makes execution friction explicit, and it is opt-in:
omit it and rolling_origin_eval behaves exactly as it did before this module
existed, so previously published results stay reproducible.
Why this is not a detail¶
On Polymarket's mutually-exclusive (negRisk) events the gap between the mid and the executable price is structural, not incidental. Read at the mid, a field of outcomes sums to ~1.0 and looks coherent; read at the ask, it sums above 1.0, and in large fields half the outcomes have no book at all. Any edge measured against a mid price is a spread you have not paid yet.
The same applies inside a single market: market_price in the training panel is a
quote, and your fill is worse than a quote by the half-spread plus whatever the
book charges for your size.
Basic Usage¶
from pmlab import CostModel, rolling_origin_eval
# Default — identical to pre-0.8.3 behaviour: 30bps taker fee, fill at the quote.
result = rolling_origin_eval(panel, model)
# Realistic: pay the fee, cross the half-spread, and walk the book on size.
costs = CostModel(
taker_bps=30.0, # Polymarket CLOB taker fee
slippage_bps=50.0, # half-spread, as a fraction of price
depth_bps_per_unit=20.0, # extra bps per multiple of the reference stake
depth_reference_stake=10.0, # USDC that fills without moving the book
)
result = rolling_origin_eval(panel, model, flat_stake=25.0, costs=costs)
# Upper bound — what the strategy would earn in a frictionless world.
best_case = rolling_origin_eval(panel, model, costs=CostModel.frictionless())
Comparing the two runs is the point: the distance between them is how much of your edge is paid to the market.
The arithmetic¶
depth_penalty_bps = depth_bps_per_unit * max(0, stake - ref) / ref
total_bps = slippage_bps + depth_penalty_bps
fill = price * (1 + total_bps / 10_000) + slippage_fixed
fill = min(fill, 1.0)
fee = stake * taker_bps / 10_000
Notes on each term:
| Parameter | Unit | Models |
|---|---|---|
taker_bps |
bps of notional stake | exchange fee |
slippage_bps |
bps of price | half-spread crossed on entry |
slippage_fixed |
probability units | a floor cost (0.01 = one cent) |
depth_bps_per_unit |
bps per multiple of reference | walking the book on size |
depth_reference_stake |
USDC | size that fills at top of book |
slippage_bps is proportional to price, so a $0.10 outcome slips less in absolute
terms than a $0.90 one — which matches how a percentage spread behaves. Use
slippage_fixed when the cost is a floor rather than a percentage.
The fill is clamped at 1.0. A probability price above certainty would create a position paying more than it can settle for.
What the depth term is, and is not¶
It is a documented linear approximation, not an order-book walk. pmlab's
training panel carries market_price, not L2 depth, so the model cannot know
where the book thins out. The penalty grows linearly in excess stake above a
reference you choose.
If you have depth data, measure it and set depth_bps_per_unit from the
measurement rather than guessing. If you don't, prefer a pessimistic value: a
backtest that overstates cost fails safe, one that understates it promotes a
champion that loses money live.
Where costs propagate¶
Costs are not cosmetic — they flow into the promotion decision, and (since v0.8.4) into the live path too:
rolling_origin_evalfills atfill_priceinstead ofmarket_price, and records that column in the trade log.edgeis computed aspredicted_prob - fill_price. Measuring it against the quote would overstate every trade by the friction. Under the live policy (TradePolicy.live) the same arithmetic prices the NO side: a NO trade is a buy of the NO token, filled atcosts.fill_price(1 - market_price), worth1 - predicted_prob, andbest_edge(the larger of the two sides, after the fee) is what--min-edgeis held against.pmlab.core.edge.quote_edgesis the one place that is computed; a liveEdgeSignal.from_quoteand the backtest both call it.compute_metricsaveragesedgeintoavg_edge.HoldoutGateResultreads those metrics. A book whose edge exists only at the mid can no longer pass the gate.stability_reportbootstraps the post-cost PnL, so the confidence interval is around what you would actually have earned.ChampionManifest.publish(costs=...)records the model inchampion.json, andModelRegistry.record()carries it into the archived version — so a rollback restores a champion whose cost assumptions are known.PaperBroker(costs=...)fills at the same price the backtest would, and recordsfill_pricenext togamma_price.SettlementEnginesettles each trade at its recordedfill_price(a NO trade as a buy of the NO token at its own price), sorealized_pnlcarries the friction. It writessettled_basis("fill_price", or"quote"when none was recorded) andsettlement_versionon the trade. Releases up to 0.9.0 settled at the quote even when afill_pricewas recorded and wrote neither key;pmlab monitorleaves such trades out of its PnL comparison (see the watchdog).
Using one cost model for backtest and live¶
This is the whole point of steps 6 and 7. If the backtest models 50bps of slippage and the paper broker fills at the quote, then live-vs-backtest PnL differ for two reasons at once — the market, and your own arithmetic — and a drift alarm cannot separate them.
costs = CostModel(taker_bps=30.0, slippage_bps=50.0)
# 1. validate under that friction
result = rolling_origin_eval(panel, model, flat_stake=25.0, costs=costs)
gate = HoldoutGateResult.evaluate(result.trades, required_segments=[...])
# 2. record it with the champion, so the assumption travels with the model
manifest = ChampionManifest.publish(
model=model, gate=gate, output_dir=out, plugin_family="weather_tmax", costs=costs,
)
# 3. trade under the same friction — read it back rather than retyping it
broker = PaperBroker(
trades_path=trades, flat_stake=25.0,
allowed_segments=manifest.get_allowed_segments(),
costs=manifest.costs,
)
Step 3 is the part worth insisting on: take costs from the manifest instead of
constructing a second CostModel by hand. Two hand-written models drift the moment
someone edits one of them.
For the same quote and stake, both sides produce the same fill — asserted across a
grid of prices and stakes in tests/execution/test_cost_model_unified.py.
manifest.costs is None for champions published before v0.8.4. Treat that as
"cost assumptions unknown" rather than "no costs".
From the command line¶
backtest, promote-champion and record-trades each take --costs PATH, a JSON file
of the same fields (CostModel.to_dict()):
{"taker_bps": 30.0, "slippage_bps": 50.0, "slippage_fixed": 0.0,
"depth_bps_per_unit": 20.0, "depth_reference_stake": 10.0}
pmlab backtest --plugin my_family --panel panel.parquet --costs costs.json --output metrics.json
pmlab promote-champion model.pkl --gate-path gate.json --plugin my_family --expectation metrics.json
pmlab record-trades --plugin my_family # fills under the champion's costs
The model is written once and then carried, not retyped:
backtest --outputrecords the costs its numbers are net of (the default model if--costswas omitted) under acostskey.promote-champion --expectation metrics.jsonpublishes the champion with those costs.--coststhere is only needed without an expectation file, and it must agree with the file's if both exist: a champion cannot be promoted on a backtest it did not run.record-tradesfills underchampion.json's costs by default.--costsmay name a model when the champion records none, but not a different one: trades filled under other costs are left out of the watchdog's PnL comparison, so recording them would waste the evidence.
A champion promoted with no costs anywhere still records costs: null, "unknown", and
pmlab monitor then reports cost_basis: "unknown" instead of checking it.
Compatibility¶
- No
costs=argument → 30bps fee, fill at the quote. Byte-identical to the old behaviour; there is a test asserting frame equality. taker_bps=still works on its own for fee-only adjustments, on bothrolling_origin_evalandPaperBroker.- Passing both
taker_bps=andcosts=raisesValueError. Two sources of truth for the fee would silently double-charge it. PaperBroker.taker_bpsstill exists and reads through tocosts.taker_bps, so code inspecting it keeps working.
Reproducibility¶
CostModel.to_dict() returns a flat mapping that round-trips through
CostModel(**payload), so log it with the run:
with tracker.run(experiment="weather_tmax", run_name="with_costs") as run:
run.log_params(costs.to_dict())
A PnL number without its cost model is not reproducible — the same panel and the same model give a different answer under different friction.