Order lifecycle and account risk
Most backtests model an order as a single event. It is sent and it fills. Real orders have a life. They rest, they get repriced as the market moves, they lose queue priority when they do, they get cancelled when they go stale, and they get rejected outright when they would breach a limit.
Modelling only the fill hides two things that cost real money. Repricing sends you to the back of the queue. And a risk limit that lives in your notebook is not a risk limit.
A production backtest needs more than timestamped entries. Quotes are amended, stale orders are cancelled, and account limits must reject unsafe intent before it reaches the simulated venue. This recipe exercises that complete lifecycle with stable client order IDs and inspects the resulting audit trail.
Terms used here#
| term | meaning |
|---|---|
| order lifecycle | submit, amend, cancel, fill, expire: everything an order does after it is sent |
| amend | changing the price or size of a resting order rather than replacing it |
| client order ID | your own stable identifier for an order, so its whole history joins up |
| preflight | a check that rejects unsafe or unsupported intent before it reaches the venue |
| account limit | a cap on exposure, order size or cash that the engine enforces natively |
| audit trail | the record of what was requested, what was rejected, and why |
New to any of these? GLOSSARY.md defines them at more length, along with every other term the cookbook uses.
import datetime as dt
import h5i_db
from h5i_db import backtest
import cookbook_utils as cu
INSTRUMENT_ID = "RATE-CUT-YES"
MARKET_CUT = "lifecycle-market-cut"
SECOND = 1_000_000_000
fixture = cu.make_backtest_fixture(steps=120, instrument_id=INSTRUMENT_ID)
db = h5i_db.Database(cu.fresh_db("06_order_lifecycle_and_risk"), create=True)
for name, table in fixture.items():
db.create_table(name, table.schema, time_column="ts_init")
db.append(name, table, note="deterministic lifecycle fixture")
db.snapshot(
MARKET_CUT,
tables=["instruments", "book_deltas", "trades"],
note="Approved market-data cut for lifecycle examples",
)
{'name': 'lifecycle-market-cut',
'created_at_ns': 1785448976902500906,
'note': 'Approved market-data cut for lifecycle examples',
'entries': {'12ae4747-2c89-496d-a640-bfc6d4e9bffe': {'table_name': 'book_deltas',
'sequence': 1,
'manifest_checksum': '1966a20362ae1179d027646be70be674b99b7e58a4ff7b2ed0123a9c9f0df7a5'},
'60d9625a-5ecd-4691-b00d-bb3d80e0bdca': {'table_name': 'trades',
'sequence': 1,
'manifest_checksum': '75eb4a8f8e4be341004ec9a4c3905d8e58fabfc370e5c0b1f9a2204bca6bb8ac'},
'e48fbdc5-3b05-4faa-a420-ec1fea67bbc3': {'table_name': 'instruments',
'sequence': 1,
'manifest_checksum': 'c0a4f66d8c997482c878aa37be12f0354898e8abdb40171b0e1f142389d5444c'}},
'checksum': 'ffe2f0f22ad1330b99660c2034f222a70c23e3a75ddccd700b77050d0ca1c167'}Declare the lifecycle#
client_order_id belongs to the strategy, not the engine. Later rows use
that stable name to address the exact order created by submit. Submit
fields are nullable in the storage schema because cancel rows only need an
ID; the builder validates the fields required by each action.
base = dt.datetime(2026, 6, 1, 14, 0, 0)
lifecycle = backtest.command_table(
[
{
"ts": base + dt.timedelta(seconds=10),
"action": "submit",
"client_order_id": "yes-quote-001",
"instrument_id": INSTRUMENT_ID,
"side": "buy",
"quantity": 20.0,
"kind": "limit",
"limit_price": 0.25,
"tag": "passive-quote",
},
{
"ts": base + dt.timedelta(seconds=30),
"action": "amend",
"client_order_id": "yes-quote-001",
"quantity": 10.0,
"limit_price": 0.26,
},
{
"ts": base + dt.timedelta(seconds=60),
"action": "cancel",
"client_order_id": "yes-quote-001",
},
]
)
backtest.create_command_table(db, "lifecycle_commands")
db.append(
"lifecycle_commands",
lifecycle,
note="submit, reprice/resize, then cancel one quote",
)
lifecycle.to_pandas()
| ts | action | client_order_id | instrument_id | outcome | side | quantity | kind | limit_price | time_in_force | tag | reduce_only | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 2026-06-01 14:00:10 | submit | yes-quote-001 | RATE-CUT-YES | 0.0 | buy | 20.0 | limit | 0.25 | NaN | passive-quote | False |
| 1 | 2026-06-01 14:00:30 | amend | yes-quote-001 | NaN | NaN | NaN | 10.0 | NaN | 0.26 | NaN | NaN | None |
| 2 | 2026-06-01 14:01:00 | cancel | yes-quote-001 | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | None |
The typed configuration captures every material assumption. Preflight checks the market pin, schemas, coverage, and the strongest fidelity supported by the feed before the expensive replay starts.
lifecycle_config = backtest.BacktestConfig(
run_id="lifecycle",
portfolio=backtest.PortfolioConfig(starting_cash=10_000.0),
data=backtest.DataConfig(
commands="lifecycle_commands",
snapshot=MARKET_CUT,
),
execution=backtest.ExecutionConfig(
fee_kind="prediction_market",
fee_rate=0.02,
latency_nanos=2_000_000,
),
risk=backtest.RiskConfig(
max_order_quantity=25.0,
max_abs_position=50.0,
max_open_orders=4,
),
output=backtest.OutputConfig(equity_interval_nanos=5 * SECOND),
metadata={"research_ticket": "PM-142", "owner": "market-making"},
)
inspection = backtest.inspect(db, lifecycle_config)
inspection.to_dict()
{'ok': True,
'config_digest': '58983ab2fbcf3104c71021aa7a11c3526d48303c84c9d1aec835814aab6cb6ed',
'fidelity': 'snapshot_l2',
'tables': {'book_deltas': {'row_count': 240,
'min_time': Timestamp('2026-06-01 14:00:01'),
'max_time': Timestamp('2026-06-01 14:02:00'),
'columns': ('ts_init',
'ts_event',
'instrument_id',
'outcome',
'action',
'side',
'price',
'size',
'event_index',
'is_last',
'source_vendor'),
'actions': {'snapshot': 240}},
'instruments': {'row_count': 2,
'min_time': Timestamp('2026-06-01 14:00:00'),
'max_time': Timestamp('2026-06-01 14:00:00'),
'columns': ('ts_init',
'instrument_id',
'venue',
'kind',
'outcome',
'outcome_label',
'tick_size',
'lot_size',
'expiration_ns',
'settlement_observable_ns')},
'lifecycle_commands': {'row_count': 3,
'min_time': Timestamp('2026-06-01 14:00:10'),
'max_time': Timestamp('2026-06-01 14:01:00'),
'columns': ('ts',
'action',
'client_order_id',
'instrument_id',
'outcome',
'side',
'quantity',
'kind',
'limit_price',
'time_in_force',
'tag',
'reduce_only')},
'trades': {'row_count': 120,
'min_time': Timestamp('2026-06-01 14:00:01'),
'max_time': Timestamp('2026-06-01 14:02:00'),
'columns': ('ts_init',
'ts_event',
'instrument_id',
'outcome',
'price',
'size',
'aggressor',
'trade_id',
'source_vendor')}},
'capabilities': {'market_orders': True,
'limit_orders': True,
'queue_position': False,
'exact_intraday_path': False},
'issues': [{'severity': 'warning',
'code': 'snapshot_only',
'message': 'periodic snapshots cannot reconstruct queue transitions between events',
'remediation': None}]}inspection.raise_for_errors()
lifecycle_result = backtest.execute(db, lifecycle_config)
orders = lifecycle_result.orders.to_pandas()
orders[
[
"order_id",
"side",
"limit_price",
"quantity",
"filled",
"status",
"reject_reason",
"tag",
]
]
| order_id | side | limit_price | quantity | filled | status | reject_reason | tag | |
|---|---|---|---|---|---|---|---|---|
| 0 | 1 | buy | 0.26 | 10.0 | 0.0 | cancelled | NaN | passive-quote |
The quote was intentionally away from the market, so the expected outcome is
cancellation rather than a fill. explain() makes silence inspectable, and
verify() reruns the persisted config and compares every authoritative
output table.
assert lifecycle_result["fills"] == 0
assert orders["status"].tolist() == ["cancelled"]
explanation = lifecycle_result.explain()
verification = lifecycle_result.verify()
assert verification["verified"]
explanation
{'warnings': ["1 order(s) were cancelled without filling; the usual cause is acting before the instrument's first book update, or a limit the book never reached",
'1 orders, no fills: 1 found no liquidity at their price'],
'status_counts': {'cancelled': 1},
'rejection_reasons': {},
'orders_without_fills': 1,
'orders_sweeping_multiple_levels': 0,
'fidelity': 'snapshot_l2'}Prove that risk rejects before venue execution#
Risk controls are native engine constraints, not notebook-side filters. The
oversized market order is recorded as rejected, never enters latency or
matching, and carries a durable reason in bt_orders.
risk_commands = backtest.command_table(
[
{
"ts": base + dt.timedelta(seconds=20),
"action": "submit",
"client_order_id": "oversized-entry",
"instrument_id": INSTRUMENT_ID,
"side": "buy",
"quantity": 100.0,
"tag": "must-reject",
}
]
)
backtest.create_command_table(db, "risk_commands")
db.append("risk_commands", risk_commands, note="risk rejection demonstration")
risk_config = backtest.BacktestConfig(
run_id="risk-rejection",
portfolio=backtest.PortfolioConfig(starting_cash=10_000.0),
data=backtest.DataConfig(commands="risk_commands", snapshot=MARKET_CUT),
risk=backtest.RiskConfig(
max_order_quantity=25.0,
max_abs_position=50.0,
max_open_orders=4,
),
)
risk_result = backtest.execute(db, risk_config)
risk_order = risk_result.orders.to_pylist()[0]
assert risk_result["fills"] == 0
assert risk_order["status"] == "rejected"
assert "max_order_quantity" in risk_order["reject_reason"]
risk_result.explain()
{'warnings': ['1 orders, no fills: 1 refused by configured risk limits'],
'status_counts': {'rejected': 1},
'rejection_reasons': {'quantity 100 exceeds max_order_quantity 25': 1},
'orders_without_fills': 1,
'orders_sweeping_multiple_levels': 0,
'fidelity': 'snapshot_l2'}Takeaways#
- Stable client IDs make amend/cancel workflows independent of engine IDs.
- Amendments follow venue-like queue rules; repricing or increasing size loses priority.
- Position limits include all live orders, not only already-filled exposure.
- Rejection reasons are persisted and queryable, so a zero-fill run is diagnosable.
- Typed configs, preflight, and semantic verification form one reproducible operational contract.
db.close()