quantex 0.4.5__tar.gz → 0.4.6__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {quantex-0.4.5 → quantex-0.4.6}/PKG-INFO +1 -1
- {quantex-0.4.5 → quantex-0.4.6}/pyproject.toml +2 -1
- {quantex-0.4.5 → quantex-0.4.6}/src/quantex/backtester/backtester.py +6 -1
- {quantex-0.4.5 → quantex-0.4.6}/src/quantex/backtester/montecarlo.py +78 -33
- {quantex-0.4.5 → quantex-0.4.6}/src/quantex/backtester/reports.py +9 -5
- {quantex-0.4.5 → quantex-0.4.6}/src/quantex/broker/broker.py +9 -0
- {quantex-0.4.5 → quantex-0.4.6}/LICENSE.md +0 -0
- {quantex-0.4.5 → quantex-0.4.6}/README.md +0 -0
- {quantex-0.4.5 → quantex-0.4.6}/src/quantex/__init__.py +0 -0
- {quantex-0.4.5 → quantex-0.4.6}/src/quantex/backtester/__init__.py +0 -0
- {quantex-0.4.5 → quantex-0.4.6}/src/quantex/backtester/constants.py +0 -0
- {quantex-0.4.5 → quantex-0.4.6}/src/quantex/backtester/data_splits.py +0 -0
- {quantex-0.4.5 → quantex-0.4.6}/src/quantex/backtester/metrics.py +0 -0
- {quantex-0.4.5 → quantex-0.4.6}/src/quantex/backtester/parallel.py +0 -0
- {quantex-0.4.5 → quantex-0.4.6}/src/quantex/broker/__init__.py +0 -0
- {quantex-0.4.5 → quantex-0.4.6}/src/quantex/broker/types.py +0 -0
- {quantex-0.4.5 → quantex-0.4.6}/src/quantex/datasource.py +0 -0
- {quantex-0.4.5 → quantex-0.4.6}/src/quantex/helpers.py +0 -0
- {quantex-0.4.5 → quantex-0.4.6}/src/quantex/indicators.py +0 -0
- {quantex-0.4.5 → quantex-0.4.6}/src/quantex/strategy.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "quantex"
|
|
3
|
-
version = "0.4.
|
|
3
|
+
version = "0.4.6"
|
|
4
4
|
description = "A simple quant strategy creation and backtesting package."
|
|
5
5
|
authors = [
|
|
6
6
|
{name = "Daniel Green",email = "dangreen07@outlook.com"}
|
|
@@ -39,6 +39,7 @@ mkdocs-mermaid2-plugin = "^1.2.1"
|
|
|
39
39
|
mkdocs-print-site-plugin = "^2.7.3"
|
|
40
40
|
pytest-xdist = "^3.8.0"
|
|
41
41
|
matplotlib = "^3.10.3"
|
|
42
|
+
ipykernel = "^7.2.0"
|
|
42
43
|
|
|
43
44
|
[build-system]
|
|
44
45
|
requires = ["poetry-core>=2.0.0,<3.0.0"]
|
|
@@ -175,7 +175,12 @@ class SimpleBacktester:
|
|
|
175
175
|
final_cash=self.PnLRecord[-1],
|
|
176
176
|
PnlRecord=pd.Series(self.PnLRecord, index=index),
|
|
177
177
|
orders=orders,
|
|
178
|
-
tradeRecord=tradeRecord
|
|
178
|
+
tradeRecord=tradeRecord,
|
|
179
|
+
margin_call_events=[
|
|
180
|
+
event
|
|
181
|
+
for broker in self.strategy.positions.values()
|
|
182
|
+
for event in getattr(broker, "margin_call_events", [])
|
|
183
|
+
] or None)
|
|
179
184
|
|
|
180
185
|
def optimize(
|
|
181
186
|
self,
|
|
@@ -13,6 +13,7 @@ import math
|
|
|
13
13
|
import random
|
|
14
14
|
import numpy as np
|
|
15
15
|
import pandas as pd
|
|
16
|
+
import matplotlib.dates as mdates
|
|
16
17
|
from dataclasses import dataclass, field
|
|
17
18
|
from enum import Enum
|
|
18
19
|
from typing import Any
|
|
@@ -64,6 +65,7 @@ class MonteCarloResult:
|
|
|
64
65
|
simulations: int = 0
|
|
65
66
|
starting_cash: float = 0.0
|
|
66
67
|
drawdown_stats: dict = field(default_factory=dict)
|
|
68
|
+
plot_max_curves: int = 150
|
|
67
69
|
|
|
68
70
|
def _compute_statistics(self):
|
|
69
71
|
"""Compute summary statistics from equity curves."""
|
|
@@ -115,7 +117,7 @@ class MonteCarloResult:
|
|
|
115
117
|
self,
|
|
116
118
|
target_return: float,
|
|
117
119
|
drawdown_threshold: float,
|
|
118
|
-
horizon: int | None = None,
|
|
120
|
+
horizon: int | str | pd.Timedelta | None = None,
|
|
119
121
|
as_percent: bool = True,
|
|
120
122
|
) -> dict:
|
|
121
123
|
"""
|
|
@@ -127,8 +129,10 @@ class MonteCarloResult:
|
|
|
127
129
|
True, this is treated as a decimal return (e.g. 0.05 for 5%).
|
|
128
130
|
drawdown_threshold (float): Drawdown threshold. If `as_percent` is
|
|
129
131
|
True, this is treated as a decimal drawdown (e.g. 0.05 for 5%).
|
|
130
|
-
horizon (int | None, optional):
|
|
131
|
-
|
|
132
|
+
horizon (int | str | pd.Timedelta | None, optional): Evaluation horizon.
|
|
133
|
+
If an integer is provided, it is treated as a number of steps.
|
|
134
|
+
If a string or Timedelta is provided, it is treated as a time span
|
|
135
|
+
relative to the first timestamp in each equity curve.
|
|
132
136
|
as_percent (bool, optional): Whether thresholds are provided as
|
|
133
137
|
decimal percentages. Defaults to True.
|
|
134
138
|
|
|
@@ -144,8 +148,26 @@ class MonteCarloResult:
|
|
|
144
148
|
"drawdown_threshold": drawdown_threshold,
|
|
145
149
|
}
|
|
146
150
|
|
|
147
|
-
|
|
148
|
-
|
|
151
|
+
def _resolve_horizon(curve: pd.Series, horizon_value: int | str | pd.Timedelta | None) -> int:
|
|
152
|
+
if horizon_value is None:
|
|
153
|
+
return len(curve)
|
|
154
|
+
if isinstance(horizon_value, (int, np.integer)):
|
|
155
|
+
return max(1, min(int(horizon_value), len(curve)))
|
|
156
|
+
|
|
157
|
+
if not isinstance(curve.index, pd.DatetimeIndex):
|
|
158
|
+
return max(1, min(len(curve), len(curve)))
|
|
159
|
+
|
|
160
|
+
delta = pd.Timedelta(horizon_value)
|
|
161
|
+
if delta <= pd.Timedelta(0):
|
|
162
|
+
return 1
|
|
163
|
+
|
|
164
|
+
start_time = curve.index[0]
|
|
165
|
+
end_time = start_time + delta
|
|
166
|
+
resolved = int(curve.index.searchsorted(end_time, side="right"))
|
|
167
|
+
return max(1, min(resolved, len(curve)))
|
|
168
|
+
|
|
169
|
+
first_curve = self.equity_curves[0]
|
|
170
|
+
horizon_steps = _resolve_horizon(first_curve, horizon)
|
|
149
171
|
|
|
150
172
|
if as_percent:
|
|
151
173
|
target_return = float(target_return)
|
|
@@ -154,7 +176,7 @@ class MonteCarloResult:
|
|
|
154
176
|
return_hits = 0
|
|
155
177
|
drawdown_hits = 0
|
|
156
178
|
for curve in self.equity_curves:
|
|
157
|
-
sampled = curve.iloc[:
|
|
179
|
+
sampled = curve.iloc[:horizon_steps]
|
|
158
180
|
start_value = float(sampled.iloc[0])
|
|
159
181
|
end_value = float(sampled.iloc[-1])
|
|
160
182
|
achieved_return = (end_value / start_value) - 1.0 if start_value != 0 else 0.0
|
|
@@ -175,7 +197,7 @@ class MonteCarloResult:
|
|
|
175
197
|
}
|
|
176
198
|
|
|
177
199
|
def plot(self, figsize: tuple = (12, 8), show_original: bool = True,
|
|
178
|
-
show_percentiles: bool = True) -> None:
|
|
200
|
+
show_percentiles: bool = True, max_curves: int | None = None) -> None:
|
|
179
201
|
"""
|
|
180
202
|
Plot all Monte Carlo simulation equity curves.
|
|
181
203
|
|
|
@@ -190,6 +212,8 @@ class MonteCarloResult:
|
|
|
190
212
|
curve. Defaults to True.
|
|
191
213
|
show_percentiles (bool, optional): Whether to show percentile bands.
|
|
192
214
|
Defaults to True.
|
|
215
|
+
max_curves (int | None, optional): Maximum number of simulation curves
|
|
216
|
+
to render. Defaults to ``self.plot_max_curves``.
|
|
193
217
|
|
|
194
218
|
Note:
|
|
195
219
|
This method uses matplotlib to display the plots and requires
|
|
@@ -198,11 +222,13 @@ class MonteCarloResult:
|
|
|
198
222
|
from matplotlib import pyplot as plt
|
|
199
223
|
|
|
200
224
|
fig, ax = plt.subplots(figsize=figsize)
|
|
225
|
+
|
|
226
|
+
max_curves = self.plot_max_curves if max_curves is None else max_curves
|
|
201
227
|
|
|
202
|
-
# Plot
|
|
203
|
-
#
|
|
228
|
+
# Plot against the datetime index so the x-axis reflects the actual
|
|
229
|
+
# backtest timeline instead of a generic simulation step axis.
|
|
204
230
|
if not self.equity_curves:
|
|
205
|
-
ax.set_xlabel("
|
|
231
|
+
ax.set_xlabel("Datetime")
|
|
206
232
|
ax.set_ylabel("Portfolio Value")
|
|
207
233
|
ax.set_title(f"Monte Carlo Simulation Results ({self.simulations} simulations)")
|
|
208
234
|
ax.grid(alpha=0.3)
|
|
@@ -210,14 +236,35 @@ class MonteCarloResult:
|
|
|
210
236
|
plt.show()
|
|
211
237
|
return
|
|
212
238
|
|
|
213
|
-
|
|
239
|
+
base_index = self.equity_curves[0].index
|
|
240
|
+
if not isinstance(base_index, pd.DatetimeIndex):
|
|
241
|
+
base_index = pd.to_datetime(base_index)
|
|
242
|
+
|
|
243
|
+
def _plot_x_values(curve: pd.Series) -> pd.Index:
|
|
244
|
+
if isinstance(curve.index, pd.DatetimeIndex):
|
|
245
|
+
return curve.index
|
|
246
|
+
return pd.to_datetime(curve.index)
|
|
214
247
|
|
|
215
248
|
# Plot all simulation curves with low alpha (transparency)
|
|
216
249
|
# This makes the average path appear lightest due to overlap
|
|
217
|
-
|
|
218
|
-
|
|
250
|
+
color_cycle = plt.rcParams["axes.prop_cycle"].by_key().get("color", ["steelblue"])
|
|
251
|
+
curve_count = len(self.equity_curves)
|
|
252
|
+
if max_curves is not None and max_curves > 0 and curve_count > max_curves:
|
|
253
|
+
plot_indices = np.linspace(0, curve_count - 1, max_curves, dtype=int)
|
|
254
|
+
else:
|
|
255
|
+
plot_indices = range(curve_count)
|
|
256
|
+
|
|
257
|
+
for i in plot_indices:
|
|
258
|
+
curve = self.equity_curves[i]
|
|
259
|
+
x_vals = _plot_x_values(curve)
|
|
219
260
|
y_vals = np.asarray(curve.values, dtype=np.float64)
|
|
220
|
-
ax.plot(
|
|
261
|
+
ax.plot(
|
|
262
|
+
x_vals,
|
|
263
|
+
y_vals,
|
|
264
|
+
color=color_cycle[i % len(color_cycle)],
|
|
265
|
+
alpha=0.08,
|
|
266
|
+
linewidth=0.5,
|
|
267
|
+
)
|
|
221
268
|
|
|
222
269
|
# Compute mean and median curves for highlighting
|
|
223
270
|
if self.equity_curves:
|
|
@@ -227,18 +274,20 @@ class MonteCarloResult:
|
|
|
227
274
|
median_curve = aligned.median(axis=1)
|
|
228
275
|
|
|
229
276
|
# Plot mean curve (thicker, lighter)
|
|
230
|
-
x_mean =
|
|
277
|
+
x_mean = base_index
|
|
231
278
|
y_mean = np.asarray(mean_curve.values, dtype=np.float64)
|
|
232
279
|
ax.plot(x_mean, y_mean, color="darkblue", alpha=0.8, linewidth=2, label="Mean")
|
|
233
280
|
|
|
234
281
|
# Plot median curve
|
|
235
|
-
x_med =
|
|
282
|
+
x_med = base_index
|
|
236
283
|
y_med = np.asarray(median_curve.values, dtype=np.float64)
|
|
237
284
|
ax.plot(x_med, y_med, color="navy", alpha=0.6, linewidth=1.5, linestyle="--", label="Median")
|
|
238
285
|
|
|
239
286
|
# Show original equity curve if requested
|
|
240
287
|
if show_original and self.original_equity is not None:
|
|
241
|
-
x_orig =
|
|
288
|
+
x_orig = self.original_equity.index
|
|
289
|
+
if not isinstance(x_orig, pd.DatetimeIndex):
|
|
290
|
+
x_orig = pd.to_datetime(x_orig)
|
|
242
291
|
y_orig = np.asarray(self.original_equity.values, dtype=np.float64)
|
|
243
292
|
ax.plot(x_orig, y_orig, color="red", alpha=0.9, linewidth=2, label="Original Backtest")
|
|
244
293
|
|
|
@@ -247,19 +296,20 @@ class MonteCarloResult:
|
|
|
247
296
|
aligned = pd.concat(self.equity_curves, axis=1)
|
|
248
297
|
p5 = aligned.quantile(0.05, axis=1)
|
|
249
298
|
p95 = aligned.quantile(0.95, axis=1)
|
|
250
|
-
x_p5 =
|
|
299
|
+
x_p5 = p5.index
|
|
300
|
+
if not isinstance(x_p5, pd.DatetimeIndex):
|
|
301
|
+
x_p5 = pd.to_datetime(x_p5)
|
|
251
302
|
y_p5 = np.asarray(p5.values, dtype=np.float64)
|
|
252
303
|
y_p95 = np.asarray(p95.values, dtype=np.float64)
|
|
253
304
|
ax.fill_between(x_p5, y_p5, y_p95, alpha=0.2, color="steelblue", label="5th-95th Percentile")
|
|
254
305
|
|
|
255
|
-
ax.set_xlabel("
|
|
306
|
+
ax.set_xlabel("Datetime")
|
|
256
307
|
ax.set_ylabel("Portfolio Value")
|
|
257
308
|
ax.set_title(f"Monte Carlo Simulation Results ({self.simulations} simulations)")
|
|
258
309
|
ax.legend(loc="best")
|
|
259
310
|
ax.grid(alpha=0.3)
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
ax.set_xlim(step_index[0], step_index[-1])
|
|
311
|
+
ax.xaxis.set_major_formatter(mdates.DateFormatter("%Y-%m"))
|
|
312
|
+
fig.autofmt_xdate()
|
|
263
313
|
|
|
264
314
|
plt.tight_layout()
|
|
265
315
|
plt.show()
|
|
@@ -372,9 +422,10 @@ def _run_trade_shuffle_with_replacement_simulation(
|
|
|
372
422
|
"""
|
|
373
423
|
Run a Monte Carlo simulation that samples trade outcomes with replacement.
|
|
374
424
|
|
|
375
|
-
This mode
|
|
376
|
-
|
|
377
|
-
|
|
425
|
+
This mode reuses the original trade-return sequence as a return pool and
|
|
426
|
+
reconstructs the curve using a replacement-sampled path. Any remaining
|
|
427
|
+
steps are kept neutral so the resulting equity curve always spans the same
|
|
428
|
+
time axis as the original backtest.
|
|
378
429
|
"""
|
|
379
430
|
if seed is not None:
|
|
380
431
|
random.seed(seed)
|
|
@@ -394,13 +445,7 @@ def _run_trade_shuffle_with_replacement_simulation(
|
|
|
394
445
|
if not trade_returns:
|
|
395
446
|
return pd.Series(equity, index=index)
|
|
396
447
|
|
|
397
|
-
|
|
398
|
-
# outcomes and omit others entirely by only sampling a subset of the trade
|
|
399
|
-
# return pool on each simulation, then pad the rest with neutral returns so
|
|
400
|
-
# the full curve length is preserved.
|
|
401
|
-
sample_size = max(1, int(len(trade_returns) * 0.75))
|
|
402
|
-
sampled_returns = [random.choice(trade_returns) for _ in range(sample_size)]
|
|
403
|
-
sampled_returns.extend([0.0] * (len(trade_returns) - sample_size))
|
|
448
|
+
sampled_returns = [random.choice(trade_returns) for _ in range(len(trade_returns))]
|
|
404
449
|
|
|
405
450
|
equity_returns = np.concatenate(([0.0], np.asarray(sampled_returns, dtype=np.float64)))
|
|
406
451
|
|
|
@@ -563,7 +608,7 @@ def monte_carlo(
|
|
|
563
608
|
- "trade_order": Randomize trade execution order
|
|
564
609
|
- "price_path": Resample price returns to create synthetic paths
|
|
565
610
|
- "both": Run both analyses and combine results
|
|
566
|
-
Defaults to "
|
|
611
|
+
Defaults to "trade_order".
|
|
567
612
|
seed (int | None, optional): Random seed for reproducibility.
|
|
568
613
|
Defaults to None.
|
|
569
614
|
progress_bar (bool, optional): Whether to show progress bar during simulation.
|
|
@@ -46,16 +46,18 @@ class BacktestReport:
|
|
|
46
46
|
metrics such as Sharpe ratio and maximum drawdown.
|
|
47
47
|
|
|
48
48
|
Attributes:
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
49
|
+
starting_cash (np.float64): Initial cash amount at start of backtest.
|
|
50
|
+
final_cash (np.float64): Final cash amount at end of backtest.
|
|
51
|
+
PnlRecord (pd.Series): Time series of P&L values throughout the backtest.
|
|
52
|
+
orders (list[Order]): List of all orders executed during the backtest.
|
|
53
|
+
margin_call_events (list[dict]): Margin call events triggered during the run.
|
|
53
54
|
"""
|
|
54
55
|
starting_cash: np.float64
|
|
55
56
|
final_cash: np.float64
|
|
56
57
|
PnlRecord: pd.Series
|
|
57
58
|
orders: list
|
|
58
59
|
tradeRecord: list[np.float64]
|
|
60
|
+
margin_call_events: list[dict] | None = None
|
|
59
61
|
|
|
60
62
|
@property
|
|
61
63
|
def annual_rf(self):
|
|
@@ -201,6 +203,7 @@ class BacktestReport:
|
|
|
201
203
|
tot_return = float(equity.iloc[-1] / equity.iloc[0] - 1.0)
|
|
202
204
|
annualized_return = float((1.0 + tot_return) ** (self.periods_per_year / max(len(returns), 1)) - 1.0)
|
|
203
205
|
tot_orders = len(self.orders)
|
|
206
|
+
margin_calls = len(self.margin_call_events or [])
|
|
204
207
|
|
|
205
208
|
return (
|
|
206
209
|
f"Starting Cash: ${self.starting_cash:,.2f}\n"
|
|
@@ -215,5 +218,6 @@ class BacktestReport:
|
|
|
215
218
|
) + (
|
|
216
219
|
f"\nMax Drawdown: {mdd:.2%}\n"
|
|
217
220
|
f"Kelly Fraction: {self.kelly_criterion:.3}\n"
|
|
218
|
-
f"Total Trades: {tot_orders:,}"
|
|
221
|
+
f"Total Trades: {tot_orders:,}\n"
|
|
222
|
+
f"Margin Calls: {margin_calls:,}"
|
|
219
223
|
)
|
|
@@ -79,6 +79,8 @@ class Broker:
|
|
|
79
79
|
self.complete_orders = []
|
|
80
80
|
self.active_order: Order | None = None
|
|
81
81
|
self.pending_close_order: Order | None = None
|
|
82
|
+
self.margin_call_triggered: bool = False
|
|
83
|
+
self.margin_call_events: list[dict] = []
|
|
82
84
|
self._i = 0
|
|
83
85
|
self.source = source
|
|
84
86
|
self.PnLRecord = np.full(len(self.source.data['Close']), self.cash, dtype=np.float64)
|
|
@@ -604,5 +606,12 @@ class Broker:
|
|
|
604
606
|
equity = self.cash + unrealized
|
|
605
607
|
margin_call = self.margin_call * abs(self.position) * self.source.CClose
|
|
606
608
|
if equity < margin_call and self.position < 0:
|
|
609
|
+
self.margin_call_triggered = True
|
|
610
|
+
self.margin_call_events.append({
|
|
611
|
+
"timestamp": self.source.Index[self._i],
|
|
612
|
+
"equity": equity,
|
|
613
|
+
"margin_call_threshold": margin_call,
|
|
614
|
+
"position": self.position,
|
|
615
|
+
})
|
|
607
616
|
self.close() ## Close all positions immediately, margin call
|
|
608
617
|
self.PnLRecord[self._i] = equity
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|