quantex 0.4.5__tar.gz → 0.4.7__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.5 → quantex-0.4.7}/PKG-INFO +2 -1
- {quantex-0.4.5 → quantex-0.4.7}/pyproject.toml +3 -1
- {quantex-0.4.5 → quantex-0.4.7}/src/quantex/backtester/backtester.py +321 -22
- {quantex-0.4.5 → quantex-0.4.7}/src/quantex/backtester/montecarlo.py +78 -33
- {quantex-0.4.5 → quantex-0.4.7}/src/quantex/backtester/parallel.py +87 -27
- {quantex-0.4.5 → quantex-0.4.7}/src/quantex/backtester/reports.py +9 -5
- {quantex-0.4.5 → quantex-0.4.7}/src/quantex/broker/broker.py +9 -0
- {quantex-0.4.5 → quantex-0.4.7}/LICENSE.md +0 -0
- {quantex-0.4.5 → quantex-0.4.7}/README.md +0 -0
- {quantex-0.4.5 → quantex-0.4.7}/src/quantex/__init__.py +0 -0
- {quantex-0.4.5 → quantex-0.4.7}/src/quantex/backtester/__init__.py +0 -0
- {quantex-0.4.5 → quantex-0.4.7}/src/quantex/backtester/constants.py +0 -0
- {quantex-0.4.5 → quantex-0.4.7}/src/quantex/backtester/data_splits.py +0 -0
- {quantex-0.4.5 → quantex-0.4.7}/src/quantex/backtester/metrics.py +0 -0
- {quantex-0.4.5 → quantex-0.4.7}/src/quantex/broker/__init__.py +0 -0
- {quantex-0.4.5 → quantex-0.4.7}/src/quantex/broker/types.py +0 -0
- {quantex-0.4.5 → quantex-0.4.7}/src/quantex/datasource.py +0 -0
- {quantex-0.4.5 → quantex-0.4.7}/src/quantex/helpers.py +0 -0
- {quantex-0.4.5 → quantex-0.4.7}/src/quantex/indicators.py +0 -0
- {quantex-0.4.5 → quantex-0.4.7}/src/quantex/strategy.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: quantex
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.7
|
|
4
4
|
Summary: A simple quant strategy creation and backtesting package.
|
|
5
5
|
License: MIT
|
|
6
6
|
Author: Daniel Green
|
|
@@ -13,6 +13,7 @@ Classifier: Programming Language :: Python :: 3.12
|
|
|
13
13
|
Classifier: Programming Language :: Python :: 3.13
|
|
14
14
|
Requires-Dist: fastparquet (>=2024.11.0,<2025.0.0)
|
|
15
15
|
Requires-Dist: numpy (>=2.4.3,<3.0.0)
|
|
16
|
+
Requires-Dist: optuna (>=4.8.0,<5.0.0)
|
|
16
17
|
Requires-Dist: pandas (>=2.3.0,<3.0.0)
|
|
17
18
|
Requires-Dist: pyarrow (>=20.0.0,<21.0.0)
|
|
18
19
|
Requires-Dist: tqdm (>=4.67.1,<5.0.0)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "quantex"
|
|
3
|
-
version = "0.4.
|
|
3
|
+
version = "0.4.7"
|
|
4
4
|
description = "A simple quant strategy creation and backtesting package."
|
|
5
5
|
authors = [
|
|
6
6
|
{name = "Daniel Green",email = "dangreen07@outlook.com"}
|
|
@@ -14,6 +14,7 @@ dependencies = [
|
|
|
14
14
|
"pyarrow (>=20.0.0,<21.0.0)",
|
|
15
15
|
"tqdm (>=4.67.1,<5.0.0)",
|
|
16
16
|
"numpy (>=2.4.3,<3.0.0)",
|
|
17
|
+
"optuna (>=4.8.0,<5.0.0)",
|
|
17
18
|
]
|
|
18
19
|
|
|
19
20
|
[tool.poetry]
|
|
@@ -39,6 +40,7 @@ mkdocs-mermaid2-plugin = "^1.2.1"
|
|
|
39
40
|
mkdocs-print-site-plugin = "^2.7.3"
|
|
40
41
|
pytest-xdist = "^3.8.0"
|
|
41
42
|
matplotlib = "^3.10.3"
|
|
43
|
+
ipykernel = "^7.2.0"
|
|
42
44
|
|
|
43
45
|
[build-system]
|
|
44
46
|
requires = ["poetry-core>=2.0.0,<3.0.0"]
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import copy
|
|
2
2
|
import itertools
|
|
3
|
+
import math
|
|
3
4
|
import os
|
|
4
5
|
from typing import Any, Callable
|
|
5
6
|
|
|
@@ -175,7 +176,12 @@ class SimpleBacktester:
|
|
|
175
176
|
final_cash=self.PnLRecord[-1],
|
|
176
177
|
PnlRecord=pd.Series(self.PnLRecord, index=index),
|
|
177
178
|
orders=orders,
|
|
178
|
-
tradeRecord=tradeRecord
|
|
179
|
+
tradeRecord=tradeRecord,
|
|
180
|
+
margin_call_events=[
|
|
181
|
+
event
|
|
182
|
+
for broker in self.strategy.positions.values()
|
|
183
|
+
for event in getattr(broker, "margin_call_events", [])
|
|
184
|
+
] or None)
|
|
179
185
|
|
|
180
186
|
def optimize(
|
|
181
187
|
self,
|
|
@@ -267,7 +273,9 @@ class SimpleBacktester:
|
|
|
267
273
|
|
|
268
274
|
valid_metrics = {"final_cash", "total_return", "sharpe", "max_drawdown", "trades"}
|
|
269
275
|
|
|
270
|
-
|
|
276
|
+
# Use math.prod instead of len(list(itertools.product(...)))
|
|
277
|
+
# to avoid materializing all combinations in memory
|
|
278
|
+
total_combos = math.prod(len(v) for v in value_lists)
|
|
271
279
|
|
|
272
280
|
for combo in tqdm(itertools.product(*value_lists), total=(total_combos)):
|
|
273
281
|
# Build parameter dict for this combo
|
|
@@ -359,7 +367,7 @@ class SimpleBacktester:
|
|
|
359
367
|
objective: str = "sharpe",
|
|
360
368
|
risk_tolerance: dict[str, float] | None = None,
|
|
361
369
|
workers: int | None = None,
|
|
362
|
-
chunksize: int =
|
|
370
|
+
chunksize: int | str = "auto") -> OptimizationResult:
|
|
363
371
|
"""
|
|
364
372
|
Perform parallel grid search over parameter ranges for optimization.
|
|
365
373
|
|
|
@@ -381,10 +389,12 @@ class SimpleBacktester:
|
|
|
381
389
|
workers (int | None, optional): Maximum number of worker processes to use.
|
|
382
390
|
If None, defaults to min(os.cpu_count()-1, 4) to avoid overwhelming
|
|
383
391
|
the system. Defaults to None.
|
|
384
|
-
chunksize (int, optional): Chunk size for ProcessPoolExecutor.map.
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
392
|
+
chunksize (int | str, optional): Chunk size for ProcessPoolExecutor.map.
|
|
393
|
+
Can be an integer or "auto" for adaptive sizing based on total
|
|
394
|
+
combinations and worker count. Smaller values provide better load
|
|
395
|
+
balancing for many small tasks. Larger values reduce IPC overhead.
|
|
396
|
+
Defaults to "auto" (previously 1).
|
|
397
|
+
Auto-calculation: max(16, total_combos // (workers * 4))
|
|
388
398
|
|
|
389
399
|
Returns:
|
|
390
400
|
OptimizationResult: Object containing:
|
|
@@ -417,6 +427,7 @@ class SimpleBacktester:
|
|
|
417
427
|
lower multiprocessing overhead.
|
|
418
428
|
- Monitor system memory usage as each worker maintains a full
|
|
419
429
|
copy of the strategy and data.
|
|
430
|
+
- Auto chunksize provides better throughput for large parameter spaces.
|
|
420
431
|
|
|
421
432
|
Example:
|
|
422
433
|
>>> bt = SimpleBacktester(strategy)
|
|
@@ -428,13 +439,13 @@ class SimpleBacktester:
|
|
|
428
439
|
"""
|
|
429
440
|
import concurrent.futures
|
|
430
441
|
import pickle
|
|
442
|
+
import math
|
|
431
443
|
|
|
432
444
|
if not params:
|
|
433
445
|
raise ValueError("params must not be empty")
|
|
434
446
|
|
|
435
447
|
keys = list(params.keys())
|
|
436
448
|
value_lists = []
|
|
437
|
-
lens = []
|
|
438
449
|
for k in keys:
|
|
439
450
|
vals = params[k]
|
|
440
451
|
try:
|
|
@@ -444,14 +455,28 @@ class SimpleBacktester:
|
|
|
444
455
|
if len(candidates) == 0:
|
|
445
456
|
raise ValueError(f"Parameter '{k}' has no candidate values")
|
|
446
457
|
value_lists.append(candidates)
|
|
447
|
-
lens.append(len(candidates))
|
|
448
458
|
|
|
449
|
-
# determine total combos without materializing them
|
|
450
|
-
|
|
451
|
-
for
|
|
452
|
-
|
|
459
|
+
# determine total combos without materializing them using math.prod
|
|
460
|
+
# (previously used len(list(itertools.product(...))) which materialized all combos)
|
|
461
|
+
total_combos = math.prod(len(v) for v in value_lists)
|
|
462
|
+
|
|
463
|
+
# choose worker count conservatively to avoid RAM hogging
|
|
464
|
+
cpu_count = os.cpu_count() or 1
|
|
465
|
+
if workers is None:
|
|
466
|
+
workers = max(1, min(cpu_count - 1, 4))
|
|
467
|
+
else:
|
|
468
|
+
workers = max(1, int(workers))
|
|
469
|
+
|
|
470
|
+
# Adaptive chunksize calculation
|
|
471
|
+
# Previous default was chunksize=1 which causes high IPC overhead
|
|
472
|
+
# New default "auto" uses: max(16, total_combos // (workers * 4))
|
|
473
|
+
if chunksize == "auto":
|
|
474
|
+
chunksize = max(16, total_combos // (workers * 4))
|
|
475
|
+
else:
|
|
476
|
+
chunksize = max(1, int(chunksize))
|
|
453
477
|
|
|
454
478
|
# prepare iterable of param dicts as sequences of items (so pickling is slightly cheaper)
|
|
479
|
+
# Also pre-compute constraint results to avoid repeated checks
|
|
455
480
|
def _param_items_iter():
|
|
456
481
|
for combo in itertools.product(*value_lists):
|
|
457
482
|
row_params = {k: v for k, v in zip(keys, combo)}
|
|
@@ -464,13 +489,6 @@ class SimpleBacktester:
|
|
|
464
489
|
# yield as tuple of items for stable order and smaller IPC
|
|
465
490
|
yield tuple(row_params.items())
|
|
466
491
|
|
|
467
|
-
# choose worker count conservatively to avoid RAM hogging
|
|
468
|
-
cpu_count = os.cpu_count() or 1
|
|
469
|
-
if workers is None:
|
|
470
|
-
workers = max(1, min(cpu_count - 1, 4))
|
|
471
|
-
else:
|
|
472
|
-
workers = max(1, int(workers))
|
|
473
|
-
|
|
474
492
|
# pickle the base strategy once and send bytes to worker initializer
|
|
475
493
|
pickled_strategy = pickle.dumps(self.strategy)
|
|
476
494
|
|
|
@@ -557,7 +575,287 @@ class SimpleBacktester:
|
|
|
557
575
|
train_metrics=best_metrics,
|
|
558
576
|
validate_metrics={},
|
|
559
577
|
test_metrics={},
|
|
560
|
-
|
|
578
|
+
all_results=results_df
|
|
579
|
+
)
|
|
580
|
+
|
|
581
|
+
def optimize_optuna(
|
|
582
|
+
self,
|
|
583
|
+
param_space: dict[str, tuple[Any, Any] | list[Any]],
|
|
584
|
+
n_trials: int = 100,
|
|
585
|
+
objective: str = "sharpe",
|
|
586
|
+
risk_tolerance: dict[str, float] | None = None,
|
|
587
|
+
constraint: Callable[[dict[str, Any]], bool] | None = None,
|
|
588
|
+
timeout: int | None = None,
|
|
589
|
+
random_seed: int | None = None,
|
|
590
|
+
workers: int | None = None,
|
|
591
|
+
progress_bar: bool = True,
|
|
592
|
+
) -> OptimizationResult:
|
|
593
|
+
"""
|
|
594
|
+
Optimize strategy parameters using Optuna (Bayesian optimization).
|
|
595
|
+
|
|
596
|
+
This method uses Optuna's optimization framework with TPE (Tree-structured
|
|
597
|
+
Parzen Estimator) sampler for intelligent parameter search. It typically
|
|
598
|
+
finds better solutions than grid search with fewer evaluations.
|
|
599
|
+
|
|
600
|
+
The method supports:
|
|
601
|
+
- Continuous parameter ranges (sampled uniformly)
|
|
602
|
+
- Discrete/categorical parameter lists
|
|
603
|
+
- Early pruning of unpromising trials
|
|
604
|
+
- Parallel execution for faster optimization
|
|
605
|
+
|
|
606
|
+
Args:
|
|
607
|
+
param_space (dict[str, tuple[Any, Any] | list[Any]]): Parameter search space.
|
|
608
|
+
Can be:
|
|
609
|
+
- Continuous range: (min, max) tuple for uniform sampling
|
|
610
|
+
- Discrete list: [val1, val2, ...] for categorical sampling
|
|
611
|
+
Example: {'period': (5, 50), 'threshold': [0.01, 0.02, 0.05]}
|
|
612
|
+
n_trials (int, optional): Maximum number of optimization trials.
|
|
613
|
+
Defaults to 100.
|
|
614
|
+
objective (str, optional): Metric to optimize. Defaults to "sharpe".
|
|
615
|
+
Supports: "final_cash", "total_return", "sharpe", "max_drawdown", "trades".
|
|
616
|
+
risk_tolerance (dict[str, float] | None, optional): Maximum allowed values
|
|
617
|
+
for risk metrics. Trials exceeding thresholds are pruned. Defaults to None.
|
|
618
|
+
constraint (Callable[[dict[str, Any]], bool] | None, optional): Optional
|
|
619
|
+
callable to enforce parameter constraints. Defaults to None.
|
|
620
|
+
timeout (int | None, optional): Maximum time in seconds for optimization.
|
|
621
|
+
Defaults to None (no limit).
|
|
622
|
+
random_seed (int | None, optional): Random seed for reproducibility.
|
|
623
|
+
Defaults to None.
|
|
624
|
+
workers (int | None, optional): Number of parallel workers for Optuna
|
|
625
|
+
study. Defaults to None (sequential).
|
|
626
|
+
progress_bar (bool, optional): Whether to show progress bar. Defaults to True.
|
|
627
|
+
|
|
628
|
+
Returns:
|
|
629
|
+
OptimizationResult: Object containing:
|
|
630
|
+
- best_params: Best parameter values found
|
|
631
|
+
- train_report: BacktestReport for best parameters (None for Optuna)
|
|
632
|
+
- validate_report: None
|
|
633
|
+
- test_report: None
|
|
634
|
+
- train_metrics: Metrics for best parameters
|
|
635
|
+
- validate_metrics: Empty dict
|
|
636
|
+
- test_metrics: Empty dict
|
|
637
|
+
- all_results: DataFrame with all trial results
|
|
638
|
+
|
|
639
|
+
Performance Notes:
|
|
640
|
+
- Optuna typically finds good solutions in 50-200 trials
|
|
641
|
+
- For 10,000+ grid combos, Optuna can be 50-100x faster
|
|
642
|
+
- Use workers > 1 for parallel trial evaluation
|
|
643
|
+
- Pruning callbacks significantly speed up optimization
|
|
644
|
+
|
|
645
|
+
Example:
|
|
646
|
+
>>> # Optimize with continuous and discrete parameters
|
|
647
|
+
>>> result = bt.optimize_optuna({
|
|
648
|
+
... 'fast_period': (5, 50), # Continuous: 5-50
|
|
649
|
+
... 'slow_period': [20, 30, 50], # Discrete: pick one
|
|
650
|
+
... 'threshold': (0.01, 0.1), # Continuous: 1%-10%
|
|
651
|
+
... }, n_trials=100)
|
|
652
|
+
>>> print(f"Best params: {result.best_params}")
|
|
653
|
+
>>> print(f"Best Sharpe: {result.train_metrics['sharpe']}")
|
|
654
|
+
|
|
655
|
+
Note:
|
|
656
|
+
Requires optuna package: pip install optuna
|
|
657
|
+
"""
|
|
658
|
+
try:
|
|
659
|
+
import optuna
|
|
660
|
+
except ImportError:
|
|
661
|
+
raise ImportError(
|
|
662
|
+
"optuna is required for optimize_optuna. "
|
|
663
|
+
"Install it with: pip install optuna"
|
|
664
|
+
)
|
|
665
|
+
|
|
666
|
+
# Check for invalid objective
|
|
667
|
+
valid_metrics = {"final_cash", "total_return", "sharpe", "max_drawdown", "trades"}
|
|
668
|
+
if objective not in valid_metrics:
|
|
669
|
+
raise ValueError(
|
|
670
|
+
f"objective must be one of {valid_metrics}, got '{objective}'"
|
|
671
|
+
)
|
|
672
|
+
|
|
673
|
+
# Convert param_space to Optuna distribution format
|
|
674
|
+
param_names = list(param_space.keys())
|
|
675
|
+
|
|
676
|
+
def _create_objective(
|
|
677
|
+
strategy_template: Strategy,
|
|
678
|
+
cash: float,
|
|
679
|
+
commission: float,
|
|
680
|
+
commission_type: CommissionType,
|
|
681
|
+
lot_size: int,
|
|
682
|
+
objective: str,
|
|
683
|
+
risk_tolerance: dict[str, float] | None,
|
|
684
|
+
constraint: Callable[[dict[str, Any]], bool] | None,
|
|
685
|
+
):
|
|
686
|
+
"""Create objective function for Optuna."""
|
|
687
|
+
|
|
688
|
+
def objective_fn(trial: optuna.Trial) -> float:
|
|
689
|
+
# Sample parameters based on space definition
|
|
690
|
+
params = {}
|
|
691
|
+
for name, space in param_space.items():
|
|
692
|
+
if isinstance(space, (list, tuple)) and len(space) == 2:
|
|
693
|
+
# Check if it's a range (numeric) or discrete list
|
|
694
|
+
if all(isinstance(v, (int, float)) for v in space):
|
|
695
|
+
# Numeric range: treat as continuous if range > 10 values
|
|
696
|
+
try:
|
|
697
|
+
if len(space) == 2 and all(isinstance(v, (int, float)) for v in space):
|
|
698
|
+
# Check if values suggest discrete or continuous
|
|
699
|
+
if all(isinstance(v, int) for v in space) and len(space) == 2:
|
|
700
|
+
# Check if it's meant to be discrete (like range values)
|
|
701
|
+
pass
|
|
702
|
+
except:
|
|
703
|
+
pass
|
|
704
|
+
# Try as discrete list first
|
|
705
|
+
try:
|
|
706
|
+
# Assume discrete if second value is list
|
|
707
|
+
if isinstance(space[1], list):
|
|
708
|
+
choice = trial.suggest_categorical(name, space)
|
|
709
|
+
params[name] = choice
|
|
710
|
+
else:
|
|
711
|
+
# Continuous range
|
|
712
|
+
low, high = sorted(space)
|
|
713
|
+
if all(isinstance(v, int) for v in space):
|
|
714
|
+
params[name] = trial.suggest_int(name, int(low), int(high))
|
|
715
|
+
else:
|
|
716
|
+
params[name] = trial.suggest_float(name, float(low), float(high))
|
|
717
|
+
except:
|
|
718
|
+
# Treat as continuous
|
|
719
|
+
low, high = sorted(space)
|
|
720
|
+
if all(isinstance(v, int) for v in space):
|
|
721
|
+
params[name] = trial.suggest_int(name, int(low), int(high))
|
|
722
|
+
else:
|
|
723
|
+
params[name] = trial.suggest_float(name, float(low), float(high))
|
|
724
|
+
else:
|
|
725
|
+
# Discrete list
|
|
726
|
+
params[name] = trial.suggest_categorical(name, space)
|
|
727
|
+
else:
|
|
728
|
+
# Direct list of choices
|
|
729
|
+
params[name] = trial.suggest_categorical(name, list(space))
|
|
730
|
+
|
|
731
|
+
# Apply constraint if provided
|
|
732
|
+
if constraint is not None:
|
|
733
|
+
try:
|
|
734
|
+
if not bool(constraint(params)):
|
|
735
|
+
raise optuna.TrialPruned("Constraint violated")
|
|
736
|
+
except optuna.TrialPruned:
|
|
737
|
+
raise
|
|
738
|
+
except Exception:
|
|
739
|
+
raise optuna.TrialPruned("Constraint error")
|
|
740
|
+
|
|
741
|
+
# Create strategy copy and apply params
|
|
742
|
+
strat_copy = copy.deepcopy(strategy_template)
|
|
743
|
+
for k, v in params.items():
|
|
744
|
+
setattr(strat_copy, k, v)
|
|
745
|
+
|
|
746
|
+
# Run backtest
|
|
747
|
+
bt = SimpleBacktester(
|
|
748
|
+
strat_copy,
|
|
749
|
+
cash=cash,
|
|
750
|
+
commission=commission,
|
|
751
|
+
commission_type=commission_type,
|
|
752
|
+
lot_size=lot_size,
|
|
753
|
+
)
|
|
754
|
+
report = bt.run(progress_bar=False)
|
|
755
|
+
|
|
756
|
+
# Compute metrics
|
|
757
|
+
metrics = _compute_backtest_metrics(report)
|
|
758
|
+
|
|
759
|
+
# Apply risk tolerance filter
|
|
760
|
+
if risk_tolerance is not None:
|
|
761
|
+
if not _risk_tolerance_passes(report, risk_tolerance):
|
|
762
|
+
raise optuna.TrialPruned("Risk tolerance exceeded")
|
|
763
|
+
|
|
764
|
+
# Get objective score
|
|
765
|
+
if objective in valid_metrics:
|
|
766
|
+
score = metrics.get(objective)
|
|
767
|
+
else:
|
|
768
|
+
score = getattr(report, objective, None)
|
|
769
|
+
if callable(score):
|
|
770
|
+
score = score()
|
|
771
|
+
|
|
772
|
+
if score is None or not np.isfinite(float(score)): # type: ignore[arg-type]
|
|
773
|
+
raise optuna.TrialPruned("Invalid objective score")
|
|
774
|
+
|
|
775
|
+
return float(score) # type: ignore[arg-type]
|
|
776
|
+
|
|
777
|
+
return objective_fn
|
|
778
|
+
|
|
779
|
+
# Create and configure Optuna study
|
|
780
|
+
sampler = optuna.samplers.TPESampler(seed=random_seed)
|
|
781
|
+
study = optuna.create_study(
|
|
782
|
+
direction="maximize",
|
|
783
|
+
sampler=sampler,
|
|
784
|
+
)
|
|
785
|
+
|
|
786
|
+
# Create objective function with closure
|
|
787
|
+
obj_fn = _create_objective(
|
|
788
|
+
strategy_template=self.strategy,
|
|
789
|
+
cash=self.cash,
|
|
790
|
+
commission=self.commission,
|
|
791
|
+
commission_type=self.commission_type,
|
|
792
|
+
lot_size=self.lot_size,
|
|
793
|
+
objective=objective,
|
|
794
|
+
risk_tolerance=risk_tolerance,
|
|
795
|
+
constraint=constraint,
|
|
796
|
+
)
|
|
797
|
+
|
|
798
|
+
# Run optimization
|
|
799
|
+
show_progress = progress_bar and workers is None # Only if sequential
|
|
800
|
+
|
|
801
|
+
if workers is not None and workers > 1:
|
|
802
|
+
# Parallel execution using joblib backend
|
|
803
|
+
study.optimize(
|
|
804
|
+
obj_fn,
|
|
805
|
+
n_trials=n_trials,
|
|
806
|
+
timeout=timeout,
|
|
807
|
+
n_jobs=workers,
|
|
808
|
+
show_progress_bar=progress_bar,
|
|
809
|
+
)
|
|
810
|
+
else:
|
|
811
|
+
# Sequential execution
|
|
812
|
+
study.optimize(
|
|
813
|
+
obj_fn,
|
|
814
|
+
n_trials=n_trials,
|
|
815
|
+
timeout=timeout,
|
|
816
|
+
show_progress_bar=show_progress,
|
|
817
|
+
)
|
|
818
|
+
|
|
819
|
+
# Get best params
|
|
820
|
+
best_params = study.best_params
|
|
821
|
+
|
|
822
|
+
# Build results DataFrame from completed trials
|
|
823
|
+
results_rows = []
|
|
824
|
+
for trial in study.trials:
|
|
825
|
+
if trial.value is not None and trial.value > -np.inf:
|
|
826
|
+
row = dict(trial.params)
|
|
827
|
+
row["objective_score"] = trial.value
|
|
828
|
+
row["state"] = trial.state.name
|
|
829
|
+
results_rows.append(row)
|
|
830
|
+
|
|
831
|
+
results_df = pd.DataFrame(results_rows)
|
|
832
|
+
if not results_df.empty:
|
|
833
|
+
results_df.sort_values(by=["objective_score"], ascending=False, inplace=True, kind="mergesort")
|
|
834
|
+
|
|
835
|
+
# Run full backtest with best params for detailed report
|
|
836
|
+
strat_copy = copy.deepcopy(self.strategy)
|
|
837
|
+
for k, v in best_params.items():
|
|
838
|
+
setattr(strat_copy, k, v)
|
|
839
|
+
|
|
840
|
+
bt = SimpleBacktester(
|
|
841
|
+
strat_copy,
|
|
842
|
+
cash=self.cash,
|
|
843
|
+
commission=self.commission,
|
|
844
|
+
commission_type=self.commission_type,
|
|
845
|
+
lot_size=self.lot_size,
|
|
846
|
+
)
|
|
847
|
+
best_report = bt.run(progress_bar=False)
|
|
848
|
+
best_metrics = _compute_backtest_metrics(best_report)
|
|
849
|
+
|
|
850
|
+
return OptimizationResult(
|
|
851
|
+
best_params=best_params,
|
|
852
|
+
train_report=best_report,
|
|
853
|
+
validate_report=None,
|
|
854
|
+
test_report=None,
|
|
855
|
+
train_metrics=best_metrics,
|
|
856
|
+
validate_metrics={},
|
|
857
|
+
test_metrics={},
|
|
858
|
+
all_results=results_df,
|
|
561
859
|
)
|
|
562
860
|
|
|
563
861
|
def optimize_with_split(
|
|
@@ -672,7 +970,8 @@ class SimpleBacktester:
|
|
|
672
970
|
|
|
673
971
|
valid_metrics = {"final_cash", "total_return", "sharpe", "max_drawdown", "trades"}
|
|
674
972
|
|
|
675
|
-
|
|
973
|
+
# Use math.prod instead of len(list(...)) to avoid materializing all combos
|
|
974
|
+
total_combos = math.prod(len(v) for v in value_lists)
|
|
676
975
|
|
|
677
976
|
# Create a modified strategy that uses data slices
|
|
678
977
|
def create_split_strategy(params_dict: dict, split_mode: DataSplitMode):
|
|
@@ -13,6 +13,7 @@ import math
|
|
|
13
13
|
import random
|
|
14
14
|
import numpy as np
|
|
15
15
|
import pandas as pd
|
|
16
|
+
import matplotlib.dates as mdates
|
|
16
17
|
from dataclasses import dataclass, field
|
|
17
18
|
from enum import Enum
|
|
18
19
|
from typing import Any
|
|
@@ -64,6 +65,7 @@ class MonteCarloResult:
|
|
|
64
65
|
simulations: int = 0
|
|
65
66
|
starting_cash: float = 0.0
|
|
66
67
|
drawdown_stats: dict = field(default_factory=dict)
|
|
68
|
+
plot_max_curves: int = 150
|
|
67
69
|
|
|
68
70
|
def _compute_statistics(self):
|
|
69
71
|
"""Compute summary statistics from equity curves."""
|
|
@@ -115,7 +117,7 @@ class MonteCarloResult:
|
|
|
115
117
|
self,
|
|
116
118
|
target_return: float,
|
|
117
119
|
drawdown_threshold: float,
|
|
118
|
-
horizon: int | None = None,
|
|
120
|
+
horizon: int | str | pd.Timedelta | None = None,
|
|
119
121
|
as_percent: bool = True,
|
|
120
122
|
) -> dict:
|
|
121
123
|
"""
|
|
@@ -127,8 +129,10 @@ class MonteCarloResult:
|
|
|
127
129
|
True, this is treated as a decimal return (e.g. 0.05 for 5%).
|
|
128
130
|
drawdown_threshold (float): Drawdown threshold. If `as_percent` is
|
|
129
131
|
True, this is treated as a decimal drawdown (e.g. 0.05 for 5%).
|
|
130
|
-
horizon (int | None, optional):
|
|
131
|
-
|
|
132
|
+
horizon (int | str | pd.Timedelta | None, optional): Evaluation horizon.
|
|
133
|
+
If an integer is provided, it is treated as a number of steps.
|
|
134
|
+
If a string or Timedelta is provided, it is treated as a time span
|
|
135
|
+
relative to the first timestamp in each equity curve.
|
|
132
136
|
as_percent (bool, optional): Whether thresholds are provided as
|
|
133
137
|
decimal percentages. Defaults to True.
|
|
134
138
|
|
|
@@ -144,8 +148,26 @@ class MonteCarloResult:
|
|
|
144
148
|
"drawdown_threshold": drawdown_threshold,
|
|
145
149
|
}
|
|
146
150
|
|
|
147
|
-
|
|
148
|
-
|
|
151
|
+
def _resolve_horizon(curve: pd.Series, horizon_value: int | str | pd.Timedelta | None) -> int:
|
|
152
|
+
if horizon_value is None:
|
|
153
|
+
return len(curve)
|
|
154
|
+
if isinstance(horizon_value, (int, np.integer)):
|
|
155
|
+
return max(1, min(int(horizon_value), len(curve)))
|
|
156
|
+
|
|
157
|
+
if not isinstance(curve.index, pd.DatetimeIndex):
|
|
158
|
+
return max(1, min(len(curve), len(curve)))
|
|
159
|
+
|
|
160
|
+
delta = pd.Timedelta(horizon_value)
|
|
161
|
+
if delta <= pd.Timedelta(0):
|
|
162
|
+
return 1
|
|
163
|
+
|
|
164
|
+
start_time = curve.index[0]
|
|
165
|
+
end_time = start_time + delta
|
|
166
|
+
resolved = int(curve.index.searchsorted(end_time, side="right"))
|
|
167
|
+
return max(1, min(resolved, len(curve)))
|
|
168
|
+
|
|
169
|
+
first_curve = self.equity_curves[0]
|
|
170
|
+
horizon_steps = _resolve_horizon(first_curve, horizon)
|
|
149
171
|
|
|
150
172
|
if as_percent:
|
|
151
173
|
target_return = float(target_return)
|
|
@@ -154,7 +176,7 @@ class MonteCarloResult:
|
|
|
154
176
|
return_hits = 0
|
|
155
177
|
drawdown_hits = 0
|
|
156
178
|
for curve in self.equity_curves:
|
|
157
|
-
sampled = curve.iloc[:
|
|
179
|
+
sampled = curve.iloc[:horizon_steps]
|
|
158
180
|
start_value = float(sampled.iloc[0])
|
|
159
181
|
end_value = float(sampled.iloc[-1])
|
|
160
182
|
achieved_return = (end_value / start_value) - 1.0 if start_value != 0 else 0.0
|
|
@@ -175,7 +197,7 @@ class MonteCarloResult:
|
|
|
175
197
|
}
|
|
176
198
|
|
|
177
199
|
def plot(self, figsize: tuple = (12, 8), show_original: bool = True,
|
|
178
|
-
show_percentiles: bool = True) -> None:
|
|
200
|
+
show_percentiles: bool = True, max_curves: int | None = None) -> None:
|
|
179
201
|
"""
|
|
180
202
|
Plot all Monte Carlo simulation equity curves.
|
|
181
203
|
|
|
@@ -190,6 +212,8 @@ class MonteCarloResult:
|
|
|
190
212
|
curve. Defaults to True.
|
|
191
213
|
show_percentiles (bool, optional): Whether to show percentile bands.
|
|
192
214
|
Defaults to True.
|
|
215
|
+
max_curves (int | None, optional): Maximum number of simulation curves
|
|
216
|
+
to render. Defaults to ``self.plot_max_curves``.
|
|
193
217
|
|
|
194
218
|
Note:
|
|
195
219
|
This method uses matplotlib to display the plots and requires
|
|
@@ -198,11 +222,13 @@ class MonteCarloResult:
|
|
|
198
222
|
from matplotlib import pyplot as plt
|
|
199
223
|
|
|
200
224
|
fig, ax = plt.subplots(figsize=figsize)
|
|
225
|
+
|
|
226
|
+
max_curves = self.plot_max_curves if max_curves is None else max_curves
|
|
201
227
|
|
|
202
|
-
# Plot
|
|
203
|
-
#
|
|
228
|
+
# Plot against the datetime index so the x-axis reflects the actual
|
|
229
|
+
# backtest timeline instead of a generic simulation step axis.
|
|
204
230
|
if not self.equity_curves:
|
|
205
|
-
ax.set_xlabel("
|
|
231
|
+
ax.set_xlabel("Datetime")
|
|
206
232
|
ax.set_ylabel("Portfolio Value")
|
|
207
233
|
ax.set_title(f"Monte Carlo Simulation Results ({self.simulations} simulations)")
|
|
208
234
|
ax.grid(alpha=0.3)
|
|
@@ -210,14 +236,35 @@ class MonteCarloResult:
|
|
|
210
236
|
plt.show()
|
|
211
237
|
return
|
|
212
238
|
|
|
213
|
-
|
|
239
|
+
base_index = self.equity_curves[0].index
|
|
240
|
+
if not isinstance(base_index, pd.DatetimeIndex):
|
|
241
|
+
base_index = pd.to_datetime(base_index)
|
|
242
|
+
|
|
243
|
+
def _plot_x_values(curve: pd.Series) -> pd.Index:
|
|
244
|
+
if isinstance(curve.index, pd.DatetimeIndex):
|
|
245
|
+
return curve.index
|
|
246
|
+
return pd.to_datetime(curve.index)
|
|
214
247
|
|
|
215
248
|
# Plot all simulation curves with low alpha (transparency)
|
|
216
249
|
# This makes the average path appear lightest due to overlap
|
|
217
|
-
|
|
218
|
-
|
|
250
|
+
color_cycle = plt.rcParams["axes.prop_cycle"].by_key().get("color", ["steelblue"])
|
|
251
|
+
curve_count = len(self.equity_curves)
|
|
252
|
+
if max_curves is not None and max_curves > 0 and curve_count > max_curves:
|
|
253
|
+
plot_indices = np.linspace(0, curve_count - 1, max_curves, dtype=int)
|
|
254
|
+
else:
|
|
255
|
+
plot_indices = range(curve_count)
|
|
256
|
+
|
|
257
|
+
for i in plot_indices:
|
|
258
|
+
curve = self.equity_curves[i]
|
|
259
|
+
x_vals = _plot_x_values(curve)
|
|
219
260
|
y_vals = np.asarray(curve.values, dtype=np.float64)
|
|
220
|
-
ax.plot(
|
|
261
|
+
ax.plot(
|
|
262
|
+
x_vals,
|
|
263
|
+
y_vals,
|
|
264
|
+
color=color_cycle[i % len(color_cycle)],
|
|
265
|
+
alpha=0.08,
|
|
266
|
+
linewidth=0.5,
|
|
267
|
+
)
|
|
221
268
|
|
|
222
269
|
# Compute mean and median curves for highlighting
|
|
223
270
|
if self.equity_curves:
|
|
@@ -227,18 +274,20 @@ class MonteCarloResult:
|
|
|
227
274
|
median_curve = aligned.median(axis=1)
|
|
228
275
|
|
|
229
276
|
# Plot mean curve (thicker, lighter)
|
|
230
|
-
x_mean =
|
|
277
|
+
x_mean = base_index
|
|
231
278
|
y_mean = np.asarray(mean_curve.values, dtype=np.float64)
|
|
232
279
|
ax.plot(x_mean, y_mean, color="darkblue", alpha=0.8, linewidth=2, label="Mean")
|
|
233
280
|
|
|
234
281
|
# Plot median curve
|
|
235
|
-
x_med =
|
|
282
|
+
x_med = base_index
|
|
236
283
|
y_med = np.asarray(median_curve.values, dtype=np.float64)
|
|
237
284
|
ax.plot(x_med, y_med, color="navy", alpha=0.6, linewidth=1.5, linestyle="--", label="Median")
|
|
238
285
|
|
|
239
286
|
# Show original equity curve if requested
|
|
240
287
|
if show_original and self.original_equity is not None:
|
|
241
|
-
x_orig =
|
|
288
|
+
x_orig = self.original_equity.index
|
|
289
|
+
if not isinstance(x_orig, pd.DatetimeIndex):
|
|
290
|
+
x_orig = pd.to_datetime(x_orig)
|
|
242
291
|
y_orig = np.asarray(self.original_equity.values, dtype=np.float64)
|
|
243
292
|
ax.plot(x_orig, y_orig, color="red", alpha=0.9, linewidth=2, label="Original Backtest")
|
|
244
293
|
|
|
@@ -247,19 +296,20 @@ class MonteCarloResult:
|
|
|
247
296
|
aligned = pd.concat(self.equity_curves, axis=1)
|
|
248
297
|
p5 = aligned.quantile(0.05, axis=1)
|
|
249
298
|
p95 = aligned.quantile(0.95, axis=1)
|
|
250
|
-
x_p5 =
|
|
299
|
+
x_p5 = p5.index
|
|
300
|
+
if not isinstance(x_p5, pd.DatetimeIndex):
|
|
301
|
+
x_p5 = pd.to_datetime(x_p5)
|
|
251
302
|
y_p5 = np.asarray(p5.values, dtype=np.float64)
|
|
252
303
|
y_p95 = np.asarray(p95.values, dtype=np.float64)
|
|
253
304
|
ax.fill_between(x_p5, y_p5, y_p95, alpha=0.2, color="steelblue", label="5th-95th Percentile")
|
|
254
305
|
|
|
255
|
-
ax.set_xlabel("
|
|
306
|
+
ax.set_xlabel("Datetime")
|
|
256
307
|
ax.set_ylabel("Portfolio Value")
|
|
257
308
|
ax.set_title(f"Monte Carlo Simulation Results ({self.simulations} simulations)")
|
|
258
309
|
ax.legend(loc="best")
|
|
259
310
|
ax.grid(alpha=0.3)
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
ax.set_xlim(step_index[0], step_index[-1])
|
|
311
|
+
ax.xaxis.set_major_formatter(mdates.DateFormatter("%Y-%m"))
|
|
312
|
+
fig.autofmt_xdate()
|
|
263
313
|
|
|
264
314
|
plt.tight_layout()
|
|
265
315
|
plt.show()
|
|
@@ -372,9 +422,10 @@ def _run_trade_shuffle_with_replacement_simulation(
|
|
|
372
422
|
"""
|
|
373
423
|
Run a Monte Carlo simulation that samples trade outcomes with replacement.
|
|
374
424
|
|
|
375
|
-
This mode
|
|
376
|
-
|
|
377
|
-
|
|
425
|
+
This mode reuses the original trade-return sequence as a return pool and
|
|
426
|
+
reconstructs the curve using a replacement-sampled path. Any remaining
|
|
427
|
+
steps are kept neutral so the resulting equity curve always spans the same
|
|
428
|
+
time axis as the original backtest.
|
|
378
429
|
"""
|
|
379
430
|
if seed is not None:
|
|
380
431
|
random.seed(seed)
|
|
@@ -394,13 +445,7 @@ def _run_trade_shuffle_with_replacement_simulation(
|
|
|
394
445
|
if not trade_returns:
|
|
395
446
|
return pd.Series(equity, index=index)
|
|
396
447
|
|
|
397
|
-
|
|
398
|
-
# outcomes and omit others entirely by only sampling a subset of the trade
|
|
399
|
-
# return pool on each simulation, then pad the rest with neutral returns so
|
|
400
|
-
# the full curve length is preserved.
|
|
401
|
-
sample_size = max(1, int(len(trade_returns) * 0.75))
|
|
402
|
-
sampled_returns = [random.choice(trade_returns) for _ in range(sample_size)]
|
|
403
|
-
sampled_returns.extend([0.0] * (len(trade_returns) - sample_size))
|
|
448
|
+
sampled_returns = [random.choice(trade_returns) for _ in range(len(trade_returns))]
|
|
404
449
|
|
|
405
450
|
equity_returns = np.concatenate(([0.0], np.asarray(sampled_returns, dtype=np.float64)))
|
|
406
451
|
|
|
@@ -563,7 +608,7 @@ def monte_carlo(
|
|
|
563
608
|
- "trade_order": Randomize trade execution order
|
|
564
609
|
- "price_path": Resample price returns to create synthetic paths
|
|
565
610
|
- "both": Run both analyses and combine results
|
|
566
|
-
Defaults to "
|
|
611
|
+
Defaults to "trade_order".
|
|
567
612
|
seed (int | None, optional): Random seed for reproducibility.
|
|
568
613
|
Defaults to None.
|
|
569
614
|
progress_bar (bool, optional): Whether to show progress bar during simulation.
|
|
@@ -50,6 +50,76 @@ def _worker_init(
|
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
|
|
53
|
+
def _compute_metrics_numpy(
|
|
54
|
+
equity: np.ndarray,
|
|
55
|
+
periods_per_year: float,
|
|
56
|
+
n_trades: int,
|
|
57
|
+
) -> dict[str, Any]:
|
|
58
|
+
"""
|
|
59
|
+
Compute performance metrics using numpy arrays directly.
|
|
60
|
+
|
|
61
|
+
This is more efficient than using pandas operations for the
|
|
62
|
+
inner loop of optimization since we avoid pandas overhead.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
equity: Numpy array of equity values over time.
|
|
66
|
+
periods_per_year: Number of periods in a year for annualization.
|
|
67
|
+
n_trades: Number of trades executed.
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
Dictionary with computed metrics.
|
|
71
|
+
"""
|
|
72
|
+
# Calculate returns using numpy (avoid pandas overhead)
|
|
73
|
+
equity_arr = equity.astype(np.float64)
|
|
74
|
+
|
|
75
|
+
# Handle edge cases
|
|
76
|
+
if len(equity_arr) < 2:
|
|
77
|
+
return {
|
|
78
|
+
"final_cash": float(equity_arr[-1]) if len(equity_arr) > 0 else 0.0,
|
|
79
|
+
"total_return": 0.0,
|
|
80
|
+
"sharpe": float("nan"),
|
|
81
|
+
"max_drawdown": 0.0,
|
|
82
|
+
"trades": n_trades,
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
# Compute returns using numpy
|
|
86
|
+
returns = np.diff(equity_arr) / equity_arr[:-1]
|
|
87
|
+
|
|
88
|
+
# Remove NaN/Inf values
|
|
89
|
+
valid_returns = returns[np.isfinite(returns)]
|
|
90
|
+
|
|
91
|
+
# Total return
|
|
92
|
+
tot_return = float(equity_arr[-1] / equity_arr[0] - 1.0) if equity_arr[0] != 0 else 0.0
|
|
93
|
+
|
|
94
|
+
# Sharpe ratio
|
|
95
|
+
annual_rf = 0.04
|
|
96
|
+
rf_per_period = annual_rf / periods_per_year
|
|
97
|
+
|
|
98
|
+
if len(valid_returns) < 2:
|
|
99
|
+
sharpe = float("nan")
|
|
100
|
+
else:
|
|
101
|
+
excess = valid_returns - rf_per_period
|
|
102
|
+
mean_excess = np.mean(excess)
|
|
103
|
+
std_excess = np.std(excess, ddof=1)
|
|
104
|
+
if std_excess == 0:
|
|
105
|
+
sharpe = float("nan")
|
|
106
|
+
else:
|
|
107
|
+
sharpe = float((mean_excess / std_excess) * (periods_per_year ** 0.5))
|
|
108
|
+
|
|
109
|
+
# Maximum drawdown using numpy
|
|
110
|
+
running_max = np.maximum.accumulate(equity_arr)
|
|
111
|
+
drawdowns = (equity_arr - running_max) / running_max
|
|
112
|
+
mdd = float(abs(np.min(drawdowns)))
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
"final_cash": float(equity_arr[-1]),
|
|
116
|
+
"total_return": tot_return,
|
|
117
|
+
"sharpe": sharpe,
|
|
118
|
+
"max_drawdown": mdd,
|
|
119
|
+
"trades": n_trades,
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
|
|
53
123
|
def _worker_eval(param_items: tuple[tuple[str, Any], ...]) -> dict[str, Any]:
|
|
54
124
|
"""
|
|
55
125
|
Worker evaluation function for parallel parameter optimization.
|
|
@@ -57,6 +127,11 @@ def _worker_eval(param_items: tuple[tuple[str, Any], ...]) -> dict[str, Any]:
|
|
|
57
127
|
This function runs in worker processes to evaluate a single
|
|
58
128
|
parameter combination and return performance metrics.
|
|
59
129
|
|
|
130
|
+
Optimizations applied:
|
|
131
|
+
1. Uses numpy for metric computation instead of pandas (faster)
|
|
132
|
+
2. Returns only essential metrics (reduces IPC overhead)
|
|
133
|
+
3. Explicit cleanup of references to help GC
|
|
134
|
+
|
|
60
135
|
Args:
|
|
61
136
|
param_items: Sequence of (key, value) pairs (tuple) to reconstruct dict.
|
|
62
137
|
Each tuple represents a parameter name and its value.
|
|
@@ -104,39 +179,24 @@ def _worker_eval(param_items: tuple[tuple[str, Any], ...]) -> dict[str, Any]:
|
|
|
104
179
|
)
|
|
105
180
|
report = bt.run(progress_bar=False)
|
|
106
181
|
|
|
107
|
-
# Compute metrics
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
else:
|
|
117
|
-
excess = returns - rf_per_period
|
|
118
|
-
mean = excess.mean()
|
|
119
|
-
vol = excess.std(ddof=1)
|
|
120
|
-
sharpe = float((mean / vol) * (report.periods_per_year ** 0.5))
|
|
121
|
-
|
|
122
|
-
running_max = equity.cummax()
|
|
123
|
-
drawdown = ((equity - running_max) / running_max).min()
|
|
124
|
-
mdd = float(abs(drawdown))
|
|
125
|
-
|
|
126
|
-
tot_return = float(equity.iloc[-1] / equity.iloc[0] - 1.0)
|
|
182
|
+
# Compute metrics using optimized numpy version
|
|
183
|
+
# This avoids pandas overhead for metric computation
|
|
184
|
+
# Use to_numpy() with copy=False for efficiency, convert to float64
|
|
185
|
+
equity_values = np.asarray(report.PnlRecord, dtype=np.float64)
|
|
186
|
+
metrics = _compute_metrics_numpy(
|
|
187
|
+
equity=equity_values,
|
|
188
|
+
periods_per_year=report.periods_per_year,
|
|
189
|
+
n_trades=len(report.orders),
|
|
190
|
+
)
|
|
127
191
|
|
|
128
|
-
#
|
|
192
|
+
# Build result with params
|
|
129
193
|
result: dict[str, Any] = {
|
|
130
194
|
"params": params,
|
|
131
|
-
|
|
132
|
-
"total_return": tot_return,
|
|
133
|
-
"sharpe": sharpe,
|
|
134
|
-
"max_drawdown": mdd,
|
|
135
|
-
"trades": len(report.orders),
|
|
195
|
+
**metrics,
|
|
136
196
|
}
|
|
137
197
|
|
|
138
198
|
# Cleanup references to free memory inside worker
|
|
139
|
-
del strat, bt, report
|
|
199
|
+
del strat, bt, report
|
|
140
200
|
gc.collect()
|
|
141
201
|
|
|
142
202
|
return result
|
|
@@ -46,16 +46,18 @@ class BacktestReport:
|
|
|
46
46
|
metrics such as Sharpe ratio and maximum drawdown.
|
|
47
47
|
|
|
48
48
|
Attributes:
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
49
|
+
starting_cash (np.float64): Initial cash amount at start of backtest.
|
|
50
|
+
final_cash (np.float64): Final cash amount at end of backtest.
|
|
51
|
+
PnlRecord (pd.Series): Time series of P&L values throughout the backtest.
|
|
52
|
+
orders (list[Order]): List of all orders executed during the backtest.
|
|
53
|
+
margin_call_events (list[dict]): Margin call events triggered during the run.
|
|
53
54
|
"""
|
|
54
55
|
starting_cash: np.float64
|
|
55
56
|
final_cash: np.float64
|
|
56
57
|
PnlRecord: pd.Series
|
|
57
58
|
orders: list
|
|
58
59
|
tradeRecord: list[np.float64]
|
|
60
|
+
margin_call_events: list[dict] | None = None
|
|
59
61
|
|
|
60
62
|
@property
|
|
61
63
|
def annual_rf(self):
|
|
@@ -201,6 +203,7 @@ class BacktestReport:
|
|
|
201
203
|
tot_return = float(equity.iloc[-1] / equity.iloc[0] - 1.0)
|
|
202
204
|
annualized_return = float((1.0 + tot_return) ** (self.periods_per_year / max(len(returns), 1)) - 1.0)
|
|
203
205
|
tot_orders = len(self.orders)
|
|
206
|
+
margin_calls = len(self.margin_call_events or [])
|
|
204
207
|
|
|
205
208
|
return (
|
|
206
209
|
f"Starting Cash: ${self.starting_cash:,.2f}\n"
|
|
@@ -215,5 +218,6 @@ class BacktestReport:
|
|
|
215
218
|
) + (
|
|
216
219
|
f"\nMax Drawdown: {mdd:.2%}\n"
|
|
217
220
|
f"Kelly Fraction: {self.kelly_criterion:.3}\n"
|
|
218
|
-
f"Total Trades: {tot_orders:,}"
|
|
221
|
+
f"Total Trades: {tot_orders:,}\n"
|
|
222
|
+
f"Margin Calls: {margin_calls:,}"
|
|
219
223
|
)
|
|
@@ -79,6 +79,8 @@ class Broker:
|
|
|
79
79
|
self.complete_orders = []
|
|
80
80
|
self.active_order: Order | None = None
|
|
81
81
|
self.pending_close_order: Order | None = None
|
|
82
|
+
self.margin_call_triggered: bool = False
|
|
83
|
+
self.margin_call_events: list[dict] = []
|
|
82
84
|
self._i = 0
|
|
83
85
|
self.source = source
|
|
84
86
|
self.PnLRecord = np.full(len(self.source.data['Close']), self.cash, dtype=np.float64)
|
|
@@ -604,5 +606,12 @@ class Broker:
|
|
|
604
606
|
equity = self.cash + unrealized
|
|
605
607
|
margin_call = self.margin_call * abs(self.position) * self.source.CClose
|
|
606
608
|
if equity < margin_call and self.position < 0:
|
|
609
|
+
self.margin_call_triggered = True
|
|
610
|
+
self.margin_call_events.append({
|
|
611
|
+
"timestamp": self.source.Index[self._i],
|
|
612
|
+
"equity": equity,
|
|
613
|
+
"margin_call_threshold": margin_call,
|
|
614
|
+
"position": self.position,
|
|
615
|
+
})
|
|
607
616
|
self.close() ## Close all positions immediately, margin call
|
|
608
617
|
self.PnLRecord[self._i] = equity
|
|
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
|