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",
)
output
{'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()
output
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()
output
{'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",
    ]
]
output
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
output
{'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()
output
{'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#

db.close()