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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: quantex
3
- Version: 0.4.5
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.5"
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
- total_combos = len(list(itertools.product(*value_lists)))
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 = 1) -> OptimizationResult:
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
- Smaller values provide better load balancing for many small tasks.
386
- Larger values reduce overhead for fewer, larger tasks.
387
- Defaults to 1.
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
- total_combos = 1
451
- for L in lens:
452
- total_combos *= L
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
- all_results=results_df
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
- total_combos = len(list(itertools.product(*value_lists)))
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): Number of steps to evaluate. Defaults
131
- to the full length of the simulated curves.
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
- horizon = horizon or len(self.equity_curves[0])
148
- horizon = max(1, min(horizon, len(self.equity_curves[0])))
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[:horizon]
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 using a numeric simulation step axis to avoid date conversion
203
- # artifacts when equity curves share the same time index.
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("Step")
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
- step_index = np.arange(len(self.equity_curves[0]), dtype=np.float64)
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
- for curve in self.equity_curves:
218
- x_vals = np.arange(len(curve), dtype=np.float64)
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(x_vals, y_vals, color="steelblue", alpha=0.1, linewidth=0.5)
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 = np.arange(len(mean_curve), dtype=np.float64)
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 = np.arange(len(median_curve), dtype=np.float64)
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 = np.arange(len(self.original_equity), dtype=np.float64)
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 = np.arange(len(p5), dtype=np.float64)
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("Step")
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
- # Match the more compact spaghetti-plot look by tightening x-limits.
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 allows some trades to be repeated while others may be omitted.
376
- The sampled trade outcomes are then applied as percentage returns to the
377
- portfolio curve.
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
- # Build a replacement-sampled sequence that may include repeated trade
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 "both".
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
- 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)
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
- # Keep worker returned payload small — don't send large objects back.
192
+ # Build result with params
129
193
  result: dict[str, Any] = {
130
194
  "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),
195
+ **metrics,
136
196
  }
137
197
 
138
198
  # Cleanup references to free memory inside worker
139
- del strat, bt, report, equity, returns
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
- 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.
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