quantex 0.4.1__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.1
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.1"
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
 
@@ -298,32 +393,78 @@ def _run_price_path_simulation(
298
393
  synthetic_sources = {}
299
394
 
300
395
  for symbol, source in data_sources.items():
301
- close_prices = source.data['Close'].values
302
-
303
- # Calculate log returns
396
+ close_prices = np.asarray(source.data["Close"].values, dtype=np.float64)
397
+ if len(close_prices) < 2:
398
+ synthetic_sources[symbol] = DataSource(source.data.copy())
399
+ continue
400
+
401
+ # Use a block-bootstrap on log returns to preserve local serial dependence
402
+ # and then re-price the path using a geometric Brownian motion style
403
+ # reconstruction with the sampled return distribution.
304
404
  log_returns = np.diff(np.log(close_prices))
305
-
306
- # Resample with replacement
307
405
  n_samples = len(log_returns)
308
- resampled_indices = np.random.randint(0, n_samples, size=n_samples)
309
- resampled_returns = log_returns[resampled_indices]
310
-
311
- # Reconstruct price path starting from initial price
312
- synthetic_prices = np.zeros(n_samples + 1)
313
- synthetic_prices[0] = close_prices[0]
314
- synthetic_prices[1:] = close_prices[0] * np.exp(np.cumsum(resampled_returns))
315
-
316
- # Create synthetic OHLCV data
317
- # Use the same pattern but with resampled close prices
318
- synthetic_df = source.data.copy()
319
- synthetic_df['Close'] = synthetic_prices
320
-
321
- # Adjust Open, High, Low based on close (simple approximation)
322
- # This is a simplification - real implementation would need proper OHLC generation
323
- synthetic_df['Open'] = synthetic_prices * (1 + np.random.uniform(-0.001, 0.001, n_samples + 1))
324
- synthetic_df['High'] = np.maximum(synthetic_prices, synthetic_df['Open']) * (1 + np.random.uniform(0, 0.002, n_samples + 1))
325
- synthetic_df['Low'] = np.minimum(synthetic_prices, synthetic_df['Open']) * (1 - np.random.uniform(0, 0.002, n_samples + 1))
326
-
406
+ block_size = max(2, min(10, int(np.sqrt(n_samples))))
407
+ synthetic_log_returns = []
408
+
409
+ while len(synthetic_log_returns) < n_samples:
410
+ start = int(np.random.randint(0, n_samples))
411
+ block = log_returns[start : start + block_size]
412
+ if len(block) < block_size:
413
+ wrap = block_size - len(block)
414
+ block = np.concatenate((block, log_returns[:wrap]))
415
+ synthetic_log_returns.extend(block.tolist())
416
+
417
+ synthetic_log_returns = np.asarray(synthetic_log_returns[:n_samples], dtype=np.float64)
418
+
419
+ # Keep the simulated path realistic by matching the original return
420
+ # center and volatility rather than letting the bootstrap drift too far.
421
+ original_mean = float(np.mean(log_returns))
422
+ original_std = float(np.std(log_returns))
423
+ synthetic_mean = float(np.mean(synthetic_log_returns))
424
+ synthetic_std = float(np.std(synthetic_log_returns))
425
+ if synthetic_std > 0 and original_std > 0:
426
+ synthetic_log_returns = (synthetic_log_returns - synthetic_mean) * (original_std / synthetic_std) + original_mean
427
+ else:
428
+ synthetic_log_returns = synthetic_log_returns - synthetic_mean + original_mean
429
+
430
+ synthetic_close = np.empty(n_samples + 1, dtype=np.float64)
431
+ synthetic_close[0] = close_prices[0]
432
+ synthetic_close[1:] = synthetic_close[0] * np.exp(np.cumsum(synthetic_log_returns))
433
+ synthetic_close = np.maximum(synthetic_close, np.finfo(np.float64).tiny)
434
+
435
+ # Derive intraday range from the historical candle shape so OHLC remains coherent.
436
+ source_df = source.data.copy()
437
+ if "Open" in source_df.columns:
438
+ open_close_gap = np.log(np.asarray(source_df["Open"].values, dtype=np.float64) / close_prices)
439
+ open_close_gap = np.nan_to_num(open_close_gap, nan=0.0, posinf=0.0, neginf=0.0)
440
+ else:
441
+ open_close_gap = np.zeros_like(synthetic_close)
442
+
443
+ open_noise = np.random.choice(open_close_gap, size=n_samples + 1, replace=True)
444
+ synthetic_open = synthetic_close * np.exp(open_noise)
445
+
446
+ if {"High", "Low"}.issubset(source_df.columns):
447
+ high_wick = np.log(np.asarray(source_df["High"].values, dtype=np.float64) / np.maximum(close_prices, np.finfo(np.float64).tiny))
448
+ low_wick = np.log(np.asarray(source_df["Low"].values, dtype=np.float64) / np.maximum(close_prices, np.finfo(np.float64).tiny))
449
+ high_wick = np.nan_to_num(high_wick, nan=0.0, posinf=0.0, neginf=0.0)
450
+ low_wick = np.nan_to_num(low_wick, nan=0.0, posinf=0.0, neginf=0.0)
451
+ synthetic_high = np.maximum(synthetic_open, synthetic_close) * np.exp(np.abs(np.random.choice(high_wick, size=n_samples + 1, replace=True)))
452
+ synthetic_low = np.minimum(synthetic_open, synthetic_close) * np.exp(-np.abs(np.random.choice(low_wick, size=n_samples + 1, replace=True)))
453
+ else:
454
+ synthetic_high = np.maximum(synthetic_open, synthetic_close)
455
+ synthetic_low = np.minimum(synthetic_open, synthetic_close)
456
+
457
+ synthetic_df = source_df.copy()
458
+ synthetic_df["Close"] = synthetic_close
459
+ synthetic_df["Open"] = synthetic_open
460
+ synthetic_df["High"] = np.maximum.reduce([synthetic_high, synthetic_open, synthetic_close])
461
+ synthetic_df["Low"] = np.minimum.reduce([synthetic_low, synthetic_open, synthetic_close])
462
+
463
+ if "Volume" in synthetic_df.columns:
464
+ volume = np.asarray(source_df["Volume"].values, dtype=np.float64)
465
+ if len(volume) == n_samples + 1:
466
+ synthetic_df["Volume"] = np.maximum(0.0, np.random.choice(volume, size=n_samples + 1, replace=True))
467
+
327
468
  synthetic_sources[symbol] = DataSource(synthetic_df)
328
469
 
329
470
  # Update strategy with synthetic data sources
@@ -349,8 +490,8 @@ def _run_price_path_simulation(
349
490
 
350
491
  def monte_carlo(
351
492
  self,
352
- simulations: int = 100,
353
- mode: MonteCarloMode | str = MonteCarloMode.BOTH,
493
+ simulations: int | None = None,
494
+ mode: MonteCarloMode | str = MonteCarloMode.TRADE_ORDER,
354
495
  seed: int | None = None,
355
496
  progress_bar: bool = False,
356
497
  ) -> MonteCarloResult:
@@ -361,7 +502,9 @@ def monte_carlo(
361
502
  either trade order randomization, price path resampling, or both.
362
503
 
363
504
  Args:
364
- 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.
365
508
  mode (MonteCarloMode | str, optional): Simulation mode. Options:
366
509
  - "trade_order": Randomize trade execution order
367
510
  - "price_path": Resample price returns to create synthetic paths
@@ -402,6 +545,12 @@ def monte_carlo(
402
545
  original_equity = original_report.PnlRecord
403
546
  original_cash = float(original_report.starting_cash)
404
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
405
554
 
406
555
  equity_curves = []
407
556
 
@@ -446,14 +595,55 @@ def monte_carlo(
446
595
  )
447
596
  equity_curves.append(curve)
448
597
 
449
- # Create result object
450
- result = MonteCarloResult(
451
- mode=mode,
452
- equity_curves=equity_curves,
453
- original_equity=original_equity,
454
- simulations=simulations,
455
- starting_cash=original_cash,
456
- )
457
- result._compute_statistics()
458
-
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
+
459
649
  return result
File without changes
File without changes
File without changes
File without changes
File without changes