quantex 0.4.6__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.6 → quantex-0.4.7}/PKG-INFO +2 -1
- {quantex-0.4.6 → quantex-0.4.7}/pyproject.toml +2 -1
- {quantex-0.4.6 → quantex-0.4.7}/src/quantex/backtester/backtester.py +315 -21
- {quantex-0.4.6 → quantex-0.4.7}/src/quantex/backtester/parallel.py +87 -27
- {quantex-0.4.6 → quantex-0.4.7}/LICENSE.md +0 -0
- {quantex-0.4.6 → quantex-0.4.7}/README.md +0 -0
- {quantex-0.4.6 → quantex-0.4.7}/src/quantex/__init__.py +0 -0
- {quantex-0.4.6 → quantex-0.4.7}/src/quantex/backtester/__init__.py +0 -0
- {quantex-0.4.6 → quantex-0.4.7}/src/quantex/backtester/constants.py +0 -0
- {quantex-0.4.6 → quantex-0.4.7}/src/quantex/backtester/data_splits.py +0 -0
- {quantex-0.4.6 → quantex-0.4.7}/src/quantex/backtester/metrics.py +0 -0
- {quantex-0.4.6 → quantex-0.4.7}/src/quantex/backtester/montecarlo.py +0 -0
- {quantex-0.4.6 → quantex-0.4.7}/src/quantex/backtester/reports.py +0 -0
- {quantex-0.4.6 → quantex-0.4.7}/src/quantex/broker/__init__.py +0 -0
- {quantex-0.4.6 → quantex-0.4.7}/src/quantex/broker/broker.py +0 -0
- {quantex-0.4.6 → quantex-0.4.7}/src/quantex/broker/types.py +0 -0
- {quantex-0.4.6 → quantex-0.4.7}/src/quantex/datasource.py +0 -0
- {quantex-0.4.6 → quantex-0.4.7}/src/quantex/helpers.py +0 -0
- {quantex-0.4.6 → quantex-0.4.7}/src/quantex/indicators.py +0 -0
- {quantex-0.4.6 → 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]
|
|
@@ -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
|
|
|
@@ -272,7 +273,9 @@ class SimpleBacktester:
|
|
|
272
273
|
|
|
273
274
|
valid_metrics = {"final_cash", "total_return", "sharpe", "max_drawdown", "trades"}
|
|
274
275
|
|
|
275
|
-
|
|
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)
|
|
276
279
|
|
|
277
280
|
for combo in tqdm(itertools.product(*value_lists), total=(total_combos)):
|
|
278
281
|
# Build parameter dict for this combo
|
|
@@ -364,7 +367,7 @@ class SimpleBacktester:
|
|
|
364
367
|
objective: str = "sharpe",
|
|
365
368
|
risk_tolerance: dict[str, float] | None = None,
|
|
366
369
|
workers: int | None = None,
|
|
367
|
-
chunksize: int =
|
|
370
|
+
chunksize: int | str = "auto") -> OptimizationResult:
|
|
368
371
|
"""
|
|
369
372
|
Perform parallel grid search over parameter ranges for optimization.
|
|
370
373
|
|
|
@@ -386,10 +389,12 @@ class SimpleBacktester:
|
|
|
386
389
|
workers (int | None, optional): Maximum number of worker processes to use.
|
|
387
390
|
If None, defaults to min(os.cpu_count()-1, 4) to avoid overwhelming
|
|
388
391
|
the system. Defaults to None.
|
|
389
|
-
chunksize (int, optional): Chunk size for ProcessPoolExecutor.map.
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
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))
|
|
393
398
|
|
|
394
399
|
Returns:
|
|
395
400
|
OptimizationResult: Object containing:
|
|
@@ -422,6 +427,7 @@ class SimpleBacktester:
|
|
|
422
427
|
lower multiprocessing overhead.
|
|
423
428
|
- Monitor system memory usage as each worker maintains a full
|
|
424
429
|
copy of the strategy and data.
|
|
430
|
+
- Auto chunksize provides better throughput for large parameter spaces.
|
|
425
431
|
|
|
426
432
|
Example:
|
|
427
433
|
>>> bt = SimpleBacktester(strategy)
|
|
@@ -433,13 +439,13 @@ class SimpleBacktester:
|
|
|
433
439
|
"""
|
|
434
440
|
import concurrent.futures
|
|
435
441
|
import pickle
|
|
442
|
+
import math
|
|
436
443
|
|
|
437
444
|
if not params:
|
|
438
445
|
raise ValueError("params must not be empty")
|
|
439
446
|
|
|
440
447
|
keys = list(params.keys())
|
|
441
448
|
value_lists = []
|
|
442
|
-
lens = []
|
|
443
449
|
for k in keys:
|
|
444
450
|
vals = params[k]
|
|
445
451
|
try:
|
|
@@ -449,14 +455,28 @@ class SimpleBacktester:
|
|
|
449
455
|
if len(candidates) == 0:
|
|
450
456
|
raise ValueError(f"Parameter '{k}' has no candidate values")
|
|
451
457
|
value_lists.append(candidates)
|
|
452
|
-
lens.append(len(candidates))
|
|
453
458
|
|
|
454
|
-
# determine total combos without materializing them
|
|
455
|
-
|
|
456
|
-
for
|
|
457
|
-
|
|
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))
|
|
458
477
|
|
|
459
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
|
|
460
480
|
def _param_items_iter():
|
|
461
481
|
for combo in itertools.product(*value_lists):
|
|
462
482
|
row_params = {k: v for k, v in zip(keys, combo)}
|
|
@@ -469,13 +489,6 @@ class SimpleBacktester:
|
|
|
469
489
|
# yield as tuple of items for stable order and smaller IPC
|
|
470
490
|
yield tuple(row_params.items())
|
|
471
491
|
|
|
472
|
-
# choose worker count conservatively to avoid RAM hogging
|
|
473
|
-
cpu_count = os.cpu_count() or 1
|
|
474
|
-
if workers is None:
|
|
475
|
-
workers = max(1, min(cpu_count - 1, 4))
|
|
476
|
-
else:
|
|
477
|
-
workers = max(1, int(workers))
|
|
478
|
-
|
|
479
492
|
# pickle the base strategy once and send bytes to worker initializer
|
|
480
493
|
pickled_strategy = pickle.dumps(self.strategy)
|
|
481
494
|
|
|
@@ -562,7 +575,287 @@ class SimpleBacktester:
|
|
|
562
575
|
train_metrics=best_metrics,
|
|
563
576
|
validate_metrics={},
|
|
564
577
|
test_metrics={},
|
|
565
|
-
|
|
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,
|
|
566
859
|
)
|
|
567
860
|
|
|
568
861
|
def optimize_with_split(
|
|
@@ -677,7 +970,8 @@ class SimpleBacktester:
|
|
|
677
970
|
|
|
678
971
|
valid_metrics = {"final_cash", "total_return", "sharpe", "max_drawdown", "trades"}
|
|
679
972
|
|
|
680
|
-
|
|
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)
|
|
681
975
|
|
|
682
976
|
# Create a modified strategy that uses data slices
|
|
683
977
|
def create_split_strategy(params_dict: dict, split_mode: DataSplitMode):
|
|
@@ -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
|
|
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
|