quantex 0.4.2__tar.gz → 0.4.3__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.2
3
+ Version: 0.4.3
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.2"
3
+ version = "0.4.3"
4
4
  description = "A simple quant strategy creation and backtesting package."
5
5
  authors = [
6
6
  {name = "Daniel Green",email = "dangreen07@outlook.com"}
@@ -8,6 +8,7 @@ robustness through two approaches:
8
8
  """
9
9
 
10
10
  import copy
11
+ import math
11
12
  import random
12
13
  import numpy as np
13
14
  import pandas as pd
@@ -60,6 +61,7 @@ class MonteCarloResult:
60
61
  original_equity: pd.Series | None = None
61
62
  simulations: int = 0
62
63
  starting_cash: float = 0.0
64
+ drawdown_stats: dict = field(default_factory=dict)
63
65
 
64
66
  def _compute_statistics(self):
65
67
  """Compute summary statistics from equity curves."""
@@ -86,6 +88,89 @@ class MonteCarloResult:
86
88
  "p75": np.percentile(final_values, 75),
87
89
  "p95": np.percentile(final_values, 95),
88
90
  }
91
+
92
+ drawdowns = []
93
+ for curve in self.equity_curves:
94
+ running_max = curve.cummax()
95
+ dd = (curve / running_max) - 1.0
96
+ drawdowns.append(float(dd.min()))
97
+
98
+ drawdown_values = np.asarray(drawdowns, dtype=np.float64)
99
+ self.drawdown_stats = {
100
+ "mean": float(np.mean(drawdown_values)),
101
+ "std": float(np.std(drawdown_values)),
102
+ "min": float(np.min(drawdown_values)),
103
+ "max": float(np.max(drawdown_values)),
104
+ "median": float(np.median(drawdown_values)),
105
+ "p5": float(np.percentile(drawdown_values, 5)),
106
+ "p25": float(np.percentile(drawdown_values, 25)),
107
+ "p50": float(np.percentile(drawdown_values, 50)),
108
+ "p75": float(np.percentile(drawdown_values, 75)),
109
+ "p95": float(np.percentile(drawdown_values, 95)),
110
+ }
111
+
112
+ def probabilities(
113
+ self,
114
+ target_return: float,
115
+ drawdown_threshold: float,
116
+ horizon: int | None = None,
117
+ as_percent: bool = True,
118
+ ) -> dict:
119
+ """
120
+ Calculate the probability of reaching a target return and exceeding
121
+ a drawdown threshold within a given time horizon.
122
+
123
+ Args:
124
+ target_return (float): Target return threshold. If `as_percent` is
125
+ True, this is treated as a decimal return (e.g. 0.05 for 5%).
126
+ drawdown_threshold (float): Drawdown threshold. If `as_percent` is
127
+ 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.
130
+ as_percent (bool, optional): Whether thresholds are provided as
131
+ decimal percentages. Defaults to True.
132
+
133
+ Returns:
134
+ dict: Probability summary containing return and drawdown metrics.
135
+ """
136
+ if not self.equity_curves:
137
+ return {
138
+ "return_probability": 0.0,
139
+ "drawdown_probability": 0.0,
140
+ "horizon": horizon,
141
+ "target_return": target_return,
142
+ "drawdown_threshold": drawdown_threshold,
143
+ }
144
+
145
+ horizon = horizon or len(self.equity_curves[0])
146
+ horizon = max(1, min(horizon, len(self.equity_curves[0])))
147
+
148
+ if as_percent:
149
+ target_return = float(target_return)
150
+ drawdown_threshold = float(drawdown_threshold)
151
+
152
+ return_hits = 0
153
+ drawdown_hits = 0
154
+ for curve in self.equity_curves:
155
+ sampled = curve.iloc[:horizon]
156
+ start_value = float(sampled.iloc[0])
157
+ end_value = float(sampled.iloc[-1])
158
+ achieved_return = (end_value / start_value) - 1.0 if start_value != 0 else 0.0
159
+ max_drawdown = float(((sampled / sampled.cummax()) - 1.0).min())
160
+
161
+ if achieved_return >= target_return:
162
+ return_hits += 1
163
+ if abs(max_drawdown) >= drawdown_threshold:
164
+ drawdown_hits += 1
165
+
166
+ total = len(self.equity_curves)
167
+ return {
168
+ "return_probability": return_hits / total,
169
+ "drawdown_probability": drawdown_hits / total,
170
+ "horizon": horizon,
171
+ "target_return": target_return,
172
+ "drawdown_threshold": drawdown_threshold,
173
+ }
89
174
 
90
175
  def plot(self, figsize: tuple = (12, 8), show_original: bool = True,
91
176
  show_percentiles: bool = True) -> None:
@@ -198,6 +283,13 @@ class MonteCarloResult:
198
283
  f" 50th: ${self.percentile_results.get('p50', 0):,.2f} (Median)\n"
199
284
  f" 75th: ${self.percentile_results.get('p75', 0):,.2f}\n"
200
285
  f" 95th: ${self.percentile_results.get('p95', 0):,.2f}\n"
286
+ f"\nDrawdown Statistics (% of peak):\n"
287
+ f" Mean Max DD: {self.drawdown_stats.get('mean', 0):.2%}\n"
288
+ f" 5th: {self.drawdown_stats.get('p5', 0):.2%}\n"
289
+ f" 25th: {self.drawdown_stats.get('p25', 0):.2%}\n"
290
+ f" 50th: {self.drawdown_stats.get('p50', 0):.2%}\n"
291
+ f" 75th: {self.drawdown_stats.get('p75', 0):.2%}\n"
292
+ f" 95th: {self.drawdown_stats.get('p95', 0):.2%}\n"
201
293
  )
202
294
 
203
295
 
@@ -231,10 +323,10 @@ def _run_trade_order_simulation(
231
323
  """
232
324
  if seed is not None:
233
325
  random.seed(seed)
234
-
235
- # Shuffle the orders
236
- shuffled_orders = original_orders.copy()
237
- random.shuffle(shuffled_orders)
326
+
327
+ # Trade-order Monte Carlo must preserve the trade outcomes while changing
328
+ # only the order in which those outcomes are realized. We therefore shuffle
329
+ # the per-step percentage returns, not the absolute equity values.
238
330
 
239
331
  # Get time index from original equity
240
332
  index = original_equity.index
@@ -242,22 +334,25 @@ def _run_trade_order_simulation(
242
334
  # Initialize equity record
243
335
  equity = np.full(len(index), original_cash, dtype=np.float64)
244
336
 
245
- # Calculate cumulative PnL changes from original equity
246
- # Convert to numpy array first to avoid type issues
337
+ # Use step-wise percentage returns rather than absolute value changes.
338
+ # This keeps the path dependent on the sequence of returns rather than
339
+ # collapsing to the same terminal value every time.
247
340
  equity_values = np.asarray(original_equity.values, dtype=np.float64)
248
- equity_changes = np.diff(equity_values)
249
- equity_changes = np.insert(equity_changes, 0, 0)
250
-
251
- # Shuffle the equity changes to randomize trade order
252
- # Convert to list for shuffle, then back to array
253
- equity_changes_list = equity_changes.tolist()
254
- random.shuffle(equity_changes_list)
255
- equity_changes = np.array(equity_changes_list, dtype=np.float64)
341
+ equity_returns = np.zeros_like(equity_values)
342
+ if len(equity_values) > 1:
343
+ prev = np.where(np.arange(len(equity_values)) == 0, original_cash, equity_values[:-1])
344
+ equity_returns[1:] = np.where(prev > 0, (equity_values[1:] / prev) - 1.0, 0.0)
345
+
346
+ # Keep the starting cash anchored at index 0 and randomize the remaining
347
+ # returns so the path always begins from the actual initial capital.
348
+ shuffled_returns = equity_returns[1:].tolist()
349
+ random.shuffle(shuffled_returns)
350
+ equity_returns = np.concatenate(([0.0], np.asarray(shuffled_returns, dtype=np.float64)))
256
351
 
257
- # Reconstruct equity curve with shuffled changes
352
+ # Reconstruct equity curve with shuffled returns
258
353
  for i in range(1, len(equity)):
259
- equity[i] = equity[i - 1] + equity_changes[i]
260
-
354
+ equity[i] = equity[i - 1] * (1.0 + equity_returns[i])
355
+
261
356
  return pd.Series(equity, index=index)
262
357
 
263
358
 
@@ -359,7 +454,7 @@ def _run_price_path_simulation(
359
454
  synthetic_high = np.maximum(synthetic_open, synthetic_close)
360
455
  synthetic_low = np.minimum(synthetic_open, synthetic_close)
361
456
 
362
- synthetic_df = source_df
457
+ synthetic_df = source_df.copy()
363
458
  synthetic_df["Close"] = synthetic_close
364
459
  synthetic_df["Open"] = synthetic_open
365
460
  synthetic_df["High"] = np.maximum.reduce([synthetic_high, synthetic_open, synthetic_close])
@@ -395,8 +490,8 @@ def _run_price_path_simulation(
395
490
 
396
491
  def monte_carlo(
397
492
  self,
398
- simulations: int = 100,
399
- mode: MonteCarloMode | str = MonteCarloMode.BOTH,
493
+ simulations: int | None = None,
494
+ mode: MonteCarloMode | str = MonteCarloMode.TRADE_ORDER,
400
495
  seed: int | None = None,
401
496
  progress_bar: bool = False,
402
497
  ) -> MonteCarloResult:
@@ -407,7 +502,9 @@ def monte_carlo(
407
502
  either trade order randomization, price path resampling, or both.
408
503
 
409
504
  Args:
410
- simulations (int, optional): Number of simulations to run. Defaults to 100.
505
+ simulations (int | None, optional): Number of simulations to run.
506
+ Defaults to the number of unique permutations of executed trades
507
+ when mode is "trade_order", otherwise 100.
411
508
  mode (MonteCarloMode | str, optional): Simulation mode. Options:
412
509
  - "trade_order": Randomize trade execution order
413
510
  - "price_path": Resample price returns to create synthetic paths
@@ -448,6 +545,12 @@ def monte_carlo(
448
545
  original_equity = original_report.PnlRecord
449
546
  original_cash = float(original_report.starting_cash)
450
547
  original_orders = original_report.orders
548
+
549
+ if simulations is None:
550
+ if mode == MonteCarloMode.TRADE_ORDER:
551
+ simulations = math.factorial(len(original_orders)) if original_orders else 0
552
+ else:
553
+ simulations = 100
451
554
 
452
555
  equity_curves = []
453
556
 
@@ -492,14 +595,55 @@ def monte_carlo(
492
595
  )
493
596
  equity_curves.append(curve)
494
597
 
495
- # Create result object
496
- result = MonteCarloResult(
497
- mode=mode,
498
- equity_curves=equity_curves,
499
- original_equity=original_equity,
500
- simulations=simulations,
501
- starting_cash=original_cash,
502
- )
503
- result._compute_statistics()
504
-
598
+ # In BOTH mode we ran two distinct simulation families per iteration.
599
+ # Keep the results and summary statistics separate to avoid pooling
600
+ # different distributions into a single invalid summary.
601
+ if mode == MonteCarloMode.BOTH:
602
+ trade_curves = equity_curves[0::2]
603
+ price_curves = equity_curves[1::2]
604
+
605
+ trade_result = MonteCarloResult(
606
+ mode=MonteCarloMode.TRADE_ORDER,
607
+ equity_curves=trade_curves,
608
+ original_equity=original_equity,
609
+ simulations=simulations,
610
+ starting_cash=original_cash,
611
+ )
612
+ trade_result._compute_statistics()
613
+
614
+ price_result = MonteCarloResult(
615
+ mode=MonteCarloMode.PRICE_PATH,
616
+ equity_curves=price_curves,
617
+ original_equity=original_equity,
618
+ simulations=simulations,
619
+ starting_cash=original_cash,
620
+ )
621
+ price_result._compute_statistics()
622
+
623
+ result = MonteCarloResult(
624
+ mode=mode,
625
+ equity_curves=equity_curves,
626
+ original_equity=original_equity,
627
+ simulations=simulations,
628
+ starting_cash=original_cash,
629
+ )
630
+ result.summary_stats = {
631
+ "trade_order": trade_result.summary_stats,
632
+ "price_path": price_result.summary_stats,
633
+ }
634
+ result.percentile_results = {
635
+ "trade_order": trade_result.percentile_results,
636
+ "price_path": price_result.percentile_results,
637
+ }
638
+ else:
639
+ # Create result object
640
+ result = MonteCarloResult(
641
+ mode=mode,
642
+ equity_curves=equity_curves,
643
+ original_equity=original_equity,
644
+ simulations=simulations,
645
+ starting_cash=original_cash,
646
+ )
647
+ result._compute_statistics()
648
+
505
649
  return result
File without changes
File without changes
File without changes
File without changes
File without changes