quantmllibrary 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.
- quantml/__init__.py +74 -0
- quantml/autograd.py +154 -0
- quantml/cli/__init__.py +10 -0
- quantml/cli/run_experiment.py +385 -0
- quantml/config/__init__.py +28 -0
- quantml/config/config.py +259 -0
- quantml/data/__init__.py +33 -0
- quantml/data/cache.py +149 -0
- quantml/data/feature_store.py +234 -0
- quantml/data/futures.py +254 -0
- quantml/data/loaders.py +236 -0
- quantml/data/memory_optimizer.py +234 -0
- quantml/data/validators.py +390 -0
- quantml/experiments/__init__.py +23 -0
- quantml/experiments/logger.py +208 -0
- quantml/experiments/results.py +158 -0
- quantml/experiments/tracker.py +223 -0
- quantml/features/__init__.py +25 -0
- quantml/features/base.py +104 -0
- quantml/features/gap_features.py +124 -0
- quantml/features/registry.py +138 -0
- quantml/features/volatility_features.py +140 -0
- quantml/features/volume_features.py +142 -0
- quantml/functional.py +37 -0
- quantml/models/__init__.py +27 -0
- quantml/models/attention.py +258 -0
- quantml/models/dropout.py +130 -0
- quantml/models/gru.py +319 -0
- quantml/models/linear.py +112 -0
- quantml/models/lstm.py +353 -0
- quantml/models/mlp.py +286 -0
- quantml/models/normalization.py +289 -0
- quantml/models/rnn.py +154 -0
- quantml/models/tcn.py +238 -0
- quantml/online.py +209 -0
- quantml/ops.py +1707 -0
- quantml/optim/__init__.py +42 -0
- quantml/optim/adafactor.py +206 -0
- quantml/optim/adagrad.py +157 -0
- quantml/optim/adam.py +267 -0
- quantml/optim/lookahead.py +97 -0
- quantml/optim/quant_optimizer.py +228 -0
- quantml/optim/radam.py +192 -0
- quantml/optim/rmsprop.py +203 -0
- quantml/optim/schedulers.py +286 -0
- quantml/optim/sgd.py +181 -0
- quantml/py.typed +0 -0
- quantml/streaming.py +175 -0
- quantml/tensor.py +462 -0
- quantml/time_series.py +447 -0
- quantml/training/__init__.py +135 -0
- quantml/training/alpha_eval.py +203 -0
- quantml/training/backtest.py +280 -0
- quantml/training/backtest_analysis.py +168 -0
- quantml/training/cv.py +106 -0
- quantml/training/data_loader.py +177 -0
- quantml/training/ensemble.py +84 -0
- quantml/training/feature_importance.py +135 -0
- quantml/training/features.py +364 -0
- quantml/training/futures_backtest.py +266 -0
- quantml/training/gradient_clipping.py +206 -0
- quantml/training/losses.py +248 -0
- quantml/training/lr_finder.py +127 -0
- quantml/training/metrics.py +376 -0
- quantml/training/regularization.py +89 -0
- quantml/training/trainer.py +239 -0
- quantml/training/walk_forward.py +190 -0
- quantml/utils/__init__.py +51 -0
- quantml/utils/gradient_check.py +274 -0
- quantml/utils/logging.py +181 -0
- quantml/utils/ops_cpu.py +231 -0
- quantml/utils/profiling.py +364 -0
- quantml/utils/reproducibility.py +220 -0
- quantml/utils/serialization.py +335 -0
- quantmllibrary-0.1.0.dist-info/METADATA +536 -0
- quantmllibrary-0.1.0.dist-info/RECORD +79 -0
- quantmllibrary-0.1.0.dist-info/WHEEL +5 -0
- quantmllibrary-0.1.0.dist-info/licenses/LICENSE +22 -0
- quantmllibrary-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Feature engineering pipeline for quant models.
|
|
3
|
+
|
|
4
|
+
This module provides utilities for creating features from raw market data,
|
|
5
|
+
including lagged features, rolling windows, cross-sectional features, and normalization.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import List, Optional, Union, Callable, Dict, Any
|
|
9
|
+
from quantml.tensor import Tensor
|
|
10
|
+
from quantml import time_series
|
|
11
|
+
|
|
12
|
+
# Try to import NumPy
|
|
13
|
+
try:
|
|
14
|
+
import numpy as np
|
|
15
|
+
HAS_NUMPY = True
|
|
16
|
+
except ImportError:
|
|
17
|
+
HAS_NUMPY = False
|
|
18
|
+
np = None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class FeaturePipeline:
|
|
22
|
+
"""
|
|
23
|
+
Feature engineering pipeline for reproducible feature creation.
|
|
24
|
+
|
|
25
|
+
This class provides a framework for creating features from raw data,
|
|
26
|
+
with support for lagged features, rolling windows, normalization, and more.
|
|
27
|
+
|
|
28
|
+
Attributes:
|
|
29
|
+
features: List of feature definitions
|
|
30
|
+
normalizers: Dictionary of normalizers for each feature
|
|
31
|
+
|
|
32
|
+
Examples:
|
|
33
|
+
>>> pipeline = FeaturePipeline()
|
|
34
|
+
>>> pipeline.add_lagged_feature('price', lags=[1, 5, 10])
|
|
35
|
+
>>> pipeline.add_rolling_feature('price', window=20, func='mean')
|
|
36
|
+
>>> features = pipeline.transform(prices)
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(self):
|
|
40
|
+
"""Initialize feature pipeline."""
|
|
41
|
+
self.features = []
|
|
42
|
+
self.normalizers = {}
|
|
43
|
+
|
|
44
|
+
def add_lagged_feature(
|
|
45
|
+
self,
|
|
46
|
+
name: str,
|
|
47
|
+
lags: List[int],
|
|
48
|
+
fill_value: float = 0.0
|
|
49
|
+
):
|
|
50
|
+
"""
|
|
51
|
+
Add lagged features.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
name: Feature name
|
|
55
|
+
lags: List of lag values (e.g., [1, 5, 10] for 1, 5, 10 period lags)
|
|
56
|
+
fill_value: Value to use for missing lags (at beginning)
|
|
57
|
+
"""
|
|
58
|
+
self.features.append({
|
|
59
|
+
'type': 'lagged',
|
|
60
|
+
'name': name,
|
|
61
|
+
'lags': lags,
|
|
62
|
+
'fill_value': fill_value
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
def add_rolling_feature(
|
|
66
|
+
self,
|
|
67
|
+
name: str,
|
|
68
|
+
window: int,
|
|
69
|
+
func: str = 'mean',
|
|
70
|
+
min_periods: Optional[int] = None
|
|
71
|
+
):
|
|
72
|
+
"""
|
|
73
|
+
Add rolling window feature.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
name: Feature name
|
|
77
|
+
window: Window size
|
|
78
|
+
func: Function to apply ('mean', 'std', 'min', 'max', 'sum')
|
|
79
|
+
min_periods: Minimum periods required (default: window)
|
|
80
|
+
"""
|
|
81
|
+
if min_periods is None:
|
|
82
|
+
min_periods = window
|
|
83
|
+
|
|
84
|
+
self.features.append({
|
|
85
|
+
'type': 'rolling',
|
|
86
|
+
'name': name,
|
|
87
|
+
'window': window,
|
|
88
|
+
'func': func,
|
|
89
|
+
'min_periods': min_periods
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
def add_time_series_feature(
|
|
93
|
+
self,
|
|
94
|
+
name: str,
|
|
95
|
+
func: str,
|
|
96
|
+
**kwargs
|
|
97
|
+
):
|
|
98
|
+
"""
|
|
99
|
+
Add time-series feature (EMA, returns, etc.).
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
name: Feature name
|
|
103
|
+
func: Function name ('ema', 'returns', 'volatility', etc.)
|
|
104
|
+
**kwargs: Arguments for the function
|
|
105
|
+
"""
|
|
106
|
+
self.features.append({
|
|
107
|
+
'type': 'time_series',
|
|
108
|
+
'name': name,
|
|
109
|
+
'func': func,
|
|
110
|
+
'kwargs': kwargs
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
def add_normalization(
|
|
114
|
+
self,
|
|
115
|
+
feature_name: str,
|
|
116
|
+
method: str = 'zscore',
|
|
117
|
+
window: Optional[int] = None
|
|
118
|
+
):
|
|
119
|
+
"""
|
|
120
|
+
Add normalization to a feature.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
feature_name: Name of feature to normalize
|
|
124
|
+
method: Normalization method ('zscore', 'minmax', 'robust')
|
|
125
|
+
window: Rolling window for normalization (None for global)
|
|
126
|
+
"""
|
|
127
|
+
if feature_name not in self.normalizers:
|
|
128
|
+
self.normalizers[feature_name] = []
|
|
129
|
+
self.normalizers[feature_name].append({
|
|
130
|
+
'method': method,
|
|
131
|
+
'window': window
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
def transform(self, data: Dict[str, List[float]]) -> List[List[float]]:
|
|
135
|
+
"""
|
|
136
|
+
Transform raw data into features.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
data: Dictionary of feature name -> values
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
List of feature vectors (one per time step)
|
|
143
|
+
"""
|
|
144
|
+
n = len(list(data.values())[0]) if data else 0
|
|
145
|
+
if n == 0:
|
|
146
|
+
return []
|
|
147
|
+
|
|
148
|
+
feature_matrix = []
|
|
149
|
+
|
|
150
|
+
for i in range(n):
|
|
151
|
+
feature_vector = []
|
|
152
|
+
|
|
153
|
+
for feat_def in self.features:
|
|
154
|
+
feat_name = feat_def['name']
|
|
155
|
+
if feat_name not in data:
|
|
156
|
+
continue
|
|
157
|
+
|
|
158
|
+
values = data[feat_name]
|
|
159
|
+
|
|
160
|
+
if feat_def['type'] == 'lagged':
|
|
161
|
+
for lag in feat_def['lags']:
|
|
162
|
+
if i >= lag:
|
|
163
|
+
feature_vector.append(values[i - lag])
|
|
164
|
+
else:
|
|
165
|
+
feature_vector.append(feat_def['fill_value'])
|
|
166
|
+
|
|
167
|
+
elif feat_def['type'] == 'rolling':
|
|
168
|
+
window = feat_def['window']
|
|
169
|
+
func = feat_def['func']
|
|
170
|
+
min_periods = feat_def['min_periods']
|
|
171
|
+
|
|
172
|
+
start_idx = max(0, i - window + 1)
|
|
173
|
+
window_data = values[start_idx:i+1]
|
|
174
|
+
|
|
175
|
+
if len(window_data) >= min_periods:
|
|
176
|
+
if func == 'mean':
|
|
177
|
+
feat_val = sum(window_data) / len(window_data)
|
|
178
|
+
elif func == 'std':
|
|
179
|
+
mean_val = sum(window_data) / len(window_data)
|
|
180
|
+
variance = sum((x - mean_val) ** 2 for x in window_data) / len(window_data)
|
|
181
|
+
feat_val = variance ** 0.5
|
|
182
|
+
elif func == 'min':
|
|
183
|
+
feat_val = min(window_data)
|
|
184
|
+
elif func == 'max':
|
|
185
|
+
feat_val = max(window_data)
|
|
186
|
+
elif func == 'sum':
|
|
187
|
+
feat_val = sum(window_data)
|
|
188
|
+
else:
|
|
189
|
+
feat_val = 0.0
|
|
190
|
+
else:
|
|
191
|
+
feat_val = 0.0
|
|
192
|
+
|
|
193
|
+
feature_vector.append(feat_val)
|
|
194
|
+
|
|
195
|
+
elif feat_def['type'] == 'time_series':
|
|
196
|
+
func_name = feat_def['func']
|
|
197
|
+
kwargs = feat_def['kwargs']
|
|
198
|
+
|
|
199
|
+
# Convert to tensor for time_series operations
|
|
200
|
+
tensor_data = Tensor([values[:i+1]])
|
|
201
|
+
|
|
202
|
+
if func_name == 'ema':
|
|
203
|
+
n_periods = kwargs.get('n', 20)
|
|
204
|
+
ema_vals = time_series.ema(tensor_data, n=n_periods)
|
|
205
|
+
if isinstance(ema_vals.data[0], list) and len(ema_vals.data[0]) > 0:
|
|
206
|
+
feature_vector.append(ema_vals.data[0][-1])
|
|
207
|
+
else:
|
|
208
|
+
feature_vector.append(0.0)
|
|
209
|
+
|
|
210
|
+
elif func_name == 'returns':
|
|
211
|
+
rets = time_series.returns(tensor_data)
|
|
212
|
+
if isinstance(rets.data[0], list) and len(rets.data[0]) > 0:
|
|
213
|
+
feature_vector.append(rets.data[0][-1])
|
|
214
|
+
else:
|
|
215
|
+
feature_vector.append(0.0)
|
|
216
|
+
|
|
217
|
+
elif func_name == 'volatility':
|
|
218
|
+
n_periods = kwargs.get('n', 20)
|
|
219
|
+
vol = time_series.volatility(tensor_data, n=n_periods)
|
|
220
|
+
if isinstance(vol.data[0], list) and len(vol.data[0]) > 0:
|
|
221
|
+
feature_vector.append(vol.data[0][-1])
|
|
222
|
+
else:
|
|
223
|
+
feature_vector.append(0.0)
|
|
224
|
+
|
|
225
|
+
else:
|
|
226
|
+
feature_vector.append(0.0)
|
|
227
|
+
|
|
228
|
+
feature_matrix.append(feature_vector)
|
|
229
|
+
|
|
230
|
+
# Apply normalization
|
|
231
|
+
if self.normalizers:
|
|
232
|
+
feature_matrix = self._apply_normalization(feature_matrix, data)
|
|
233
|
+
|
|
234
|
+
return feature_matrix
|
|
235
|
+
|
|
236
|
+
def _apply_normalization(
|
|
237
|
+
self,
|
|
238
|
+
feature_matrix: List[List[float]],
|
|
239
|
+
original_data: Dict[str, List[float]]
|
|
240
|
+
) -> List[List[float]]:
|
|
241
|
+
"""Apply normalization to features."""
|
|
242
|
+
# Simplified normalization - in practice would track feature indices
|
|
243
|
+
# For now, apply z-score normalization globally
|
|
244
|
+
if HAS_NUMPY:
|
|
245
|
+
try:
|
|
246
|
+
matrix = np.array(feature_matrix, dtype=np.float64)
|
|
247
|
+
mean_vals = np.mean(matrix, axis=0)
|
|
248
|
+
std_vals = np.std(matrix, axis=0)
|
|
249
|
+
std_vals = np.where(std_vals == 0, 1.0, std_vals)
|
|
250
|
+
normalized = (matrix - mean_vals) / std_vals
|
|
251
|
+
return normalized.tolist()
|
|
252
|
+
except (ValueError, TypeError):
|
|
253
|
+
pass
|
|
254
|
+
|
|
255
|
+
# Pure Python fallback
|
|
256
|
+
if len(feature_matrix) == 0:
|
|
257
|
+
return feature_matrix
|
|
258
|
+
|
|
259
|
+
n_features = len(feature_matrix[0])
|
|
260
|
+
means = [sum(row[i] for row in feature_matrix) / len(feature_matrix)
|
|
261
|
+
for i in range(n_features)]
|
|
262
|
+
stds = []
|
|
263
|
+
for i in range(n_features):
|
|
264
|
+
variance = sum((row[i] - means[i]) ** 2 for row in feature_matrix) / len(feature_matrix)
|
|
265
|
+
stds.append(variance ** 0.5 if variance > 0 else 1.0)
|
|
266
|
+
|
|
267
|
+
normalized = []
|
|
268
|
+
for row in feature_matrix:
|
|
269
|
+
normalized.append([
|
|
270
|
+
(row[i] - means[i]) / stds[i] if stds[i] > 0 else 0.0
|
|
271
|
+
for i in range(n_features)
|
|
272
|
+
])
|
|
273
|
+
|
|
274
|
+
return normalized
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def create_lagged_features(data: List[float], lags: List[int]) -> List[List[float]]:
|
|
278
|
+
"""
|
|
279
|
+
Create lagged features from a time series.
|
|
280
|
+
|
|
281
|
+
Args:
|
|
282
|
+
data: Time series data
|
|
283
|
+
lags: List of lag values
|
|
284
|
+
|
|
285
|
+
Returns:
|
|
286
|
+
List of feature vectors with lagged values
|
|
287
|
+
"""
|
|
288
|
+
features = []
|
|
289
|
+
for i in range(len(data)):
|
|
290
|
+
feature_vec = []
|
|
291
|
+
for lag in lags:
|
|
292
|
+
if i >= lag:
|
|
293
|
+
feature_vec.append(data[i - lag])
|
|
294
|
+
else:
|
|
295
|
+
feature_vec.append(0.0)
|
|
296
|
+
features.append(feature_vec)
|
|
297
|
+
return features
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def normalize_features(
|
|
301
|
+
features: List[List[float]],
|
|
302
|
+
method: str = 'zscore'
|
|
303
|
+
) -> List[List[float]]:
|
|
304
|
+
"""
|
|
305
|
+
Normalize feature matrix.
|
|
306
|
+
|
|
307
|
+
Args:
|
|
308
|
+
features: Feature matrix
|
|
309
|
+
method: Normalization method ('zscore', 'minmax')
|
|
310
|
+
|
|
311
|
+
Returns:
|
|
312
|
+
Normalized feature matrix
|
|
313
|
+
"""
|
|
314
|
+
if len(features) == 0:
|
|
315
|
+
return features
|
|
316
|
+
|
|
317
|
+
if HAS_NUMPY:
|
|
318
|
+
try:
|
|
319
|
+
matrix = np.array(features, dtype=np.float64)
|
|
320
|
+
if method == 'zscore':
|
|
321
|
+
mean_vals = np.mean(matrix, axis=0)
|
|
322
|
+
std_vals = np.std(matrix, axis=0)
|
|
323
|
+
std_vals = np.where(std_vals == 0, 1.0, std_vals)
|
|
324
|
+
normalized = (matrix - mean_vals) / std_vals
|
|
325
|
+
elif method == 'minmax':
|
|
326
|
+
min_vals = np.min(matrix, axis=0)
|
|
327
|
+
max_vals = np.max(matrix, axis=0)
|
|
328
|
+
ranges = max_vals - min_vals
|
|
329
|
+
ranges = np.where(ranges == 0, 1.0, ranges)
|
|
330
|
+
normalized = (matrix - min_vals) / ranges
|
|
331
|
+
else:
|
|
332
|
+
normalized = matrix
|
|
333
|
+
return normalized.tolist()
|
|
334
|
+
except (ValueError, TypeError):
|
|
335
|
+
pass
|
|
336
|
+
|
|
337
|
+
# Pure Python fallback
|
|
338
|
+
n_features = len(features[0])
|
|
339
|
+
|
|
340
|
+
if method == 'zscore':
|
|
341
|
+
means = [sum(row[i] for row in features) / len(features) for i in range(n_features)]
|
|
342
|
+
stds = []
|
|
343
|
+
for i in range(n_features):
|
|
344
|
+
variance = sum((row[i] - means[i]) ** 2 for row in features) / len(features)
|
|
345
|
+
stds.append(variance ** 0.5 if variance > 0 else 1.0)
|
|
346
|
+
|
|
347
|
+
normalized = [
|
|
348
|
+
[(row[i] - means[i]) / stds[i] if stds[i] > 0 else 0.0 for i in range(n_features)]
|
|
349
|
+
for row in features
|
|
350
|
+
]
|
|
351
|
+
elif method == 'minmax':
|
|
352
|
+
mins = [min(row[i] for row in features) for i in range(n_features)]
|
|
353
|
+
maxs = [max(row[i] for row in features) for i in range(n_features)]
|
|
354
|
+
ranges = [maxs[i] - mins[i] if maxs[i] > mins[i] else 1.0 for i in range(n_features)]
|
|
355
|
+
|
|
356
|
+
normalized = [
|
|
357
|
+
[(row[i] - mins[i]) / ranges[i] if ranges[i] > 0 else 0.0 for i in range(n_features)]
|
|
358
|
+
for row in features
|
|
359
|
+
]
|
|
360
|
+
else:
|
|
361
|
+
normalized = features
|
|
362
|
+
|
|
363
|
+
return normalized
|
|
364
|
+
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Futures-specific backtesting engine.
|
|
3
|
+
|
|
4
|
+
Handles contract rolls, margin requirements, overnight gaps, and session-based trading.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from typing import List, Optional, Dict, Any, Tuple
|
|
8
|
+
from quantml.training.backtest import BacktestEngine
|
|
9
|
+
from quantml.training.metrics import sharpe_ratio, max_drawdown
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class FuturesBacktestEngine(BacktestEngine):
|
|
13
|
+
"""
|
|
14
|
+
Backtesting engine for futures contracts.
|
|
15
|
+
|
|
16
|
+
Extends BacktestEngine with futures-specific features:
|
|
17
|
+
- Contract roll handling
|
|
18
|
+
- Margin requirements
|
|
19
|
+
- Overnight gap simulation
|
|
20
|
+
- Session-based trading (RTH vs ETH)
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
initial_capital: float = 100000.0,
|
|
26
|
+
commission: float = 0.001,
|
|
27
|
+
slippage: float = 0.0005,
|
|
28
|
+
margin_requirement: float = 0.05, # 5% margin
|
|
29
|
+
contract_size: float = 50.0, # ES contract multiplier
|
|
30
|
+
roll_dates: Optional[List[int]] = None,
|
|
31
|
+
session_type: str = "RTH" # RTH or ETH
|
|
32
|
+
):
|
|
33
|
+
"""
|
|
34
|
+
Initialize futures backtesting engine.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
initial_capital: Starting capital
|
|
38
|
+
commission: Commission per trade
|
|
39
|
+
slippage: Slippage per trade
|
|
40
|
+
margin_requirement: Margin requirement as fraction (e.g., 0.05 = 5%)
|
|
41
|
+
contract_size: Contract multiplier (50 for ES, 20 for NQ)
|
|
42
|
+
roll_dates: List of indices where contract rolls occur
|
|
43
|
+
session_type: "RTH" (regular trading hours) or "ETH" (extended)
|
|
44
|
+
"""
|
|
45
|
+
super().__init__(initial_capital, commission, slippage)
|
|
46
|
+
self.margin_requirement = margin_requirement
|
|
47
|
+
self.contract_size = contract_size
|
|
48
|
+
self.roll_dates = roll_dates or []
|
|
49
|
+
self.session_type = session_type
|
|
50
|
+
|
|
51
|
+
def _calculate_margin(self, position: float, price: float) -> float:
|
|
52
|
+
"""
|
|
53
|
+
Calculate margin requirement for position.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
position: Position size (number of contracts)
|
|
57
|
+
price: Current price
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
Required margin
|
|
61
|
+
"""
|
|
62
|
+
position_value = abs(position) * price * self.contract_size
|
|
63
|
+
return position_value * self.margin_requirement
|
|
64
|
+
|
|
65
|
+
def _apply_overnight_gap(
|
|
66
|
+
self,
|
|
67
|
+
position: float,
|
|
68
|
+
prev_close: float,
|
|
69
|
+
current_open: float
|
|
70
|
+
) -> Tuple[float, float]:
|
|
71
|
+
"""
|
|
72
|
+
Apply overnight gap to position.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
position: Current position
|
|
76
|
+
prev_close: Previous day's close
|
|
77
|
+
current_open: Current day's open
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
(updated_capital, gap_pnl)
|
|
81
|
+
"""
|
|
82
|
+
if position == 0 or prev_close == 0:
|
|
83
|
+
return 0.0, 0.0
|
|
84
|
+
|
|
85
|
+
gap = (current_open - prev_close) / prev_close
|
|
86
|
+
position_value = abs(position) * prev_close * self.contract_size
|
|
87
|
+
|
|
88
|
+
if position > 0: # Long
|
|
89
|
+
gap_pnl = position_value * gap
|
|
90
|
+
else: # Short
|
|
91
|
+
gap_pnl = -position_value * gap
|
|
92
|
+
|
|
93
|
+
return gap_pnl, gap_pnl
|
|
94
|
+
|
|
95
|
+
def run_futures(
|
|
96
|
+
self,
|
|
97
|
+
signals: List[float],
|
|
98
|
+
prices: List[float],
|
|
99
|
+
opens: Optional[List[float]] = None,
|
|
100
|
+
closes: Optional[List[float]] = None,
|
|
101
|
+
volumes: Optional[List[float]] = None
|
|
102
|
+
) -> Dict[str, Any]:
|
|
103
|
+
"""
|
|
104
|
+
Run futures backtest with contract rolls and overnight gaps.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
signals: Trading signals
|
|
108
|
+
prices: Price data (can be close prices)
|
|
109
|
+
opens: Opening prices (for gap calculation)
|
|
110
|
+
closes: Closing prices (for gap calculation)
|
|
111
|
+
volumes: Volume data
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
Backtest results with futures-specific metrics
|
|
115
|
+
"""
|
|
116
|
+
if len(signals) != len(prices):
|
|
117
|
+
raise ValueError("signals and prices must have same length")
|
|
118
|
+
|
|
119
|
+
# Use closes if provided, otherwise use prices
|
|
120
|
+
if closes is None:
|
|
121
|
+
closes = prices
|
|
122
|
+
if opens is None:
|
|
123
|
+
opens = prices
|
|
124
|
+
|
|
125
|
+
n = len(signals)
|
|
126
|
+
capital = self.initial_capital
|
|
127
|
+
position = 0.0 # Position in contracts
|
|
128
|
+
equity_curve = [capital]
|
|
129
|
+
trades = []
|
|
130
|
+
returns = []
|
|
131
|
+
overnight_gaps = []
|
|
132
|
+
margin_used = []
|
|
133
|
+
|
|
134
|
+
for i in range(n):
|
|
135
|
+
price = prices[i]
|
|
136
|
+
signal = signals[i]
|
|
137
|
+
|
|
138
|
+
# Check for contract roll
|
|
139
|
+
if i in self.roll_dates:
|
|
140
|
+
# Close position before roll
|
|
141
|
+
if position != 0:
|
|
142
|
+
trade_value = abs(position) * price * self.contract_size
|
|
143
|
+
commission_cost = trade_value * self.commission
|
|
144
|
+
capital -= commission_cost
|
|
145
|
+
|
|
146
|
+
trades.append({
|
|
147
|
+
'index': i,
|
|
148
|
+
'price': price,
|
|
149
|
+
'execution_price': price,
|
|
150
|
+
'size': -position, # Close position
|
|
151
|
+
'cost': commission_cost,
|
|
152
|
+
'type': 'roll'
|
|
153
|
+
})
|
|
154
|
+
position = 0.0
|
|
155
|
+
|
|
156
|
+
# Apply overnight gap (if not first bar)
|
|
157
|
+
if i > 0:
|
|
158
|
+
prev_close = closes[i-1] if i-1 < len(closes) else prices[i-1]
|
|
159
|
+
current_open = opens[i] if i < len(opens) else price
|
|
160
|
+
|
|
161
|
+
if position != 0:
|
|
162
|
+
gap_pnl, _ = self._apply_overnight_gap(position, prev_close, current_open)
|
|
163
|
+
capital += gap_pnl
|
|
164
|
+
overnight_gaps.append({
|
|
165
|
+
'index': i,
|
|
166
|
+
'gap': (current_open - prev_close) / prev_close if prev_close > 0 else 0.0,
|
|
167
|
+
'pnl': gap_pnl
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
# Determine target position
|
|
171
|
+
target_position_value = self.position_sizing(signal, capital, price)
|
|
172
|
+
target_position = target_position_value / (price * self.contract_size) if price > 0 else 0.0
|
|
173
|
+
|
|
174
|
+
# Round to integer contracts
|
|
175
|
+
target_position = round(target_position)
|
|
176
|
+
|
|
177
|
+
# Calculate trade
|
|
178
|
+
trade_size = target_position - position
|
|
179
|
+
|
|
180
|
+
if abs(trade_size) > 0.5: # Only trade if at least 1 contract
|
|
181
|
+
# Check margin requirement
|
|
182
|
+
new_position_value = abs(target_position) * price * self.contract_size
|
|
183
|
+
required_margin = new_position_value * self.margin_requirement
|
|
184
|
+
|
|
185
|
+
if required_margin > capital:
|
|
186
|
+
# Insufficient margin, reduce position
|
|
187
|
+
max_position = int(capital / (price * self.contract_size * self.margin_requirement))
|
|
188
|
+
target_position = max_position if target_position > 0 else -max_position
|
|
189
|
+
trade_size = target_position - position
|
|
190
|
+
|
|
191
|
+
if abs(trade_size) > 0.5:
|
|
192
|
+
# Apply slippage
|
|
193
|
+
execution_price = price * (1 + self.slippage * (1 if trade_size > 0 else -1))
|
|
194
|
+
|
|
195
|
+
# Calculate costs
|
|
196
|
+
trade_value = abs(trade_size) * execution_price * self.contract_size
|
|
197
|
+
commission_cost = trade_value * self.commission
|
|
198
|
+
slippage_cost = abs(trade_size) * price * self.contract_size * self.slippage
|
|
199
|
+
total_cost = commission_cost + slippage_cost
|
|
200
|
+
|
|
201
|
+
# Update capital
|
|
202
|
+
capital -= trade_size * execution_price * self.contract_size + total_cost
|
|
203
|
+
|
|
204
|
+
# Update position
|
|
205
|
+
position = target_position
|
|
206
|
+
|
|
207
|
+
trades.append({
|
|
208
|
+
'index': i,
|
|
209
|
+
'price': price,
|
|
210
|
+
'execution_price': execution_price,
|
|
211
|
+
'size': trade_size,
|
|
212
|
+
'cost': total_cost,
|
|
213
|
+
'type': 'trade'
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
# Update equity (mark-to-market)
|
|
217
|
+
current_value = capital + position * price * self.contract_size
|
|
218
|
+
equity_curve.append(current_value)
|
|
219
|
+
|
|
220
|
+
# Track margin usage
|
|
221
|
+
margin = self._calculate_margin(position, price)
|
|
222
|
+
margin_used.append(margin)
|
|
223
|
+
|
|
224
|
+
# Calculate return
|
|
225
|
+
if i > 0:
|
|
226
|
+
prev_value = equity_curve[-2]
|
|
227
|
+
ret = (current_value - prev_value) / prev_value if prev_value > 0 else 0.0
|
|
228
|
+
returns.append(ret)
|
|
229
|
+
|
|
230
|
+
# Calculate metrics
|
|
231
|
+
final_value = equity_curve[-1]
|
|
232
|
+
total_return = (final_value - self.initial_capital) / self.initial_capital
|
|
233
|
+
|
|
234
|
+
sharpe = sharpe_ratio(returns) if returns else 0.0
|
|
235
|
+
max_dd = max_drawdown(returns) if returns else 0.0
|
|
236
|
+
|
|
237
|
+
# Trade statistics
|
|
238
|
+
n_trades = len([t for t in trades if t.get('type') == 'trade'])
|
|
239
|
+
avg_margin_usage = sum(margin_used) / len(margin_used) if margin_used else 0.0
|
|
240
|
+
max_margin_usage = max(margin_used) if margin_used else 0.0
|
|
241
|
+
|
|
242
|
+
# Overnight gap statistics
|
|
243
|
+
gap_pnls = [g['pnl'] for g in overnight_gaps]
|
|
244
|
+
total_gap_pnl = sum(gap_pnls)
|
|
245
|
+
avg_gap = sum(g['gap'] for g in overnight_gaps) / len(overnight_gaps) if overnight_gaps else 0.0
|
|
246
|
+
|
|
247
|
+
return {
|
|
248
|
+
'initial_capital': self.initial_capital,
|
|
249
|
+
'final_value': final_value,
|
|
250
|
+
'total_return': total_return,
|
|
251
|
+
'equity_curve': equity_curve,
|
|
252
|
+
'returns': returns,
|
|
253
|
+
'trades': trades,
|
|
254
|
+
'n_trades': n_trades,
|
|
255
|
+
'overnight_gaps': overnight_gaps,
|
|
256
|
+
'total_gap_pnl': total_gap_pnl,
|
|
257
|
+
'avg_gap': avg_gap,
|
|
258
|
+
'margin_used': margin_used,
|
|
259
|
+
'avg_margin_usage': avg_margin_usage,
|
|
260
|
+
'max_margin_usage': max_margin_usage,
|
|
261
|
+
'sharpe_ratio': sharpe,
|
|
262
|
+
'max_drawdown': max_dd,
|
|
263
|
+
'contract_size': self.contract_size,
|
|
264
|
+
'margin_requirement': self.margin_requirement
|
|
265
|
+
}
|
|
266
|
+
|