quantcore-lite 0.1.0__py3-none-any.whl

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.
quantcore/__init__.py ADDED
@@ -0,0 +1,15 @@
1
+ """QuantCore — open-core backtesting framework for quantitative trading strategies."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ from quantcore.engine import Backtest
6
+ from quantcore.metrics import compute_metrics
7
+ from quantcore.strategies import MeanReversion, MomentumStrategy, MovingAverageCrossover
8
+
9
+ __all__ = [
10
+ "Backtest",
11
+ "compute_metrics",
12
+ "MomentumStrategy",
13
+ "MeanReversion",
14
+ "MovingAverageCrossover",
15
+ ]
quantcore/cli.py ADDED
@@ -0,0 +1,208 @@
1
+ """Command-line interface for QuantCore."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ import pandas as pd
10
+
11
+ from quantcore.engine import Backtest
12
+ from quantcore.strategies import FREE_STRATEGIES
13
+
14
+
15
+ def _get_all_strategies() -> dict:
16
+ """Return combined dict of free + pro strategies (pro only if licensed)."""
17
+ all_strats = dict(FREE_STRATEGIES)
18
+ try:
19
+ from quantcore.pro.license import validate_license
20
+
21
+ if validate_license():
22
+ from quantcore.pro.strategies import PRO_STRATEGIES
23
+
24
+ all_strats.update(PRO_STRATEGIES)
25
+ except Exception:
26
+ pass
27
+ return all_strats
28
+
29
+
30
+ def cmd_backtest(args: argparse.Namespace) -> None:
31
+ """Run a backtest from the CLI."""
32
+ data_path = Path(args.data)
33
+ if not data_path.exists():
34
+ print(f"Error: Data file not found: {data_path}")
35
+ sys.exit(1)
36
+
37
+ data = pd.read_csv(data_path, parse_dates=True, index_col=0)
38
+
39
+ all_strategies = _get_all_strategies()
40
+ strategy_key = args.strategy.lower().replace("-", "_").replace(" ", "_")
41
+
42
+ if strategy_key not in all_strategies:
43
+ # Check if it's a pro strategy that requires license
44
+ from quantcore.pro.strategies import PRO_STRATEGIES
45
+
46
+ if strategy_key in PRO_STRATEGIES:
47
+ from quantcore.pro.license import require_pro
48
+
49
+ if not require_pro():
50
+ sys.exit(0)
51
+ all_strategies.update(PRO_STRATEGIES)
52
+ else:
53
+ available = ", ".join(sorted(all_strategies.keys()))
54
+ print(f"Error: Unknown strategy '{args.strategy}'. Available: {available}")
55
+ sys.exit(1)
56
+
57
+ strategy_cls = all_strategies[strategy_key]
58
+ strategy = strategy_cls()
59
+
60
+ bt = Backtest(
61
+ data,
62
+ strategy,
63
+ initial_capital=args.capital,
64
+ commission=args.commission,
65
+ )
66
+ bt.run()
67
+
68
+ print(bt.summary())
69
+ print()
70
+
71
+ if args.output:
72
+ out_path = bt.export_csv(args.output)
73
+ print(f"Results exported to: {out_path}")
74
+
75
+ if args.tearsheet:
76
+ from quantcore.pro.license import require_pro
77
+
78
+ if require_pro():
79
+ from quantcore.pro.monte_carlo import monte_carlo_simulation
80
+ from quantcore.pro.tearsheet import generate_tearsheet
81
+
82
+ mc = monte_carlo_simulation(bt.results, initial_capital=args.capital)
83
+ pdf_path = generate_tearsheet(
84
+ bt.results, bt.metrics, mc, strategy.name, args.tearsheet
85
+ )
86
+ print(f"Tearsheet saved to: {pdf_path}")
87
+
88
+ if args.monte_carlo:
89
+ from quantcore.pro.license import require_pro
90
+
91
+ if require_pro():
92
+ from quantcore.pro.monte_carlo import monte_carlo_simulation
93
+
94
+ mc = monte_carlo_simulation(
95
+ bt.results, n_simulations=args.mc_runs, initial_capital=args.capital
96
+ )
97
+ ci = mc["confidence_intervals"]
98
+ print(f"\nMonte Carlo ({mc['n_simulations']} simulations):")
99
+ print(f" Median final value: ${mc['median_final']:,.2f}")
100
+ print(f" Mean final value: ${mc['mean_final']:,.2f}")
101
+ for pct, val in ci.items():
102
+ print(f" {pct} percentile: ${val:,.2f}")
103
+
104
+
105
+ def cmd_strategies(args: argparse.Namespace) -> None:
106
+ """List available strategies."""
107
+ print("Free strategies:")
108
+ for name in sorted(FREE_STRATEGIES.keys()):
109
+ print(f" - {name}")
110
+
111
+ print("\nPro strategies (requires license):")
112
+ from quantcore.pro.strategies import PRO_STRATEGIES
113
+
114
+ for name in sorted(PRO_STRATEGIES.keys()):
115
+ print(f" - {name}")
116
+
117
+
118
+ def cmd_optimize(args: argparse.Namespace) -> None:
119
+ """Run parameter grid search optimization."""
120
+ from quantcore.pro.license import require_pro
121
+
122
+ if not require_pro():
123
+ sys.exit(0)
124
+
125
+ from quantcore.pro.optimizer import grid_search
126
+
127
+ data = pd.read_csv(args.data, parse_dates=True, index_col=0)
128
+
129
+ all_strategies = dict(FREE_STRATEGIES)
130
+ from quantcore.pro.strategies import PRO_STRATEGIES
131
+
132
+ all_strategies.update(PRO_STRATEGIES)
133
+
134
+ strategy_key = args.strategy.lower().replace("-", "_").replace(" ", "_")
135
+ if strategy_key not in all_strategies:
136
+ print(f"Error: Unknown strategy '{args.strategy}'")
137
+ sys.exit(1)
138
+
139
+ # Default param grids per strategy
140
+ default_grids = {
141
+ "momentum": {"lookback": [10, 20, 30, 50]},
142
+ "mean_reversion": {"window": [10, 20, 30], "threshold": [1.0, 1.5, 2.0]},
143
+ "moving_average": {"short_window": [10, 20], "long_window": [30, 50, 100]},
144
+ "rsi": {"period": [7, 14, 21], "oversold": [20, 30], "overbought": [70, 80]},
145
+ "bollinger_bands": {"window": [10, 20, 30], "num_std": [1.5, 2.0, 2.5]},
146
+ "macd": {"fast": [8, 12], "slow": [21, 26], "signal": [7, 9]},
147
+ }
148
+
149
+ param_grid = default_grids.get(strategy_key, {"lookback": [10, 20, 30]})
150
+ results = grid_search(data, all_strategies[strategy_key], param_grid, metric=args.metric)
151
+
152
+ print(f"\nGrid search results for {strategy_key} (optimizing {args.metric}):\n")
153
+ for i, r in enumerate(results[:10]):
154
+ print(f" #{i + 1}: {r['params']} -> {args.metric}={r['score']:.4f}")
155
+
156
+
157
+ def build_parser() -> argparse.ArgumentParser:
158
+ """Build the CLI argument parser."""
159
+ parser = argparse.ArgumentParser(
160
+ prog="quantcore",
161
+ description="QuantCore — backtesting framework for quantitative trading strategies",
162
+ )
163
+ subparsers = parser.add_subparsers(dest="command", help="Available commands")
164
+
165
+ # backtest
166
+ bt_parser = subparsers.add_parser("backtest", help="Run a backtest")
167
+ bt_parser.add_argument("--data", "-d", required=True, help="Path to OHLCV CSV file")
168
+ bt_parser.add_argument(
169
+ "--strategy", "-s", required=True, help="Strategy name (e.g. momentum, mean_reversion)"
170
+ )
171
+ bt_parser.add_argument("--capital", "-c", type=float, default=100_000, help="Initial capital")
172
+ bt_parser.add_argument("--commission", type=float, default=0.001, help="Commission rate")
173
+ bt_parser.add_argument("--output", "-o", help="Output CSV path for results")
174
+ bt_parser.add_argument("--tearsheet", help="Output PDF path for tearsheet (Pro)")
175
+ bt_parser.add_argument("--monte-carlo", action="store_true", help="Run Monte Carlo sim (Pro)")
176
+ bt_parser.add_argument("--mc-runs", type=int, default=1000, help="Number of MC simulations")
177
+
178
+ # strategies
179
+ subparsers.add_parser("strategies", help="List available strategies")
180
+
181
+ # optimize
182
+ opt_parser = subparsers.add_parser("optimize", help="Grid search optimization (Pro)")
183
+ opt_parser.add_argument("--data", "-d", required=True, help="Path to OHLCV CSV file")
184
+ opt_parser.add_argument("--strategy", "-s", required=True, help="Strategy to optimize")
185
+ opt_parser.add_argument("--metric", "-m", default="sharpe_ratio", help="Metric to optimize")
186
+
187
+ return parser
188
+
189
+
190
+ def main(argv: list[str] | None = None) -> None:
191
+ """Entry point for the quantcore CLI."""
192
+ parser = build_parser()
193
+ args = parser.parse_args(argv)
194
+
195
+ if args.command is None:
196
+ parser.print_help()
197
+ sys.exit(0)
198
+
199
+ commands = {
200
+ "backtest": cmd_backtest,
201
+ "strategies": cmd_strategies,
202
+ "optimize": cmd_optimize,
203
+ }
204
+ commands[args.command](args)
205
+
206
+
207
+ if __name__ == "__main__":
208
+ main()
quantcore/engine.py ADDED
@@ -0,0 +1,142 @@
1
+ """Core backtesting engine that processes OHLCV data through trading strategies."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Optional, Union
7
+
8
+ import numpy as np
9
+ import pandas as pd
10
+
11
+ from quantcore.metrics import compute_metrics
12
+
13
+
14
+ class Backtest:
15
+ """Run a strategy against historical OHLCV data and collect results.
16
+
17
+ Parameters
18
+ ----------
19
+ data : pd.DataFrame
20
+ OHLCV DataFrame with columns: open, high, low, close, volume.
21
+ Index should be datetime or integer.
22
+ strategy : object
23
+ A strategy instance implementing ``generate_signals(data) -> pd.Series``.
24
+ initial_capital : float
25
+ Starting portfolio value (default 100_000).
26
+ commission : float
27
+ Per-trade commission as a fraction (default 0.001 = 0.1%).
28
+ """
29
+
30
+ def __init__(
31
+ self,
32
+ data: pd.DataFrame,
33
+ strategy,
34
+ initial_capital: float = 100_000,
35
+ commission: float = 0.001,
36
+ ):
37
+ self.data = self._validate_data(data)
38
+ self.strategy = strategy
39
+ self.initial_capital = initial_capital
40
+ self.commission = commission
41
+ self.results: Optional[pd.DataFrame] = None
42
+ self.metrics: Optional[dict] = None
43
+
44
+ @staticmethod
45
+ def _validate_data(data: pd.DataFrame) -> pd.DataFrame:
46
+ required = {"open", "high", "low", "close", "volume"}
47
+ columns_lower = {c.lower() for c in data.columns}
48
+ missing = required - columns_lower
49
+ if missing:
50
+ raise ValueError(f"Missing required OHLCV columns: {missing}")
51
+ data.columns = [c.lower() for c in data.columns]
52
+ return data
53
+
54
+ def run(self) -> pd.DataFrame:
55
+ """Execute the backtest and return a results DataFrame."""
56
+ signals = self.strategy.generate_signals(self.data)
57
+ results = self._simulate(signals)
58
+ self.results = results
59
+ self.metrics = compute_metrics(results, self.initial_capital)
60
+ return results
61
+
62
+ def _simulate(self, signals: pd.Series) -> pd.DataFrame:
63
+ """Simulate trades based on signals (+1 buy, -1 sell, 0 hold)."""
64
+ prices = self.data["close"].values
65
+ n = len(prices)
66
+
67
+ positions = np.zeros(n)
68
+ cash = np.full(n, self.initial_capital, dtype=float)
69
+ holdings = np.zeros(n)
70
+ portfolio = np.zeros(n)
71
+ trades = np.zeros(n)
72
+
73
+ sig = signals.values if hasattr(signals, "values") else np.asarray(signals)
74
+
75
+ position = 0.0
76
+ current_cash = self.initial_capital
77
+
78
+ for i in range(n):
79
+ price = prices[i]
80
+ signal = sig[i] if i < len(sig) else 0
81
+
82
+ if signal == 1 and position == 0:
83
+ shares = int(current_cash * 0.95 / price)
84
+ cost = shares * price * (1 + self.commission)
85
+ if cost <= current_cash and shares > 0:
86
+ position = shares
87
+ current_cash -= cost
88
+ trades[i] = 1
89
+ elif signal == -1 and position > 0:
90
+ proceeds = position * price * (1 - self.commission)
91
+ current_cash += proceeds
92
+ trades[i] = -1
93
+ position = 0
94
+
95
+ positions[i] = position
96
+ cash[i] = current_cash
97
+ holdings[i] = position * price
98
+ portfolio[i] = current_cash + position * price
99
+
100
+ results = pd.DataFrame(
101
+ {
102
+ "close": prices,
103
+ "signal": sig[:n] if len(sig) >= n else np.pad(sig, (0, n - len(sig))),
104
+ "position": positions,
105
+ "cash": cash,
106
+ "holdings": holdings,
107
+ "portfolio": portfolio,
108
+ "trades": trades,
109
+ },
110
+ index=self.data.index,
111
+ )
112
+ return results
113
+
114
+ def get_metrics(self) -> dict:
115
+ """Return performance metrics. Runs backtest first if needed."""
116
+ if self.metrics is None:
117
+ self.run()
118
+ return self.metrics
119
+
120
+ def export_csv(self, path: Union[str, Path] = "backtest_results.csv") -> Path:
121
+ """Export results to CSV."""
122
+ if self.results is None:
123
+ self.run()
124
+ path = Path(path)
125
+ self.results.to_csv(path)
126
+ return path
127
+
128
+ def summary(self) -> str:
129
+ """Return a formatted summary of backtest metrics."""
130
+ m = self.get_metrics()
131
+ lines = [
132
+ f"Strategy: {self.strategy.name}",
133
+ f"Initial Capital: ${self.initial_capital:,.2f}",
134
+ f"Final Value: ${m['final_value']:,.2f}",
135
+ f"Total Return: {m['total_return']:.2%}",
136
+ f"CAGR: {m['cagr']:.2%}",
137
+ f"Sharpe Ratio: {m['sharpe_ratio']:.4f}",
138
+ f"Max Drawdown: {m['max_drawdown']:.2%}",
139
+ f"Win Rate: {m['win_rate']:.2%}",
140
+ f"Total Trades: {m['total_trades']}",
141
+ ]
142
+ return "\n".join(lines)
quantcore/metrics.py ADDED
@@ -0,0 +1,75 @@
1
+ """Performance metrics for backtest results."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import numpy as np
6
+ import pandas as pd
7
+
8
+
9
+ def compute_metrics(results: pd.DataFrame, initial_capital: float = 100_000) -> dict:
10
+ """Compute standard performance metrics from backtest results.
11
+
12
+ Parameters
13
+ ----------
14
+ results : pd.DataFrame
15
+ Backtest results with at least ``portfolio`` and ``trades`` columns.
16
+ initial_capital : float
17
+ The starting portfolio value.
18
+
19
+ Returns
20
+ -------
21
+ dict
22
+ Dictionary with sharpe_ratio, max_drawdown, cagr, win_rate, and more.
23
+ """
24
+ portfolio = results["portfolio"].values
25
+ final_value = portfolio[-1]
26
+ total_return = (final_value - initial_capital) / initial_capital
27
+
28
+ # Daily returns
29
+ daily_returns = np.diff(portfolio) / portfolio[:-1]
30
+ daily_returns = daily_returns[np.isfinite(daily_returns)]
31
+
32
+ # Sharpe ratio (annualized, assuming 252 trading days)
33
+ if len(daily_returns) > 1 and np.std(daily_returns) > 0:
34
+ sharpe_ratio = (np.mean(daily_returns) / np.std(daily_returns)) * np.sqrt(252)
35
+ else:
36
+ sharpe_ratio = 0.0
37
+
38
+ # Max drawdown
39
+ cumulative_max = np.maximum.accumulate(portfolio)
40
+ drawdowns = (portfolio - cumulative_max) / cumulative_max
41
+ max_drawdown = float(np.min(drawdowns))
42
+
43
+ # CAGR
44
+ n_days = len(portfolio)
45
+ n_years = n_days / 252 if n_days > 0 else 1
46
+ if initial_capital > 0 and final_value > 0 and n_years > 0:
47
+ cagr = (final_value / initial_capital) ** (1 / n_years) - 1
48
+ else:
49
+ cagr = 0.0
50
+
51
+ # Win rate
52
+ trades = results["trades"].values
53
+ buy_indices = np.where(trades == 1)[0]
54
+ sell_indices = np.where(trades == -1)[0]
55
+ n_trades = min(len(buy_indices), len(sell_indices))
56
+ wins = 0
57
+ for i in range(n_trades):
58
+ buy_price = results["close"].iloc[buy_indices[i]]
59
+ sell_price = results["close"].iloc[sell_indices[i]]
60
+ if sell_price > buy_price:
61
+ wins += 1
62
+ win_rate = wins / n_trades if n_trades > 0 else 0.0
63
+
64
+ return {
65
+ "initial_capital": initial_capital,
66
+ "final_value": final_value,
67
+ "total_return": total_return,
68
+ "sharpe_ratio": sharpe_ratio,
69
+ "max_drawdown": max_drawdown,
70
+ "cagr": cagr,
71
+ "win_rate": win_rate,
72
+ "total_trades": n_trades,
73
+ "winning_trades": wins,
74
+ "losing_trades": n_trades - wins,
75
+ }
@@ -0,0 +1,6 @@
1
+ """QuantCore Pro — premium features gated behind Polar.sh license validation."""
2
+
3
+ from quantcore.pro.license import require_pro
4
+ from quantcore.pro.strategies import PRO_STRATEGIES
5
+
6
+ __all__ = ["require_pro", "PRO_STRATEGIES"]
@@ -0,0 +1,67 @@
1
+ """Polar.sh license validation for QuantCore Pro features."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import uuid
8
+ from functools import lru_cache
9
+ from typing import Optional
10
+ from urllib import request, error as urllib_error
11
+
12
+ POLAR_VALIDATE_URL = "https://api.polar.sh/v1/licenses/validate"
13
+ POLAR_ORG_ID = "1f3ada33-0e12-48b8-8efe-79e00d29e5e0"
14
+ PRO_UNLOCK_MESSAGE = "\U0001f512 Pro feature \u2014 unlock at https://buy.polar.sh/polar_cl_rA97pLblKd1pRhwXezgssGgCp1NaKlDV0CeG74fP4q4"
15
+
16
+
17
+ @lru_cache(maxsize=1)
18
+ def validate_license(license_key: Optional[str] = None) -> bool:
19
+ """Validate a Polar.sh license key.
20
+
21
+ Checks the ``QUANTCORE_LICENSE_KEY`` environment variable if no key is
22
+ provided directly.
23
+
24
+ Returns
25
+ -------
26
+ bool
27
+ True if the license is valid, False otherwise.
28
+ """
29
+ key = license_key or os.environ.get("QUANTCORE_LICENSE_KEY", "")
30
+ if not key:
31
+ return False
32
+
33
+ machine_id = str(uuid.getnode())
34
+ payload = json.dumps({
35
+ "key": key,
36
+ "organization_id": POLAR_ORG_ID,
37
+ "benefit_id": "quantcore-pro",
38
+ "machine_id": machine_id,
39
+ }).encode("utf-8")
40
+
41
+ req = request.Request(
42
+ POLAR_VALIDATE_URL,
43
+ data=payload,
44
+ headers={"Content-Type": "application/json"},
45
+ method="POST",
46
+ )
47
+
48
+ try:
49
+ with request.urlopen(req, timeout=10) as resp:
50
+ data = json.loads(resp.read().decode("utf-8"))
51
+ return data.get("valid", False)
52
+ except (urllib_error.URLError, json.JSONDecodeError, OSError):
53
+ return False
54
+
55
+
56
+ def require_pro(license_key: Optional[str] = None) -> bool:
57
+ """Check for a valid Pro license. Prints unlock message if invalid.
58
+
59
+ Returns
60
+ -------
61
+ bool
62
+ True if licensed, False otherwise.
63
+ """
64
+ if validate_license(license_key):
65
+ return True
66
+ print(PRO_UNLOCK_MESSAGE)
67
+ return False
@@ -0,0 +1,67 @@
1
+ """Monte Carlo simulation for backtest results."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import numpy as np
6
+ import pandas as pd
7
+
8
+
9
+ def monte_carlo_simulation(
10
+ results: pd.DataFrame,
11
+ n_simulations: int = 1000,
12
+ initial_capital: float = 100_000,
13
+ ) -> dict:
14
+ """Run Monte Carlo simulation by resampling daily returns.
15
+
16
+ Parameters
17
+ ----------
18
+ results : pd.DataFrame
19
+ Backtest results with a ``portfolio`` column.
20
+ n_simulations : int
21
+ Number of simulation runs (default 1000).
22
+ initial_capital : float
23
+ Starting portfolio value.
24
+
25
+ Returns
26
+ -------
27
+ dict
28
+ Contains ``simulations`` (array), ``confidence_intervals``, and summary stats.
29
+ """
30
+ portfolio = results["portfolio"].values
31
+ daily_returns = np.diff(portfolio) / portfolio[:-1]
32
+ daily_returns = daily_returns[np.isfinite(daily_returns)]
33
+ n_days = len(daily_returns)
34
+
35
+ if n_days == 0:
36
+ return {
37
+ "simulations": np.full((n_simulations, 1), initial_capital),
38
+ "confidence_intervals": {},
39
+ "median_final": initial_capital,
40
+ "mean_final": initial_capital,
41
+ }
42
+
43
+ simulations = np.zeros((n_simulations, n_days + 1))
44
+ simulations[:, 0] = initial_capital
45
+
46
+ for i in range(n_simulations):
47
+ sampled = np.random.choice(daily_returns, size=n_days, replace=True)
48
+ simulations[i, 1:] = initial_capital * np.cumprod(1 + sampled)
49
+
50
+ final_values = simulations[:, -1]
51
+
52
+ ci = {
53
+ "5%": float(np.percentile(final_values, 5)),
54
+ "25%": float(np.percentile(final_values, 25)),
55
+ "50%": float(np.percentile(final_values, 50)),
56
+ "75%": float(np.percentile(final_values, 75)),
57
+ "95%": float(np.percentile(final_values, 95)),
58
+ }
59
+
60
+ return {
61
+ "simulations": simulations,
62
+ "confidence_intervals": ci,
63
+ "median_final": float(np.median(final_values)),
64
+ "mean_final": float(np.mean(final_values)),
65
+ "std_final": float(np.std(final_values)),
66
+ "n_simulations": n_simulations,
67
+ }
@@ -0,0 +1,66 @@
1
+ """Parameter grid search optimizer for strategy tuning."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import itertools
6
+ from typing import Any
7
+
8
+ import pandas as pd
9
+
10
+ from quantcore.engine import Backtest
11
+ from quantcore.metrics import compute_metrics
12
+
13
+
14
+ def grid_search(
15
+ data: pd.DataFrame,
16
+ strategy_class,
17
+ param_grid: dict[str, list],
18
+ initial_capital: float = 100_000,
19
+ metric: str = "sharpe_ratio",
20
+ ) -> list[dict[str, Any]]:
21
+ """Run a grid search over strategy parameters and rank by a metric.
22
+
23
+ Parameters
24
+ ----------
25
+ data : pd.DataFrame
26
+ OHLCV data for backtesting.
27
+ strategy_class : type
28
+ Strategy class to instantiate with each parameter combination.
29
+ param_grid : dict
30
+ Mapping of parameter names to lists of values to try.
31
+ initial_capital : float
32
+ Starting portfolio value.
33
+ metric : str
34
+ Metric to optimize (default ``sharpe_ratio``).
35
+
36
+ Returns
37
+ -------
38
+ list[dict]
39
+ Sorted list of results, best first. Each dict contains ``params``,
40
+ ``metrics``, and the target ``score``.
41
+ """
42
+ keys = list(param_grid.keys())
43
+ values = list(param_grid.values())
44
+ results = []
45
+
46
+ for combo in itertools.product(*values):
47
+ params = dict(zip(keys, combo))
48
+ try:
49
+ strategy = strategy_class(**params)
50
+ bt = Backtest(data.copy(), strategy, initial_capital=initial_capital)
51
+ bt_results = bt.run()
52
+ m = compute_metrics(bt_results, initial_capital)
53
+ results.append({
54
+ "params": params,
55
+ "metrics": m,
56
+ "score": m.get(metric, 0),
57
+ })
58
+ except Exception:
59
+ results.append({
60
+ "params": params,
61
+ "metrics": {},
62
+ "score": float("-inf"),
63
+ })
64
+
65
+ results.sort(key=lambda x: x["score"], reverse=True)
66
+ return results