quantex 0.4.2__tar.gz → 0.4.4__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.4
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.4"
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,27 @@ 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.empty_like(equity_values)
344
+ prev[0] = original_cash
345
+ prev[1:] = equity_values[:-1]
346
+ equity_returns[1:] = np.where(prev[1:] > 0, (equity_values[1:] / prev[1:]) - 1.0, 0.0)
347
+
348
+ # Keep the starting cash anchored at index 0 and randomize the remaining
349
+ # returns so the path always begins from the actual initial capital.
350
+ shuffled_returns = equity_returns[1:].tolist()
351
+ random.shuffle(shuffled_returns)
352
+ equity_returns = np.concatenate(([0.0], np.asarray(shuffled_returns, dtype=np.float64)))
256
353
 
257
- # Reconstruct equity curve with shuffled changes
354
+ # Reconstruct equity curve with shuffled returns
258
355
  for i in range(1, len(equity)):
259
- equity[i] = equity[i - 1] + equity_changes[i]
260
-
356
+ equity[i] = equity[i - 1] * (1.0 + equity_returns[i])
357
+
261
358
  return pd.Series(equity, index=index)
262
359
 
263
360
 
@@ -359,7 +456,7 @@ def _run_price_path_simulation(
359
456
  synthetic_high = np.maximum(synthetic_open, synthetic_close)
360
457
  synthetic_low = np.minimum(synthetic_open, synthetic_close)
361
458
 
362
- synthetic_df = source_df
459
+ synthetic_df = source_df.copy()
363
460
  synthetic_df["Close"] = synthetic_close
364
461
  synthetic_df["Open"] = synthetic_open
365
462
  synthetic_df["High"] = np.maximum.reduce([synthetic_high, synthetic_open, synthetic_close])
@@ -395,8 +492,8 @@ def _run_price_path_simulation(
395
492
 
396
493
  def monte_carlo(
397
494
  self,
398
- simulations: int = 100,
399
- mode: MonteCarloMode | str = MonteCarloMode.BOTH,
495
+ simulations: int | None = None,
496
+ mode: MonteCarloMode | str = MonteCarloMode.TRADE_ORDER,
400
497
  seed: int | None = None,
401
498
  progress_bar: bool = False,
402
499
  ) -> MonteCarloResult:
@@ -407,7 +504,9 @@ def monte_carlo(
407
504
  either trade order randomization, price path resampling, or both.
408
505
 
409
506
  Args:
410
- simulations (int, optional): Number of simulations to run. Defaults to 100.
507
+ simulations (int | None, optional): Number of simulations to run.
508
+ Defaults to the number of unique permutations of executed trades
509
+ when mode is "trade_order", otherwise 100.
411
510
  mode (MonteCarloMode | str, optional): Simulation mode. Options:
412
511
  - "trade_order": Randomize trade execution order
413
512
  - "price_path": Resample price returns to create synthetic paths
@@ -448,6 +547,12 @@ def monte_carlo(
448
547
  original_equity = original_report.PnlRecord
449
548
  original_cash = float(original_report.starting_cash)
450
549
  original_orders = original_report.orders
550
+
551
+ if simulations is None:
552
+ if mode == MonteCarloMode.TRADE_ORDER:
553
+ simulations = math.factorial(len(original_orders)) if original_orders else 0
554
+ else:
555
+ simulations = 100
451
556
 
452
557
  equity_curves = []
453
558
 
@@ -492,14 +597,55 @@ def monte_carlo(
492
597
  )
493
598
  equity_curves.append(curve)
494
599
 
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
-
600
+ # In BOTH mode we ran two distinct simulation families per iteration.
601
+ # Keep the results and summary statistics separate to avoid pooling
602
+ # different distributions into a single invalid summary.
603
+ if mode == MonteCarloMode.BOTH:
604
+ trade_curves = equity_curves[0::2]
605
+ price_curves = equity_curves[1::2]
606
+
607
+ trade_result = MonteCarloResult(
608
+ mode=MonteCarloMode.TRADE_ORDER,
609
+ equity_curves=trade_curves,
610
+ original_equity=original_equity,
611
+ simulations=simulations,
612
+ starting_cash=original_cash,
613
+ )
614
+ trade_result._compute_statistics()
615
+
616
+ price_result = MonteCarloResult(
617
+ mode=MonteCarloMode.PRICE_PATH,
618
+ equity_curves=price_curves,
619
+ original_equity=original_equity,
620
+ simulations=simulations,
621
+ starting_cash=original_cash,
622
+ )
623
+ price_result._compute_statistics()
624
+
625
+ result = MonteCarloResult(
626
+ mode=mode,
627
+ equity_curves=equity_curves,
628
+ original_equity=original_equity,
629
+ simulations=simulations,
630
+ starting_cash=original_cash,
631
+ )
632
+ result.summary_stats = {
633
+ "trade_order": trade_result.summary_stats,
634
+ "price_path": price_result.summary_stats,
635
+ }
636
+ result.percentile_results = {
637
+ "trade_order": trade_result.percentile_results,
638
+ "price_path": price_result.percentile_results,
639
+ }
640
+ else:
641
+ # Create result object
642
+ result = MonteCarloResult(
643
+ mode=mode,
644
+ equity_curves=equity_curves,
645
+ original_equity=original_equity,
646
+ simulations=simulations,
647
+ starting_cash=original_cash,
648
+ )
649
+ result._compute_statistics()
650
+
505
651
  return result
File without changes
File without changes
File without changes
File without changes
File without changes