quantex 0.4.6__tar.gz → 0.4.8__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: quantex
3
- Version: 0.4.6
3
+ Version: 0.4.8
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.6"
3
+ version = "0.4.8"
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
 
@@ -37,6 +38,7 @@ class SimpleBacktester:
37
38
  - Position management
38
39
  - Margin calls
39
40
  - P&L tracking
41
+ - Leverage for amplified position sizing
40
42
 
41
43
  Example:
42
44
  >>> from quantex import SimpleBacktester, CSVDataSource
@@ -53,7 +55,8 @@ class SimpleBacktester:
53
55
  commission: float = 0.002,
54
56
  commission_type: CommissionType = CommissionType.PERCENTAGE,
55
57
  lot_size: int = 1,
56
- margin_call: float = 0.5 ## 50% of the cash lost
58
+ margin_call: float = 0.5, ## 50% of the cash lost
59
+ leverage: float = 1.0 ## Leverage multiplier (1.0 = no leverage)
57
60
  ):
58
61
  """
59
62
  Initialize the backtester with strategy and configuration parameters.
@@ -69,16 +72,25 @@ class SimpleBacktester:
69
72
  lot_size (int, optional): Size of trading lots. Defaults to 1.
70
73
  margin_call (float, optional): Margin call threshold as fraction of
71
74
  cash value. Defaults to 0.5 (50%).
75
+ leverage (float, optional): Leverage multiplier for position sizing.
76
+ Defaults to 1.0 (no leverage). For example:
77
+ - 2.0 = 2x leverage (control 2x the position with same cash)
78
+ - 0.5 = half leverage (control half the position)
72
79
 
73
80
  Raises:
74
81
  ValueError: If strategy is None or commission rate is negative.
75
82
  """
83
+ if leverage < 0.1:
84
+ raise ValueError("leverage must be at least 0.1")
85
+ if leverage > 100:
86
+ raise ValueError("leverage cannot exceed 100")
76
87
  self.strategy = copy.deepcopy(strategy)
77
88
  self.cash = cash
78
89
  self.commission = commission
79
90
  self.commission_type = commission_type
80
91
  self.lot_size = lot_size
81
92
  self.margin_call = margin_call
93
+ self.leverage = leverage
82
94
  source = self.strategy.positions[list(self.strategy.positions.keys())[0]].source
83
95
  self.PnLRecord = np.zeros(len(source.data['Close']), dtype=np.float64)
84
96
 
@@ -139,6 +151,7 @@ class SimpleBacktester:
139
151
  broker.cash = per_position_cash
140
152
  broker.lot_size = self.lot_size
141
153
  broker.margin_call = self.margin_call
154
+ broker.leverage = self.leverage
142
155
  broker.commision = np.float64(self.commission)
143
156
  broker.commision_type = self.commission_type
144
157
 
@@ -272,7 +285,9 @@ class SimpleBacktester:
272
285
 
273
286
  valid_metrics = {"final_cash", "total_return", "sharpe", "max_drawdown", "trades"}
274
287
 
275
- total_combos = len(list(itertools.product(*value_lists)))
288
+ # Use math.prod instead of len(list(itertools.product(...)))
289
+ # to avoid materializing all combinations in memory
290
+ total_combos = math.prod(len(v) for v in value_lists)
276
291
 
277
292
  for combo in tqdm(itertools.product(*value_lists), total=(total_combos)):
278
293
  # Build parameter dict for this combo
@@ -299,6 +314,7 @@ class SimpleBacktester:
299
314
  commission=self.commission,
300
315
  commission_type=self.commission_type,
301
316
  lot_size=self.lot_size,
317
+ leverage=self.leverage,
302
318
  )
303
319
  report = bt.run(progress_bar=False)
304
320
 
@@ -364,7 +380,7 @@ class SimpleBacktester:
364
380
  objective: str = "sharpe",
365
381
  risk_tolerance: dict[str, float] | None = None,
366
382
  workers: int | None = None,
367
- chunksize: int = 1) -> OptimizationResult:
383
+ chunksize: int | str = "auto") -> OptimizationResult:
368
384
  """
369
385
  Perform parallel grid search over parameter ranges for optimization.
370
386
 
@@ -386,10 +402,12 @@ class SimpleBacktester:
386
402
  workers (int | None, optional): Maximum number of worker processes to use.
387
403
  If None, defaults to min(os.cpu_count()-1, 4) to avoid overwhelming
388
404
  the system. Defaults to None.
389
- chunksize (int, optional): Chunk size for ProcessPoolExecutor.map.
390
- Smaller values provide better load balancing for many small tasks.
391
- Larger values reduce overhead for fewer, larger tasks.
392
- Defaults to 1.
405
+ chunksize (int | str, optional): Chunk size for ProcessPoolExecutor.map.
406
+ Can be an integer or "auto" for adaptive sizing based on total
407
+ combinations and worker count. Smaller values provide better load
408
+ balancing for many small tasks. Larger values reduce IPC overhead.
409
+ Defaults to "auto" (previously 1).
410
+ Auto-calculation: max(16, total_combos // (workers * 4))
393
411
 
394
412
  Returns:
395
413
  OptimizationResult: Object containing:
@@ -422,6 +440,7 @@ class SimpleBacktester:
422
440
  lower multiprocessing overhead.
423
441
  - Monitor system memory usage as each worker maintains a full
424
442
  copy of the strategy and data.
443
+ - Auto chunksize provides better throughput for large parameter spaces.
425
444
 
426
445
  Example:
427
446
  >>> bt = SimpleBacktester(strategy)
@@ -433,13 +452,13 @@ class SimpleBacktester:
433
452
  """
434
453
  import concurrent.futures
435
454
  import pickle
455
+ import math
436
456
 
437
457
  if not params:
438
458
  raise ValueError("params must not be empty")
439
459
 
440
460
  keys = list(params.keys())
441
461
  value_lists = []
442
- lens = []
443
462
  for k in keys:
444
463
  vals = params[k]
445
464
  try:
@@ -449,14 +468,28 @@ class SimpleBacktester:
449
468
  if len(candidates) == 0:
450
469
  raise ValueError(f"Parameter '{k}' has no candidate values")
451
470
  value_lists.append(candidates)
452
- lens.append(len(candidates))
453
471
 
454
- # determine total combos without materializing them
455
- total_combos = 1
456
- for L in lens:
457
- total_combos *= L
472
+ # determine total combos without materializing them using math.prod
473
+ # (previously used len(list(itertools.product(...))) which materialized all combos)
474
+ total_combos = math.prod(len(v) for v in value_lists)
475
+
476
+ # choose worker count conservatively to avoid RAM hogging
477
+ cpu_count = os.cpu_count() or 1
478
+ if workers is None:
479
+ workers = max(1, min(cpu_count - 1, 4))
480
+ else:
481
+ workers = max(1, int(workers))
482
+
483
+ # Adaptive chunksize calculation
484
+ # Previous default was chunksize=1 which causes high IPC overhead
485
+ # New default "auto" uses: max(16, total_combos // (workers * 4))
486
+ if chunksize == "auto":
487
+ chunksize = max(16, total_combos // (workers * 4))
488
+ else:
489
+ chunksize = max(1, int(chunksize))
458
490
 
459
491
  # prepare iterable of param dicts as sequences of items (so pickling is slightly cheaper)
492
+ # Also pre-compute constraint results to avoid repeated checks
460
493
  def _param_items_iter():
461
494
  for combo in itertools.product(*value_lists):
462
495
  row_params = {k: v for k, v in zip(keys, combo)}
@@ -469,13 +502,6 @@ class SimpleBacktester:
469
502
  # yield as tuple of items for stable order and smaller IPC
470
503
  yield tuple(row_params.items())
471
504
 
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
505
  # pickle the base strategy once and send bytes to worker initializer
480
506
  pickled_strategy = pickle.dumps(self.strategy)
481
507
 
@@ -491,6 +517,7 @@ class SimpleBacktester:
491
517
  self.commission,
492
518
  self.commission_type,
493
519
  self.lot_size,
520
+ self.leverage,
494
521
  ),
495
522
  ) as exe:
496
523
  # map the worker over param item tuples
@@ -548,6 +575,7 @@ class SimpleBacktester:
548
575
  commission=self.commission,
549
576
  commission_type=self.commission_type,
550
577
  lot_size=self.lot_size,
578
+ leverage=self.leverage,
551
579
  )
552
580
  best_report = bt.run(progress_bar=False)
553
581
 
@@ -562,7 +590,291 @@ class SimpleBacktester:
562
590
  train_metrics=best_metrics,
563
591
  validate_metrics={},
564
592
  test_metrics={},
565
- all_results=results_df
593
+ all_results=results_df
594
+ )
595
+
596
+ def optimize_optuna(
597
+ self,
598
+ param_space: dict[str, tuple[Any, Any] | list[Any]],
599
+ n_trials: int = 100,
600
+ objective: str = "sharpe",
601
+ risk_tolerance: dict[str, float] | None = None,
602
+ constraint: Callable[[dict[str, Any]], bool] | None = None,
603
+ timeout: int | None = None,
604
+ random_seed: int | None = None,
605
+ workers: int | None = None,
606
+ progress_bar: bool = True,
607
+ ) -> OptimizationResult:
608
+ """
609
+ Optimize strategy parameters using Optuna (Bayesian optimization).
610
+
611
+ This method uses Optuna's optimization framework with TPE (Tree-structured
612
+ Parzen Estimator) sampler for intelligent parameter search. It typically
613
+ finds better solutions than grid search with fewer evaluations.
614
+
615
+ The method supports:
616
+ - Continuous parameter ranges (sampled uniformly)
617
+ - Discrete/categorical parameter lists
618
+ - Early pruning of unpromising trials
619
+ - Parallel execution for faster optimization
620
+
621
+ Args:
622
+ param_space (dict[str, tuple[Any, Any] | list[Any]]): Parameter search space.
623
+ Can be:
624
+ - Continuous range: (min, max) tuple for uniform sampling
625
+ - Discrete list: [val1, val2, ...] for categorical sampling
626
+ Example: {'period': (5, 50), 'threshold': [0.01, 0.02, 0.05]}
627
+ n_trials (int, optional): Maximum number of optimization trials.
628
+ Defaults to 100.
629
+ objective (str, optional): Metric to optimize. Defaults to "sharpe".
630
+ Supports: "final_cash", "total_return", "sharpe", "max_drawdown", "trades".
631
+ risk_tolerance (dict[str, float] | None, optional): Maximum allowed values
632
+ for risk metrics. Trials exceeding thresholds are pruned. Defaults to None.
633
+ constraint (Callable[[dict[str, Any]], bool] | None, optional): Optional
634
+ callable to enforce parameter constraints. Defaults to None.
635
+ timeout (int | None, optional): Maximum time in seconds for optimization.
636
+ Defaults to None (no limit).
637
+ random_seed (int | None, optional): Random seed for reproducibility.
638
+ Defaults to None.
639
+ workers (int | None, optional): Number of parallel workers for Optuna
640
+ study. Defaults to None (sequential).
641
+ progress_bar (bool, optional): Whether to show progress bar. Defaults to True.
642
+
643
+ Returns:
644
+ OptimizationResult: Object containing:
645
+ - best_params: Best parameter values found
646
+ - train_report: BacktestReport for best parameters (None for Optuna)
647
+ - validate_report: None
648
+ - test_report: None
649
+ - train_metrics: Metrics for best parameters
650
+ - validate_metrics: Empty dict
651
+ - test_metrics: Empty dict
652
+ - all_results: DataFrame with all trial results
653
+
654
+ Performance Notes:
655
+ - Optuna typically finds good solutions in 50-200 trials
656
+ - For 10,000+ grid combos, Optuna can be 50-100x faster
657
+ - Use workers > 1 for parallel trial evaluation
658
+ - Pruning callbacks significantly speed up optimization
659
+
660
+ Example:
661
+ >>> # Optimize with continuous and discrete parameters
662
+ >>> result = bt.optimize_optuna({
663
+ ... 'fast_period': (5, 50), # Continuous: 5-50
664
+ ... 'slow_period': [20, 30, 50], # Discrete: pick one
665
+ ... 'threshold': (0.01, 0.1), # Continuous: 1%-10%
666
+ ... }, n_trials=100)
667
+ >>> print(f"Best params: {result.best_params}")
668
+ >>> print(f"Best Sharpe: {result.train_metrics['sharpe']}")
669
+
670
+ Note:
671
+ Requires optuna package: pip install optuna
672
+ """
673
+ try:
674
+ import optuna
675
+ except ImportError:
676
+ raise ImportError(
677
+ "optuna is required for optimize_optuna. "
678
+ "Install it with: pip install optuna"
679
+ )
680
+
681
+ # Check for invalid objective
682
+ valid_metrics = {"final_cash", "total_return", "sharpe", "max_drawdown", "trades"}
683
+ if objective not in valid_metrics:
684
+ raise ValueError(
685
+ f"objective must be one of {valid_metrics}, got '{objective}'"
686
+ )
687
+
688
+ # Convert param_space to Optuna distribution format
689
+ param_names = list(param_space.keys())
690
+
691
+ def _create_objective(
692
+ strategy_template: Strategy,
693
+ cash: float,
694
+ commission: float,
695
+ commission_type: CommissionType,
696
+ lot_size: int,
697
+ leverage: float,
698
+ objective: str,
699
+ risk_tolerance: dict[str, float] | None,
700
+ constraint: Callable[[dict[str, Any]], bool] | None,
701
+ ):
702
+ """Create objective function for Optuna."""
703
+
704
+ def objective_fn(trial: optuna.Trial) -> float:
705
+ # Sample parameters based on space definition
706
+ params = {}
707
+ for name, space in param_space.items():
708
+ if isinstance(space, (list, tuple)) and len(space) == 2:
709
+ # Check if it's a range (numeric) or discrete list
710
+ if all(isinstance(v, (int, float)) for v in space):
711
+ # Numeric range: treat as continuous if range > 10 values
712
+ try:
713
+ if len(space) == 2 and all(isinstance(v, (int, float)) for v in space):
714
+ # Check if values suggest discrete or continuous
715
+ if all(isinstance(v, int) for v in space) and len(space) == 2:
716
+ # Check if it's meant to be discrete (like range values)
717
+ pass
718
+ except:
719
+ pass
720
+ # Try as discrete list first
721
+ try:
722
+ # Assume discrete if second value is list
723
+ if isinstance(space[1], list):
724
+ choice = trial.suggest_categorical(name, space)
725
+ params[name] = choice
726
+ else:
727
+ # Continuous range
728
+ low, high = sorted(space)
729
+ if all(isinstance(v, int) for v in space):
730
+ params[name] = trial.suggest_int(name, int(low), int(high))
731
+ else:
732
+ params[name] = trial.suggest_float(name, float(low), float(high))
733
+ except:
734
+ # Treat as continuous
735
+ low, high = sorted(space)
736
+ if all(isinstance(v, int) for v in space):
737
+ params[name] = trial.suggest_int(name, int(low), int(high))
738
+ else:
739
+ params[name] = trial.suggest_float(name, float(low), float(high))
740
+ else:
741
+ # Discrete list
742
+ params[name] = trial.suggest_categorical(name, space)
743
+ else:
744
+ # Direct list of choices
745
+ params[name] = trial.suggest_categorical(name, list(space))
746
+
747
+ # Apply constraint if provided
748
+ if constraint is not None:
749
+ try:
750
+ if not bool(constraint(params)):
751
+ raise optuna.TrialPruned("Constraint violated")
752
+ except optuna.TrialPruned:
753
+ raise
754
+ except Exception:
755
+ raise optuna.TrialPruned("Constraint error")
756
+
757
+ # Create strategy copy and apply params
758
+ strat_copy = copy.deepcopy(strategy_template)
759
+ for k, v in params.items():
760
+ setattr(strat_copy, k, v)
761
+
762
+ # Run backtest
763
+ bt = SimpleBacktester(
764
+ strat_copy,
765
+ cash=cash,
766
+ commission=commission,
767
+ commission_type=commission_type,
768
+ lot_size=lot_size,
769
+ leverage=leverage,
770
+ )
771
+ report = bt.run(progress_bar=False)
772
+
773
+ # Compute metrics
774
+ metrics = _compute_backtest_metrics(report)
775
+
776
+ # Apply risk tolerance filter
777
+ if risk_tolerance is not None:
778
+ if not _risk_tolerance_passes(report, risk_tolerance):
779
+ raise optuna.TrialPruned("Risk tolerance exceeded")
780
+
781
+ # Get objective score
782
+ if objective in valid_metrics:
783
+ score = metrics.get(objective)
784
+ else:
785
+ score = getattr(report, objective, None)
786
+ if callable(score):
787
+ score = score()
788
+
789
+ if score is None or not np.isfinite(float(score)): # type: ignore[arg-type]
790
+ raise optuna.TrialPruned("Invalid objective score")
791
+
792
+ return float(score) # type: ignore[arg-type]
793
+
794
+ return objective_fn
795
+
796
+ # Create and configure Optuna study
797
+ sampler = optuna.samplers.TPESampler(seed=random_seed)
798
+ study = optuna.create_study(
799
+ direction="maximize",
800
+ sampler=sampler,
801
+ )
802
+
803
+ # Create objective function with closure
804
+ obj_fn = _create_objective(
805
+ strategy_template=self.strategy,
806
+ cash=self.cash,
807
+ commission=self.commission,
808
+ commission_type=self.commission_type,
809
+ lot_size=self.lot_size,
810
+ leverage=self.leverage,
811
+ objective=objective,
812
+ risk_tolerance=risk_tolerance,
813
+ constraint=constraint,
814
+ )
815
+
816
+ # Run optimization
817
+ show_progress = progress_bar and workers is None # Only if sequential
818
+
819
+ if workers is not None and workers > 1:
820
+ # Parallel execution using joblib backend
821
+ study.optimize(
822
+ obj_fn,
823
+ n_trials=n_trials,
824
+ timeout=timeout,
825
+ n_jobs=workers,
826
+ show_progress_bar=progress_bar,
827
+ )
828
+ else:
829
+ # Sequential execution
830
+ study.optimize(
831
+ obj_fn,
832
+ n_trials=n_trials,
833
+ timeout=timeout,
834
+ show_progress_bar=show_progress,
835
+ )
836
+
837
+ # Get best params
838
+ best_params = study.best_params
839
+
840
+ # Build results DataFrame from completed trials
841
+ results_rows = []
842
+ for trial in study.trials:
843
+ if trial.value is not None and trial.value > -np.inf:
844
+ row = dict(trial.params)
845
+ row["objective_score"] = trial.value
846
+ row["state"] = trial.state.name
847
+ results_rows.append(row)
848
+
849
+ results_df = pd.DataFrame(results_rows)
850
+ if not results_df.empty:
851
+ results_df.sort_values(by=["objective_score"], ascending=False, inplace=True, kind="mergesort")
852
+
853
+ # Run full backtest with best params for detailed report
854
+ strat_copy = copy.deepcopy(self.strategy)
855
+ for k, v in best_params.items():
856
+ setattr(strat_copy, k, v)
857
+
858
+ bt = SimpleBacktester(
859
+ strat_copy,
860
+ cash=self.cash,
861
+ commission=self.commission,
862
+ commission_type=self.commission_type,
863
+ lot_size=self.lot_size,
864
+ leverage=self.leverage,
865
+ )
866
+ best_report = bt.run(progress_bar=False)
867
+ best_metrics = _compute_backtest_metrics(best_report)
868
+
869
+ return OptimizationResult(
870
+ best_params=best_params,
871
+ train_report=best_report,
872
+ validate_report=None,
873
+ test_report=None,
874
+ train_metrics=best_metrics,
875
+ validate_metrics={},
876
+ test_metrics={},
877
+ all_results=results_df,
566
878
  )
567
879
 
568
880
  def optimize_with_split(
@@ -677,7 +989,8 @@ class SimpleBacktester:
677
989
 
678
990
  valid_metrics = {"final_cash", "total_return", "sharpe", "max_drawdown", "trades"}
679
991
 
680
- total_combos = len(list(itertools.product(*value_lists)))
992
+ # Use math.prod instead of len(list(...)) to avoid materializing all combos
993
+ total_combos = math.prod(len(v) for v in value_lists)
681
994
 
682
995
  # Create a modified strategy that uses data slices
683
996
  def create_split_strategy(params_dict: dict, split_mode: DataSplitMode):
@@ -730,6 +1043,7 @@ class SimpleBacktester:
730
1043
  commission=self.commission,
731
1044
  commission_type=self.commission_type,
732
1045
  lot_size=self.lot_size,
1046
+ leverage=self.leverage,
733
1047
  )
734
1048
  report = bt.run(progress_bar=False)
735
1049
  metrics = _compute_backtest_metrics(report)
@@ -792,7 +1106,7 @@ class SimpleBacktester:
792
1106
  best_params = {}
793
1107
  best_validate_score = -np.inf
794
1108
 
795
- # Get full reports for best parameters
1109
+ # Get full reports for best parameters
796
1110
  train_report = None
797
1111
  validate_report = None
798
1112
  test_report = None
@@ -814,6 +1128,7 @@ class SimpleBacktester:
814
1128
  commission=self.commission,
815
1129
  commission_type=self.commission_type,
816
1130
  lot_size=self.lot_size,
1131
+ leverage=self.leverage,
817
1132
  )
818
1133
  report = bt.run(progress_bar=False)
819
1134
  metrics = _compute_backtest_metrics(report)
@@ -1008,6 +1323,7 @@ class SimpleBacktester:
1008
1323
  commission=self.commission,
1009
1324
  commission_type=self.commission_type,
1010
1325
  lot_size=self.lot_size,
1326
+ leverage=self.leverage,
1011
1327
  )
1012
1328
  report = bt.run(progress_bar=False)
1013
1329
  metrics = _compute_backtest_metrics(report)
@@ -1131,6 +1447,7 @@ class SimpleBacktester:
1131
1447
  commission=self.commission,
1132
1448
  commission_type=self.commission_type,
1133
1449
  lot_size=self.lot_size,
1450
+ leverage=self.leverage,
1134
1451
  )
1135
1452
  report = bt.run(progress_bar=False)
1136
1453
  metrics = _compute_backtest_metrics(report)
@@ -462,6 +462,7 @@ def _run_price_path_simulation(
462
462
  commission: float,
463
463
  commission_type,
464
464
  lot_size: int,
465
+ leverage: float = 1.0,
465
466
  seed: int | None = None,
466
467
  ) -> pd.Series:
467
468
  """
@@ -477,6 +478,7 @@ def _run_price_path_simulation(
477
478
  commission (float): Commission rate.
478
479
  commission_type: Commission type enum.
479
480
  lot_size (int): Lot size for trading.
481
+ leverage (float): Leverage multiplier for position sizing.
480
482
  seed (int | None): Random seed for reproducibility.
481
483
 
482
484
  Returns:
@@ -581,6 +583,7 @@ def _run_price_path_simulation(
581
583
  commission=commission,
582
584
  commission_type=commission_type,
583
585
  lot_size=lot_size,
586
+ leverage=leverage,
584
587
  )
585
588
  report = bt.run(progress_bar=False)
586
589
 
@@ -705,6 +708,7 @@ def monte_carlo(
705
708
  self.commission,
706
709
  self.commission_type,
707
710
  self.lot_size,
711
+ leverage=self.leverage,
708
712
  seed=iter_seed,
709
713
  )
710
714
  equity_curves.append(curve)
@@ -20,7 +20,8 @@ def _worker_init(
20
20
  cash: float,
21
21
  commision: float,
22
22
  commision_type: Any,
23
- lot_size: int
23
+ lot_size: int,
24
+ leverage: float,
24
25
  ) -> None:
25
26
  """
26
27
  Initializer for worker processes in parallel optimization.
@@ -35,6 +36,7 @@ def _worker_init(
35
36
  commision (float): Commission rate for trades.
36
37
  commision_type: Type of commission calculation (CommissionType enum).
37
38
  lot_size (int): Size of trading lots.
39
+ leverage (float): Leverage multiplier for position sizing.
38
40
 
39
41
  Note:
40
42
  This function is designed to be called by worker processes
@@ -47,6 +49,77 @@ def _worker_init(
47
49
  "commision": commision,
48
50
  "commision_type": commision_type,
49
51
  "lot_size": lot_size,
52
+ "leverage": leverage,
53
+ }
54
+
55
+
56
+ def _compute_metrics_numpy(
57
+ equity: np.ndarray,
58
+ periods_per_year: float,
59
+ n_trades: int,
60
+ ) -> dict[str, Any]:
61
+ """
62
+ Compute performance metrics using numpy arrays directly.
63
+
64
+ This is more efficient than using pandas operations for the
65
+ inner loop of optimization since we avoid pandas overhead.
66
+
67
+ Args:
68
+ equity: Numpy array of equity values over time.
69
+ periods_per_year: Number of periods in a year for annualization.
70
+ n_trades: Number of trades executed.
71
+
72
+ Returns:
73
+ Dictionary with computed metrics.
74
+ """
75
+ # Calculate returns using numpy (avoid pandas overhead)
76
+ equity_arr = equity.astype(np.float64)
77
+
78
+ # Handle edge cases
79
+ if len(equity_arr) < 2:
80
+ return {
81
+ "final_cash": float(equity_arr[-1]) if len(equity_arr) > 0 else 0.0,
82
+ "total_return": 0.0,
83
+ "sharpe": float("nan"),
84
+ "max_drawdown": 0.0,
85
+ "trades": n_trades,
86
+ }
87
+
88
+ # Compute returns using numpy
89
+ returns = np.diff(equity_arr) / equity_arr[:-1]
90
+
91
+ # Remove NaN/Inf values
92
+ valid_returns = returns[np.isfinite(returns)]
93
+
94
+ # Total return
95
+ tot_return = float(equity_arr[-1] / equity_arr[0] - 1.0) if equity_arr[0] != 0 else 0.0
96
+
97
+ # Sharpe ratio
98
+ annual_rf = 0.04
99
+ rf_per_period = annual_rf / periods_per_year
100
+
101
+ if len(valid_returns) < 2:
102
+ sharpe = float("nan")
103
+ else:
104
+ excess = valid_returns - rf_per_period
105
+ mean_excess = np.mean(excess)
106
+ std_excess = np.std(excess, ddof=1)
107
+ if std_excess == 0:
108
+ sharpe = float("nan")
109
+ else:
110
+ sharpe = float((mean_excess / std_excess) * (periods_per_year ** 0.5))
111
+
112
+ # Maximum drawdown using numpy
113
+ running_max = np.maximum.accumulate(equity_arr)
114
+ drawdowns = (equity_arr - running_max) / running_max
115
+ mdd = float(abs(np.min(drawdowns)))
116
+
117
+ return {
118
+ "final_cash": float(equity_arr[-1]),
119
+ "total_return": tot_return,
120
+ "sharpe": sharpe,
121
+ "max_drawdown": mdd,
122
+ "trades": n_trades,
50
123
  }
51
124
 
52
125
 
@@ -57,6 +130,11 @@ def _worker_eval(param_items: tuple[tuple[str, Any], ...]) -> dict[str, Any]:
57
130
  This function runs in worker processes to evaluate a single
58
131
  parameter combination and return performance metrics.
59
132
 
133
+ Optimizations applied:
134
+ 1. Uses numpy for metric computation instead of pandas (faster)
135
+ 2. Returns only essential metrics (reduces IPC overhead)
136
+ 3. Explicit cleanup of references to help GC
137
+
60
138
  Args:
61
139
  param_items: Sequence of (key, value) pairs (tuple) to reconstruct dict.
62
140
  Each tuple represents a parameter name and its value.
@@ -101,42 +179,28 @@ def _worker_eval(param_items: tuple[tuple[str, Any], ...]) -> dict[str, Any]:
101
179
  commission=config["commision"],
102
180
  commission_type=config["commision_type"],
103
181
  lot_size=config["lot_size"],
182
+ leverage=config["leverage"],
104
183
  )
105
184
  report = bt.run(progress_bar=False)
106
185
 
107
- # Compute metrics
108
- equity = report.PnlRecord.astype(float)
109
- returns = equity.pct_change().dropna()
110
-
111
- annual_rf = 0.04
112
- rf_per_period = annual_rf / report.periods_per_year
113
-
114
- if len(returns) < 2 or returns.std(ddof=1) == 0:
115
- sharpe = float("nan")
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)
186
+ # Compute metrics using optimized numpy version
187
+ # This avoids pandas overhead for metric computation
188
+ # Use to_numpy() with copy=False for efficiency, convert to float64
189
+ equity_values = np.asarray(report.PnlRecord, dtype=np.float64)
190
+ metrics = _compute_metrics_numpy(
191
+ equity=equity_values,
192
+ periods_per_year=report.periods_per_year,
193
+ n_trades=len(report.orders),
194
+ )
127
195
 
128
- # Keep worker returned payload small — don't send large objects back.
196
+ # Build result with params
129
197
  result: dict[str, Any] = {
130
198
  "params": params,
131
- "final_cash": report.final_cash,
132
- "total_return": tot_return,
133
- "sharpe": sharpe,
134
- "max_drawdown": mdd,
135
- "trades": len(report.orders),
199
+ **metrics,
136
200
  }
137
201
 
138
202
  # Cleanup references to free memory inside worker
139
- del strat, bt, report, equity, returns
203
+ del strat, bt, report
140
204
  gc.collect()
141
205
 
142
206
  return result
@@ -52,6 +52,7 @@ class Broker:
52
52
  - Commission calculations
53
53
  - Stop loss and take profit order management
54
54
  - P&L record tracking
55
+ - Leverage for amplified position sizing
55
56
 
56
57
  Example:
57
58
  >>> source = CSVDataSource("data.csv")
@@ -74,6 +75,7 @@ class Broker:
74
75
  self.commision_type: CommissionType = CommissionType.PERCENTAGE
75
76
  self.lot_size: int = 1
76
77
  self.margin_call: float = 0.5 ## 50% of the cash value
78
+ self.leverage: float = 1.0 ## Leverage multiplier (1.0 = no leverage)
77
79
  self.share_decimals = 1
78
80
  self.orders: list[Order] = []
79
81
  self.complete_orders = []
@@ -154,7 +156,9 @@ class Broker:
154
156
  during the next iteration.
155
157
  - Market orders execute immediately at current open price.
156
158
  - Limit orders only execute when price reaches the specified level.
157
-
159
+ - Leverage amplifies position size - with 2x leverage and quantity=1,
160
+ you control 2x the shares while only using 1x cash as margin.
161
+
158
162
  Example:
159
163
  >>> broker = Broker(source)
160
164
  >>> # Buy with 25% of available cash
@@ -176,9 +180,15 @@ class Broker:
176
180
  else:
177
181
  type = OrderType.MARKET
178
182
  current_price = self.source.Close[-1]
179
- total_shares = round((self.cash * quantity) / current_price, self.share_decimals)
180
183
  if (amount):
184
+ # When using absolute amount, still apply leverage to the base calculation
185
+ # but the user-provided amount is the final leveraged position size
181
186
  total_shares = amount
187
+ else:
188
+ # Calculate shares: base on cash * quantity, then apply leverage
189
+ base_shares = round((self.cash * quantity) / current_price, self.share_decimals)
190
+ # Apply leverage to increase position size
191
+ total_shares = round(base_shares * self.leverage, self.share_decimals)
182
192
  order = Order(
183
193
  side=OrderSide.BUY,
184
194
  quantity=total_shares,
@@ -489,7 +499,9 @@ class Broker:
489
499
  self.position_avg_price = (old_pos * self.position_avg_price + order.quantity * order.price) / new_pos
490
500
  else:
491
501
  self.position_avg_price = order.price
492
- self._debit(order.price * order.quantity)
502
+ # Calculate margin (cash used) for leveraged positions
503
+ margin = order.price * order.quantity / self.leverage
504
+ self._debit(margin)
493
505
  self._apply_commission(order.quantity, order.price)
494
506
  self.position = new_pos
495
507
  else:
@@ -504,7 +516,9 @@ class Broker:
504
516
  self.position_avg_price = (old_pos * self.position_avg_price + order.quantity * order.price) / new_pos
505
517
  else:
506
518
  self.position_avg_price = order.price
507
- self._credit(order.price * order.quantity)
519
+ # Calculate margin released for leveraged positions
520
+ margin = order.price * order.quantity / self.leverage
521
+ self._credit(margin)
508
522
  self._apply_commission(order.quantity, order.price)
509
523
  self.position = new_pos
510
524
  if (order.stop_loss or order.take_profit):
@@ -526,7 +540,9 @@ class Broker:
526
540
  self.position_avg_price = (old_pos * self.position_avg_price + order.quantity * price) / new_pos
527
541
  else:
528
542
  self.position_avg_price = price
529
- self._debit(self.source.COpen * order.quantity)
543
+ # Calculate margin (cash used) for leveraged positions
544
+ margin = self.source.COpen * order.quantity / self.leverage
545
+ self._debit(margin)
530
546
  self._apply_commission(order.quantity, self.source.COpen)
531
547
  self.position = new_pos
532
548
  else:
@@ -540,7 +556,9 @@ class Broker:
540
556
  self.position_avg_price = (old_pos * self.position_avg_price + order.quantity * price) / new_pos
541
557
  else:
542
558
  self.position_avg_price = price
543
- self._credit(self.source.COpen * order.quantity)
559
+ # Calculate margin released for leveraged positions
560
+ margin = self.source.COpen * order.quantity / self.leverage
561
+ self._credit(margin)
544
562
  self._apply_commission(order.quantity, self.source.COpen)
545
563
  self.position = new_pos
546
564
  if (order.stop_loss or order.take_profit):
@@ -604,13 +622,15 @@ class Broker:
604
622
  self.pending_close_order = None
605
623
  unrealized = self.position * self.source.CClose
606
624
  equity = self.cash + unrealized
607
- margin_call = self.margin_call * abs(self.position) * self.source.CClose
608
- if equity < margin_call and self.position < 0:
625
+ # Calculate actual margin used, accounting for leverage
626
+ actual_margin = abs(self.position) * self.source.CClose / self.leverage
627
+ margin_call_threshold = self.margin_call * actual_margin
628
+ if equity < margin_call_threshold and self.position < 0:
609
629
  self.margin_call_triggered = True
610
630
  self.margin_call_events.append({
611
631
  "timestamp": self.source.Index[self._i],
612
632
  "equity": equity,
613
- "margin_call_threshold": margin_call,
633
+ "margin_call_threshold": margin_call_threshold,
614
634
  "position": self.position,
615
635
  })
616
636
  self.close() ## Close all positions immediately, margin call
@@ -119,7 +119,9 @@ class Strategy(ABC):
119
119
  >>> class MyStrategy(Strategy):
120
120
  ... def init(self):
121
121
  ... self.add_data(CSVDataSource("AAPL.csv"), "AAPL")
122
- ... self.add_data(CSVDataSource("EURUSD.csv"), "EURUSD")
122
+ ... def next(self):
123
+ ... if self.data['AAPL'].CClose > self.data['AAPL'].Close[-1]:
124
+ ... self.positions['AAPL'].buy(quantity=0.1)
123
125
  """
124
126
  self.data[symbol] = source
125
127
  self.positions[symbol] = Broker(source)
File without changes
File without changes
File without changes
File without changes