quantex 0.3.0__tar.gz → 0.3.2__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.3.0
3
+ Version: 0.3.2
4
4
  Summary: A simple quant strategy creation and backtesting package.
5
5
  License: MIT
6
6
  Author: Daniel Green
@@ -34,7 +34,7 @@ The project is intentionally small. It does not try to be a full research platfo
34
34
 
35
35
  ## Installation
36
36
 
37
- Quantex requires Python 3.10 or newer and is published as [`quantex`](pyproject.toml).
37
+ Quantex requires Python 3.11 or newer and is published as [`quantex`](pyproject.toml).
38
38
 
39
39
  ```bash
40
40
  pip install quantex
@@ -14,7 +14,7 @@ The project is intentionally small. It does not try to be a full research platfo
14
14
 
15
15
  ## Installation
16
16
 
17
- Quantex requires Python 3.10 or newer and is published as [`quantex`](pyproject.toml).
17
+ Quantex requires Python 3.11 or newer and is published as [`quantex`](pyproject.toml).
18
18
 
19
19
  ```bash
20
20
  pip install quantex
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "quantex"
3
- version = "0.3.0"
3
+ version = "0.3.2"
4
4
  description = "A simple quant strategy creation and backtesting package."
5
5
  authors = [
6
6
  {name = "Daniel Green",email = "dangreen07@outlook.com"}
@@ -185,6 +185,59 @@ def _worker_eval(param_items):
185
185
 
186
186
  return result
187
187
 
188
+ def _compute_backtest_metrics(report: "BacktestReport") -> dict[str, Any]:
189
+ equity = report.PnlRecord.astype(float)
190
+ returns = equity.pct_change().dropna()
191
+
192
+ annual_rf = report.annual_rf
193
+ rf_per_period = annual_rf / report.periods_per_year
194
+
195
+ if len(returns) < 2 or returns.std(ddof=1) == 0:
196
+ sharpe = float("nan")
197
+ else:
198
+ excess = returns - rf_per_period
199
+ mean = excess.mean()
200
+ vol = excess.std(ddof=1)
201
+ sharpe = float((mean / vol) * (report.periods_per_year ** 0.5))
202
+
203
+ running_max = equity.cummax()
204
+ drawdown = ((equity - running_max) / running_max).min()
205
+ mdd = float(abs(drawdown))
206
+
207
+ tot_return = float(equity.iloc[-1] / equity.iloc[0] - 1.0)
208
+
209
+ return {
210
+ "final_cash": report.final_cash,
211
+ "total_return": tot_return,
212
+ "sharpe": sharpe,
213
+ "max_drawdown": mdd,
214
+ "trades": len(report.orders),
215
+ }
216
+
217
+ def _extract_metric_value(report: "BacktestReport", metric: str) -> Any:
218
+ value = getattr(report, metric, None)
219
+ if callable(value):
220
+ value = value()
221
+ return value
222
+
223
+ def _risk_tolerance_passes(report: "BacktestReport", risk_tolerance: dict[str, float] | None) -> bool:
224
+ if not risk_tolerance:
225
+ return True
226
+
227
+ metrics = _compute_backtest_metrics(report)
228
+ for metric, max_value in risk_tolerance.items():
229
+ if max_value is None:
230
+ continue
231
+ current_value = metrics.get(metric, _extract_metric_value(report, metric))
232
+ if current_value is None:
233
+ raise AttributeError(f"BacktestReport does not expose metric '{metric}'")
234
+ if not np.isfinite(float(current_value)):
235
+ return False
236
+ if float(current_value) > float(max_value):
237
+ return False
238
+
239
+ return True
240
+
188
241
  @dataclass
189
242
  class BacktestReport:
190
243
  """
@@ -346,12 +399,14 @@ class BacktestReport:
346
399
  mdd = float(abs(drawdown))
347
400
 
348
401
  tot_return = float(equity.iloc[-1] / equity.iloc[0] - 1.0)
402
+ annualized_return = float((1.0 + tot_return) ** (self.periods_per_year / max(len(returns), 1)) - 1.0)
349
403
  tot_orders = len(self.orders)
350
404
 
351
405
  return (
352
406
  f"Starting Cash: ${self.starting_cash:,.2f}\n"
353
407
  f"Final Cash: ${self.final_cash:,.2f}\n"
354
408
  f"Total Return: {tot_return:,.2%}\n"
409
+ f"Annualized Return: {annualized_return:,.2%}\n"
355
410
  f"Sharpe Ratio: {sharpe:.2f}" if np.isfinite(sharpe) else
356
411
  f"Sharpe Ratio: nan"
357
412
  ) + (
@@ -517,7 +572,13 @@ class SimpleBacktester():
517
572
  orders=orders,
518
573
  tradeRecord=tradeRecord)
519
574
 
520
- def optimize(self, params: dict[str, range], constraint: Callable[[dict[str, Any]], bool] | None = None):
575
+ def optimize(
576
+ self,
577
+ params: dict[str, range],
578
+ constraint: Callable[[dict[str, Any]], bool] | None = None,
579
+ objective: str = "sharpe",
580
+ risk_tolerance: dict[str, float] | None = None,
581
+ ):
521
582
  """
522
583
  Perform a grid search over the provided parameter ranges.
523
584
 
@@ -540,6 +601,15 @@ class SimpleBacktester():
540
601
  True to evaluate the combo or False to skip it. Useful for enforcing
541
602
  logical constraints like ensuring fast_period < slow_period.
542
603
  Defaults to None (no constraints).
604
+ objective (str, optional): BacktestReport attribute or computed metric to
605
+ optimize. Defaults to "sharpe". Supports any attribute exposed by
606
+ BacktestReport and the computed metrics "final_cash", "total_return",
607
+ "sharpe", "max_drawdown", and "trades".
608
+ risk_tolerance (dict[str, float] | None, optional): Optional maximum
609
+ allowed values for candidate metrics. Any candidate that exceeds a
610
+ threshold is discarded before scoring. For example,
611
+ {"max_drawdown": 0.05} rejects strategies with drawdown above 5%.
612
+ Defaults to None.
543
613
 
544
614
  Returns:
545
615
  tuple: A tuple containing (best_params, best_report, results):
@@ -555,9 +625,8 @@ class SimpleBacktester():
555
625
  TypeError: If any parameter values are not iterable.
556
626
 
557
627
  Note:
558
- The optimization uses Sharpe ratio as the primary selection criterion.
559
- If Sharpe ratio is invalid (NaN), it falls back to total return,
560
- then to final cash amount.
628
+ The optimization uses the selected objective as the primary selection
629
+ criterion. If the objective is invalid (NaN), the candidate is skipped.
561
630
 
562
631
  Example:
563
632
  >>> bt = SimpleBacktester(strategy)
@@ -589,6 +658,8 @@ class SimpleBacktester():
589
658
  best_params = None
590
659
  best_score = -np.inf
591
660
 
661
+ valid_metrics = {"final_cash", "total_return", "sharpe", "max_drawdown", "trades"}
662
+
592
663
  total_combos = len(list(itertools.product(*value_lists)))
593
664
 
594
665
  for combo in tqdm(itertools.product(*value_lists), total=(total_combos)):
@@ -619,48 +690,34 @@ class SimpleBacktester():
619
690
  )
620
691
  report = bt.run(progress_bar=False)
621
692
 
622
- # Compute metrics
623
- equity = report.PnlRecord.astype(float)
624
- returns = equity.pct_change().dropna()
693
+ metrics = _compute_backtest_metrics(report)
625
694
 
626
- # Risk-free per period from an annual rate
627
- annual_rf = 0.04
628
- rf_per_period = annual_rf / report.periods_per_year
695
+ if not _risk_tolerance_passes(report, risk_tolerance):
696
+ continue
629
697
 
630
- if len(returns) < 2 or returns.std(ddof=1) == 0:
631
- sharpe = np.nan
632
- lo = np.nan
633
- hi = np.nan
698
+ if objective in valid_metrics:
699
+ score = metrics.get(objective)
634
700
  else:
635
- excess = returns - rf_per_period
636
- mean = excess.mean()
637
- vol = excess.std(ddof=1)
638
- sharpe = (mean / vol) * np.sqrt(report.periods_per_year)
701
+ score = getattr(report, objective, None)
702
+ if callable(score):
703
+ score = score()
639
704
 
640
- # Max drawdown on equity curve
641
- running_max = equity.cummax()
642
- drawdown = ((equity - running_max) / running_max).min()
643
- mdd = float(abs(drawdown))
705
+ if score is None:
706
+ raise AttributeError(f"BacktestReport does not expose objective '{objective}'")
644
707
 
645
- tot_return = float(equity.iloc[-1] / equity.iloc[0] - 1.0)
708
+ try:
709
+ score = float(score) # type: ignore[arg-type]
710
+ except (TypeError, ValueError):
711
+ raise TypeError(f"Objective '{objective}' must be numeric")
712
+
713
+ if not np.isfinite(score):
714
+ continue
646
715
 
647
716
  row = dict(row_params)
648
- row.update(
649
- {
650
- "final_cash": report.final_cash,
651
- "total_return": tot_return,
652
- "sharpe": sharpe,
653
- "max_drawdown": mdd,
654
- "trades": report.orders,
655
- }
656
- )
717
+ row.update(metrics)
718
+ row["objective_score"] = score
657
719
  results_rows.append(row)
658
720
 
659
- # Selection score: prefer Sharpe, then total return, then final cash
660
- score = sharpe
661
- if not np.isfinite(score):
662
- score = -1000 ## Really bad
663
-
664
721
  if score > best_score:
665
722
  best_score = score
666
723
  best_params = {k: v for k, v in zip(keys, combo)}
@@ -670,30 +727,16 @@ class SimpleBacktester():
670
727
 
671
728
  # Sort results by composite score (Sharpe desc, then return, then cash)
672
729
  if not results_df.empty:
673
- def _to_score(val):
674
- try:
675
- v = float(val)
676
- except (TypeError, ValueError):
677
- return None
678
- return v if np.isfinite(v) else None
679
-
680
- scores = []
681
- for _, r in results_df.iterrows():
682
- s = _to_score(r.get("sharpe"))
683
- if s is None:
684
- s = _to_score(r.get("total_return"))
685
- if s is None:
686
- s = _to_score(r.get("final_cash"))
687
- scores.append(s if s is not None else float("-inf"))
688
- results_df["_score"] = scores
689
- results_df.sort_values(by=["_score"], ascending=False, inplace=True, kind="mergesort")
690
- results_df.drop(columns=["_score"], inplace=True)
730
+ results_df.sort_values(by=["objective_score"], ascending=False, inplace=True, kind="mergesort")
691
731
 
692
732
  return best_params or {}, best_report, results_df
693
733
 
694
- def optimize_parallel(self,
734
+ def optimize_parallel(
735
+ self,
695
736
  params: dict[str, range],
696
737
  constraint: Callable[[dict[str, Any]], bool] | None = None,
738
+ objective: str = "sharpe",
739
+ risk_tolerance: dict[str, float] | None = None,
697
740
  workers: int | None = None,
698
741
  chunksize: int = 1):
699
742
  """
@@ -706,9 +749,14 @@ class SimpleBacktester():
706
749
  Args:
707
750
  params (dict[str, range]): Dictionary mapping strategy attribute names
708
751
  to iterables of candidate values (same format as optimize()).
709
- constraint (Callable[[dict[str, Any]], bool] | None, optional):
752
+ constraint (Callable[[dict[str, Any]], bool] | None, optional):
710
753
  Optional callable for parameter constraints (same as optimize()).
711
754
  Defaults to None.
755
+ objective (str, optional): BacktestReport attribute or computed metric to
756
+ optimize. Defaults to "sharpe".
757
+ risk_tolerance (dict[str, float] | None, optional): Optional maximum
758
+ allowed metric values for candidate rejection before scoring.
759
+ Defaults to None.
712
760
  workers (int | None, optional): Maximum number of worker processes to use.
713
761
  If None, defaults to min(os.cpu_count()-1, 4) to avoid overwhelming
714
762
  the system. Defaults to None.
@@ -816,27 +864,26 @@ class SimpleBacktester():
816
864
  for res in tqdm(it, total=total_combos, disable=(total_combos <= 1)):
817
865
  results_rows.append(res)
818
866
 
867
+ valid_metrics = {"final_cash", "total_return", "sharpe", "max_drawdown", "trades"}
868
+ filtered_rows = []
869
+ for row in results_rows:
870
+ row_params = row["params"]
871
+ if risk_tolerance is not None:
872
+ if any(float(row.get(metric, np.inf)) > float(limit) for metric, limit in risk_tolerance.items() if limit is not None):
873
+ continue
874
+ if objective in valid_metrics:
875
+ score = row.get(objective)
876
+ else:
877
+ score = row.get(objective)
878
+ if score is None or not np.isfinite(float(score)):
879
+ continue
880
+ row["objective_score"] = float(score)
881
+ filtered_rows.append(row)
882
+
819
883
  # Build DataFrame of small metrics returned from workers
820
- results_df = pd.DataFrame(results_rows)
821
- # Compute a composite score like before: prefer sharpe, then return, then final_cash
884
+ results_df = pd.DataFrame(filtered_rows)
822
885
  if not results_df.empty:
823
- def _to_score(val):
824
- try:
825
- v = float(val)
826
- except (TypeError, ValueError):
827
- return None
828
- return v if np.isfinite(v) else None
829
-
830
- scores = []
831
- for _, r in results_df.iterrows():
832
- ret = r.get("total_return")
833
- s = None
834
- if (not ret == None and ret > 0):
835
- s = _to_score(r.get("sharpe"))
836
- scores.append(s if s is not None else float("-inf"))
837
- results_df["_score"] = scores
838
- results_df.sort_values(by=["_score"], ascending=False, inplace=True, kind="mergesort")
839
- results_df.drop(columns=["_score"], inplace=True)
886
+ results_df.sort_values(by=["objective_score"], ascending=False, inplace=True, kind="mergesort")
840
887
 
841
888
  # Determine best params from results_df if any
842
889
  if results_df.empty:
@@ -319,6 +319,22 @@ def mfi(high: ArrayLike, low: ArrayLike, close: ArrayLike, volume: ArrayLike, pe
319
319
  return result
320
320
 
321
321
 
322
+ def vwap(high: ArrayLike, low: ArrayLike, close: ArrayLike, volume: ArrayLike) -> np.ndarray:
323
+ high_array = _as_float_array(high)
324
+ low_array = _as_float_array(low)
325
+ close_array = _as_float_array(close)
326
+ volume_array = _as_float_array(volume)
327
+ _validate_same_length(high_array, low_array, close_array, volume_array)
328
+ typical_price = (high_array + low_array + close_array) / 3.0
329
+ price_volume = typical_price * volume_array
330
+ cumulative_price_volume = np.cumsum(price_volume)
331
+ cumulative_volume = np.cumsum(volume_array)
332
+ with np.errstate(divide="ignore", invalid="ignore"):
333
+ result = cumulative_price_volume / cumulative_volume
334
+ result = np.where(cumulative_volume == 0.0, np.nan, result)
335
+ return result
336
+
337
+
322
338
  def adx(high: ArrayLike, low: ArrayLike, close: ArrayLike, period: int = 14) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
323
339
  high_array = _as_float_array(high)
324
340
  low_array = _as_float_array(low)
@@ -589,6 +605,7 @@ class IndicatorCatalog:
589
605
  self.williams_r = williams_r
590
606
  self.obv = obv
591
607
  self.mfi = mfi
608
+ self.vwap = vwap
592
609
  self.adx = adx
593
610
  self.ichimoku_cloud = ichimoku_cloud
594
611
  self.keltner_channels = keltner_channels
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes