quantex 0.4.4__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: quantex
3
- Version: 0.4.4
3
+ Version: 0.4.6
4
4
  Summary: A simple quant strategy creation and backtesting package.
5
5
  License: MIT
6
6
  Author: Daniel Green
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "quantex"
3
- version = "0.4.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,
@@ -1,10 +1,11 @@
1
1
  """
2
2
  Monte Carlo simulation module for quantex backtesting.
3
3
 
4
- This module provides Monte Carlo simulation capabilities to test strategy
5
- robustness through two approaches:
6
- 1. Trade Order Randomization - shuffles the sequence of executed trades
7
- 2. Price Path Resampling (Bootstrap) - resamples historical returns to create synthetic paths
4
+ This module provides Monte Carlo simulation capabilities to test strategy
5
+ robustness through three approaches:
6
+ 1. Trade Order Randomization - shuffles the sequence of executed trades
7
+ 2. Trade Shuffle With Replacement - resamples trade returns with replacement
8
+ 3. Price Path Resampling (Bootstrap) - resamples historical returns to create synthetic paths
8
9
  """
9
10
 
10
11
  import copy
@@ -12,6 +13,7 @@ import math
12
13
  import random
13
14
  import numpy as np
14
15
  import pandas as pd
16
+ import matplotlib.dates as mdates
15
17
  from dataclasses import dataclass, field
16
18
  from enum import Enum
17
19
  from typing import Any
@@ -33,6 +35,7 @@ class MonteCarloMode(Enum):
33
35
  BOTH: Run both analyses and combine results
34
36
  """
35
37
  TRADE_ORDER = "trade_order"
38
+ TRADE_SHUFFLE_WITH_REPLACEMENT = "trade_shuffle_with_replacement"
36
39
  PRICE_PATH = "price_path"
37
40
  BOTH = "both"
38
41
 
@@ -62,6 +65,7 @@ class MonteCarloResult:
62
65
  simulations: int = 0
63
66
  starting_cash: float = 0.0
64
67
  drawdown_stats: dict = field(default_factory=dict)
68
+ plot_max_curves: int = 150
65
69
 
66
70
  def _compute_statistics(self):
67
71
  """Compute summary statistics from equity curves."""
@@ -113,7 +117,7 @@ class MonteCarloResult:
113
117
  self,
114
118
  target_return: float,
115
119
  drawdown_threshold: float,
116
- horizon: int | None = None,
120
+ horizon: int | str | pd.Timedelta | None = None,
117
121
  as_percent: bool = True,
118
122
  ) -> dict:
119
123
  """
@@ -125,8 +129,10 @@ class MonteCarloResult:
125
129
  True, this is treated as a decimal return (e.g. 0.05 for 5%).
126
130
  drawdown_threshold (float): Drawdown threshold. If `as_percent` is
127
131
  True, this is treated as a decimal drawdown (e.g. 0.05 for 5%).
128
- horizon (int | None, optional): Number of steps to evaluate. Defaults
129
- to the full length of the simulated curves.
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.
130
136
  as_percent (bool, optional): Whether thresholds are provided as
131
137
  decimal percentages. Defaults to True.
132
138
 
@@ -142,8 +148,26 @@ class MonteCarloResult:
142
148
  "drawdown_threshold": drawdown_threshold,
143
149
  }
144
150
 
145
- horizon = horizon or len(self.equity_curves[0])
146
- horizon = max(1, min(horizon, len(self.equity_curves[0])))
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)
147
171
 
148
172
  if as_percent:
149
173
  target_return = float(target_return)
@@ -152,7 +176,7 @@ class MonteCarloResult:
152
176
  return_hits = 0
153
177
  drawdown_hits = 0
154
178
  for curve in self.equity_curves:
155
- sampled = curve.iloc[:horizon]
179
+ sampled = curve.iloc[:horizon_steps]
156
180
  start_value = float(sampled.iloc[0])
157
181
  end_value = float(sampled.iloc[-1])
158
182
  achieved_return = (end_value / start_value) - 1.0 if start_value != 0 else 0.0
@@ -173,7 +197,7 @@ class MonteCarloResult:
173
197
  }
174
198
 
175
199
  def plot(self, figsize: tuple = (12, 8), show_original: bool = True,
176
- show_percentiles: bool = True) -> None:
200
+ show_percentiles: bool = True, max_curves: int | None = None) -> None:
177
201
  """
178
202
  Plot all Monte Carlo simulation equity curves.
179
203
 
@@ -188,6 +212,8 @@ class MonteCarloResult:
188
212
  curve. Defaults to True.
189
213
  show_percentiles (bool, optional): Whether to show percentile bands.
190
214
  Defaults to True.
215
+ max_curves (int | None, optional): Maximum number of simulation curves
216
+ to render. Defaults to ``self.plot_max_curves``.
191
217
 
192
218
  Note:
193
219
  This method uses matplotlib to display the plots and requires
@@ -196,11 +222,13 @@ class MonteCarloResult:
196
222
  from matplotlib import pyplot as plt
197
223
 
198
224
  fig, ax = plt.subplots(figsize=figsize)
225
+
226
+ max_curves = self.plot_max_curves if max_curves is None else max_curves
199
227
 
200
- # Plot using a numeric simulation step axis to avoid date conversion
201
- # artifacts when equity curves share the same time index.
228
+ # Plot against the datetime index so the x-axis reflects the actual
229
+ # backtest timeline instead of a generic simulation step axis.
202
230
  if not self.equity_curves:
203
- ax.set_xlabel("Step")
231
+ ax.set_xlabel("Datetime")
204
232
  ax.set_ylabel("Portfolio Value")
205
233
  ax.set_title(f"Monte Carlo Simulation Results ({self.simulations} simulations)")
206
234
  ax.grid(alpha=0.3)
@@ -208,14 +236,35 @@ class MonteCarloResult:
208
236
  plt.show()
209
237
  return
210
238
 
211
- step_index = np.arange(len(self.equity_curves[0]), dtype=np.float64)
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)
212
247
 
213
248
  # Plot all simulation curves with low alpha (transparency)
214
249
  # This makes the average path appear lightest due to overlap
215
- for curve in self.equity_curves:
216
- x_vals = np.arange(len(curve), dtype=np.float64)
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)
217
260
  y_vals = np.asarray(curve.values, dtype=np.float64)
218
- ax.plot(x_vals, y_vals, color="steelblue", alpha=0.1, linewidth=0.5)
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
+ )
219
268
 
220
269
  # Compute mean and median curves for highlighting
221
270
  if self.equity_curves:
@@ -225,18 +274,20 @@ class MonteCarloResult:
225
274
  median_curve = aligned.median(axis=1)
226
275
 
227
276
  # Plot mean curve (thicker, lighter)
228
- x_mean = np.arange(len(mean_curve), dtype=np.float64)
277
+ x_mean = base_index
229
278
  y_mean = np.asarray(mean_curve.values, dtype=np.float64)
230
279
  ax.plot(x_mean, y_mean, color="darkblue", alpha=0.8, linewidth=2, label="Mean")
231
280
 
232
281
  # Plot median curve
233
- x_med = np.arange(len(median_curve), dtype=np.float64)
282
+ x_med = base_index
234
283
  y_med = np.asarray(median_curve.values, dtype=np.float64)
235
284
  ax.plot(x_med, y_med, color="navy", alpha=0.6, linewidth=1.5, linestyle="--", label="Median")
236
285
 
237
286
  # Show original equity curve if requested
238
287
  if show_original and self.original_equity is not None:
239
- x_orig = np.arange(len(self.original_equity), dtype=np.float64)
288
+ x_orig = self.original_equity.index
289
+ if not isinstance(x_orig, pd.DatetimeIndex):
290
+ x_orig = pd.to_datetime(x_orig)
240
291
  y_orig = np.asarray(self.original_equity.values, dtype=np.float64)
241
292
  ax.plot(x_orig, y_orig, color="red", alpha=0.9, linewidth=2, label="Original Backtest")
242
293
 
@@ -245,19 +296,20 @@ class MonteCarloResult:
245
296
  aligned = pd.concat(self.equity_curves, axis=1)
246
297
  p5 = aligned.quantile(0.05, axis=1)
247
298
  p95 = aligned.quantile(0.95, axis=1)
248
- x_p5 = np.arange(len(p5), dtype=np.float64)
299
+ x_p5 = p5.index
300
+ if not isinstance(x_p5, pd.DatetimeIndex):
301
+ x_p5 = pd.to_datetime(x_p5)
249
302
  y_p5 = np.asarray(p5.values, dtype=np.float64)
250
303
  y_p95 = np.asarray(p95.values, dtype=np.float64)
251
304
  ax.fill_between(x_p5, y_p5, y_p95, alpha=0.2, color="steelblue", label="5th-95th Percentile")
252
305
 
253
- ax.set_xlabel("Step")
306
+ ax.set_xlabel("Datetime")
254
307
  ax.set_ylabel("Portfolio Value")
255
308
  ax.set_title(f"Monte Carlo Simulation Results ({self.simulations} simulations)")
256
309
  ax.legend(loc="best")
257
310
  ax.grid(alpha=0.3)
258
-
259
- # Match the more compact spaghetti-plot look by tightening x-limits.
260
- ax.set_xlim(step_index[0], step_index[-1])
311
+ ax.xaxis.set_major_formatter(mdates.DateFormatter("%Y-%m"))
312
+ fig.autofmt_xdate()
261
313
 
262
314
  plt.tight_layout()
263
315
  plt.show()
@@ -358,6 +410,51 @@ def _run_trade_order_simulation(
358
410
  return pd.Series(equity, index=index)
359
411
 
360
412
 
413
+ def _run_trade_shuffle_with_replacement_simulation(
414
+ original_orders: list[Order],
415
+ original_cash: float,
416
+ original_equity: pd.Series,
417
+ commission: float,
418
+ commission_type,
419
+ lot_size: int,
420
+ seed: int | None = None,
421
+ ) -> pd.Series:
422
+ """
423
+ Run a Monte Carlo simulation that samples trade outcomes with replacement.
424
+
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.
429
+ """
430
+ if seed is not None:
431
+ random.seed(seed)
432
+
433
+ index = original_equity.index
434
+ equity = np.full(len(index), original_cash, dtype=np.float64)
435
+
436
+ equity_values = np.asarray(original_equity.values, dtype=np.float64)
437
+ equity_returns = np.zeros_like(equity_values)
438
+ if len(equity_values) > 1:
439
+ prev = np.empty_like(equity_values)
440
+ prev[0] = original_cash
441
+ prev[1:] = equity_values[:-1]
442
+ equity_returns[1:] = np.where(prev[1:] > 0, (equity_values[1:] / prev[1:]) - 1.0, 0.0)
443
+
444
+ trade_returns = equity_returns[1:].tolist()
445
+ if not trade_returns:
446
+ return pd.Series(equity, index=index)
447
+
448
+ sampled_returns = [random.choice(trade_returns) for _ in range(len(trade_returns))]
449
+
450
+ equity_returns = np.concatenate(([0.0], np.asarray(sampled_returns, dtype=np.float64)))
451
+
452
+ for i in range(1, len(equity)):
453
+ equity[i] = equity[i - 1] * (1.0 + equity_returns[i])
454
+
455
+ return pd.Series(equity, index=index)
456
+
457
+
361
458
  def _run_price_path_simulation(
362
459
  strategy: Strategy,
363
460
  data_sources: dict[str, DataSource],
@@ -511,7 +608,7 @@ def monte_carlo(
511
608
  - "trade_order": Randomize trade execution order
512
609
  - "price_path": Resample price returns to create synthetic paths
513
610
  - "both": Run both analyses and combine results
514
- Defaults to "both".
611
+ Defaults to "trade_order".
515
612
  seed (int | None, optional): Random seed for reproducibility.
516
613
  Defaults to None.
517
614
  progress_bar (bool, optional): Whether to show progress bar during simulation.
@@ -583,6 +680,21 @@ def monte_carlo(
583
680
  # No trades, just return original equity
584
681
  curve = original_equity.copy()
585
682
  equity_curves.append(curve)
683
+
684
+ if mode == MonteCarloMode.TRADE_SHUFFLE_WITH_REPLACEMENT:
685
+ if len(original_orders) > 0:
686
+ curve = _run_trade_shuffle_with_replacement_simulation(
687
+ original_orders,
688
+ original_cash,
689
+ original_equity,
690
+ self.commission,
691
+ self.commission_type,
692
+ self.lot_size,
693
+ seed=iter_seed,
694
+ )
695
+ else:
696
+ curve = original_equity.copy()
697
+ equity_curves.append(curve)
586
698
 
587
699
  if mode == MonteCarloMode.PRICE_PATH or mode == MonteCarloMode.BOTH:
588
700
  # Price path resampling
@@ -637,6 +749,15 @@ def monte_carlo(
637
749
  "trade_order": trade_result.percentile_results,
638
750
  "price_path": price_result.percentile_results,
639
751
  }
752
+ elif mode == MonteCarloMode.TRADE_SHUFFLE_WITH_REPLACEMENT:
753
+ result = MonteCarloResult(
754
+ mode=mode,
755
+ equity_curves=equity_curves,
756
+ original_equity=original_equity,
757
+ simulations=simulations,
758
+ starting_cash=original_cash,
759
+ )
760
+ result._compute_statistics()
640
761
  else:
641
762
  # Create result object
642
763
  result = MonteCarloResult(
@@ -46,16 +46,18 @@ class BacktestReport:
46
46
  metrics such as Sharpe ratio and maximum drawdown.
47
47
 
48
48
  Attributes:
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.
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