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 +15 -0
- quantcore/cli.py +208 -0
- quantcore/engine.py +142 -0
- quantcore/metrics.py +75 -0
- quantcore/pro/__init__.py +6 -0
- quantcore/pro/license.py +67 -0
- quantcore/pro/monte_carlo.py +67 -0
- quantcore/pro/optimizer.py +66 -0
- quantcore/pro/strategies.py +270 -0
- quantcore/pro/tearsheet.py +131 -0
- quantcore/strategies/__init__.py +18 -0
- quantcore/strategies/base.py +33 -0
- quantcore/strategies/mean_reversion.py +38 -0
- quantcore/strategies/momentum.py +32 -0
- quantcore/strategies/moving_average.py +37 -0
- quantcore_lite-0.1.0.dist-info/METADATA +172 -0
- quantcore_lite-0.1.0.dist-info/RECORD +21 -0
- quantcore_lite-0.1.0.dist-info/WHEEL +5 -0
- quantcore_lite-0.1.0.dist-info/entry_points.txt +2 -0
- quantcore_lite-0.1.0.dist-info/licenses/LICENSE +21 -0
- quantcore_lite-0.1.0.dist-info/top_level.txt +1 -0
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
|
+
}
|
quantcore/pro/license.py
ADDED
|
@@ -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
|