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.
- {quantex-0.3.0 → quantex-0.3.2}/PKG-INFO +2 -2
- {quantex-0.3.0 → quantex-0.3.2}/README.md +1 -1
- {quantex-0.3.0 → quantex-0.3.2}/pyproject.toml +1 -1
- {quantex-0.3.0 → quantex-0.3.2}/src/quantex/backtester.py +123 -76
- {quantex-0.3.0 → quantex-0.3.2}/src/quantex/indicators.py +17 -0
- {quantex-0.3.0 → quantex-0.3.2}/LICENSE.md +0 -0
- {quantex-0.3.0 → quantex-0.3.2}/src/quantex/__init__.py +0 -0
- {quantex-0.3.0 → quantex-0.3.2}/src/quantex/broker.py +0 -0
- {quantex-0.3.0 → quantex-0.3.2}/src/quantex/datasource.py +0 -0
- {quantex-0.3.0 → quantex-0.3.2}/src/quantex/enums.py +0 -0
- {quantex-0.3.0 → quantex-0.3.2}/src/quantex/helpers.py +0 -0
- {quantex-0.3.0 → quantex-0.3.2}/src/quantex/strategy.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: quantex
|
|
3
|
-
Version: 0.3.
|
|
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.
|
|
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.
|
|
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
|
|
@@ -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(
|
|
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
|
|
559
|
-
If
|
|
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
|
-
|
|
623
|
-
equity = report.PnlRecord.astype(float)
|
|
624
|
-
returns = equity.pct_change().dropna()
|
|
693
|
+
metrics = _compute_backtest_metrics(report)
|
|
625
694
|
|
|
626
|
-
|
|
627
|
-
|
|
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
|
|
631
|
-
|
|
632
|
-
lo = np.nan
|
|
633
|
-
hi = np.nan
|
|
698
|
+
if objective in valid_metrics:
|
|
699
|
+
score = metrics.get(objective)
|
|
634
700
|
else:
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
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
|
-
|
|
641
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
File without changes
|