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.
- {quantex-0.4.1 → quantex-0.4.3}/PKG-INFO +1 -1
- {quantex-0.4.1 → quantex-0.4.3}/pyproject.toml +1 -1
- {quantex-0.4.1 → quantex-0.4.3}/src/quantex/backtester/montecarlo.py +244 -54
- {quantex-0.4.1 → quantex-0.4.3}/LICENSE.md +0 -0
- {quantex-0.4.1 → quantex-0.4.3}/README.md +0 -0
- {quantex-0.4.1 → quantex-0.4.3}/src/quantex/__init__.py +0 -0
- {quantex-0.4.1 → quantex-0.4.3}/src/quantex/backtester/__init__.py +0 -0
- {quantex-0.4.1 → quantex-0.4.3}/src/quantex/backtester/backtester.py +0 -0
- {quantex-0.4.1 → quantex-0.4.3}/src/quantex/backtester/constants.py +0 -0
- {quantex-0.4.1 → quantex-0.4.3}/src/quantex/backtester/data_splits.py +0 -0
- {quantex-0.4.1 → quantex-0.4.3}/src/quantex/backtester/metrics.py +0 -0
- {quantex-0.4.1 → quantex-0.4.3}/src/quantex/backtester/parallel.py +0 -0
- {quantex-0.4.1 → quantex-0.4.3}/src/quantex/backtester/reports.py +0 -0
- {quantex-0.4.1 → quantex-0.4.3}/src/quantex/broker/__init__.py +0 -0
- {quantex-0.4.1 → quantex-0.4.3}/src/quantex/broker/broker.py +0 -0
- {quantex-0.4.1 → quantex-0.4.3}/src/quantex/broker/types.py +0 -0
- {quantex-0.4.1 → quantex-0.4.3}/src/quantex/datasource.py +0 -0
- {quantex-0.4.1 → quantex-0.4.3}/src/quantex/helpers.py +0 -0
- {quantex-0.4.1 → quantex-0.4.3}/src/quantex/indicators.py +0 -0
- {quantex-0.4.1 → quantex-0.4.3}/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,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
|
-
#
|
|
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.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
|
|
352
|
+
# Reconstruct equity curve with shuffled returns
|
|
258
353
|
for i in range(1, len(equity)):
|
|
259
|
-
equity[i] = equity[i - 1] +
|
|
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[
|
|
302
|
-
|
|
303
|
-
|
|
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
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
#
|
|
322
|
-
#
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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 =
|
|
353
|
-
mode: MonteCarloMode | str = MonteCarloMode.
|
|
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.
|
|
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
|
-
#
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
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
|
|
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
|