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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: quantex
3
- Version: 0.4.5
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.5"
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): Number of steps to evaluate. Defaults
131
- 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.
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
- horizon = horizon or len(self.equity_curves[0])
148
- 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)
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[:horizon]
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 using a numeric simulation step axis to avoid date conversion
203
- # 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.
204
230
  if not self.equity_curves:
205
- ax.set_xlabel("Step")
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
- 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)
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
- for curve in self.equity_curves:
218
- 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)
219
260
  y_vals = np.asarray(curve.values, dtype=np.float64)
220
- 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
+ )
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 = np.arange(len(mean_curve), dtype=np.float64)
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 = np.arange(len(median_curve), dtype=np.float64)
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 = 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)
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 = 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)
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("Step")
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
- # Match the more compact spaghetti-plot look by tightening x-limits.
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 allows some trades to be repeated while others may be omitted.
376
- The sampled trade outcomes are then applied as percentage returns to the
377
- portfolio curve.
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
- # Build a replacement-sampled sequence that may include repeated trade
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 "both".
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
- 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