quantex 0.3.2__tar.gz → 0.3.4__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.2 → quantex-0.3.4}/PKG-INFO +1 -1
- {quantex-0.3.2 → quantex-0.3.4}/pyproject.toml +1 -1
- quantex-0.3.4/src/quantex/__init__.py +12 -0
- {quantex-0.3.2 → quantex-0.3.4}/src/quantex/backtester.py +723 -0
- quantex-0.3.2/src/quantex/__init__.py +0 -5
- {quantex-0.3.2 → quantex-0.3.4}/LICENSE.md +0 -0
- {quantex-0.3.2 → quantex-0.3.4}/README.md +0 -0
- {quantex-0.3.2 → quantex-0.3.4}/src/quantex/broker.py +0 -0
- {quantex-0.3.2 → quantex-0.3.4}/src/quantex/datasource.py +0 -0
- {quantex-0.3.2 → quantex-0.3.4}/src/quantex/enums.py +0 -0
- {quantex-0.3.2 → quantex-0.3.4}/src/quantex/helpers.py +0 -0
- {quantex-0.3.2 → quantex-0.3.4}/src/quantex/indicators.py +0 -0
- {quantex-0.3.2 → quantex-0.3.4}/src/quantex/strategy.py +0 -0
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from .datasource import CSVDataSource as CSVDataSource, ParquetDataSource as ParquetDataSource, DataSource as DataSource
|
|
2
|
+
from .strategy import Strategy as Strategy
|
|
3
|
+
from .backtester import (
|
|
4
|
+
SimpleBacktester as SimpleBacktester,
|
|
5
|
+
BacktestReport as BacktestReport,
|
|
6
|
+
OptimizationResult as OptimizationResult,
|
|
7
|
+
TrainValidateTestSplit as TrainValidateTestSplit,
|
|
8
|
+
DataSplitMode as DataSplitMode,
|
|
9
|
+
create_train_validate_test_split as create_train_validate_test_split,
|
|
10
|
+
)
|
|
11
|
+
from .enums import CommissionType as CommissionType
|
|
12
|
+
from .indicators import indicators as indicators
|
|
@@ -15,6 +15,140 @@ import concurrent.futures
|
|
|
15
15
|
import pickle
|
|
16
16
|
import os
|
|
17
17
|
import gc
|
|
18
|
+
from enum import Enum
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class DataSplitMode(Enum):
|
|
22
|
+
"""Enumeration for data split modes in optimization."""
|
|
23
|
+
TRAIN = "train"
|
|
24
|
+
VALIDATE = "validate"
|
|
25
|
+
TEST = "test"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class TrainValidateTestSplit:
|
|
30
|
+
"""
|
|
31
|
+
Container for train/validate/test data splits.
|
|
32
|
+
|
|
33
|
+
This class holds the split configuration and indices for dividing
|
|
34
|
+
historical data into training, validation, and test sets for
|
|
35
|
+
machine learning-style optimization workflows.
|
|
36
|
+
|
|
37
|
+
Attributes:
|
|
38
|
+
train_start (int): Starting index for training data.
|
|
39
|
+
train_end (int): Ending index for training data.
|
|
40
|
+
validate_start (int): Starting index for validation data.
|
|
41
|
+
validate_end (int): Ending index for validation data.
|
|
42
|
+
test_start (int): Starting index for test data.
|
|
43
|
+
test_end (int): Ending index for test data.
|
|
44
|
+
train_ratio (float): Ratio of data used for training.
|
|
45
|
+
validate_ratio (float): Ratio of data used for validation.
|
|
46
|
+
test_ratio (float): Ratio of data used for testing.
|
|
47
|
+
"""
|
|
48
|
+
train_start: int
|
|
49
|
+
train_end: int
|
|
50
|
+
validate_start: int
|
|
51
|
+
validate_end: int
|
|
52
|
+
test_start: int
|
|
53
|
+
test_end: int
|
|
54
|
+
train_ratio: float = 0.6
|
|
55
|
+
validate_ratio: float = 0.2
|
|
56
|
+
test_ratio: float = 0.2
|
|
57
|
+
|
|
58
|
+
def __post_init__(self):
|
|
59
|
+
"""Validate split ratios sum to 1.0."""
|
|
60
|
+
total = self.train_ratio + self.validate_ratio + self.test_ratio
|
|
61
|
+
if not np.isclose(total, 1.0):
|
|
62
|
+
raise ValueError(
|
|
63
|
+
f"Split ratios must sum to 1.0, got {total:.3f}"
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def create_train_validate_test_split(
|
|
68
|
+
data_length: int,
|
|
69
|
+
train_ratio: float = 0.6,
|
|
70
|
+
validate_ratio: float = 0.2,
|
|
71
|
+
test_ratio: float = 0.2
|
|
72
|
+
) -> TrainValidateTestSplit:
|
|
73
|
+
"""
|
|
74
|
+
Create indices for train/validate/test split.
|
|
75
|
+
|
|
76
|
+
This function divides the data indices into three sets for ML-style
|
|
77
|
+
optimization: training (parameter fitting), validation (hyperparameter
|
|
78
|
+
selection), and testing (final evaluation).
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
data_length (int): Total number of data points.
|
|
82
|
+
train_ratio (float, optional): Fraction of data for training.
|
|
83
|
+
Defaults to 0.6 (60%).
|
|
84
|
+
validate_ratio (float, optional): Fraction of data for validation.
|
|
85
|
+
Defaults to 0.2 (20%).
|
|
86
|
+
test_ratio (float, optional): Fraction of data for testing.
|
|
87
|
+
Defaults to 0.2 (20%).
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
TrainValidateTestSplit: Object containing start/end indices for
|
|
91
|
+
each split.
|
|
92
|
+
|
|
93
|
+
Raises:
|
|
94
|
+
ValueError: If ratios don't sum to 1.0 or are invalid.
|
|
95
|
+
|
|
96
|
+
Example:
|
|
97
|
+
>>> split = create_train_validate_test_split(1000, 0.6, 0.2, 0.2)
|
|
98
|
+
>>> print(f"Train: {split.train_start}-{split.train_end}")
|
|
99
|
+
>>> print(f"Validate: {split.validate_start}-{split.validate_end}")
|
|
100
|
+
>>> print(f"Test: {split.test_start}-{split.test_end}")
|
|
101
|
+
"""
|
|
102
|
+
if not np.isclose(train_ratio + validate_ratio + test_ratio, 1.0):
|
|
103
|
+
raise ValueError("Split ratios must sum to 1.0")
|
|
104
|
+
|
|
105
|
+
if train_ratio <= 0 or validate_ratio <= 0 or test_ratio <= 0:
|
|
106
|
+
raise ValueError("All split ratios must be positive")
|
|
107
|
+
|
|
108
|
+
train_end = int(data_length * train_ratio)
|
|
109
|
+
validate_end = int(data_length * (train_ratio + validate_ratio))
|
|
110
|
+
|
|
111
|
+
return TrainValidateTestSplit(
|
|
112
|
+
train_start=0,
|
|
113
|
+
train_end=train_end,
|
|
114
|
+
validate_start=train_end,
|
|
115
|
+
validate_end=validate_end,
|
|
116
|
+
test_start=validate_end,
|
|
117
|
+
test_end=data_length,
|
|
118
|
+
train_ratio=train_ratio,
|
|
119
|
+
validate_ratio=validate_ratio,
|
|
120
|
+
test_ratio=test_ratio
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@dataclass
|
|
125
|
+
class OptimizationResult:
|
|
126
|
+
"""
|
|
127
|
+
Container for optimization results with train/validate/test splits.
|
|
128
|
+
|
|
129
|
+
This class holds the complete results of an optimization run that
|
|
130
|
+
includes evaluation on all three data splits, enabling proper
|
|
131
|
+
model selection and generalization assessment.
|
|
132
|
+
|
|
133
|
+
Attributes:
|
|
134
|
+
best_params (dict): Best parameter values found.
|
|
135
|
+
train_report: Backtest report for training data.
|
|
136
|
+
validate_report: Backtest report for validation data.
|
|
137
|
+
test_report: Backtest report for test data.
|
|
138
|
+
train_metrics (dict): Computed metrics for training performance.
|
|
139
|
+
validate_metrics (dict): Computed metrics for validation performance.
|
|
140
|
+
test_metrics (dict): Computed metrics for test performance.
|
|
141
|
+
all_results (pd.DataFrame): DataFrame with all parameter combinations
|
|
142
|
+
and their metrics for each split.
|
|
143
|
+
"""
|
|
144
|
+
best_params: dict
|
|
145
|
+
train_report: Any
|
|
146
|
+
validate_report: Any
|
|
147
|
+
test_report: Any
|
|
148
|
+
train_metrics: dict
|
|
149
|
+
validate_metrics: dict
|
|
150
|
+
test_metrics: dict
|
|
151
|
+
all_results: pd.DataFrame
|
|
18
152
|
|
|
19
153
|
def max_drawdown(equity: pd.Series) -> float:
|
|
20
154
|
"""
|
|
@@ -907,3 +1041,592 @@ class SimpleBacktester():
|
|
|
907
1041
|
best_report = bt.run(progress_bar=False)
|
|
908
1042
|
|
|
909
1043
|
return best_params, best_report, results_df
|
|
1044
|
+
|
|
1045
|
+
def optimize_with_split(
|
|
1046
|
+
self,
|
|
1047
|
+
params: dict[str, Any],
|
|
1048
|
+
constraint: Callable[[dict[str, Any]], bool] | None = None,
|
|
1049
|
+
objective: str = "sharpe",
|
|
1050
|
+
risk_tolerance: dict[str, float] | None = None,
|
|
1051
|
+
train_ratio: float = 0.6,
|
|
1052
|
+
validate_ratio: float = 0.2,
|
|
1053
|
+
test_ratio: float = 0.2,
|
|
1054
|
+
selection_criterion: str = "validate",
|
|
1055
|
+
) -> OptimizationResult:
|
|
1056
|
+
"""
|
|
1057
|
+
Optimize strategy parameters using train/validate/test splits.
|
|
1058
|
+
|
|
1059
|
+
This method implements ML-style optimization with three data splits:
|
|
1060
|
+
- Training set: Used to fit strategy parameters
|
|
1061
|
+
- Validation set: Used to select the best parameters
|
|
1062
|
+
- Test set: Used for final out-of-sample evaluation
|
|
1063
|
+
|
|
1064
|
+
This approach helps prevent overfitting by evaluating generalization
|
|
1065
|
+
performance on held-out data before final selection.
|
|
1066
|
+
|
|
1067
|
+
Args:
|
|
1068
|
+
params (dict[str, range]): Dictionary mapping strategy attribute names
|
|
1069
|
+
to iterables of candidate values (same format as optimize()).
|
|
1070
|
+
constraint (Callable[[dict[str, Any]], bool] | None, optional):
|
|
1071
|
+
Optional callable for parameter constraints. Defaults to None.
|
|
1072
|
+
objective (str, optional): Metric to optimize. Defaults to "sharpe".
|
|
1073
|
+
Supports same metrics as optimize().
|
|
1074
|
+
risk_tolerance (dict[str, float] | None, optional): Optional maximum
|
|
1075
|
+
allowed metric values. Defaults to None.
|
|
1076
|
+
train_ratio (float, optional): Fraction of data for training.
|
|
1077
|
+
Defaults to 0.6 (60%).
|
|
1078
|
+
validate_ratio (float, optional): Fraction of data for validation.
|
|
1079
|
+
Defaults to 0.2 (20%).
|
|
1080
|
+
test_ratio (float, optional): Fraction of data for testing.
|
|
1081
|
+
Defaults to 0.2 (20%).
|
|
1082
|
+
selection_criterion (str, optional): Which split to use for final
|
|
1083
|
+
parameter selection. Options: "train", "validate", "test".
|
|
1084
|
+
Defaults to "validate".
|
|
1085
|
+
|
|
1086
|
+
Returns:
|
|
1087
|
+
OptimizationResult: Object containing:
|
|
1088
|
+
- best_params: Best parameters found
|
|
1089
|
+
- train_report: BacktestReport for training data
|
|
1090
|
+
- validate_report: BacktestReport for validation data
|
|
1091
|
+
- test_report: BacktestReport for test data
|
|
1092
|
+
- train_metrics: Metrics computed on training data
|
|
1093
|
+
- validate_metrics: Metrics computed on validation data
|
|
1094
|
+
- test_metrics: Metrics computed on test data
|
|
1095
|
+
- all_results: DataFrame with all results
|
|
1096
|
+
|
|
1097
|
+
Raises:
|
|
1098
|
+
ValueError: If split ratios don't sum to 1.0 or selection_criterion
|
|
1099
|
+
is invalid.
|
|
1100
|
+
|
|
1101
|
+
Example:
|
|
1102
|
+
>>> bt = SimpleBacktester(strategy)
|
|
1103
|
+
>>> result = bt.optimize_with_split(
|
|
1104
|
+
... {'fast_period': [5, 10, 15], 'slow_period': [20, 30, 50]},
|
|
1105
|
+
... selection_criterion='validate'
|
|
1106
|
+
... )
|
|
1107
|
+
>>> print(f"Best params: {result.best_params}")
|
|
1108
|
+
>>> print(f"Train Sharpe: {result.train_metrics['sharpe']}")
|
|
1109
|
+
>>> print(f"Validate Sharpe: {result.validate_metrics['sharpe']}")
|
|
1110
|
+
>>> print(f"Test Sharpe: {result.test_metrics['sharpe']}")
|
|
1111
|
+
"""
|
|
1112
|
+
# Validate selection criterion
|
|
1113
|
+
valid_criteria = {"train", "validate", "test"}
|
|
1114
|
+
if selection_criterion not in valid_criteria:
|
|
1115
|
+
raise ValueError(
|
|
1116
|
+
f"selection_criterion must be one of {valid_criteria}, "
|
|
1117
|
+
f"got '{selection_criterion}'"
|
|
1118
|
+
)
|
|
1119
|
+
|
|
1120
|
+
# Get data length from the strategy's data source
|
|
1121
|
+
source = self.strategy.positions[list(self.strategy.positions.keys())[0]].source
|
|
1122
|
+
data_length = len(source.data)
|
|
1123
|
+
|
|
1124
|
+
# Create the split
|
|
1125
|
+
split = create_train_validate_test_split(
|
|
1126
|
+
data_length,
|
|
1127
|
+
train_ratio,
|
|
1128
|
+
validate_ratio,
|
|
1129
|
+
test_ratio
|
|
1130
|
+
)
|
|
1131
|
+
|
|
1132
|
+
# Prepare parameter combinations
|
|
1133
|
+
if not params:
|
|
1134
|
+
raise ValueError("params must not be empty")
|
|
1135
|
+
|
|
1136
|
+
keys = list(params.keys())
|
|
1137
|
+
value_lists = []
|
|
1138
|
+
for k in keys:
|
|
1139
|
+
vals = params[k]
|
|
1140
|
+
try:
|
|
1141
|
+
candidates = list(vals)
|
|
1142
|
+
except TypeError:
|
|
1143
|
+
raise TypeError(f"Parameter '{k}' must be iterable")
|
|
1144
|
+
if len(candidates) == 0:
|
|
1145
|
+
raise ValueError(f"Parameter '{k}' has no candidate values")
|
|
1146
|
+
value_lists.append(candidates)
|
|
1147
|
+
|
|
1148
|
+
# Store results for each split
|
|
1149
|
+
train_results = []
|
|
1150
|
+
validate_results = []
|
|
1151
|
+
test_results = []
|
|
1152
|
+
|
|
1153
|
+
valid_metrics = {"final_cash", "total_return", "sharpe", "max_drawdown", "trades"}
|
|
1154
|
+
|
|
1155
|
+
total_combos = len(list(itertools.product(*value_lists)))
|
|
1156
|
+
|
|
1157
|
+
# Create a modified strategy that uses data slices
|
|
1158
|
+
def create_split_strategy(params_dict: dict, split_mode: DataSplitMode):
|
|
1159
|
+
"""Create a strategy copy with data sliced to the specified split."""
|
|
1160
|
+
strat_copy = copy.deepcopy(self.strategy)
|
|
1161
|
+
for k, v in params_dict.items():
|
|
1162
|
+
# Convert float to int if the strategy expects integer parameters
|
|
1163
|
+
if isinstance(v, float) and v == int(v):
|
|
1164
|
+
v = int(v)
|
|
1165
|
+
setattr(strat_copy, k, v)
|
|
1166
|
+
|
|
1167
|
+
# Slice each data source to the appropriate split
|
|
1168
|
+
for key, broker in strat_copy.positions.items():
|
|
1169
|
+
source = broker.source
|
|
1170
|
+
if split_mode == DataSplitMode.TRAIN:
|
|
1171
|
+
start, end = split.train_start, split.train_end
|
|
1172
|
+
elif split_mode == DataSplitMode.VALIDATE:
|
|
1173
|
+
start, end = split.validate_start, split.validate_end
|
|
1174
|
+
else: # TEST
|
|
1175
|
+
start, end = split.test_start, split.test_end
|
|
1176
|
+
|
|
1177
|
+
# Create a new data source with sliced data
|
|
1178
|
+
sliced_df = source.data.iloc[start:end].copy()
|
|
1179
|
+
from .datasource import DataSource
|
|
1180
|
+
new_source = DataSource(sliced_df)
|
|
1181
|
+
broker.source = new_source
|
|
1182
|
+
# Also update the strategy's data dictionary
|
|
1183
|
+
strat_copy.data[key] = new_source
|
|
1184
|
+
|
|
1185
|
+
return strat_copy, split_mode
|
|
1186
|
+
|
|
1187
|
+
# Run optimization for each split
|
|
1188
|
+
for combo in tqdm(itertools.product(*value_lists), total=total_combos, desc="Optimizing"):
|
|
1189
|
+
row_params = {k: v for k, v in zip(keys, combo)}
|
|
1190
|
+
|
|
1191
|
+
# Apply constraint
|
|
1192
|
+
if constraint is not None:
|
|
1193
|
+
try:
|
|
1194
|
+
if not bool(constraint(row_params)):
|
|
1195
|
+
continue
|
|
1196
|
+
except Exception:
|
|
1197
|
+
continue
|
|
1198
|
+
|
|
1199
|
+
# Evaluate on all three splits
|
|
1200
|
+
for mode in [DataSplitMode.TRAIN, DataSplitMode.VALIDATE, DataSplitMode.TEST]:
|
|
1201
|
+
strat_copy, _ = create_split_strategy(row_params, mode)
|
|
1202
|
+
|
|
1203
|
+
bt = SimpleBacktester(
|
|
1204
|
+
strat_copy,
|
|
1205
|
+
cash=self.cash,
|
|
1206
|
+
commission=self.commission,
|
|
1207
|
+
commission_type=self.commission_type,
|
|
1208
|
+
lot_size=self.lot_size,
|
|
1209
|
+
)
|
|
1210
|
+
report = bt.run(progress_bar=False)
|
|
1211
|
+
metrics = _compute_backtest_metrics(report)
|
|
1212
|
+
|
|
1213
|
+
# Apply risk tolerance filter
|
|
1214
|
+
if risk_tolerance is not None:
|
|
1215
|
+
if not _risk_tolerance_passes(report, risk_tolerance):
|
|
1216
|
+
continue
|
|
1217
|
+
|
|
1218
|
+
# Compute objective score
|
|
1219
|
+
if objective in valid_metrics:
|
|
1220
|
+
score = metrics.get(objective)
|
|
1221
|
+
else:
|
|
1222
|
+
score = getattr(report, objective, None)
|
|
1223
|
+
if callable(score):
|
|
1224
|
+
score = score()
|
|
1225
|
+
|
|
1226
|
+
if score is None or not np.isfinite(float(score)): # type: ignore[arg-type]
|
|
1227
|
+
continue
|
|
1228
|
+
|
|
1229
|
+
row = dict(row_params)
|
|
1230
|
+
row["objective_score"] = float(score) # type: ignore[arg-type]
|
|
1231
|
+
row.update(metrics)
|
|
1232
|
+
|
|
1233
|
+
if mode == DataSplitMode.TRAIN:
|
|
1234
|
+
train_results.append(row)
|
|
1235
|
+
elif mode == DataSplitMode.VALIDATE:
|
|
1236
|
+
validate_results.append(row)
|
|
1237
|
+
else:
|
|
1238
|
+
test_results.append(row)
|
|
1239
|
+
|
|
1240
|
+
# Create DataFrames
|
|
1241
|
+
train_df = pd.DataFrame(train_results) if train_results else pd.DataFrame()
|
|
1242
|
+
validate_df = pd.DataFrame(validate_results) if validate_results else pd.DataFrame()
|
|
1243
|
+
test_df = pd.DataFrame(test_results) if test_results else pd.DataFrame()
|
|
1244
|
+
|
|
1245
|
+
# Select best parameters based on selection criterion
|
|
1246
|
+
if selection_criterion == "validate" and not validate_df.empty:
|
|
1247
|
+
validate_df_sorted = validate_df.sort_values(
|
|
1248
|
+
by=["objective_score"], ascending=False, kind="mergesort"
|
|
1249
|
+
)
|
|
1250
|
+
best_idx = validate_df_sorted.index[0]
|
|
1251
|
+
best_params = {k: validate_df.loc[best_idx, k] for k in keys}
|
|
1252
|
+
best_validate_score = validate_df.loc[best_idx, "objective_score"]
|
|
1253
|
+
elif selection_criterion == "train" and not train_df.empty:
|
|
1254
|
+
train_df_sorted = train_df.sort_values(
|
|
1255
|
+
by=["objective_score"], ascending=False, kind="mergesort"
|
|
1256
|
+
)
|
|
1257
|
+
best_idx = train_df_sorted.index[0]
|
|
1258
|
+
best_params = {k: train_df.loc[best_idx, k] for k in keys}
|
|
1259
|
+
best_validate_score = train_df.loc[best_idx, "objective_score"]
|
|
1260
|
+
elif selection_criterion == "test" and not test_df.empty:
|
|
1261
|
+
test_df_sorted = test_df.sort_values(
|
|
1262
|
+
by=["objective_score"], ascending=False, kind="mergesort"
|
|
1263
|
+
)
|
|
1264
|
+
best_idx = test_df_sorted.index[0]
|
|
1265
|
+
best_params = {k: test_df.loc[best_idx, k] for k in keys}
|
|
1266
|
+
best_validate_score = test_df.loc[best_idx, "objective_score"]
|
|
1267
|
+
else:
|
|
1268
|
+
best_params = {}
|
|
1269
|
+
best_validate_score = -np.inf
|
|
1270
|
+
|
|
1271
|
+
# Get full reports for best parameters
|
|
1272
|
+
train_report = None
|
|
1273
|
+
validate_report = None
|
|
1274
|
+
test_report = None
|
|
1275
|
+
train_metrics = {}
|
|
1276
|
+
validate_metrics = {}
|
|
1277
|
+
test_metrics = {}
|
|
1278
|
+
|
|
1279
|
+
if best_params:
|
|
1280
|
+
# Run full backtests for best parameters on each split
|
|
1281
|
+
for mode, report_attr, metrics_attr in [
|
|
1282
|
+
(DataSplitMode.TRAIN, 'train_report', 'train_metrics'),
|
|
1283
|
+
(DataSplitMode.VALIDATE, 'validate_report', 'validate_metrics'),
|
|
1284
|
+
(DataSplitMode.TEST, 'test_report', 'test_metrics'),
|
|
1285
|
+
]:
|
|
1286
|
+
strat_copy, _ = create_split_strategy(best_params, mode)
|
|
1287
|
+
bt = SimpleBacktester(
|
|
1288
|
+
strat_copy,
|
|
1289
|
+
cash=self.cash,
|
|
1290
|
+
commission=self.commission,
|
|
1291
|
+
commission_type=self.commission_type,
|
|
1292
|
+
lot_size=self.lot_size,
|
|
1293
|
+
)
|
|
1294
|
+
report = bt.run(progress_bar=False)
|
|
1295
|
+
metrics = _compute_backtest_metrics(report)
|
|
1296
|
+
|
|
1297
|
+
if mode == DataSplitMode.TRAIN:
|
|
1298
|
+
train_report = report
|
|
1299
|
+
train_metrics = metrics
|
|
1300
|
+
elif mode == DataSplitMode.VALIDATE:
|
|
1301
|
+
validate_report = report
|
|
1302
|
+
validate_metrics = metrics
|
|
1303
|
+
else:
|
|
1304
|
+
test_report = report
|
|
1305
|
+
test_metrics = metrics
|
|
1306
|
+
|
|
1307
|
+
# Combine all results
|
|
1308
|
+
all_results = pd.DataFrame()
|
|
1309
|
+
if not train_df.empty:
|
|
1310
|
+
train_df_copy = train_df.copy()
|
|
1311
|
+
train_df_copy["split"] = "train"
|
|
1312
|
+
all_results = pd.concat([all_results, train_df_copy], ignore_index=True)
|
|
1313
|
+
if not validate_df.empty:
|
|
1314
|
+
validate_df_copy = validate_df.copy()
|
|
1315
|
+
validate_df_copy["split"] = "validate"
|
|
1316
|
+
all_results = pd.concat([all_results, validate_df_copy], ignore_index=True)
|
|
1317
|
+
if not test_df.empty:
|
|
1318
|
+
test_df_copy = test_df.copy()
|
|
1319
|
+
test_df_copy["split"] = "test"
|
|
1320
|
+
all_results = pd.concat([all_results, test_df_copy], ignore_index=True)
|
|
1321
|
+
|
|
1322
|
+
return OptimizationResult(
|
|
1323
|
+
best_params=best_params,
|
|
1324
|
+
train_report=train_report,
|
|
1325
|
+
validate_report=validate_report,
|
|
1326
|
+
test_report=test_report,
|
|
1327
|
+
train_metrics=train_metrics,
|
|
1328
|
+
validate_metrics=validate_metrics,
|
|
1329
|
+
test_metrics=test_metrics,
|
|
1330
|
+
all_results=all_results
|
|
1331
|
+
)
|
|
1332
|
+
|
|
1333
|
+
def optimize_gradient_descent(
|
|
1334
|
+
self,
|
|
1335
|
+
param_init: dict[str, float],
|
|
1336
|
+
param_bounds: dict[str, tuple[float, float]],
|
|
1337
|
+
objective: str = "sharpe",
|
|
1338
|
+
learning_rate: float = 0.01,
|
|
1339
|
+
max_iterations: int = 100,
|
|
1340
|
+
tolerance: float = 1e-6,
|
|
1341
|
+
momentum: float = 0.9,
|
|
1342
|
+
train_ratio: float = 0.7,
|
|
1343
|
+
validate_ratio: float = 0.15,
|
|
1344
|
+
test_ratio: float = 0.15,
|
|
1345
|
+
selection_criterion: str = "validate",
|
|
1346
|
+
progress_bar: bool = True,
|
|
1347
|
+
integer_params: set[str] | None = None,
|
|
1348
|
+
) -> OptimizationResult:
|
|
1349
|
+
"""
|
|
1350
|
+
Optimize strategy parameters using gradient descent.
|
|
1351
|
+
|
|
1352
|
+
This method performs gradient-based optimization on continuous
|
|
1353
|
+
strategy parameters, similar to machine learning workflows. It uses
|
|
1354
|
+
train/validate/test splits to prevent overfitting and select the best
|
|
1355
|
+
model based on validation performance.
|
|
1356
|
+
|
|
1357
|
+
The optimization computes numerical gradients by evaluating small
|
|
1358
|
+
perturbations around the current parameter values.
|
|
1359
|
+
|
|
1360
|
+
Args:
|
|
1361
|
+
param_init (dict[str, float]): Initial parameter values.
|
|
1362
|
+
param_bounds (dict[str, tuple[float, float]]): Bounds for each
|
|
1363
|
+
parameter as (min, max) tuples.
|
|
1364
|
+
objective (str, optional): Metric to optimize. Defaults to "sharpe".
|
|
1365
|
+
Supports same metrics as optimize().
|
|
1366
|
+
learning_rate (float, optional): Step size for gradient descent.
|
|
1367
|
+
Defaults to 0.01.
|
|
1368
|
+
max_iterations (int, optional): Maximum number of iterations.
|
|
1369
|
+
Defaults to 100.
|
|
1370
|
+
tolerance (float, optional): Convergence tolerance. Optimization
|
|
1371
|
+
stops when gradient magnitude falls below this threshold.
|
|
1372
|
+
Defaults to 1e-6.
|
|
1373
|
+
momentum (float, optional): Momentum factor for accelerated
|
|
1374
|
+
descent. Defaults to 0.9.
|
|
1375
|
+
train_ratio (float, optional): Fraction of data for training.
|
|
1376
|
+
Defaults to 0.7 (70%).
|
|
1377
|
+
validate_ratio (float, optional): Fraction of data for validation.
|
|
1378
|
+
Defaults to 0.15 (15%).
|
|
1379
|
+
test_ratio (float, optional): Fraction of data for testing.
|
|
1380
|
+
Defaults to 0.15 (15%).
|
|
1381
|
+
selection_criterion (str, optional): Which split to use for final
|
|
1382
|
+
parameter selection. Options: "train", "validate", "test".
|
|
1383
|
+
Defaults to "validate".
|
|
1384
|
+
progress_bar (bool, optional): Whether to show progress bar.
|
|
1385
|
+
Defaults to True.
|
|
1386
|
+
integer_params (set[str] | None, optional): Set of parameter names
|
|
1387
|
+
that should be treated as integers. These parameters will be
|
|
1388
|
+
rounded to the nearest integer after each gradient update.
|
|
1389
|
+
Defaults to None (all parameters are continuous).
|
|
1390
|
+
|
|
1391
|
+
Returns:
|
|
1392
|
+
OptimizationResult: Object containing:
|
|
1393
|
+
- best_params: Optimized parameter values
|
|
1394
|
+
- train_report: BacktestReport for training data
|
|
1395
|
+
- validate_report: BacktestReport for validation data
|
|
1396
|
+
- test_report: BacktestReport for test data
|
|
1397
|
+
- train_metrics: Metrics computed on training data
|
|
1398
|
+
- validate_metrics: Metrics computed on validation data
|
|
1399
|
+
- test_metrics: Metrics computed on test data
|
|
1400
|
+
- all_results: DataFrame with iteration history
|
|
1401
|
+
|
|
1402
|
+
Example:
|
|
1403
|
+
>>> # Optimize with integer parameters
|
|
1404
|
+
>>> result = bt.optimize_gradient_descent(
|
|
1405
|
+
... param_init={'fast_period': 10.0, 'slow_period': 30.0},
|
|
1406
|
+
... param_bounds={
|
|
1407
|
+
... 'fast_period': (2.0, 50.0),
|
|
1408
|
+
... 'slow_period': (10.0, 100.0)
|
|
1409
|
+
... },
|
|
1410
|
+
... integer_params={'fast_period', 'slow_period'},
|
|
1411
|
+
... learning_rate=0.05,
|
|
1412
|
+
... max_iterations=50
|
|
1413
|
+
... )
|
|
1414
|
+
>>> print(f"Optimized params: {result.best_params}")
|
|
1415
|
+
>>> print(f"Final validation Sharpe: {result.validate_metrics['sharpe']}")
|
|
1416
|
+
"""
|
|
1417
|
+
if integer_params is None:
|
|
1418
|
+
integer_params = set()
|
|
1419
|
+
# Validate selection criterion
|
|
1420
|
+
valid_criteria = {"train", "validate", "test"}
|
|
1421
|
+
if selection_criterion not in valid_criteria:
|
|
1422
|
+
raise ValueError(
|
|
1423
|
+
f"selection_criterion must be one of {valid_criteria}, "
|
|
1424
|
+
f"got '{selection_criterion}'"
|
|
1425
|
+
)
|
|
1426
|
+
|
|
1427
|
+
# Validate parameters
|
|
1428
|
+
if not param_init:
|
|
1429
|
+
raise ValueError("param_init must not be empty")
|
|
1430
|
+
if set(param_init.keys()) != set(param_bounds.keys()):
|
|
1431
|
+
raise ValueError("param_init and param_bounds must have the same keys")
|
|
1432
|
+
|
|
1433
|
+
# Get data length from the strategy's data source
|
|
1434
|
+
source = self.strategy.positions[list(self.strategy.positions.keys())[0]].source
|
|
1435
|
+
data_length = len(source.data)
|
|
1436
|
+
|
|
1437
|
+
# Create the split
|
|
1438
|
+
split = create_train_validate_test_split(
|
|
1439
|
+
data_length,
|
|
1440
|
+
train_ratio,
|
|
1441
|
+
validate_ratio,
|
|
1442
|
+
test_ratio
|
|
1443
|
+
)
|
|
1444
|
+
|
|
1445
|
+
valid_metrics = {"final_cash", "total_return", "sharpe", "max_drawdown", "trades"}
|
|
1446
|
+
|
|
1447
|
+
# Helper to create sliced strategy
|
|
1448
|
+
def create_split_strategy(params_dict: dict, split_mode: DataSplitMode):
|
|
1449
|
+
"""Create a strategy copy with data sliced to the specified split."""
|
|
1450
|
+
strat_copy = copy.deepcopy(self.strategy)
|
|
1451
|
+
for k, v in params_dict.items():
|
|
1452
|
+
# Convert float to int if the strategy expects integer parameters
|
|
1453
|
+
if isinstance(v, float) and v == int(v):
|
|
1454
|
+
v = int(v)
|
|
1455
|
+
setattr(strat_copy, k, v)
|
|
1456
|
+
|
|
1457
|
+
# Slice each data source to the appropriate split
|
|
1458
|
+
for key, broker in strat_copy.positions.items():
|
|
1459
|
+
source = broker.source
|
|
1460
|
+
if split_mode == DataSplitMode.TRAIN:
|
|
1461
|
+
start, end = split.train_start, split.train_end
|
|
1462
|
+
elif split_mode == DataSplitMode.VALIDATE:
|
|
1463
|
+
start, end = split.validate_start, split.validate_end
|
|
1464
|
+
else: # TEST
|
|
1465
|
+
start, end = split.test_start, split.test_end
|
|
1466
|
+
|
|
1467
|
+
sliced_df = source.data.iloc[start:end].copy()
|
|
1468
|
+
from .datasource import DataSource
|
|
1469
|
+
new_source = DataSource(sliced_df)
|
|
1470
|
+
broker.source = new_source
|
|
1471
|
+
# Also update the strategy's data dictionary
|
|
1472
|
+
strat_copy.data[key] = new_source
|
|
1473
|
+
|
|
1474
|
+
return strat_copy
|
|
1475
|
+
|
|
1476
|
+
# Function to evaluate parameters on a specific split
|
|
1477
|
+
def evaluate_params(params_dict: dict, split_mode: DataSplitMode) -> float:
|
|
1478
|
+
"""Evaluate objective function on specified split."""
|
|
1479
|
+
strat_copy = create_split_strategy(params_dict, split_mode)
|
|
1480
|
+
bt = SimpleBacktester(
|
|
1481
|
+
strat_copy,
|
|
1482
|
+
cash=self.cash,
|
|
1483
|
+
commission=self.commission,
|
|
1484
|
+
commission_type=self.commission_type,
|
|
1485
|
+
lot_size=self.lot_size,
|
|
1486
|
+
)
|
|
1487
|
+
report = bt.run(progress_bar=False)
|
|
1488
|
+
metrics = _compute_backtest_metrics(report)
|
|
1489
|
+
|
|
1490
|
+
if objective in valid_metrics:
|
|
1491
|
+
score = metrics.get(objective)
|
|
1492
|
+
else:
|
|
1493
|
+
score = getattr(report, objective, None)
|
|
1494
|
+
if callable(score):
|
|
1495
|
+
score = score()
|
|
1496
|
+
|
|
1497
|
+
if score is None or not np.isfinite(float(score)): # type: ignore[arg-type]
|
|
1498
|
+
return -np.inf
|
|
1499
|
+
|
|
1500
|
+
return float(score) # type: ignore[arg-type]
|
|
1501
|
+
|
|
1502
|
+
# Compute numerical gradient
|
|
1503
|
+
def compute_gradient(params: dict, eps: float = 1e-5) -> dict:
|
|
1504
|
+
"""Compute numerical gradient using central differences."""
|
|
1505
|
+
grad = {}
|
|
1506
|
+
for key in params:
|
|
1507
|
+
params_plus = params.copy()
|
|
1508
|
+
params_minus = params.copy()
|
|
1509
|
+
params_plus[key] = params[key] + eps
|
|
1510
|
+
params_minus[key] = params[key] - eps
|
|
1511
|
+
|
|
1512
|
+
# Use validation set for gradient computation
|
|
1513
|
+
f_plus = evaluate_params(params_plus, DataSplitMode.VALIDATE)
|
|
1514
|
+
f_minus = evaluate_params(params_minus, DataSplitMode.VALIDATE)
|
|
1515
|
+
|
|
1516
|
+
grad[key] = (f_plus - f_minus) / (2 * eps)
|
|
1517
|
+
|
|
1518
|
+
return grad
|
|
1519
|
+
|
|
1520
|
+
# Gradient descent optimization
|
|
1521
|
+
current_params = param_init.copy()
|
|
1522
|
+
velocities = {k: 0.0 for k in current_params}
|
|
1523
|
+
|
|
1524
|
+
iteration_history = []
|
|
1525
|
+
best_params = current_params.copy()
|
|
1526
|
+
best_score = -np.inf
|
|
1527
|
+
|
|
1528
|
+
param_names = list(current_params.keys())
|
|
1529
|
+
|
|
1530
|
+
iterator = range(max_iterations)
|
|
1531
|
+
if progress_bar:
|
|
1532
|
+
iterator = tqdm(iterator, desc="Gradient Descent")
|
|
1533
|
+
|
|
1534
|
+
for iteration in iterator:
|
|
1535
|
+
# Compute gradient
|
|
1536
|
+
gradient = compute_gradient(current_params)
|
|
1537
|
+
|
|
1538
|
+
# Check for convergence (gradient magnitude)
|
|
1539
|
+
grad_magnitude = np.sqrt(sum(g**2 for g in gradient.values()))
|
|
1540
|
+
if grad_magnitude < tolerance:
|
|
1541
|
+
if progress_bar:
|
|
1542
|
+
print(f"\nConverged at iteration {iteration}")
|
|
1543
|
+
break
|
|
1544
|
+
|
|
1545
|
+
# Update velocities with momentum
|
|
1546
|
+
for key in param_names:
|
|
1547
|
+
velocities[key] = momentum * velocities[key] - learning_rate * gradient[key]
|
|
1548
|
+
|
|
1549
|
+
# Update parameters
|
|
1550
|
+
for key in param_names:
|
|
1551
|
+
current_params[key] += velocities[key]
|
|
1552
|
+
|
|
1553
|
+
# Apply bounds
|
|
1554
|
+
min_val, max_val = param_bounds[key]
|
|
1555
|
+
current_params[key] = np.clip(current_params[key], min_val, max_val)
|
|
1556
|
+
|
|
1557
|
+
# Round integer parameters to nearest integer
|
|
1558
|
+
if key in integer_params:
|
|
1559
|
+
current_params[key] = round(current_params[key])
|
|
1560
|
+
|
|
1561
|
+
# Evaluate on all splits
|
|
1562
|
+
train_score = evaluate_params(current_params, DataSplitMode.TRAIN)
|
|
1563
|
+
validate_score = evaluate_params(current_params, DataSplitMode.VALIDATE)
|
|
1564
|
+
test_score = evaluate_params(current_params, DataSplitMode.TEST)
|
|
1565
|
+
|
|
1566
|
+
# Track best parameters based on selection criterion
|
|
1567
|
+
if selection_criterion == "validate" and validate_score > best_score:
|
|
1568
|
+
best_score = validate_score
|
|
1569
|
+
best_params = current_params.copy()
|
|
1570
|
+
elif selection_criterion == "train" and train_score > best_score:
|
|
1571
|
+
best_score = train_score
|
|
1572
|
+
best_params = current_params.copy()
|
|
1573
|
+
elif selection_criterion == "test" and test_score > best_score:
|
|
1574
|
+
best_score = test_score
|
|
1575
|
+
best_params = current_params.copy()
|
|
1576
|
+
|
|
1577
|
+
# Record iteration history
|
|
1578
|
+
row = current_params.copy()
|
|
1579
|
+
row["iteration"] = iteration
|
|
1580
|
+
row["train_score"] = train_score
|
|
1581
|
+
row["validate_score"] = validate_score
|
|
1582
|
+
row["test_score"] = test_score
|
|
1583
|
+
row["gradient_magnitude"] = grad_magnitude
|
|
1584
|
+
iteration_history.append(row)
|
|
1585
|
+
|
|
1586
|
+
# Create history DataFrame
|
|
1587
|
+
history_df = pd.DataFrame(iteration_history)
|
|
1588
|
+
|
|
1589
|
+
# Get final reports for best parameters
|
|
1590
|
+
train_report = None
|
|
1591
|
+
validate_report = None
|
|
1592
|
+
test_report = None
|
|
1593
|
+
train_metrics = {}
|
|
1594
|
+
validate_metrics = {}
|
|
1595
|
+
test_metrics = {}
|
|
1596
|
+
|
|
1597
|
+
for mode, report_attr, metrics_attr in [
|
|
1598
|
+
(DataSplitMode.TRAIN, 'train_report', 'train_metrics'),
|
|
1599
|
+
(DataSplitMode.VALIDATE, 'validate_report', 'validate_metrics'),
|
|
1600
|
+
(DataSplitMode.TEST, 'test_report', 'test_metrics'),
|
|
1601
|
+
]:
|
|
1602
|
+
strat_copy = create_split_strategy(best_params, mode)
|
|
1603
|
+
bt = SimpleBacktester(
|
|
1604
|
+
strat_copy,
|
|
1605
|
+
cash=self.cash,
|
|
1606
|
+
commission=self.commission,
|
|
1607
|
+
commission_type=self.commission_type,
|
|
1608
|
+
lot_size=self.lot_size,
|
|
1609
|
+
)
|
|
1610
|
+
report = bt.run(progress_bar=False)
|
|
1611
|
+
metrics = _compute_backtest_metrics(report)
|
|
1612
|
+
|
|
1613
|
+
if mode == DataSplitMode.TRAIN:
|
|
1614
|
+
train_report = report
|
|
1615
|
+
train_metrics = metrics
|
|
1616
|
+
elif mode == DataSplitMode.VALIDATE:
|
|
1617
|
+
validate_report = report
|
|
1618
|
+
validate_metrics = metrics
|
|
1619
|
+
else:
|
|
1620
|
+
test_report = report
|
|
1621
|
+
test_metrics = metrics
|
|
1622
|
+
|
|
1623
|
+
return OptimizationResult(
|
|
1624
|
+
best_params=best_params,
|
|
1625
|
+
train_report=train_report,
|
|
1626
|
+
validate_report=validate_report,
|
|
1627
|
+
test_report=test_report,
|
|
1628
|
+
train_metrics=train_metrics,
|
|
1629
|
+
validate_metrics=validate_metrics,
|
|
1630
|
+
test_metrics=test_metrics,
|
|
1631
|
+
all_results=history_df
|
|
1632
|
+
)
|
|
@@ -1,5 +0,0 @@
|
|
|
1
|
-
from .datasource import CSVDataSource as CSVDataSource, ParquetDataSource as ParquetDataSource, DataSource as DataSource
|
|
2
|
-
from .strategy import Strategy as Strategy
|
|
3
|
-
from .backtester import SimpleBacktester as SimpleBacktester
|
|
4
|
-
from .enums import CommissionType as CommissionType
|
|
5
|
-
from .indicators import indicators as indicators
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|