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.
- {quantex-0.4.2 → quantex-0.4.4}/PKG-INFO +1 -1
- {quantex-0.4.2 → quantex-0.4.4}/pyproject.toml +1 -1
- {quantex-0.4.2 → quantex-0.4.4}/src/quantex/backtester/montecarlo.py +177 -31
- {quantex-0.4.2 → quantex-0.4.4}/LICENSE.md +0 -0
- {quantex-0.4.2 → quantex-0.4.4}/README.md +0 -0
- {quantex-0.4.2 → quantex-0.4.4}/src/quantex/__init__.py +0 -0
- {quantex-0.4.2 → quantex-0.4.4}/src/quantex/backtester/__init__.py +0 -0
- {quantex-0.4.2 → quantex-0.4.4}/src/quantex/backtester/backtester.py +0 -0
- {quantex-0.4.2 → quantex-0.4.4}/src/quantex/backtester/constants.py +0 -0
- {quantex-0.4.2 → quantex-0.4.4}/src/quantex/backtester/data_splits.py +0 -0
- {quantex-0.4.2 → quantex-0.4.4}/src/quantex/backtester/metrics.py +0 -0
- {quantex-0.4.2 → quantex-0.4.4}/src/quantex/backtester/parallel.py +0 -0
- {quantex-0.4.2 → quantex-0.4.4}/src/quantex/backtester/reports.py +0 -0
- {quantex-0.4.2 → quantex-0.4.4}/src/quantex/broker/__init__.py +0 -0
- {quantex-0.4.2 → quantex-0.4.4}/src/quantex/broker/broker.py +0 -0
- {quantex-0.4.2 → quantex-0.4.4}/src/quantex/broker/types.py +0 -0
- {quantex-0.4.2 → quantex-0.4.4}/src/quantex/datasource.py +0 -0
- {quantex-0.4.2 → quantex-0.4.4}/src/quantex/helpers.py +0 -0
- {quantex-0.4.2 → quantex-0.4.4}/src/quantex/indicators.py +0 -0
- {quantex-0.4.2 → quantex-0.4.4}/src/quantex/strategy.py +0 -0
|
@@ -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
|
-
#
|
|
236
|
-
|
|
237
|
-
|
|
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
|
-
#
|
|
246
|
-
#
|
|
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
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
|
354
|
+
# Reconstruct equity curve with shuffled returns
|
|
258
355
|
for i in range(1, len(equity)):
|
|
259
|
-
equity[i] = equity[i - 1] +
|
|
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 =
|
|
399
|
-
mode: MonteCarloMode | str = MonteCarloMode.
|
|
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.
|
|
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
|
-
#
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|