Path-dependent Python strategies

Some strategies cannot be written as a table of order intent. A rule that enters only once the previous position is confirmed closed, or that waits thirty seconds after a fill before acting, depends on what already happened rather than on the current row.

That is what path-dependent means, and it needs the strategy to carry state between events. The cost is a crossing into Python for every event the strategy sees, which is why this recipe closes by naming the cheaper boundaries and when to prefer them.

Signals and command tables are the fastest strategy boundary because the replay stays entirely in Rust. Some strategies genuinely need state, fill-driven decisions, or timers. This recipe uses the opt-in Python callback surface without giving strategy code direct access to borrowed engine internals.

Terms used here#

term meaning
callback your Python function, called by the engine when something happens
path-dependent a decision that depends on what already happened, not only on current data
stateful the strategy carries variables between events
timer a callback scheduled for a future instant rather than triggered by data
strategy identity a stable name for a strategy, so its runs stay comparable across reruns
determinism the same inputs producing the same outputs, every time, which reruns verify

New to any of these? GLOSSARY.md defines them at more length, along with every other term the cookbook uses.

import h5i_db
from h5i_db import backtest
import cookbook_utils as cu

INSTRUMENT_ID = "RATE-CUT-YES"
MARKET_CUT = "callback-market-cut"
SECOND = 1_000_000_000

fixture = cu.make_backtest_fixture(steps=150, instrument_id=INSTRUMENT_ID)
db = h5i_db.Database(cu.fresh_db("07_python_strategy_callbacks"), create=True)
for name, table in fixture.items():
    db.create_table(name, table.schema, time_column="ts_init")
    db.append(name, table, note="deterministic callback fixture")
db.snapshot(
    MARKET_CUT,
    tables=["instruments", "book_deltas", "trades"],
    note="Approved market-data cut for callback examples",
)
output
{'name': 'callback-market-cut',
 'created_at_ns': 1785448979339156569,
 'note': 'Approved market-data cut for callback examples',
 'entries': {'afa2cd03-2959-4639-b75d-11da2ce5b922': {'table_name': 'trades',
   'sequence': 1,
   'manifest_checksum': 'a29bc6154b1c4483ae2834b302ccdb0c60287eb828618750b6c2b3374bda6ab0'},
  'c519f097-f2bf-4364-8687-27684195075e': {'table_name': 'instruments',
   'sequence': 1,
   'manifest_checksum': '42e9dfe0a2608693dc23b580a8f002c11d34b57f1b99745652f47106bf0a58da'},
  'e3de3023-0901-43eb-bdfd-9722061e87e9': {'table_name': 'book_deltas',
   'sequence': 1,
   'manifest_checksum': '2d7987af472b7822caaa788c90e3ef2f28950cd056548ba738668a9211840aec'}},
 'checksum': '8b1a121d6a6c76fa69d3c154084ee128953724ae73de550afc0cd2e5a4105aca'}

Write a state machine with explicit effects#

Callback inputs are ordinary dictionaries. A callback returns None, one action mapping, or an iterable of mappings. The engine applies those actions after the callback, preserving causal event ordering. This strategy:

  1. schedules an entry timer from the first observable market event;
  2. submits a market buy when that timer fires;
  3. schedules an exit only after the entry fill is confirmed; and
  4. submits a reduce-only sell from the exit timer.
class TimedRoundTrip(backtest.EventStrategy):
    def __init__(self):
        self.entry_scheduled = False
        self.exit_scheduled = False
        self.fill_log = []

    def on_event(self, context, event):
        assert context["now"] == event["ts_init"]
        if not self.entry_scheduled:
            self.entry_scheduled = True
            return {
                "action": "timer",
                "name": "enter",
                "ts": context["now"] + 20 * SECOND,
            }
        return None

    def on_timer(self, context, event):
        if event["name"] == "enter":
            return {
                "action": "submit",
                "client_order_id": "entry",
                "instrument_id": INSTRUMENT_ID,
                "side": "buy",
                "quantity": 25.0,
                "tag": "timed-entry",
            }
        if event["name"] == "exit":
            return {
                "action": "submit",
                "client_order_id": "exit",
                "instrument_id": INSTRUMENT_ID,
                "side": "sell",
                "quantity": 25.0,
                "reduce_only": True,
                "tag": "fill-driven-exit",
            }
        raise ValueError(f"unexpected timer {event['name']!r}")

    def on_fill(self, context, event):
        self.fill_log.append(event)
        if event["tag"] == "timed-entry" and not self.exit_scheduled:
            self.exit_scheduled = True
            return {
                "action": "timer",
                "name": "exit",
                "ts": event["ts"] + 60 * SECOND,
            }
        return None


strategy = TimedRoundTrip()

strategy_id is persisted with the run. In packaged research code, run_strategy can derive it from class source; an explicit version is often preferable in notebooks and production because code review can tie it to a release or commit.

result = backtest.run_strategy(
    db,
    "timed-round-trip",
    strategy,
    strategy_id="cookbook.TimedRoundTrip:v1",
    starting_cash=10_000.0,
    data=backtest.DataConfig(
        snapshot=MARKET_CUT,
        minimum_coverage=0.95,
    ),
    execution=backtest.ExecutionConfig(
        fee_kind="prediction_market",
        fee_rate=0.02,
        latency_nanos=1_000_000,
    ),
    risk=backtest.RiskConfig(
        max_order_quantity=25.0,
        max_abs_position=25.0,
        max_open_orders=2,
    ),
    output=backtest.OutputConfig(equity_interval_nanos=5 * SECOND),
    metadata={"purpose": "callback and timer contract demonstration"},
)
result
output
{'run_id': 'timed-round-trip',
 'fork': 'bt-timed-round-trip',
 'digest': 'eca719a0093562462aae3e40a900136ca5d999425bf0b842306322f1d4c11589',
 'starting_cash': 10000.0,
 'final_cash': 9999.427559665,
 'realized_pnl': -0.572440335,
 'commissions': 0.249940335,
 'funding_paid': 0.0,
 'fills': 2,
 'orders': 2,
 'records_processed': 300,
 'simulated_through_ns': 1780322550000000000,
 'equity_points': 31,
 'settlement_applied': False,
 'coverage': None,
 'liquidations': 0,
 'rejected_for_margin': 0,
 'self_trades_prevented': 0,
 'calibration_samples': [],
 'set_operations': [],
 'forecasts': 0,
 'mark_points': 31,
 'expirations': [],
 'metrics': {'orders_submitted': 2,
  'orders_filled': 2,
  'orders_cancelled_unfilled': 0,
  'orders_rejected_margin': 0,
  'orders_rejected_risk': 0,
  'orders_rejected_self_trade': 0,
  'orders_rejected_naked_short': 0,
  'orders_rejected_expired': 0,
  'fills_taker': 2,
  'fills_maker': 0,
  'book_gaps': 0,
  'liquidations': 0,
  'set_operations': 0,
  'set_operations_rejected': 0,
  'instruments_expired': 0},
 'warnings': [],
 'cached': False,
 'trial_digest': '49fb5ae60bfd8a456a04423cf6f2e1e2f5f6240bf54b0f0ebb9a5983dd6b4218'}

Fill callbacks ran on the same strategy object under the GIL. The persisted tables remain the source of truth; local state is useful for decisions and diagnostics, not as the audit record.

orders = result.orders.to_pandas()
fills = result.fills.to_pandas()
assert result["fills"] == 2
assert [fill["tag"] for fill in strategy.fill_log] == [
    "timed-entry",
    "fill-driven-exit",
]
assert fills["tag"].tolist() == ["timed-entry", "fill-driven-exit"]
assert result.positions.to_pandas()["quantity"].abs().sum() < 1e-9
orders[["order_id", "side", "quantity", "filled", "status", "tag"]]
output
order_id side quantity filled status tag
0 1 buy 25.0 25.0 filled timed-entry
1 2 sell 25.0 25.0 filled fill-driven-exit

Callback runs are reproducible when the same strategy implementation is supplied. Verification creates an isolated rerun, compares metrics and all authoritative output tables, then removes the temporary fork.

verification = result.verify(strategy=TimedRoundTrip())
assert verification["verified"]
verification
output
{'left': 'timed-round-trip',
 'right': 'verify-timed-round-trip-47ff67f2697a',
 'same_digest': False,
 'metrics': {'records_processed': {'left': 300,
   'right': 300,
   'delta': 0,
   'equal': True},
  'orders': {'left': 2, 'right': 2, 'delta': 0, 'equal': True},
  'fills': {'left': 2, 'right': 2, 'delta': 0, 'equal': True},
  'final_cash': {'left': 9999.427559665,
   'right': 9999.427559665,
   'delta': 0.0,
   'equal': True},
  'realized_pnl': {'left': -0.572440335,
   'right': -0.572440335,
   'delta': 0.0,
   'equal': True},
  'commissions': {'left': 0.249940335,
   'right': 0.249940335,
   'delta': 0.0,
   'equal': True},
  'equity_points': {'left': 31, 'right': 31, 'delta': 0, 'equal': True},
  'coverage': {'left': None, 'right': None, 'delta': None, 'equal': True}},
 'tables_equal': {'bt_orders': True,
  'bt_fills': True,
  'bt_positions': True,
  'bt_equity': True},
 'verified': True}

When to choose each strategy boundary#

boundary best for performance lifecycle
signals vectorized entries/exits and target positions native hot loop submit
commands quoting and predetermined execution schedules native hot loop submit/amend/cancel
Python callbacks state machines, fill reactions, timers one GIL crossing per callback full

Prefer the simplest boundary that expresses the strategy. Callback flexibility is valuable, but it should be an explicit choice rather than an accidental tax on every backtest.

db.close()