quantex.execution — Execution utilities.
This module implements ImmediateFillSimulator, a zero-latency execution
engine used by the Quantex backtesting framework.
ImmediateFillSimulator converts strategy-generated quantex.models.Order
objects directly into quantex.models.Fill
objects at the supplied
execution price, then updates the provided quantex.models.Portfolio
so
metrics such as positions, cash, and equity remain consistent during a
backtest.
Key characteristics
- Immediate fills – no market latency, orders are filled in the
same timestamp they are received.
- Configurable commission – per-fill commission (default 0.0) to
approximate transaction costs. Slippage is currently assumed to be
zero but can be incorporated in future extensions.
This concise implementation serves as a reference template; more
sophisticated execution models (e.g., with latency, partial fills, or
order books) can inherit from or replace this class in future versions.
Immediate (zero-latency) execution simulator.
Converts incoming quantex.models.Order
objects into
quantex.models.Fill
objects instantly at the supplied execution
price. Commission and slippage are currently fixed to zero but the
constructor exposes a commission argument so users can override the
default behaviour in future iterations.
Source code in src/quantex/execution.py
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118 | class ImmediateFillSimulator:
"""Immediate (zero-latency) execution simulator.
Converts incoming `quantex.models.Order` objects into
`quantex.models.Fill` objects *instantly* at the supplied execution
price. Commission and slippage are currently fixed to *zero* but the
constructor exposes a *commission* argument so users can override the
default behaviour in future iterations.
"""
def __init__(
self,
portfolio: Portfolio,
commission: float = 0.0,
min_holding_period: pd.Timedelta | None = None,
) -> None:
"""Initializes the ImmediateFillSimulator.
Args:
portfolio: The portfolio to update upon fill.
commission: The commission to apply to each fill.
"""
self.portfolio = portfolio
self.commission = commission
self.min_holding_period = min_holding_period
def execute(
self, order: Order, execution_price: float, timestamp: datetime
) -> Fill:
"""Converts order into a Fill and updates the portfolio.
Args:
order: The order generated by the strategy.
execution_price: The price used for the fill.
timestamp: Timestamp for the resulting `quantex.models.Fill`.
Returns:
The created Fill object.
"""
# --- Optional minimum holding period enforcement --------------
if (
self.min_holding_period is not None
and order.symbol in self.portfolio.positions
):
pos = self.portfolio.positions[order.symbol]
if pos.open_timestamp is not None:
holding_time = timestamp - pos.open_timestamp
# Closing or reducing existing exposure before min period?
if (
(order.side == "sell" and pos.position > 0)
or (order.side == "buy" and pos.position < 0)
) and holding_time < self.min_holding_period:
raise ValueError(
"Order violates minimum holding period: "
f"held for {holding_time}, required {self.min_holding_period}."
)
if order.side == "buy":
required_cash = order.quantity * execution_price + self.commission
if required_cash > self.portfolio.cash:
raise ValueError(
"Insufficient cash to execute buy order: "
f"required={required_cash:.2f}, available={self.portfolio.cash:.2f}"
)
signed_qty = order.quantity if order.side == "buy" else -order.quantity
fill = Fill(
order_id=order.id,
symbol=order.symbol,
quantity=signed_qty,
price=execution_price,
timestamp=timestamp,
commission=self.commission,
)
# Immediately reflect the fill in the portfolio.
self.portfolio.process_fill(fill)
return fill
|
Initializes the ImmediateFillSimulator.
Parameters:
Name |
Type |
Description |
Default |
portfolio
|
Portfolio
|
The portfolio to update upon fill.
|
required
|
commission
|
float
|
The commission to apply to each fill.
|
0.0
|
Source code in src/quantex/execution.py
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63 | def __init__(
self,
portfolio: Portfolio,
commission: float = 0.0,
min_holding_period: pd.Timedelta | None = None,
) -> None:
"""Initializes the ImmediateFillSimulator.
Args:
portfolio: The portfolio to update upon fill.
commission: The commission to apply to each fill.
"""
self.portfolio = portfolio
self.commission = commission
self.min_holding_period = min_holding_period
|
Converts order into a Fill and updates the portfolio.
Parameters:
Name |
Type |
Description |
Default |
order
|
Order
|
The order generated by the strategy.
|
required
|
execution_price
|
float
|
The price used for the fill.
|
required
|
timestamp
|
datetime
|
Timestamp for the resulting quantex.models.Fill .
|
required
|
Returns:
Source code in src/quantex/execution.py
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118 | def execute(
self, order: Order, execution_price: float, timestamp: datetime
) -> Fill:
"""Converts order into a Fill and updates the portfolio.
Args:
order: The order generated by the strategy.
execution_price: The price used for the fill.
timestamp: Timestamp for the resulting `quantex.models.Fill`.
Returns:
The created Fill object.
"""
# --- Optional minimum holding period enforcement --------------
if (
self.min_holding_period is not None
and order.symbol in self.portfolio.positions
):
pos = self.portfolio.positions[order.symbol]
if pos.open_timestamp is not None:
holding_time = timestamp - pos.open_timestamp
# Closing or reducing existing exposure before min period?
if (
(order.side == "sell" and pos.position > 0)
or (order.side == "buy" and pos.position < 0)
) and holding_time < self.min_holding_period:
raise ValueError(
"Order violates minimum holding period: "
f"held for {holding_time}, required {self.min_holding_period}."
)
if order.side == "buy":
required_cash = order.quantity * execution_price + self.commission
if required_cash > self.portfolio.cash:
raise ValueError(
"Insufficient cash to execute buy order: "
f"required={required_cash:.2f}, available={self.portfolio.cash:.2f}"
)
signed_qty = order.quantity if order.side == "buy" else -order.quantity
fill = Fill(
order_id=order.id,
symbol=order.symbol,
quantity=signed_qty,
price=execution_price,
timestamp=timestamp,
commission=self.commission,
)
# Immediately reflect the fill in the portfolio.
self.portfolio.process_fill(fill)
return fill
|
NextBarSimulator
Bases: ImmediateFillSimulator
Execution simulator that fills market orders at the next bar.
Orders received during bar t are queued and executed at the beginning
of bar t+1 using that bar's open price.
The EventBus calls :pycode{flush_pending()} before it injects the current
bar's market snapshot into the strategy. This ensures that fills appear in
the portfolio before the strategy logic for the new bar runs – matching
the behaviour of many off-the-shelf backtesting libraries where orders are
assumed to execute on the next bar's open.
Source code in src/quantex/execution.py
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239 | class NextBarSimulator(ImmediateFillSimulator):
"""Execution simulator that fills market orders at the *next* bar.
Orders received during bar *t* are **queued** and executed at the **beginning
of bar *t+1*** using that bar's **open** price.
The EventBus calls :pycode{flush_pending()} before it injects the current
bar's market snapshot into the strategy. This ensures that fills appear in
the portfolio *before* the strategy logic for the new bar runs – matching
the behaviour of many off-the-shelf backtesting libraries where orders are
assumed to execute on the next bar's open.
"""
def __init__(
self,
portfolio: Portfolio,
commission: float = 0.0,
min_holding_period: pd.Timedelta | None = None,
*,
fill_at: str = "open",
) -> None:
if fill_at not in {"open", "close"}:
raise ValueError("fill_at must be 'open' or 'close'")
super().__init__(portfolio, commission, min_holding_period)
self._pending_orders: list[Order] = []
self._fill_at = fill_at
self.event_bus: EventBus | None = None
# ------------------------------------------------------------------
# Public API – called by EventBus for *new* orders in bar t
# ------------------------------------------------------------------
def execute(
self, order: Order, execution_price: float, timestamp: datetime
) -> None: # type: ignore[override]
"""Queues *order* for execution on the following bar.
Unlike :class:`ImmediateFillSimulator`, this method **does not** return
a :class:`quantex.models.Fill` immediately. The fill will be generated
by :meth:`flush_pending` when the next timestamp is processed.
"""
# Store the original *Order* object – we intentionally ignore the
# *execution_price* passed in for the current bar because the trade
# should fill at the next bar's price.
self._pending_orders.append(order)
# Returning *None* signals the EventBus that nothing was filled yet.
return None # type: ignore[return-value]
# ------------------------------------------------------------------
# Internal API – invoked by EventBus at the start of bar t+1
# ------------------------------------------------------------------
def flush_pending(
self,
timestamp: datetime,
price_row: Sequence[float],
symbol_idx: dict[str, int],
) -> list[Fill]:
"""Converts *all* queued orders into fills at *timestamp*.
Args:
timestamp: The timestamp that will be assigned to each fill
(i.e. the *current* bar processed by the EventBus).
price_row: Numpy-converted prices for the current bar **aligned**
to ``symbol_idx`` – obtained directly from the EventBus.
symbol_idx: Mapping symbol→column index allowing O(1) look-ups.
Returns:
List of created :class:`quantex.models.Fill` objects.
"""
fills: list[Fill] = []
# We iterate over a *copy* of the list so we can clear it safely even
# if an exception is raised inside ``super().execute``.
pending, self._pending_orders = self._pending_orders, []
for order in pending:
# Prefer the *open* price for the current bar. We fetch it from
# the underlying DataSource via the EventBus reference.
event_bus: EventBus | None = self.event_bus
ds: BacktestingDataSource | None = None
if event_bus is not None:
ds = event_bus.data_sources.get(order.symbol)
if ds is not None:
raw = ds.get_raw_data()
if timestamp not in raw.index:
# In case of missing timestamp fallback to close via price_row
ds = None
else:
if self._fill_at == "open":
if "open" in raw.columns:
execution_price = float(raw.at[timestamp, "open"])
else:
execution_price = float(raw.at[timestamp, "close"])
else: # fill_at == "close"
execution_price = float(raw.at[timestamp, "close"])
else:
# Fallback to price_row close price when symbol missing or ds None
idx = symbol_idx.get(order.symbol)
if idx is None:
continue
execution_price = float(price_row[idx])
# If price moved unfavourably we may not have enough cash for the
# original quantity (sized with yesterday's close). Adjust down.
if order.side == "buy":
max_affordable = math.floor(self.portfolio.cash / execution_price)
if max_affordable == 0:
# Skip order entirely – cannot afford even 1 share
continue
if max_affordable < order.quantity:
order.quantity = max_affordable # type: ignore[assignment]
fill = super().execute(order, execution_price, timestamp)
fills.append(fill)
return fills
|
execute(order, execution_price, timestamp)
Queues order for execution on the following bar.
Unlike :class:ImmediateFillSimulator
, this method does not return
a :class:quantex.models.Fill
immediately. The fill will be generated
by :meth:flush_pending
when the next timestamp is processed.
Source code in src/quantex/execution.py
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168 | def execute(
self, order: Order, execution_price: float, timestamp: datetime
) -> None: # type: ignore[override]
"""Queues *order* for execution on the following bar.
Unlike :class:`ImmediateFillSimulator`, this method **does not** return
a :class:`quantex.models.Fill` immediately. The fill will be generated
by :meth:`flush_pending` when the next timestamp is processed.
"""
# Store the original *Order* object – we intentionally ignore the
# *execution_price* passed in for the current bar because the trade
# should fill at the next bar's price.
self._pending_orders.append(order)
# Returning *None* signals the EventBus that nothing was filled yet.
return None # type: ignore[return-value]
|
flush_pending(timestamp, price_row, symbol_idx)
Converts all queued orders into fills at timestamp.
Parameters:
Name |
Type |
Description |
Default |
timestamp
|
datetime
|
The timestamp that will be assigned to each fill
(i.e. the current bar processed by the EventBus).
|
required
|
price_row
|
Sequence[float]
|
Numpy-converted prices for the current bar aligned
to symbol_idx – obtained directly from the EventBus.
|
required
|
symbol_idx
|
dict[str, int]
|
Mapping symbol→column index allowing O(1) look-ups.
|
required
|
Returns:
Type |
Description |
list[Fill]
|
List of created :class:quantex.models.Fill objects.
|
Source code in src/quantex/execution.py
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239 | def flush_pending(
self,
timestamp: datetime,
price_row: Sequence[float],
symbol_idx: dict[str, int],
) -> list[Fill]:
"""Converts *all* queued orders into fills at *timestamp*.
Args:
timestamp: The timestamp that will be assigned to each fill
(i.e. the *current* bar processed by the EventBus).
price_row: Numpy-converted prices for the current bar **aligned**
to ``symbol_idx`` – obtained directly from the EventBus.
symbol_idx: Mapping symbol→column index allowing O(1) look-ups.
Returns:
List of created :class:`quantex.models.Fill` objects.
"""
fills: list[Fill] = []
# We iterate over a *copy* of the list so we can clear it safely even
# if an exception is raised inside ``super().execute``.
pending, self._pending_orders = self._pending_orders, []
for order in pending:
# Prefer the *open* price for the current bar. We fetch it from
# the underlying DataSource via the EventBus reference.
event_bus: EventBus | None = self.event_bus
ds: BacktestingDataSource | None = None
if event_bus is not None:
ds = event_bus.data_sources.get(order.symbol)
if ds is not None:
raw = ds.get_raw_data()
if timestamp not in raw.index:
# In case of missing timestamp fallback to close via price_row
ds = None
else:
if self._fill_at == "open":
if "open" in raw.columns:
execution_price = float(raw.at[timestamp, "open"])
else:
execution_price = float(raw.at[timestamp, "close"])
else: # fill_at == "close"
execution_price = float(raw.at[timestamp, "close"])
else:
# Fallback to price_row close price when symbol missing or ds None
idx = symbol_idx.get(order.symbol)
if idx is None:
continue
execution_price = float(price_row[idx])
# If price moved unfavourably we may not have enough cash for the
# original quantity (sized with yesterday's close). Adjust down.
if order.side == "buy":
max_affordable = math.floor(self.portfolio.cash / execution_price)
if max_affordable == 0:
# Skip order entirely – cannot afford even 1 share
continue
if max_affordable < order.quantity:
order.quantity = max_affordable # type: ignore[assignment]
fill = super().execute(order, execution_price, timestamp)
fills.append(fill)
return fills
|