ivolatility-backtesting 1.7.0__py3-none-any.whl → 1.7.1__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.
Potentially problematic release.
This version of ivolatility-backtesting might be problematic. Click here for more details.
- {ivolatility_backtesting-1.7.0.dist-info → ivolatility_backtesting-1.7.1.dist-info}/METADATA +1 -1
- ivolatility_backtesting-1.7.1.dist-info/RECORD +6 -0
- ivolatility_backtesting/ivolatility_backtesting.py +0 -4147
- ivolatility_backtesting-1.7.0.dist-info/RECORD +0 -7
- {ivolatility_backtesting-1.7.0.dist-info → ivolatility_backtesting-1.7.1.dist-info}/WHEEL +0 -0
- {ivolatility_backtesting-1.7.0.dist-info → ivolatility_backtesting-1.7.1.dist-info}/licenses/LICENSE +0 -0
- {ivolatility_backtesting-1.7.0.dist-info → ivolatility_backtesting-1.7.1.dist-info}/top_level.txt +0 -0
|
@@ -1,4147 +0,0 @@
|
|
|
1
|
-
# ============================================================
|
|
2
|
-
# ivolatility_backtesting.py - ENHANCED VERSION
|
|
3
|
-
#
|
|
4
|
-
# NEW FEATURES:
|
|
5
|
-
# 1. Combined stop-loss (requires BOTH conditions)
|
|
6
|
-
# 2. Parameter optimization framework
|
|
7
|
-
# 3. Optimization results visualization
|
|
8
|
-
# ============================================================
|
|
9
|
-
|
|
10
|
-
import pandas as pd
|
|
11
|
-
import numpy as np
|
|
12
|
-
import matplotlib.pyplot as plt
|
|
13
|
-
import seaborn as sns
|
|
14
|
-
from datetime import datetime, timedelta
|
|
15
|
-
import ivolatility as ivol
|
|
16
|
-
import os
|
|
17
|
-
import time
|
|
18
|
-
import psutil
|
|
19
|
-
import warnings
|
|
20
|
-
from itertools import product
|
|
21
|
-
import sys
|
|
22
|
-
from typing import Dict, List, Optional, Tuple, Union, Any
|
|
23
|
-
warnings.filterwarnings('ignore', category=pd.errors.SettingWithCopyWarning)
|
|
24
|
-
warnings.filterwarnings('ignore', message='.*SettingWithCopyWarning.*')
|
|
25
|
-
warnings.filterwarnings('ignore', category=FutureWarning)
|
|
26
|
-
warnings.filterwarnings('ignore', category=DeprecationWarning)
|
|
27
|
-
|
|
28
|
-
sns.set_style('darkgrid')
|
|
29
|
-
plt.rcParams['figure.figsize'] = (15, 8)
|
|
30
|
-
|
|
31
|
-
def create_optimization_folder(base_dir='optimization_results'):
|
|
32
|
-
"""
|
|
33
|
-
Create timestamped folder for optimization run
|
|
34
|
-
Returns: folder path (e.g., 'optimization_results/20250122_143025')
|
|
35
|
-
"""
|
|
36
|
-
from pathlib import Path
|
|
37
|
-
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
|
38
|
-
folder_path = Path(base_dir) / timestamp
|
|
39
|
-
folder_path.mkdir(parents=True, exist_ok=True)
|
|
40
|
-
print(f"\n📁 Created optimization folder: {folder_path}")
|
|
41
|
-
return str(folder_path)
|
|
42
|
-
|
|
43
|
-
# ============================================================
|
|
44
|
-
# RESOURCE MONITOR
|
|
45
|
-
# ============================================================
|
|
46
|
-
class ResourceMonitor:
|
|
47
|
-
"""Monitor CPU and RAM with container support"""
|
|
48
|
-
|
|
49
|
-
def __init__(self, show_container_total=False):
|
|
50
|
-
self.process = psutil.Process()
|
|
51
|
-
self.cpu_count = psutil.cpu_count()
|
|
52
|
-
self.last_cpu_time = None
|
|
53
|
-
self.last_check_time = None
|
|
54
|
-
self.use_cgroups = self._check_cgroups_v2()
|
|
55
|
-
self.show_container_total = show_container_total
|
|
56
|
-
self.cpu_history = []
|
|
57
|
-
self.cpu_history_max = 5
|
|
58
|
-
|
|
59
|
-
if self.use_cgroups:
|
|
60
|
-
quota = self._read_cpu_quota()
|
|
61
|
-
if quota and quota > 0:
|
|
62
|
-
self.cpu_count = quota
|
|
63
|
-
|
|
64
|
-
self.context = "Container" if self.use_cgroups else "Host"
|
|
65
|
-
|
|
66
|
-
def _read_cpu_quota(self):
|
|
67
|
-
try:
|
|
68
|
-
with open('/sys/fs/cgroup/cpu.max', 'r') as f:
|
|
69
|
-
line = f.read().strip()
|
|
70
|
-
if line == 'max':
|
|
71
|
-
return None
|
|
72
|
-
parts = line.split()
|
|
73
|
-
if len(parts) == 2:
|
|
74
|
-
quota = int(parts[0])
|
|
75
|
-
period = int(parts[1])
|
|
76
|
-
return quota / period
|
|
77
|
-
except:
|
|
78
|
-
pass
|
|
79
|
-
return None
|
|
80
|
-
|
|
81
|
-
def get_context_info(self):
|
|
82
|
-
if self.use_cgroups:
|
|
83
|
-
current, max_mem = self._read_cgroup_memory()
|
|
84
|
-
ram_info = ""
|
|
85
|
-
if max_mem:
|
|
86
|
-
max_mem_gb = max_mem / (1024**3)
|
|
87
|
-
ram_info = f", {max_mem_gb:.1f}GB limit"
|
|
88
|
-
|
|
89
|
-
mem_type = "container total" if self.show_container_total else "process only"
|
|
90
|
-
return f"Container (CPU: {self.cpu_count:.1f} cores{ram_info}) - RAM: {mem_type}"
|
|
91
|
-
else:
|
|
92
|
-
total_ram_gb = psutil.virtual_memory().total / (1024**3)
|
|
93
|
-
return f"Host ({self.cpu_count} cores, {total_ram_gb:.0f}GB RAM) - RAM: process"
|
|
94
|
-
|
|
95
|
-
def _check_cgroups_v2(self):
|
|
96
|
-
try:
|
|
97
|
-
return os.path.exists('/sys/fs/cgroup/cpu.stat') and \
|
|
98
|
-
os.path.exists('/sys/fs/cgroup/memory.current')
|
|
99
|
-
except:
|
|
100
|
-
return False
|
|
101
|
-
|
|
102
|
-
def _read_cgroup_cpu(self):
|
|
103
|
-
try:
|
|
104
|
-
with open('/sys/fs/cgroup/cpu.stat', 'r') as f:
|
|
105
|
-
for line in f:
|
|
106
|
-
if line.startswith('usage_usec'):
|
|
107
|
-
return int(line.split()[1])
|
|
108
|
-
except:
|
|
109
|
-
pass
|
|
110
|
-
return None
|
|
111
|
-
|
|
112
|
-
def _read_cgroup_memory(self):
|
|
113
|
-
try:
|
|
114
|
-
with open('/sys/fs/cgroup/memory.current', 'r') as f:
|
|
115
|
-
current = int(f.read().strip())
|
|
116
|
-
with open('/sys/fs/cgroup/memory.max', 'r') as f:
|
|
117
|
-
max_mem = f.read().strip()
|
|
118
|
-
if max_mem == 'max':
|
|
119
|
-
max_mem = psutil.virtual_memory().total
|
|
120
|
-
else:
|
|
121
|
-
max_mem = int(max_mem)
|
|
122
|
-
return current, max_mem
|
|
123
|
-
except:
|
|
124
|
-
pass
|
|
125
|
-
return None, None
|
|
126
|
-
|
|
127
|
-
def get_cpu_percent(self):
|
|
128
|
-
if self.use_cgroups:
|
|
129
|
-
current_time = time.time()
|
|
130
|
-
current_cpu = self._read_cgroup_cpu()
|
|
131
|
-
|
|
132
|
-
if current_cpu and self.last_cpu_time and self.last_check_time:
|
|
133
|
-
time_delta = current_time - self.last_check_time
|
|
134
|
-
cpu_delta = current_cpu - self.last_cpu_time
|
|
135
|
-
|
|
136
|
-
if time_delta > 0:
|
|
137
|
-
cpu_percent = (cpu_delta / (time_delta * 1_000_000)) * 100
|
|
138
|
-
cpu_percent = min(cpu_percent, 100 * self.cpu_count)
|
|
139
|
-
|
|
140
|
-
self.cpu_history.append(cpu_percent)
|
|
141
|
-
if len(self.cpu_history) > self.cpu_history_max:
|
|
142
|
-
self.cpu_history.pop(0)
|
|
143
|
-
|
|
144
|
-
self.last_cpu_time = current_cpu
|
|
145
|
-
self.last_check_time = current_time
|
|
146
|
-
|
|
147
|
-
return round(sum(self.cpu_history) / len(self.cpu_history), 1)
|
|
148
|
-
|
|
149
|
-
self.last_cpu_time = current_cpu
|
|
150
|
-
self.last_check_time = current_time
|
|
151
|
-
|
|
152
|
-
try:
|
|
153
|
-
cpu = self.process.cpu_percent(interval=0.1)
|
|
154
|
-
if cpu == 0:
|
|
155
|
-
cpu = psutil.cpu_percent(interval=0.1)
|
|
156
|
-
|
|
157
|
-
self.cpu_history.append(cpu)
|
|
158
|
-
if len(self.cpu_history) > self.cpu_history_max:
|
|
159
|
-
self.cpu_history.pop(0)
|
|
160
|
-
|
|
161
|
-
return round(sum(self.cpu_history) / len(self.cpu_history), 1)
|
|
162
|
-
except:
|
|
163
|
-
return 0.0
|
|
164
|
-
|
|
165
|
-
def get_memory_info(self):
|
|
166
|
-
try:
|
|
167
|
-
mem = self.process.memory_info()
|
|
168
|
-
process_mb = mem.rss / (1024 * 1024)
|
|
169
|
-
|
|
170
|
-
if self.use_cgroups:
|
|
171
|
-
current, max_mem = self._read_cgroup_memory()
|
|
172
|
-
if max_mem:
|
|
173
|
-
process_percent = (mem.rss / max_mem) * 100
|
|
174
|
-
|
|
175
|
-
if current:
|
|
176
|
-
container_mb = current / (1024 * 1024)
|
|
177
|
-
container_percent = (current / max_mem) * 100
|
|
178
|
-
return (
|
|
179
|
-
round(process_mb, 1),
|
|
180
|
-
round(process_percent, 1),
|
|
181
|
-
round(container_mb, 1),
|
|
182
|
-
round(container_percent, 1)
|
|
183
|
-
)
|
|
184
|
-
|
|
185
|
-
return (
|
|
186
|
-
round(process_mb, 1),
|
|
187
|
-
round(process_percent, 1),
|
|
188
|
-
round(process_mb, 1),
|
|
189
|
-
round(process_percent, 1)
|
|
190
|
-
)
|
|
191
|
-
|
|
192
|
-
total = psutil.virtual_memory().total
|
|
193
|
-
percent = (mem.rss / total) * 100
|
|
194
|
-
|
|
195
|
-
return (
|
|
196
|
-
round(process_mb, 1),
|
|
197
|
-
round(percent, 1),
|
|
198
|
-
round(process_mb, 1),
|
|
199
|
-
round(percent, 1)
|
|
200
|
-
)
|
|
201
|
-
|
|
202
|
-
except:
|
|
203
|
-
return 0.0, 0.0, 0.0, 0.0
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
def create_progress_bar(reuse_existing=None):
|
|
207
|
-
"""Create or reuse enhanced progress bar"""
|
|
208
|
-
if reuse_existing is not None:
|
|
209
|
-
progress_bar, status_label, monitor, start_time = reuse_existing
|
|
210
|
-
progress_bar.value = 0
|
|
211
|
-
progress_bar.bar_style = 'info'
|
|
212
|
-
status_label.value = "<b style='color:#0066cc'>Starting...</b>"
|
|
213
|
-
return progress_bar, status_label, monitor, time.time()
|
|
214
|
-
|
|
215
|
-
try:
|
|
216
|
-
from IPython.display import display
|
|
217
|
-
import ipywidgets as widgets
|
|
218
|
-
|
|
219
|
-
progress_bar = widgets.FloatProgress(
|
|
220
|
-
value=0, min=0, max=100,
|
|
221
|
-
description='Progress:',
|
|
222
|
-
bar_style='info',
|
|
223
|
-
style={'bar_color': '#00ff00'},
|
|
224
|
-
layout=widgets.Layout(width='100%', height='30px')
|
|
225
|
-
)
|
|
226
|
-
|
|
227
|
-
status_label = widgets.HTML(
|
|
228
|
-
value="<b style='color:#0066cc'>Starting...</b>"
|
|
229
|
-
)
|
|
230
|
-
|
|
231
|
-
display(widgets.VBox([progress_bar, status_label]))
|
|
232
|
-
|
|
233
|
-
monitor = ResourceMonitor()
|
|
234
|
-
start_time = time.time()
|
|
235
|
-
|
|
236
|
-
return progress_bar, status_label, monitor, start_time
|
|
237
|
-
except ImportError:
|
|
238
|
-
print("Warning: ipywidgets not available. Progress bar disabled.")
|
|
239
|
-
return None, None, ResourceMonitor(), time.time()
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
def update_progress(progress_bar, status_label, monitor, current, total, start_time, message="Processing"):
|
|
243
|
-
"""Update progress bar with ETA, CPU%, RAM"""
|
|
244
|
-
if progress_bar is None or status_label is None:
|
|
245
|
-
return
|
|
246
|
-
|
|
247
|
-
progress = (current / total) * 100
|
|
248
|
-
progress_bar.value = progress
|
|
249
|
-
|
|
250
|
-
elapsed = time.time() - start_time
|
|
251
|
-
if current > 0:
|
|
252
|
-
eta_seconds = (elapsed / current) * (total - current)
|
|
253
|
-
eta_str = format_time(eta_seconds)
|
|
254
|
-
else:
|
|
255
|
-
eta_str = "calculating..."
|
|
256
|
-
|
|
257
|
-
cpu = monitor.get_cpu_percent()
|
|
258
|
-
process_mb, process_pct, container_mb, container_pct = monitor.get_memory_info()
|
|
259
|
-
|
|
260
|
-
if abs(container_mb - process_mb) > 10:
|
|
261
|
-
ram_display = (
|
|
262
|
-
f"RAM: <span style='color:#4CAF50'>{process_mb}MB ({process_pct}%)</span> Python | "
|
|
263
|
-
f"<span style='color:#2196F3'>{container_mb}MB ({container_pct}%)</span> Container"
|
|
264
|
-
)
|
|
265
|
-
else:
|
|
266
|
-
ram_display = f"RAM: {process_mb}MB ({process_pct}%)"
|
|
267
|
-
|
|
268
|
-
context_info = monitor.get_context_info()
|
|
269
|
-
|
|
270
|
-
elapsed_str = format_time(elapsed)
|
|
271
|
-
start_time_str = datetime.fromtimestamp(start_time).strftime('%H:%M:%S')
|
|
272
|
-
|
|
273
|
-
status_label.value = (
|
|
274
|
-
f"<b style='color:#0066cc'>{message} ({current}/{total})</b><br>"
|
|
275
|
-
f"<span style='color:#666'>⏱️ Elapsed: {elapsed_str} | ETA: {eta_str} | Started: {start_time_str}</span><br>"
|
|
276
|
-
f"<span style='color:#666'>CPU: {cpu}% | {ram_display}</span><br>"
|
|
277
|
-
f"<span style='color:#999;font-size:10px'>{context_info}</span>"
|
|
278
|
-
)
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
def format_time(seconds):
|
|
282
|
-
"""Format seconds to human readable time"""
|
|
283
|
-
if seconds < 60:
|
|
284
|
-
return f"{int(seconds)}s"
|
|
285
|
-
elif seconds < 3600:
|
|
286
|
-
return f"{int(seconds // 60)}m {int(seconds % 60)}s"
|
|
287
|
-
else:
|
|
288
|
-
hours = int(seconds // 3600)
|
|
289
|
-
minutes = int((seconds % 3600) // 60)
|
|
290
|
-
return f"{hours}h {minutes}m"
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
# ============================================================
|
|
294
|
-
# API HELPER
|
|
295
|
-
# ============================================================
|
|
296
|
-
class APIHelper:
|
|
297
|
-
"""Normalizes API responses"""
|
|
298
|
-
|
|
299
|
-
@staticmethod
|
|
300
|
-
def normalize_response(response, debug=False):
|
|
301
|
-
if response is None:
|
|
302
|
-
if debug:
|
|
303
|
-
print("[APIHelper] Response is None")
|
|
304
|
-
return None
|
|
305
|
-
|
|
306
|
-
if isinstance(response, dict):
|
|
307
|
-
if 'data' in response:
|
|
308
|
-
if debug:
|
|
309
|
-
print(f"[APIHelper] Dict response: {len(response['data'])} records")
|
|
310
|
-
return response
|
|
311
|
-
else:
|
|
312
|
-
if debug:
|
|
313
|
-
print("[APIHelper] Dict without 'data' key")
|
|
314
|
-
return None
|
|
315
|
-
|
|
316
|
-
if isinstance(response, pd.DataFrame):
|
|
317
|
-
if response.empty:
|
|
318
|
-
if debug:
|
|
319
|
-
print("[APIHelper] Empty DataFrame")
|
|
320
|
-
return None
|
|
321
|
-
|
|
322
|
-
records = response.to_dict('records')
|
|
323
|
-
if debug:
|
|
324
|
-
print(f"[APIHelper] DataFrame converted: {len(records)} records")
|
|
325
|
-
return {'data': records, 'status': 'success'}
|
|
326
|
-
|
|
327
|
-
if debug:
|
|
328
|
-
print(f"[APIHelper] Unexpected type: {type(response)}")
|
|
329
|
-
return None
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
class APIManager:
|
|
333
|
-
"""Centralized API key management"""
|
|
334
|
-
_api_key = None
|
|
335
|
-
_methods = {}
|
|
336
|
-
|
|
337
|
-
@classmethod
|
|
338
|
-
def initialize(cls, api_key):
|
|
339
|
-
if not api_key:
|
|
340
|
-
raise ValueError("API key cannot be empty")
|
|
341
|
-
cls._api_key = api_key
|
|
342
|
-
ivol.setLoginParams(apiKey=api_key)
|
|
343
|
-
print(f"[API] Initialized: {api_key[:10]}...{api_key[-5:]}")
|
|
344
|
-
|
|
345
|
-
@classmethod
|
|
346
|
-
def get_method(cls, endpoint):
|
|
347
|
-
if cls._api_key is None:
|
|
348
|
-
api_key = os.getenv("API_KEY")
|
|
349
|
-
if not api_key:
|
|
350
|
-
raise ValueError("API key not set. Call init_api(key) first")
|
|
351
|
-
cls.initialize(api_key)
|
|
352
|
-
|
|
353
|
-
if endpoint not in cls._methods:
|
|
354
|
-
ivol.setLoginParams(apiKey=cls._api_key)
|
|
355
|
-
cls._methods[endpoint] = ivol.setMethod(endpoint)
|
|
356
|
-
|
|
357
|
-
return cls._methods[endpoint]
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
def init_api(api_key=None):
|
|
361
|
-
"""Initialize IVolatility API"""
|
|
362
|
-
if api_key is None:
|
|
363
|
-
api_key = os.getenv("API_KEY")
|
|
364
|
-
APIManager.initialize(api_key)
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
def api_call(endpoint, cache_config=None, debug=False, **kwargs):
|
|
368
|
-
"""
|
|
369
|
-
Make API call with automatic response normalization and caching
|
|
370
|
-
|
|
371
|
-
Args:
|
|
372
|
-
endpoint: API endpoint path
|
|
373
|
-
cache_config: Cache configuration dict (optional, enables caching if provided)
|
|
374
|
-
debug: Debug mode flag
|
|
375
|
-
**kwargs: API parameters
|
|
376
|
-
|
|
377
|
-
Returns:
|
|
378
|
-
Normalized API response or None
|
|
379
|
-
"""
|
|
380
|
-
try:
|
|
381
|
-
# Check if caching is enabled
|
|
382
|
-
use_cache = cache_config is not None and (
|
|
383
|
-
cache_config.get('disk_enabled', False) or
|
|
384
|
-
cache_config.get('memory_enabled', False)
|
|
385
|
-
)
|
|
386
|
-
|
|
387
|
-
cache_manager = None
|
|
388
|
-
cache_key = None
|
|
389
|
-
data_type = None
|
|
390
|
-
|
|
391
|
-
if use_cache:
|
|
392
|
-
# Initialize cache manager
|
|
393
|
-
cache_manager = UniversalCacheManager(cache_config)
|
|
394
|
-
|
|
395
|
-
# Create cache key from endpoint and params (human-readable)
|
|
396
|
-
# Determine data type based on endpoint (supports EOD + INTRADAY for both STOCK + OPTIONS)
|
|
397
|
-
is_intraday = 'intraday' in endpoint
|
|
398
|
-
is_options = 'options' in endpoint
|
|
399
|
-
is_stock = 'stock' in endpoint
|
|
400
|
-
|
|
401
|
-
if is_intraday and is_options:
|
|
402
|
-
# Intraday options data: /equities/intraday/options-rawiv
|
|
403
|
-
data_type = 'options_intraday'
|
|
404
|
-
symbol = kwargs.get('symbol', 'UNKNOWN')
|
|
405
|
-
date = kwargs.get('date', 'UNKNOWN')
|
|
406
|
-
cache_key = f"{symbol}_{date}"
|
|
407
|
-
elif is_intraday and is_stock:
|
|
408
|
-
# Intraday stock data: /equities/intraday/stock-prices
|
|
409
|
-
data_type = 'stock_intraday'
|
|
410
|
-
symbol = kwargs.get('symbol', 'UNKNOWN')
|
|
411
|
-
date = kwargs.get('date', 'UNKNOWN')
|
|
412
|
-
cache_key = f"{symbol}_{date}"
|
|
413
|
-
elif is_options:
|
|
414
|
-
# EOD options data: /equities/eod/options-rawiv
|
|
415
|
-
data_type = 'options_eod'
|
|
416
|
-
symbol = kwargs.get('symbol', 'UNKNOWN')
|
|
417
|
-
from_date = kwargs.get('from_', kwargs.get('date', 'UNKNOWN'))
|
|
418
|
-
to_date = kwargs.get('to', from_date)
|
|
419
|
-
if from_date != to_date:
|
|
420
|
-
cache_key = f"{symbol}_{from_date}_{to_date}"
|
|
421
|
-
else:
|
|
422
|
-
cache_key = f"{symbol}_{from_date}"
|
|
423
|
-
elif is_stock:
|
|
424
|
-
# EOD stock data: /equities/eod/stock-prices
|
|
425
|
-
data_type = 'stock_eod'
|
|
426
|
-
symbol = kwargs.get('symbol', 'UNKNOWN')
|
|
427
|
-
from_date = kwargs.get('from_', kwargs.get('date', 'UNKNOWN'))
|
|
428
|
-
to_date = kwargs.get('to', from_date)
|
|
429
|
-
if from_date != to_date:
|
|
430
|
-
cache_key = f"{symbol}_{from_date}_{to_date}"
|
|
431
|
-
else:
|
|
432
|
-
cache_key = f"{symbol}_{from_date}"
|
|
433
|
-
else:
|
|
434
|
-
# Fallback for other endpoints
|
|
435
|
-
sorted_params = sorted([(k, v) for k, v in kwargs.items()])
|
|
436
|
-
param_hash = abs(hash(str(sorted_params)))
|
|
437
|
-
cache_key = f"{endpoint.replace('/', '_')}_{param_hash}"
|
|
438
|
-
data_type = 'default'
|
|
439
|
-
|
|
440
|
-
# Try to get from cache
|
|
441
|
-
cached_data = cache_manager.get(cache_key, data_type)
|
|
442
|
-
if cached_data is not None:
|
|
443
|
-
if debug or cache_config.get('debug', False):
|
|
444
|
-
print(f"[CACHE] ✓ Cache hit: {endpoint} ({len(cached_data) if hasattr(cached_data, '__len__') else '?'} records)")
|
|
445
|
-
# Return in same format as API (dict with 'data' key)
|
|
446
|
-
if isinstance(cached_data, pd.DataFrame):
|
|
447
|
-
return {'data': cached_data.to_dict('records'), 'status': 'success'}
|
|
448
|
-
return cached_data
|
|
449
|
-
|
|
450
|
-
# Cache miss or caching disabled - make API call
|
|
451
|
-
if debug and APIManager._api_key:
|
|
452
|
-
base_url = "https://restapi.ivolatility.com"
|
|
453
|
-
url_params = {}
|
|
454
|
-
for key, value in kwargs.items():
|
|
455
|
-
clean_key = key.rstrip('_') if key.endswith('_') else key
|
|
456
|
-
url_params[clean_key] = value
|
|
457
|
-
|
|
458
|
-
params_str = "&".join([f"{k}={v}" for k, v in url_params.items()])
|
|
459
|
-
full_url = f"{base_url}{endpoint}?apiKey={APIManager._api_key}&{params_str}"
|
|
460
|
-
print(f"\n[API] Full URL:")
|
|
461
|
-
print(f"[API] {full_url}\n")
|
|
462
|
-
|
|
463
|
-
method = APIManager.get_method(endpoint)
|
|
464
|
-
response = method(**kwargs)
|
|
465
|
-
|
|
466
|
-
normalized = APIHelper.normalize_response(response, debug=debug)
|
|
467
|
-
|
|
468
|
-
if normalized is None and debug:
|
|
469
|
-
print(f"[api_call] Failed to get data")
|
|
470
|
-
print(f"[api_call] Endpoint: {endpoint}")
|
|
471
|
-
print(f"[api_call] Params: {kwargs}")
|
|
472
|
-
|
|
473
|
-
# Save to cache if enabled and data is valid
|
|
474
|
-
if use_cache and normalized is not None and cache_manager is not None:
|
|
475
|
-
# Convert dict response to DataFrame for caching
|
|
476
|
-
if isinstance(normalized, dict) and 'data' in normalized:
|
|
477
|
-
try:
|
|
478
|
-
cache_data = pd.DataFrame(normalized['data'])
|
|
479
|
-
if len(cache_data) > 0: # Only cache non-empty data
|
|
480
|
-
cache_manager.set(cache_key, cache_data, data_type)
|
|
481
|
-
if debug or cache_config.get('debug', False):
|
|
482
|
-
print(f"[CACHE] 💾 Saved to cache: {endpoint} ({len(cache_data)} records)")
|
|
483
|
-
else:
|
|
484
|
-
if debug or cache_config.get('debug', False):
|
|
485
|
-
print(f"[CACHE] ⚠️ Skipped caching empty data: {endpoint}")
|
|
486
|
-
except Exception as e:
|
|
487
|
-
if debug or cache_config.get('debug', False):
|
|
488
|
-
print(f"[CACHE] ❌ Error converting to cache format: {e}")
|
|
489
|
-
|
|
490
|
-
return normalized
|
|
491
|
-
|
|
492
|
-
except Exception as e:
|
|
493
|
-
if debug:
|
|
494
|
-
print(f"[api_call] Exception: {e}")
|
|
495
|
-
print(f"[api_call] Endpoint: {endpoint}")
|
|
496
|
-
print(f"[api_call] Params: {kwargs}")
|
|
497
|
-
return None
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
# ============================================================
|
|
501
|
-
# BACKTEST RESULTS
|
|
502
|
-
# ============================================================
|
|
503
|
-
class BacktestResults:
|
|
504
|
-
"""Universal container for backtest results"""
|
|
505
|
-
|
|
506
|
-
def __init__(self, equity_curve, equity_dates, trades, initial_capital,
|
|
507
|
-
config, benchmark_prices=None, benchmark_symbol='SPY',
|
|
508
|
-
daily_returns=None, debug_info=None):
|
|
509
|
-
|
|
510
|
-
self.equity_curve = equity_curve
|
|
511
|
-
self.equity_dates = equity_dates
|
|
512
|
-
self.trades = trades
|
|
513
|
-
self.initial_capital = initial_capital
|
|
514
|
-
self.final_capital = equity_curve[-1] if len(equity_curve) > 0 else initial_capital
|
|
515
|
-
self.config = config
|
|
516
|
-
self.benchmark_prices = benchmark_prices
|
|
517
|
-
self.benchmark_symbol = benchmark_symbol
|
|
518
|
-
self.debug_info = debug_info if debug_info else []
|
|
519
|
-
|
|
520
|
-
if daily_returns is None and len(equity_curve) > 1:
|
|
521
|
-
self.daily_returns = [
|
|
522
|
-
(equity_curve[i] - equity_curve[i-1]) / equity_curve[i-1]
|
|
523
|
-
for i in range(1, len(equity_curve))
|
|
524
|
-
]
|
|
525
|
-
else:
|
|
526
|
-
self.daily_returns = daily_returns if daily_returns else []
|
|
527
|
-
|
|
528
|
-
self.max_drawdown = self._calculate_max_drawdown()
|
|
529
|
-
|
|
530
|
-
def _calculate_max_drawdown(self):
|
|
531
|
-
if len(self.equity_curve) < 2:
|
|
532
|
-
return 0
|
|
533
|
-
running_max = np.maximum.accumulate(self.equity_curve)
|
|
534
|
-
drawdowns = (np.array(self.equity_curve) - running_max) / running_max * 100
|
|
535
|
-
return abs(np.min(drawdowns))
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
# ============================================================
|
|
539
|
-
# STOP-LOSS MANAGER (ENHANCED VERSION WITH COMBINED STOP)
|
|
540
|
-
# ============================================================
|
|
541
|
-
class StopLossManager:
|
|
542
|
-
"""
|
|
543
|
-
Enhanced stop-loss manager with COMBINED STOP support
|
|
544
|
-
|
|
545
|
-
NEW STOP TYPE:
|
|
546
|
-
- combined: Requires BOTH pl_loss AND directional conditions (from code 2)
|
|
547
|
-
"""
|
|
548
|
-
|
|
549
|
-
def __init__(self):
|
|
550
|
-
self.positions = {}
|
|
551
|
-
|
|
552
|
-
def add_position(self, position_id, entry_price, entry_date, stop_type='fixed_pct',
|
|
553
|
-
stop_value=0.05, atr=None, trailing_distance=None, use_pnl_pct=False,
|
|
554
|
-
is_short_bias=False, **kwargs):
|
|
555
|
-
"""
|
|
556
|
-
Add position with stop-loss
|
|
557
|
-
|
|
558
|
-
NEW for combined stop:
|
|
559
|
-
stop_type='combined'
|
|
560
|
-
stop_value={'pl_loss': 0.05, 'directional': 0.03}
|
|
561
|
-
"""
|
|
562
|
-
self.positions[position_id] = {
|
|
563
|
-
'entry_price': entry_price,
|
|
564
|
-
'entry_date': entry_date,
|
|
565
|
-
'stop_type': stop_type,
|
|
566
|
-
'stop_value': stop_value,
|
|
567
|
-
'atr': atr,
|
|
568
|
-
'trailing_distance': trailing_distance,
|
|
569
|
-
'highest_price': entry_price if not use_pnl_pct else 0,
|
|
570
|
-
'lowest_price': entry_price if not use_pnl_pct else 0,
|
|
571
|
-
'max_profit': 0,
|
|
572
|
-
'use_pnl_pct': use_pnl_pct,
|
|
573
|
-
'is_short_bias': is_short_bias,
|
|
574
|
-
**kwargs # Store additional parameters for combined stop
|
|
575
|
-
}
|
|
576
|
-
|
|
577
|
-
def check_stop(self, position_id, current_price, current_date, position_type='LONG', **kwargs):
|
|
578
|
-
"""
|
|
579
|
-
Check if stop-loss triggered
|
|
580
|
-
|
|
581
|
-
NEW: Supports 'combined' stop type
|
|
582
|
-
"""
|
|
583
|
-
if position_id not in self.positions:
|
|
584
|
-
return False, None, None
|
|
585
|
-
|
|
586
|
-
pos = self.positions[position_id]
|
|
587
|
-
stop_type = pos['stop_type']
|
|
588
|
-
use_pnl_pct = pos.get('use_pnl_pct', False)
|
|
589
|
-
|
|
590
|
-
# Update tracking
|
|
591
|
-
if use_pnl_pct:
|
|
592
|
-
pnl_pct = current_price
|
|
593
|
-
pos['highest_price'] = max(pos['highest_price'], pnl_pct)
|
|
594
|
-
pos['lowest_price'] = min(pos['lowest_price'], pnl_pct)
|
|
595
|
-
pos['max_profit'] = max(pos['max_profit'], pnl_pct)
|
|
596
|
-
else:
|
|
597
|
-
if position_type == 'LONG':
|
|
598
|
-
pos['highest_price'] = max(pos['highest_price'], current_price)
|
|
599
|
-
current_profit = current_price - pos['entry_price']
|
|
600
|
-
else:
|
|
601
|
-
pos['lowest_price'] = min(pos['lowest_price'], current_price)
|
|
602
|
-
current_profit = pos['entry_price'] - current_price
|
|
603
|
-
|
|
604
|
-
pos['max_profit'] = max(pos['max_profit'], current_profit)
|
|
605
|
-
|
|
606
|
-
# Route to appropriate check method
|
|
607
|
-
if stop_type == 'fixed_pct':
|
|
608
|
-
if use_pnl_pct:
|
|
609
|
-
return self._check_fixed_pct_stop_pnl(pos, current_price)
|
|
610
|
-
else:
|
|
611
|
-
return self._check_fixed_pct_stop(pos, current_price, position_type)
|
|
612
|
-
|
|
613
|
-
elif stop_type == 'trailing':
|
|
614
|
-
if use_pnl_pct:
|
|
615
|
-
return self._check_trailing_stop_pnl(pos, current_price)
|
|
616
|
-
else:
|
|
617
|
-
return self._check_trailing_stop(pos, current_price, position_type)
|
|
618
|
-
|
|
619
|
-
elif stop_type == 'time_based':
|
|
620
|
-
return self._check_time_stop(pos, current_date)
|
|
621
|
-
|
|
622
|
-
elif stop_type == 'volatility':
|
|
623
|
-
return self._check_volatility_stop(pos, current_price, position_type)
|
|
624
|
-
|
|
625
|
-
elif stop_type == 'pl_loss':
|
|
626
|
-
return self._check_pl_loss_stop(pos, kwargs)
|
|
627
|
-
|
|
628
|
-
elif stop_type == 'directional':
|
|
629
|
-
return self._check_directional_stop(pos, kwargs)
|
|
630
|
-
|
|
631
|
-
# NEW: COMBINED STOP (requires BOTH conditions)
|
|
632
|
-
elif stop_type == 'combined':
|
|
633
|
-
return self._check_combined_stop(pos, kwargs)
|
|
634
|
-
|
|
635
|
-
else:
|
|
636
|
-
return False, None, None
|
|
637
|
-
|
|
638
|
-
# ========================================================
|
|
639
|
-
# EXISTING STOP METHODS (unchanged)
|
|
640
|
-
# ========================================================
|
|
641
|
-
|
|
642
|
-
def _check_fixed_pct_stop(self, pos, current_price, position_type):
|
|
643
|
-
"""Fixed percentage stop-loss (price-based)"""
|
|
644
|
-
entry = pos['entry_price']
|
|
645
|
-
stop_pct = pos['stop_value']
|
|
646
|
-
|
|
647
|
-
if position_type == 'LONG':
|
|
648
|
-
stop_level = entry * (1 - stop_pct)
|
|
649
|
-
triggered = current_price <= stop_level
|
|
650
|
-
else:
|
|
651
|
-
stop_level = entry * (1 + stop_pct)
|
|
652
|
-
triggered = current_price >= stop_level
|
|
653
|
-
|
|
654
|
-
return triggered, stop_level, 'fixed_pct'
|
|
655
|
-
|
|
656
|
-
def _check_fixed_pct_stop_pnl(self, pos, pnl_pct):
|
|
657
|
-
"""Fixed percentage stop-loss (P&L%-based for options)"""
|
|
658
|
-
stop_pct = pos['stop_value']
|
|
659
|
-
stop_level = -stop_pct * 100
|
|
660
|
-
|
|
661
|
-
triggered = pnl_pct <= stop_level
|
|
662
|
-
|
|
663
|
-
return triggered, stop_level, 'fixed_pct'
|
|
664
|
-
|
|
665
|
-
def _check_trailing_stop(self, pos, current_price, position_type):
|
|
666
|
-
"""Trailing stop-loss (price-based)"""
|
|
667
|
-
if pos['trailing_distance'] is None:
|
|
668
|
-
pos['trailing_distance'] = pos['stop_value']
|
|
669
|
-
|
|
670
|
-
distance = pos['trailing_distance']
|
|
671
|
-
|
|
672
|
-
if position_type == 'LONG':
|
|
673
|
-
stop_level = pos['highest_price'] * (1 - distance)
|
|
674
|
-
triggered = current_price <= stop_level
|
|
675
|
-
else:
|
|
676
|
-
stop_level = pos['lowest_price'] * (1 + distance)
|
|
677
|
-
triggered = current_price >= stop_level
|
|
678
|
-
|
|
679
|
-
return triggered, stop_level, 'trailing'
|
|
680
|
-
|
|
681
|
-
def _check_trailing_stop_pnl(self, pos, pnl_pct):
|
|
682
|
-
"""Trailing stop-loss (P&L%-based for options)"""
|
|
683
|
-
if pos['trailing_distance'] is None:
|
|
684
|
-
pos['trailing_distance'] = pos['stop_value']
|
|
685
|
-
|
|
686
|
-
distance = pos['trailing_distance'] * 100
|
|
687
|
-
|
|
688
|
-
stop_level = pos['highest_price'] - distance
|
|
689
|
-
|
|
690
|
-
triggered = pnl_pct <= stop_level
|
|
691
|
-
|
|
692
|
-
return triggered, stop_level, 'trailing'
|
|
693
|
-
|
|
694
|
-
def _check_time_stop(self, pos, current_date):
|
|
695
|
-
"""Time-based stop"""
|
|
696
|
-
days_held = (current_date - pos['entry_date']).days
|
|
697
|
-
max_days = pos['stop_value']
|
|
698
|
-
|
|
699
|
-
triggered = days_held >= max_days
|
|
700
|
-
return triggered, None, 'time_based'
|
|
701
|
-
|
|
702
|
-
def _check_volatility_stop(self, pos, current_price, position_type):
|
|
703
|
-
"""ATR-based stop"""
|
|
704
|
-
if pos['atr'] is None:
|
|
705
|
-
return False, None, None
|
|
706
|
-
|
|
707
|
-
entry = pos['entry_price']
|
|
708
|
-
atr_multiplier = pos['stop_value']
|
|
709
|
-
stop_distance = pos['atr'] * atr_multiplier
|
|
710
|
-
|
|
711
|
-
if position_type == 'LONG':
|
|
712
|
-
stop_level = entry - stop_distance
|
|
713
|
-
triggered = current_price <= stop_level
|
|
714
|
-
else:
|
|
715
|
-
stop_level = entry + stop_distance
|
|
716
|
-
triggered = current_price >= stop_level
|
|
717
|
-
|
|
718
|
-
return triggered, stop_level, 'volatility'
|
|
719
|
-
|
|
720
|
-
def _check_pl_loss_stop(self, pos, kwargs):
|
|
721
|
-
"""Stop-loss based on actual P&L"""
|
|
722
|
-
pnl_pct = kwargs.get('pnl_pct')
|
|
723
|
-
|
|
724
|
-
if pnl_pct is None:
|
|
725
|
-
current_pnl = kwargs.get('current_pnl', 0)
|
|
726
|
-
total_cost = kwargs.get('total_cost', pos.get('total_cost', 1))
|
|
727
|
-
|
|
728
|
-
if total_cost > 0:
|
|
729
|
-
pnl_pct = (current_pnl / total_cost) * 100
|
|
730
|
-
else:
|
|
731
|
-
pnl_pct = 0
|
|
732
|
-
|
|
733
|
-
stop_threshold = -pos['stop_value'] * 100
|
|
734
|
-
triggered = pnl_pct <= stop_threshold
|
|
735
|
-
|
|
736
|
-
return triggered, stop_threshold, 'pl_loss'
|
|
737
|
-
|
|
738
|
-
def _check_directional_stop(self, pos, kwargs):
|
|
739
|
-
"""Stop-loss based on underlying price movement"""
|
|
740
|
-
underlying_change_pct = kwargs.get('underlying_change_pct')
|
|
741
|
-
|
|
742
|
-
if underlying_change_pct is None:
|
|
743
|
-
current = kwargs.get('underlying_price')
|
|
744
|
-
entry = kwargs.get('underlying_entry_price', pos.get('underlying_entry_price'))
|
|
745
|
-
|
|
746
|
-
if current is not None and entry is not None and entry != 0:
|
|
747
|
-
underlying_change_pct = ((current - entry) / entry) * 100
|
|
748
|
-
else:
|
|
749
|
-
underlying_change_pct = 0
|
|
750
|
-
|
|
751
|
-
threshold = pos['stop_value'] * 100
|
|
752
|
-
is_short_bias = pos.get('is_short_bias', False)
|
|
753
|
-
|
|
754
|
-
if is_short_bias:
|
|
755
|
-
triggered = underlying_change_pct >= threshold
|
|
756
|
-
else:
|
|
757
|
-
triggered = underlying_change_pct <= -threshold
|
|
758
|
-
|
|
759
|
-
return triggered, threshold, 'directional'
|
|
760
|
-
|
|
761
|
-
# ========================================================
|
|
762
|
-
# NEW: COMBINED STOP (REQUIRES BOTH CONDITIONS)
|
|
763
|
-
# ========================================================
|
|
764
|
-
|
|
765
|
-
def _check_combined_stop(self, pos, kwargs):
|
|
766
|
-
"""
|
|
767
|
-
Combined stop: Requires BOTH pl_loss AND directional conditions
|
|
768
|
-
|
|
769
|
-
This is the key feature from code 2:
|
|
770
|
-
- Must have P&L loss > threshold
|
|
771
|
-
- AND underlying must move adversely > threshold
|
|
772
|
-
|
|
773
|
-
Args:
|
|
774
|
-
pos: Position dict with stop_value = {'pl_loss': 0.05, 'directional': 0.03}
|
|
775
|
-
kwargs: Must contain pnl_pct and underlying_change_pct
|
|
776
|
-
|
|
777
|
-
Returns:
|
|
778
|
-
tuple: (triggered, thresholds_dict, 'combined')
|
|
779
|
-
"""
|
|
780
|
-
stop_config = pos['stop_value']
|
|
781
|
-
|
|
782
|
-
if not isinstance(stop_config, dict):
|
|
783
|
-
# Fallback: treat as simple fixed stop
|
|
784
|
-
return False, None, 'combined'
|
|
785
|
-
|
|
786
|
-
pl_threshold = stop_config.get('pl_loss', 0.05)
|
|
787
|
-
dir_threshold = stop_config.get('directional', 0.03)
|
|
788
|
-
|
|
789
|
-
# Check P&L condition
|
|
790
|
-
pnl_pct = kwargs.get('pnl_pct', 0)
|
|
791
|
-
is_losing = pnl_pct <= (-pl_threshold * 100)
|
|
792
|
-
|
|
793
|
-
# Check directional condition
|
|
794
|
-
underlying_change_pct = kwargs.get('underlying_change_pct')
|
|
795
|
-
|
|
796
|
-
if underlying_change_pct is None:
|
|
797
|
-
current = kwargs.get('underlying_price')
|
|
798
|
-
entry = kwargs.get('underlying_entry_price', pos.get('underlying_entry_price'))
|
|
799
|
-
|
|
800
|
-
if current is not None and entry is not None and entry != 0:
|
|
801
|
-
underlying_change_pct = ((current - entry) / entry) * 100
|
|
802
|
-
else:
|
|
803
|
-
underlying_change_pct = 0
|
|
804
|
-
|
|
805
|
-
is_short_bias = pos.get('is_short_bias', False)
|
|
806
|
-
|
|
807
|
-
if is_short_bias:
|
|
808
|
-
# Bearish position: adverse move is UP
|
|
809
|
-
adverse_move = underlying_change_pct >= (dir_threshold * 100)
|
|
810
|
-
else:
|
|
811
|
-
# Bullish position: adverse move is DOWN
|
|
812
|
-
adverse_move = underlying_change_pct <= (-dir_threshold * 100)
|
|
813
|
-
|
|
814
|
-
# CRITICAL: Both conditions must be true
|
|
815
|
-
triggered = is_losing and adverse_move
|
|
816
|
-
|
|
817
|
-
# Return detailed thresholds for reporting
|
|
818
|
-
thresholds = {
|
|
819
|
-
'pl_threshold': -pl_threshold * 100,
|
|
820
|
-
'dir_threshold': dir_threshold * 100,
|
|
821
|
-
'actual_pnl_pct': pnl_pct,
|
|
822
|
-
'actual_underlying_change': underlying_change_pct,
|
|
823
|
-
'pl_condition': is_losing,
|
|
824
|
-
'dir_condition': adverse_move
|
|
825
|
-
}
|
|
826
|
-
|
|
827
|
-
return triggered, thresholds, 'combined'
|
|
828
|
-
|
|
829
|
-
# ========================================================
|
|
830
|
-
# UTILITY METHODS
|
|
831
|
-
# ========================================================
|
|
832
|
-
|
|
833
|
-
def remove_position(self, position_id):
|
|
834
|
-
"""Remove position from tracking"""
|
|
835
|
-
if position_id in self.positions:
|
|
836
|
-
del self.positions[position_id]
|
|
837
|
-
|
|
838
|
-
def get_position_info(self, position_id):
|
|
839
|
-
"""Get position stop-loss info"""
|
|
840
|
-
if position_id not in self.positions:
|
|
841
|
-
return None
|
|
842
|
-
|
|
843
|
-
pos = self.positions[position_id]
|
|
844
|
-
return {
|
|
845
|
-
'stop_type': pos['stop_type'],
|
|
846
|
-
'stop_value': pos['stop_value'],
|
|
847
|
-
'max_profit_before_stop': pos['max_profit']
|
|
848
|
-
}
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
# ============================================================
|
|
852
|
-
# POSITION MANAGER (unchanged but compatible with combined stop)
|
|
853
|
-
# ============================================================
|
|
854
|
-
class PositionManager:
|
|
855
|
-
"""Universal Position Manager with automatic mode detection"""
|
|
856
|
-
|
|
857
|
-
def __init__(self, config, debug=False):
|
|
858
|
-
self.positions = {}
|
|
859
|
-
self.closed_trades = []
|
|
860
|
-
self.config = config
|
|
861
|
-
self.debug = debug
|
|
862
|
-
|
|
863
|
-
# Stop-loss enable logic:
|
|
864
|
-
# 1) Respect explicit flag if provided
|
|
865
|
-
# 2) Otherwise infer from stop_loss_config.enabled for convenience
|
|
866
|
-
explicit_flag = config.get('stop_loss_enabled')
|
|
867
|
-
sl_cfg = config.get('stop_loss_config', {})
|
|
868
|
-
inferred_flag = bool(sl_cfg.get('enabled', False))
|
|
869
|
-
|
|
870
|
-
self.sl_enabled = explicit_flag if explicit_flag is not None else inferred_flag
|
|
871
|
-
|
|
872
|
-
if self.sl_enabled:
|
|
873
|
-
self.sl_config = sl_cfg
|
|
874
|
-
self.sl_manager = StopLossManager()
|
|
875
|
-
else:
|
|
876
|
-
self.sl_config = None
|
|
877
|
-
self.sl_manager = None
|
|
878
|
-
|
|
879
|
-
def open_position(self, position_id, symbol, entry_date, entry_price,
|
|
880
|
-
quantity, position_type='LONG', **kwargs):
|
|
881
|
-
"""Open position with automatic stop-loss"""
|
|
882
|
-
|
|
883
|
-
if entry_price == 0 and self.sl_enabled:
|
|
884
|
-
if 'total_cost' not in kwargs or kwargs['total_cost'] == 0:
|
|
885
|
-
raise ValueError(
|
|
886
|
-
f"\n{'='*70}\n"
|
|
887
|
-
f"ERROR: P&L% mode requires 'total_cost' parameter\n"
|
|
888
|
-
f"{'='*70}\n"
|
|
889
|
-
)
|
|
890
|
-
|
|
891
|
-
position = {
|
|
892
|
-
'id': position_id,
|
|
893
|
-
'symbol': symbol,
|
|
894
|
-
'entry_date': entry_date,
|
|
895
|
-
'entry_price': entry_price,
|
|
896
|
-
'quantity': quantity,
|
|
897
|
-
'type': position_type,
|
|
898
|
-
'highest_price': entry_price,
|
|
899
|
-
'lowest_price': entry_price,
|
|
900
|
-
**kwargs
|
|
901
|
-
}
|
|
902
|
-
|
|
903
|
-
self.positions[position_id] = position
|
|
904
|
-
|
|
905
|
-
if self.sl_enabled and self.sl_manager:
|
|
906
|
-
sl_type = self.sl_config.get('type', 'fixed_pct')
|
|
907
|
-
sl_value = self.sl_config.get('value', 0.05)
|
|
908
|
-
|
|
909
|
-
use_pnl_pct = (entry_price == 0)
|
|
910
|
-
is_short_bias = kwargs.get('is_short_bias', False)
|
|
911
|
-
|
|
912
|
-
# Pass underlying_entry_price for combined stop
|
|
913
|
-
self.sl_manager.add_position(
|
|
914
|
-
position_id=position_id,
|
|
915
|
-
entry_price=entry_price,
|
|
916
|
-
entry_date=entry_date,
|
|
917
|
-
stop_type=sl_type,
|
|
918
|
-
stop_value=sl_value,
|
|
919
|
-
atr=kwargs.get('atr', None),
|
|
920
|
-
trailing_distance=self.sl_config.get('trailing_distance', None),
|
|
921
|
-
use_pnl_pct=use_pnl_pct,
|
|
922
|
-
is_short_bias=is_short_bias,
|
|
923
|
-
underlying_entry_price=kwargs.get('entry_stock_price') # For combined stop
|
|
924
|
-
)
|
|
925
|
-
|
|
926
|
-
if self.debug:
|
|
927
|
-
mode = "P&L%" if entry_price == 0 else "Price"
|
|
928
|
-
bias = " (SHORT BIAS)" if kwargs.get('is_short_bias') else ""
|
|
929
|
-
print(f"[PositionManager] OPEN {position_id}: {symbol} @ {entry_price} (Mode: {mode}{bias})")
|
|
930
|
-
|
|
931
|
-
return position
|
|
932
|
-
|
|
933
|
-
def check_positions(self, current_date, price_data):
|
|
934
|
-
"""Check all positions for stop-loss triggers"""
|
|
935
|
-
if not self.sl_enabled:
|
|
936
|
-
return []
|
|
937
|
-
|
|
938
|
-
to_close = []
|
|
939
|
-
|
|
940
|
-
for position_id, position in self.positions.items():
|
|
941
|
-
if position_id not in price_data:
|
|
942
|
-
continue
|
|
943
|
-
|
|
944
|
-
if isinstance(price_data[position_id], dict):
|
|
945
|
-
data = price_data[position_id]
|
|
946
|
-
current_price = data.get('price', position['entry_price'])
|
|
947
|
-
current_pnl = data.get('pnl', 0)
|
|
948
|
-
current_pnl_pct = data.get('pnl_pct', 0)
|
|
949
|
-
|
|
950
|
-
# NEW: Pass underlying data for combined stop
|
|
951
|
-
underlying_price = data.get('underlying_price')
|
|
952
|
-
underlying_entry_price = data.get('underlying_entry_price')
|
|
953
|
-
underlying_change_pct = data.get('underlying_change_pct')
|
|
954
|
-
else:
|
|
955
|
-
current_price = price_data[position_id]
|
|
956
|
-
current_pnl = (current_price - position['entry_price']) * position['quantity']
|
|
957
|
-
current_pnl_pct = (current_price - position['entry_price']) / position['entry_price'] if position['entry_price'] != 0 else 0
|
|
958
|
-
underlying_price = None
|
|
959
|
-
underlying_entry_price = None
|
|
960
|
-
underlying_change_pct = None
|
|
961
|
-
|
|
962
|
-
position['highest_price'] = max(position['highest_price'], current_price)
|
|
963
|
-
position['lowest_price'] = min(position['lowest_price'], current_price)
|
|
964
|
-
|
|
965
|
-
if position['entry_price'] == 0:
|
|
966
|
-
check_value = current_pnl_pct
|
|
967
|
-
else:
|
|
968
|
-
check_value = current_price
|
|
969
|
-
|
|
970
|
-
# Pass all data to stop manager
|
|
971
|
-
stop_kwargs = {
|
|
972
|
-
'pnl_pct': current_pnl_pct,
|
|
973
|
-
'current_pnl': current_pnl,
|
|
974
|
-
'total_cost': position.get('total_cost', 1),
|
|
975
|
-
'underlying_price': underlying_price,
|
|
976
|
-
'underlying_entry_price': underlying_entry_price or position.get('entry_stock_price'),
|
|
977
|
-
'underlying_change_pct': underlying_change_pct
|
|
978
|
-
}
|
|
979
|
-
|
|
980
|
-
triggered, stop_level, stop_type = self.sl_manager.check_stop(
|
|
981
|
-
position_id=position_id,
|
|
982
|
-
current_price=check_value,
|
|
983
|
-
current_date=current_date,
|
|
984
|
-
position_type=position['type'],
|
|
985
|
-
**stop_kwargs
|
|
986
|
-
)
|
|
987
|
-
|
|
988
|
-
if triggered:
|
|
989
|
-
to_close.append({
|
|
990
|
-
'position_id': position_id,
|
|
991
|
-
'symbol': position['symbol'],
|
|
992
|
-
'stop_type': stop_type,
|
|
993
|
-
'stop_level': stop_level,
|
|
994
|
-
'current_price': current_price,
|
|
995
|
-
'pnl': current_pnl,
|
|
996
|
-
'pnl_pct': current_pnl_pct
|
|
997
|
-
})
|
|
998
|
-
|
|
999
|
-
if self.debug:
|
|
1000
|
-
mode = "P&L%" if position['entry_price'] == 0 else "Price"
|
|
1001
|
-
print(f"[PositionManager] STOP-LOSS: {position_id} ({stop_type}, {mode}) @ {check_value:.2f}")
|
|
1002
|
-
|
|
1003
|
-
return to_close
|
|
1004
|
-
|
|
1005
|
-
def close_position(self, position_id, exit_date, exit_price,
|
|
1006
|
-
close_reason='manual', pnl=None, **kwargs):
|
|
1007
|
-
"""Close position"""
|
|
1008
|
-
if position_id not in self.positions:
|
|
1009
|
-
if self.debug:
|
|
1010
|
-
print(f"[PositionManager] WARNING: Position {position_id} not found")
|
|
1011
|
-
return None
|
|
1012
|
-
|
|
1013
|
-
position = self.positions.pop(position_id)
|
|
1014
|
-
|
|
1015
|
-
if pnl is None:
|
|
1016
|
-
pnl = (exit_price - position['entry_price']) * position['quantity']
|
|
1017
|
-
|
|
1018
|
-
if position['entry_price'] != 0:
|
|
1019
|
-
pnl_pct = (exit_price - position['entry_price']) / position['entry_price'] * 100
|
|
1020
|
-
else:
|
|
1021
|
-
if 'total_cost' in position and position['total_cost'] != 0:
|
|
1022
|
-
pnl_pct = (pnl / position['total_cost']) * 100
|
|
1023
|
-
elif 'total_cost' in kwargs and kwargs['total_cost'] != 0:
|
|
1024
|
-
pnl_pct = (pnl / kwargs['total_cost']) * 100
|
|
1025
|
-
else:
|
|
1026
|
-
pnl_pct = 0.0
|
|
1027
|
-
|
|
1028
|
-
trade = {
|
|
1029
|
-
'entry_date': position['entry_date'],
|
|
1030
|
-
'exit_date': exit_date,
|
|
1031
|
-
'symbol': position['symbol'],
|
|
1032
|
-
'signal': position['type'],
|
|
1033
|
-
'entry_price': position['entry_price'],
|
|
1034
|
-
'exit_price': exit_price,
|
|
1035
|
-
'quantity': position['quantity'],
|
|
1036
|
-
'pnl': pnl,
|
|
1037
|
-
'return_pct': pnl_pct,
|
|
1038
|
-
'exit_reason': close_reason,
|
|
1039
|
-
'stop_type': self.sl_config.get('type', 'none') if self.sl_enabled else 'none',
|
|
1040
|
-
**kwargs
|
|
1041
|
-
}
|
|
1042
|
-
|
|
1043
|
-
for key in ['call_strike', 'put_strike', 'expiration', 'contracts',
|
|
1044
|
-
'short_strike', 'long_strike', 'opt_type', 'spread_type',
|
|
1045
|
-
'entry_z_score', 'is_short_bias', 'entry_lean', 'exit_lean',
|
|
1046
|
-
'call_iv_entry', 'put_iv_entry', 'iv_lean_entry']:
|
|
1047
|
-
if key in position:
|
|
1048
|
-
trade[key] = position[key]
|
|
1049
|
-
|
|
1050
|
-
for key in ['short_entry_bid', 'short_entry_ask', 'short_entry_mid',
|
|
1051
|
-
'long_entry_bid', 'long_entry_ask', 'long_entry_mid',
|
|
1052
|
-
'underlying_entry_price']:
|
|
1053
|
-
if key in position:
|
|
1054
|
-
trade[key] = position[key]
|
|
1055
|
-
|
|
1056
|
-
for key in ['short_exit_bid', 'short_exit_ask',
|
|
1057
|
-
'long_exit_bid', 'long_exit_ask',
|
|
1058
|
-
'underlying_exit_price', 'underlying_change_pct',
|
|
1059
|
-
'stop_threshold', 'actual_value',
|
|
1060
|
-
'call_iv_exit', 'put_iv_exit', 'iv_lean_exit',
|
|
1061
|
-
'spy_intraday_high', 'spy_intraday_low', 'spy_intraday_close',
|
|
1062
|
-
'spy_stop_trigger_time', 'spy_stop_trigger_price',
|
|
1063
|
-
'spy_stop_trigger_bid', 'spy_stop_trigger_ask', 'spy_stop_trigger_last',
|
|
1064
|
-
'intraday_data_points', 'intraday_data_available', 'stop_triggered_by']:
|
|
1065
|
-
if key in kwargs:
|
|
1066
|
-
trade[key] = kwargs[key]
|
|
1067
|
-
|
|
1068
|
-
self.closed_trades.append(trade)
|
|
1069
|
-
|
|
1070
|
-
if self.sl_enabled and self.sl_manager:
|
|
1071
|
-
self.sl_manager.remove_position(position_id)
|
|
1072
|
-
|
|
1073
|
-
if self.debug:
|
|
1074
|
-
print(f"[PositionManager] CLOSE {position_id}: P&L=${pnl:.2f} ({pnl_pct:.2f}%) - {close_reason}")
|
|
1075
|
-
|
|
1076
|
-
return trade
|
|
1077
|
-
|
|
1078
|
-
def get_open_positions(self):
|
|
1079
|
-
return list(self.positions.values())
|
|
1080
|
-
|
|
1081
|
-
def get_closed_trades(self):
|
|
1082
|
-
return self.closed_trades
|
|
1083
|
-
|
|
1084
|
-
def close_all_positions(self, final_date, price_data, reason='end_of_backtest'):
|
|
1085
|
-
"""Close all open positions at end of backtest"""
|
|
1086
|
-
for position_id in list(self.positions.keys()):
|
|
1087
|
-
if position_id in price_data:
|
|
1088
|
-
position = self.positions[position_id]
|
|
1089
|
-
|
|
1090
|
-
if isinstance(price_data[position_id], dict):
|
|
1091
|
-
data = price_data[position_id]
|
|
1092
|
-
exit_price = data.get('price', position['entry_price'])
|
|
1093
|
-
pnl = data.get('pnl', None)
|
|
1094
|
-
else:
|
|
1095
|
-
exit_price = price_data[position_id]
|
|
1096
|
-
pnl = None
|
|
1097
|
-
|
|
1098
|
-
if pnl is None and position['entry_price'] == 0:
|
|
1099
|
-
if isinstance(price_data[position_id], dict) and 'pnl' in price_data[position_id]:
|
|
1100
|
-
pnl = price_data[position_id]['pnl']
|
|
1101
|
-
|
|
1102
|
-
self.close_position(
|
|
1103
|
-
position_id=position_id,
|
|
1104
|
-
exit_date=final_date,
|
|
1105
|
-
exit_price=exit_price,
|
|
1106
|
-
close_reason=reason,
|
|
1107
|
-
pnl=pnl
|
|
1108
|
-
)
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
# ============================================================
|
|
1112
|
-
# BACKTEST ANALYZER (unchanged)
|
|
1113
|
-
# ============================================================
|
|
1114
|
-
class BacktestAnalyzer:
|
|
1115
|
-
"""Calculate all metrics from BacktestResults"""
|
|
1116
|
-
|
|
1117
|
-
def __init__(self, results):
|
|
1118
|
-
self.results = results
|
|
1119
|
-
self.metrics = {}
|
|
1120
|
-
|
|
1121
|
-
def calculate_all_metrics(self):
|
|
1122
|
-
r = self.results
|
|
1123
|
-
|
|
1124
|
-
self.metrics['initial_capital'] = r.initial_capital
|
|
1125
|
-
self.metrics['final_equity'] = r.final_capital
|
|
1126
|
-
|
|
1127
|
-
self.metrics['total_pnl'] = r.final_capital - r.initial_capital
|
|
1128
|
-
self.metrics['total_return'] = (self.metrics['total_pnl'] / r.initial_capital) * 100
|
|
1129
|
-
|
|
1130
|
-
if len(r.equity_dates) > 0:
|
|
1131
|
-
start_date = min(r.equity_dates)
|
|
1132
|
-
end_date = max(r.equity_dates)
|
|
1133
|
-
days_diff = (end_date - start_date).days
|
|
1134
|
-
|
|
1135
|
-
if days_diff <= 0:
|
|
1136
|
-
self.metrics['cagr'] = 0
|
|
1137
|
-
self.metrics['show_cagr'] = False
|
|
1138
|
-
else:
|
|
1139
|
-
years = days_diff / 365.25
|
|
1140
|
-
if years >= 1.0:
|
|
1141
|
-
self.metrics['cagr'] = ((r.final_capital / r.initial_capital) ** (1/years) - 1) * 100
|
|
1142
|
-
self.metrics['show_cagr'] = True
|
|
1143
|
-
else:
|
|
1144
|
-
self.metrics['cagr'] = self.metrics['total_return'] * (365.25 / days_diff)
|
|
1145
|
-
self.metrics['show_cagr'] = False
|
|
1146
|
-
else:
|
|
1147
|
-
self.metrics['cagr'] = 0
|
|
1148
|
-
self.metrics['show_cagr'] = False
|
|
1149
|
-
|
|
1150
|
-
self.metrics['sharpe'] = self._sharpe_ratio(r.daily_returns)
|
|
1151
|
-
self.metrics['sortino'] = self._sortino_ratio(r.daily_returns)
|
|
1152
|
-
self.metrics['max_drawdown'] = r.max_drawdown
|
|
1153
|
-
self.metrics['volatility'] = np.std(r.daily_returns) * np.sqrt(252) * 100 if len(r.daily_returns) > 0 else 0
|
|
1154
|
-
self.metrics['calmar'] = abs(self.metrics['total_return'] / r.max_drawdown) if r.max_drawdown > 0 else 0
|
|
1155
|
-
self.metrics['omega'] = self._omega_ratio(r.daily_returns)
|
|
1156
|
-
self.metrics['ulcer'] = self._ulcer_index(r.equity_curve)
|
|
1157
|
-
|
|
1158
|
-
self.metrics['var_95'], self.metrics['var_95_pct'] = self._calculate_var(r.daily_returns, 0.95)
|
|
1159
|
-
self.metrics['var_99'], self.metrics['var_99_pct'] = self._calculate_var(r.daily_returns, 0.99)
|
|
1160
|
-
self.metrics['cvar_95'], self.metrics['cvar_95_pct'] = self._calculate_cvar(r.daily_returns, 0.95)
|
|
1161
|
-
|
|
1162
|
-
avg_equity = np.mean(r.equity_curve) if len(r.equity_curve) > 0 else r.initial_capital
|
|
1163
|
-
self.metrics['var_95_dollar'] = self.metrics['var_95'] * avg_equity
|
|
1164
|
-
self.metrics['var_99_dollar'] = self.metrics['var_99'] * avg_equity
|
|
1165
|
-
self.metrics['cvar_95_dollar'] = self.metrics['cvar_95'] * avg_equity
|
|
1166
|
-
|
|
1167
|
-
self.metrics['tail_ratio'] = self._tail_ratio(r.daily_returns)
|
|
1168
|
-
self.metrics['skewness'], self.metrics['kurtosis'] = self._skewness_kurtosis(r.daily_returns)
|
|
1169
|
-
|
|
1170
|
-
self.metrics['alpha'], self.metrics['beta'], self.metrics['r_squared'] = self._alpha_beta(r)
|
|
1171
|
-
|
|
1172
|
-
if len(r.trades) > 0:
|
|
1173
|
-
self._calculate_trading_stats(r.trades)
|
|
1174
|
-
else:
|
|
1175
|
-
self._set_empty_trading_stats()
|
|
1176
|
-
|
|
1177
|
-
running_max = np.maximum.accumulate(r.equity_curve)
|
|
1178
|
-
max_dd_dollars = np.min(np.array(r.equity_curve) - running_max)
|
|
1179
|
-
self.metrics['recovery_factor'] = self.metrics['total_pnl'] / abs(max_dd_dollars) if max_dd_dollars != 0 else 0
|
|
1180
|
-
|
|
1181
|
-
if len(r.trades) > 0 and 'start_date' in r.config and 'end_date' in r.config:
|
|
1182
|
-
total_days = (pd.to_datetime(r.config['end_date']) - pd.to_datetime(r.config['start_date'])).days
|
|
1183
|
-
self.metrics['exposure_time'] = self._exposure_time(r.trades, total_days)
|
|
1184
|
-
else:
|
|
1185
|
-
self.metrics['exposure_time'] = 0
|
|
1186
|
-
|
|
1187
|
-
return self.metrics
|
|
1188
|
-
|
|
1189
|
-
def _calculate_trading_stats(self, trades):
|
|
1190
|
-
trades_df = pd.DataFrame(trades)
|
|
1191
|
-
winning = trades_df[trades_df['pnl'] > 0]
|
|
1192
|
-
losing = trades_df[trades_df['pnl'] <= 0]
|
|
1193
|
-
|
|
1194
|
-
self.metrics['total_trades'] = len(trades_df)
|
|
1195
|
-
self.metrics['winning_trades'] = len(winning)
|
|
1196
|
-
self.metrics['losing_trades'] = len(losing)
|
|
1197
|
-
self.metrics['win_rate'] = (len(winning) / len(trades_df)) * 100 if len(trades_df) > 0 else 0
|
|
1198
|
-
|
|
1199
|
-
wins_sum = winning['pnl'].sum() if len(winning) > 0 else 0
|
|
1200
|
-
losses_sum = abs(losing['pnl'].sum()) if len(losing) > 0 else 0
|
|
1201
|
-
self.metrics['profit_factor'] = wins_sum / losses_sum if losses_sum > 0 else float('inf')
|
|
1202
|
-
|
|
1203
|
-
self.metrics['avg_win'] = winning['pnl'].mean() if len(winning) > 0 else 0
|
|
1204
|
-
self.metrics['avg_loss'] = losing['pnl'].mean() if len(losing) > 0 else 0
|
|
1205
|
-
self.metrics['best_trade'] = trades_df['pnl'].max()
|
|
1206
|
-
self.metrics['worst_trade'] = trades_df['pnl'].min()
|
|
1207
|
-
|
|
1208
|
-
if len(winning) > 0 and len(losing) > 0 and self.metrics['avg_loss'] != 0:
|
|
1209
|
-
self.metrics['avg_win_loss_ratio'] = abs(self.metrics['avg_win'] / self.metrics['avg_loss'])
|
|
1210
|
-
else:
|
|
1211
|
-
self.metrics['avg_win_loss_ratio'] = 0
|
|
1212
|
-
|
|
1213
|
-
self.metrics['max_win_streak'], self.metrics['max_loss_streak'] = self._win_loss_streaks(trades)
|
|
1214
|
-
|
|
1215
|
-
def _set_empty_trading_stats(self):
|
|
1216
|
-
self.metrics.update({
|
|
1217
|
-
'total_trades': 0, 'winning_trades': 0, 'losing_trades': 0,
|
|
1218
|
-
'win_rate': 0, 'profit_factor': 0, 'avg_win': 0, 'avg_loss': 0,
|
|
1219
|
-
'best_trade': 0, 'worst_trade': 0, 'avg_win_loss_ratio': 0,
|
|
1220
|
-
'max_win_streak': 0, 'max_loss_streak': 0
|
|
1221
|
-
})
|
|
1222
|
-
|
|
1223
|
-
def _sharpe_ratio(self, returns):
|
|
1224
|
-
if len(returns) < 2:
|
|
1225
|
-
return 0
|
|
1226
|
-
return np.sqrt(252) * np.mean(returns) / np.std(returns) if np.std(returns) > 0 else 0
|
|
1227
|
-
|
|
1228
|
-
def _sortino_ratio(self, returns):
|
|
1229
|
-
if len(returns) < 2:
|
|
1230
|
-
return 0
|
|
1231
|
-
returns_array = np.array(returns)
|
|
1232
|
-
downside = returns_array[returns_array < 0]
|
|
1233
|
-
if len(downside) == 0 or np.std(downside) == 0:
|
|
1234
|
-
return 0
|
|
1235
|
-
return np.sqrt(252) * np.mean(returns_array) / np.std(downside)
|
|
1236
|
-
|
|
1237
|
-
def _omega_ratio(self, returns, threshold=0):
|
|
1238
|
-
if len(returns) < 2:
|
|
1239
|
-
return 0
|
|
1240
|
-
returns_array = np.array(returns)
|
|
1241
|
-
gains = np.sum(np.maximum(returns_array - threshold, 0))
|
|
1242
|
-
losses = np.sum(np.maximum(threshold - returns_array, 0))
|
|
1243
|
-
return gains / losses if losses > 0 else float('inf')
|
|
1244
|
-
|
|
1245
|
-
def _ulcer_index(self, equity_curve):
|
|
1246
|
-
if len(equity_curve) < 2:
|
|
1247
|
-
return 0
|
|
1248
|
-
equity_array = np.array(equity_curve)
|
|
1249
|
-
running_max = np.maximum.accumulate(equity_array)
|
|
1250
|
-
drawdown = (equity_array - running_max) / running_max
|
|
1251
|
-
return np.sqrt(np.mean(drawdown ** 2)) * 100
|
|
1252
|
-
|
|
1253
|
-
def _calculate_var(self, returns, confidence=0.95):
|
|
1254
|
-
if len(returns) < 10:
|
|
1255
|
-
return 0, 0
|
|
1256
|
-
returns_array = np.array(returns)
|
|
1257
|
-
returns_array = returns_array[~np.isnan(returns_array)]
|
|
1258
|
-
if len(returns_array) < 10:
|
|
1259
|
-
return 0, 0
|
|
1260
|
-
var_percentile = (1 - confidence) * 100
|
|
1261
|
-
var_return = np.percentile(returns_array, var_percentile)
|
|
1262
|
-
return var_return, var_return * 100
|
|
1263
|
-
|
|
1264
|
-
def _calculate_cvar(self, returns, confidence=0.95):
|
|
1265
|
-
if len(returns) < 10:
|
|
1266
|
-
return 0, 0
|
|
1267
|
-
returns_array = np.array(returns)
|
|
1268
|
-
returns_array = returns_array[~np.isnan(returns_array)]
|
|
1269
|
-
if len(returns_array) < 10:
|
|
1270
|
-
return 0, 0
|
|
1271
|
-
var_percentile = (1 - confidence) * 100
|
|
1272
|
-
var_threshold = np.percentile(returns_array, var_percentile)
|
|
1273
|
-
tail_losses = returns_array[returns_array <= var_threshold]
|
|
1274
|
-
if len(tail_losses) == 0:
|
|
1275
|
-
return 0, 0
|
|
1276
|
-
cvar_return = np.mean(tail_losses)
|
|
1277
|
-
return cvar_return, cvar_return * 100
|
|
1278
|
-
|
|
1279
|
-
def _tail_ratio(self, returns):
|
|
1280
|
-
if len(returns) < 20:
|
|
1281
|
-
return 0
|
|
1282
|
-
returns_array = np.array(returns)
|
|
1283
|
-
right = np.percentile(returns_array, 95)
|
|
1284
|
-
left = abs(np.percentile(returns_array, 5))
|
|
1285
|
-
return right / left if left > 0 else 0
|
|
1286
|
-
|
|
1287
|
-
def _skewness_kurtosis(self, returns):
|
|
1288
|
-
if len(returns) < 10:
|
|
1289
|
-
return 0, 0
|
|
1290
|
-
returns_array = np.array(returns)
|
|
1291
|
-
mean = np.mean(returns_array)
|
|
1292
|
-
std = np.std(returns_array)
|
|
1293
|
-
if std == 0:
|
|
1294
|
-
return 0, 0
|
|
1295
|
-
skew = np.mean(((returns_array - mean) / std) ** 3)
|
|
1296
|
-
kurt = np.mean(((returns_array - mean) / std) ** 4) - 3
|
|
1297
|
-
return skew, kurt
|
|
1298
|
-
|
|
1299
|
-
def _alpha_beta(self, results):
|
|
1300
|
-
if not hasattr(results, 'benchmark_prices') or not results.benchmark_prices:
|
|
1301
|
-
return 0, 0, 0
|
|
1302
|
-
if len(results.equity_dates) < 10:
|
|
1303
|
-
return 0, 0, 0
|
|
1304
|
-
|
|
1305
|
-
benchmark_returns = []
|
|
1306
|
-
sorted_dates = sorted(results.equity_dates)
|
|
1307
|
-
|
|
1308
|
-
for i in range(1, len(sorted_dates)):
|
|
1309
|
-
prev_date = sorted_dates[i-1]
|
|
1310
|
-
curr_date = sorted_dates[i]
|
|
1311
|
-
|
|
1312
|
-
if prev_date in results.benchmark_prices and curr_date in results.benchmark_prices:
|
|
1313
|
-
prev_price = results.benchmark_prices[prev_date]
|
|
1314
|
-
curr_price = results.benchmark_prices[curr_date]
|
|
1315
|
-
bench_return = (curr_price - prev_price) / prev_price
|
|
1316
|
-
benchmark_returns.append(bench_return)
|
|
1317
|
-
else:
|
|
1318
|
-
benchmark_returns.append(0)
|
|
1319
|
-
|
|
1320
|
-
if len(benchmark_returns) != len(results.daily_returns):
|
|
1321
|
-
return 0, 0, 0
|
|
1322
|
-
|
|
1323
|
-
port_ret = np.array(results.daily_returns)
|
|
1324
|
-
bench_ret = np.array(benchmark_returns)
|
|
1325
|
-
|
|
1326
|
-
bench_mean = np.mean(bench_ret)
|
|
1327
|
-
port_mean = np.mean(port_ret)
|
|
1328
|
-
|
|
1329
|
-
covariance = np.mean((bench_ret - bench_mean) * (port_ret - port_mean))
|
|
1330
|
-
benchmark_variance = np.mean((bench_ret - bench_mean) ** 2)
|
|
1331
|
-
|
|
1332
|
-
if benchmark_variance == 0:
|
|
1333
|
-
return 0, 0, 0
|
|
1334
|
-
|
|
1335
|
-
beta = covariance / benchmark_variance
|
|
1336
|
-
alpha_daily = port_mean - beta * bench_mean
|
|
1337
|
-
alpha_annualized = alpha_daily * 252 * 100
|
|
1338
|
-
|
|
1339
|
-
ss_res = np.sum((port_ret - (alpha_daily + beta * bench_ret)) ** 2)
|
|
1340
|
-
ss_tot = np.sum((port_ret - port_mean) ** 2)
|
|
1341
|
-
r_squared = 1 - (ss_res / ss_tot) if ss_tot > 0 else 0
|
|
1342
|
-
|
|
1343
|
-
return alpha_annualized, beta, r_squared
|
|
1344
|
-
|
|
1345
|
-
def _win_loss_streaks(self, trades):
|
|
1346
|
-
if len(trades) == 0:
|
|
1347
|
-
return 0, 0
|
|
1348
|
-
max_win = max_loss = current_win = current_loss = 0
|
|
1349
|
-
for trade in trades:
|
|
1350
|
-
if trade['pnl'] > 0:
|
|
1351
|
-
current_win += 1
|
|
1352
|
-
current_loss = 0
|
|
1353
|
-
max_win = max(max_win, current_win)
|
|
1354
|
-
else:
|
|
1355
|
-
current_loss += 1
|
|
1356
|
-
current_win = 0
|
|
1357
|
-
max_loss = max(max_loss, current_loss)
|
|
1358
|
-
return max_win, max_loss
|
|
1359
|
-
|
|
1360
|
-
def _exposure_time(self, trades, total_days):
|
|
1361
|
-
if total_days <= 0 or len(trades) == 0:
|
|
1362
|
-
return 0
|
|
1363
|
-
days_with_positions = set()
|
|
1364
|
-
for trade in trades:
|
|
1365
|
-
entry = pd.to_datetime(trade['entry_date'])
|
|
1366
|
-
exit_ = pd.to_datetime(trade['exit_date'])
|
|
1367
|
-
date_range = pd.date_range(start=entry, end=exit_, freq='D')
|
|
1368
|
-
days_with_positions.update(date_range.date)
|
|
1369
|
-
exposure_pct = (len(days_with_positions) / total_days) * 100
|
|
1370
|
-
return min(exposure_pct, 100.0)
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
# ============================================================
|
|
1374
|
-
# STOP-LOSS METRICS (unchanged)
|
|
1375
|
-
# ============================================================
|
|
1376
|
-
def calculate_stoploss_metrics(analyzer):
|
|
1377
|
-
"""Calculate stop-loss specific metrics"""
|
|
1378
|
-
if len(analyzer.results.trades) == 0:
|
|
1379
|
-
_set_empty_stoploss_metrics(analyzer)
|
|
1380
|
-
return analyzer.metrics
|
|
1381
|
-
|
|
1382
|
-
trades_df = pd.DataFrame(analyzer.results.trades)
|
|
1383
|
-
|
|
1384
|
-
if 'exit_reason' not in trades_df.columns:
|
|
1385
|
-
_set_empty_stoploss_metrics(analyzer)
|
|
1386
|
-
return analyzer.metrics
|
|
1387
|
-
|
|
1388
|
-
sl_trades = trades_df[trades_df['exit_reason'].str.contains('stop_loss', na=False)]
|
|
1389
|
-
profit_target_trades = trades_df[trades_df['exit_reason'] == 'profit_target']
|
|
1390
|
-
|
|
1391
|
-
analyzer.metrics['stoploss_count'] = len(sl_trades)
|
|
1392
|
-
analyzer.metrics['stoploss_pct'] = (len(sl_trades) / len(trades_df)) * 100 if len(trades_df) > 0 else 0
|
|
1393
|
-
analyzer.metrics['profit_target_count'] = len(profit_target_trades)
|
|
1394
|
-
analyzer.metrics['profit_target_pct'] = (len(profit_target_trades) / len(trades_df)) * 100 if len(trades_df) > 0 else 0
|
|
1395
|
-
|
|
1396
|
-
if len(sl_trades) > 0:
|
|
1397
|
-
analyzer.metrics['avg_stoploss_pnl'] = sl_trades['pnl'].mean()
|
|
1398
|
-
analyzer.metrics['total_stoploss_loss'] = sl_trades['pnl'].sum()
|
|
1399
|
-
analyzer.metrics['worst_stoploss'] = sl_trades['pnl'].min()
|
|
1400
|
-
|
|
1401
|
-
if 'return_pct' in sl_trades.columns:
|
|
1402
|
-
analyzer.metrics['avg_stoploss_return_pct'] = sl_trades['return_pct'].mean()
|
|
1403
|
-
else:
|
|
1404
|
-
analyzer.metrics['avg_stoploss_return_pct'] = 0
|
|
1405
|
-
|
|
1406
|
-
if 'entry_date' in sl_trades.columns and 'exit_date' in sl_trades.columns:
|
|
1407
|
-
sl_trades_copy = sl_trades.copy()
|
|
1408
|
-
sl_trades_copy['entry_date'] = pd.to_datetime(sl_trades_copy['entry_date'])
|
|
1409
|
-
sl_trades_copy['exit_date'] = pd.to_datetime(sl_trades_copy['exit_date'])
|
|
1410
|
-
sl_trades_copy['days_held'] = (sl_trades_copy['exit_date'] - sl_trades_copy['entry_date']).dt.days
|
|
1411
|
-
analyzer.metrics['avg_days_to_stoploss'] = sl_trades_copy['days_held'].mean()
|
|
1412
|
-
analyzer.metrics['min_days_to_stoploss'] = sl_trades_copy['days_held'].min()
|
|
1413
|
-
analyzer.metrics['max_days_to_stoploss'] = sl_trades_copy['days_held'].max()
|
|
1414
|
-
else:
|
|
1415
|
-
analyzer.metrics['avg_days_to_stoploss'] = 0
|
|
1416
|
-
analyzer.metrics['min_days_to_stoploss'] = 0
|
|
1417
|
-
analyzer.metrics['max_days_to_stoploss'] = 0
|
|
1418
|
-
|
|
1419
|
-
if 'stop_type' in sl_trades.columns:
|
|
1420
|
-
stop_types = sl_trades['stop_type'].value_counts().to_dict()
|
|
1421
|
-
analyzer.metrics['stoploss_by_type'] = stop_types
|
|
1422
|
-
else:
|
|
1423
|
-
analyzer.metrics['stoploss_by_type'] = {}
|
|
1424
|
-
else:
|
|
1425
|
-
analyzer.metrics['avg_stoploss_pnl'] = 0
|
|
1426
|
-
analyzer.metrics['total_stoploss_loss'] = 0
|
|
1427
|
-
analyzer.metrics['worst_stoploss'] = 0
|
|
1428
|
-
analyzer.metrics['avg_stoploss_return_pct'] = 0
|
|
1429
|
-
analyzer.metrics['avg_days_to_stoploss'] = 0
|
|
1430
|
-
analyzer.metrics['min_days_to_stoploss'] = 0
|
|
1431
|
-
analyzer.metrics['max_days_to_stoploss'] = 0
|
|
1432
|
-
analyzer.metrics['stoploss_by_type'] = {}
|
|
1433
|
-
|
|
1434
|
-
if len(profit_target_trades) > 0 and len(sl_trades) > 0:
|
|
1435
|
-
avg_profit_target = profit_target_trades['pnl'].mean()
|
|
1436
|
-
avg_stoploss = abs(sl_trades['pnl'].mean())
|
|
1437
|
-
analyzer.metrics['profit_to_loss_ratio'] = avg_profit_target / avg_stoploss if avg_stoploss > 0 else 0
|
|
1438
|
-
else:
|
|
1439
|
-
analyzer.metrics['profit_to_loss_ratio'] = 0
|
|
1440
|
-
|
|
1441
|
-
if 'max_profit_before_stop' in sl_trades.columns:
|
|
1442
|
-
early_exits = sl_trades[sl_trades['max_profit_before_stop'] > 0]
|
|
1443
|
-
analyzer.metrics['early_exit_count'] = len(early_exits)
|
|
1444
|
-
analyzer.metrics['early_exit_pct'] = (len(early_exits) / len(sl_trades)) * 100 if len(sl_trades) > 0 else 0
|
|
1445
|
-
if len(early_exits) > 0:
|
|
1446
|
-
analyzer.metrics['avg_missed_profit'] = early_exits['max_profit_before_stop'].mean()
|
|
1447
|
-
else:
|
|
1448
|
-
analyzer.metrics['avg_missed_profit'] = 0
|
|
1449
|
-
else:
|
|
1450
|
-
analyzer.metrics['early_exit_count'] = 0
|
|
1451
|
-
analyzer.metrics['early_exit_pct'] = 0
|
|
1452
|
-
analyzer.metrics['avg_missed_profit'] = 0
|
|
1453
|
-
|
|
1454
|
-
exit_reasons = trades_df['exit_reason'].value_counts().to_dict()
|
|
1455
|
-
analyzer.metrics['exit_reasons'] = exit_reasons
|
|
1456
|
-
|
|
1457
|
-
return analyzer.metrics
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
def _set_empty_stoploss_metrics(analyzer):
|
|
1461
|
-
analyzer.metrics.update({
|
|
1462
|
-
'stoploss_count': 0, 'stoploss_pct': 0,
|
|
1463
|
-
'profit_target_count': 0, 'profit_target_pct': 0,
|
|
1464
|
-
'avg_stoploss_pnl': 0, 'total_stoploss_loss': 0,
|
|
1465
|
-
'worst_stoploss': 0, 'avg_stoploss_return_pct': 0,
|
|
1466
|
-
'avg_days_to_stoploss': 0, 'min_days_to_stoploss': 0,
|
|
1467
|
-
'max_days_to_stoploss': 0, 'stoploss_by_type': {},
|
|
1468
|
-
'profit_to_loss_ratio': 0, 'early_exit_count': 0,
|
|
1469
|
-
'early_exit_pct': 0, 'avg_missed_profit': 0,
|
|
1470
|
-
'exit_reasons': {}
|
|
1471
|
-
})
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
# ============================================================
|
|
1475
|
-
# RESULTS REPORTER (unchanged)
|
|
1476
|
-
# ============================================================
|
|
1477
|
-
class ResultsReporter:
|
|
1478
|
-
"""Print comprehensive metrics report"""
|
|
1479
|
-
|
|
1480
|
-
@staticmethod
|
|
1481
|
-
def print_full_report(analyzer):
|
|
1482
|
-
m = analyzer.metrics
|
|
1483
|
-
r = analyzer.results
|
|
1484
|
-
|
|
1485
|
-
print("="*80)
|
|
1486
|
-
print(" "*25 + "BACKTEST RESULTS")
|
|
1487
|
-
print("="*80)
|
|
1488
|
-
print()
|
|
1489
|
-
|
|
1490
|
-
print("PROFITABILITY METRICS")
|
|
1491
|
-
print("-"*80)
|
|
1492
|
-
print(f"Initial Capital: ${r.initial_capital:>15,.2f}")
|
|
1493
|
-
print(f"Final Equity: ${r.final_capital:>15,.2f}")
|
|
1494
|
-
print(f"Total P&L: ${m['total_pnl']:>15,.2f} (absolute profit/loss)")
|
|
1495
|
-
print(f"Total Return: {m['total_return']:>15.2f}% (% gain/loss)")
|
|
1496
|
-
if m['cagr'] != 0:
|
|
1497
|
-
if m['show_cagr']:
|
|
1498
|
-
print(f"CAGR: {m['cagr']:>15.2f}% (annualized compound growth)")
|
|
1499
|
-
else:
|
|
1500
|
-
print(f"Annualized Return: {m['cagr']:>15.2f}% (extrapolated to 1 year)")
|
|
1501
|
-
print()
|
|
1502
|
-
|
|
1503
|
-
print("RISK METRICS")
|
|
1504
|
-
print("-"*80)
|
|
1505
|
-
print(f"Sharpe Ratio: {m['sharpe']:>15.2f} (>1 good, >2 excellent)")
|
|
1506
|
-
print(f"Sortino Ratio: {m['sortino']:>15.2f} (downside risk, >2 good)")
|
|
1507
|
-
print(f"Calmar Ratio: {m['calmar']:>15.2f} (return/drawdown, >3 good)")
|
|
1508
|
-
if m['omega'] != 0:
|
|
1509
|
-
omega_display = f"{m['omega']:.2f}" if m['omega'] < 999 else "∞"
|
|
1510
|
-
print(f"Omega Ratio: {omega_display:>15s} (gains/losses, >1 good)")
|
|
1511
|
-
print(f"Maximum Drawdown: {m['max_drawdown']:>15.2f}% (peak to trough)")
|
|
1512
|
-
if m['ulcer'] != 0:
|
|
1513
|
-
print(f"Ulcer Index: {m['ulcer']:>15.2f}% (pain of drawdowns, lower better)")
|
|
1514
|
-
print(f"Volatility (ann.): {m['volatility']:>15.2f}% (annualized std dev)")
|
|
1515
|
-
|
|
1516
|
-
if len(r.daily_returns) >= 10:
|
|
1517
|
-
print(f"VaR (95%, 1-day): {m['var_95_pct']:>15.2f}% (${m['var_95_dollar']:>,.0f}) (max loss 95% confidence)")
|
|
1518
|
-
print(f"VaR (99%, 1-day): {m['var_99_pct']:>15.2f}% (${m['var_99_dollar']:>,.0f}) (max loss 99% confidence)")
|
|
1519
|
-
print(f"CVaR (95%, 1-day): {m['cvar_95_pct']:>15.2f}% (${m['cvar_95_dollar']:>,.0f}) (avg loss in worst 5%)")
|
|
1520
|
-
|
|
1521
|
-
if m['tail_ratio'] != 0:
|
|
1522
|
-
print(f"Tail Ratio (95/5): {m['tail_ratio']:>15.2f} (big wins/losses, >1 good)")
|
|
1523
|
-
|
|
1524
|
-
if m['skewness'] != 0 or m['kurtosis'] != 0:
|
|
1525
|
-
print(f"Skewness: {m['skewness']:>15.2f} (>0 positive tail)")
|
|
1526
|
-
print(f"Kurtosis (excess): {m['kurtosis']:>15.2f} (>0 fat tails)")
|
|
1527
|
-
|
|
1528
|
-
if m['beta'] != 0 or m['alpha'] != 0:
|
|
1529
|
-
print(f"Alpha (vs {r.benchmark_symbol}): {m['alpha']:>15.2f}% (excess return)")
|
|
1530
|
-
print(f"Beta (vs {r.benchmark_symbol}): {m['beta']:>15.2f} (<1 defensive, >1 aggressive)")
|
|
1531
|
-
print(f"R² (vs {r.benchmark_symbol}): {m['r_squared']:>15.2f} (market correlation 0-1)")
|
|
1532
|
-
|
|
1533
|
-
if abs(m['total_return']) > 200 or m['volatility'] > 150:
|
|
1534
|
-
print()
|
|
1535
|
-
print("WARNING: UNREALISTIC RESULTS DETECTED")
|
|
1536
|
-
if abs(m['total_return']) > 200:
|
|
1537
|
-
print(f" Total return {m['total_return']:.1f}% is extremely high")
|
|
1538
|
-
if m['volatility'] > 150:
|
|
1539
|
-
print(f" Volatility {m['volatility']:.1f}% is higher than leveraged ETFs")
|
|
1540
|
-
print(" Review configuration before trusting results")
|
|
1541
|
-
|
|
1542
|
-
print()
|
|
1543
|
-
|
|
1544
|
-
print("EFFICIENCY METRICS")
|
|
1545
|
-
print("-"*80)
|
|
1546
|
-
if m['recovery_factor'] != 0:
|
|
1547
|
-
print(f"Recovery Factor: {m['recovery_factor']:>15.2f} (profit/max DD, >3 good)")
|
|
1548
|
-
if m['exposure_time'] != 0:
|
|
1549
|
-
print(f"Exposure Time: {m['exposure_time']:>15.1f}% (time in market)")
|
|
1550
|
-
print()
|
|
1551
|
-
|
|
1552
|
-
print("TRADING STATISTICS")
|
|
1553
|
-
print("-"*80)
|
|
1554
|
-
print(f"Total Trades: {m['total_trades']:>15}")
|
|
1555
|
-
print(f"Winning Trades: {m['winning_trades']:>15}")
|
|
1556
|
-
print(f"Losing Trades: {m['losing_trades']:>15}")
|
|
1557
|
-
print(f"Win Rate: {m['win_rate']:>15.2f}% (% profitable trades)")
|
|
1558
|
-
print(f"Profit Factor: {m['profit_factor']:>15.2f} (gross profit/loss, >1.5 good)")
|
|
1559
|
-
if m['max_win_streak'] > 0 or m['max_loss_streak'] > 0:
|
|
1560
|
-
print(f"Max Win Streak: {m['max_win_streak']:>15} (consecutive wins)")
|
|
1561
|
-
print(f"Max Loss Streak: {m['max_loss_streak']:>15} (consecutive losses)")
|
|
1562
|
-
print(f"Average Win: ${m['avg_win']:>15,.2f}")
|
|
1563
|
-
print(f"Average Loss: ${m['avg_loss']:>15,.2f}")
|
|
1564
|
-
print(f"Best Trade: ${m['best_trade']:>15,.2f}")
|
|
1565
|
-
print(f"Worst Trade: ${m['worst_trade']:>15,.2f}")
|
|
1566
|
-
if m['avg_win_loss_ratio'] != 0:
|
|
1567
|
-
print(f"Avg Win/Loss Ratio: {m['avg_win_loss_ratio']:>15.2f} (avg win / avg loss)")
|
|
1568
|
-
print()
|
|
1569
|
-
print("="*80)
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
def print_stoploss_section(analyzer):
|
|
1573
|
-
"""Print stop-loss analysis section"""
|
|
1574
|
-
m = analyzer.metrics
|
|
1575
|
-
|
|
1576
|
-
if m.get('stoploss_count', 0) == 0:
|
|
1577
|
-
return
|
|
1578
|
-
|
|
1579
|
-
print("STOP-LOSS ANALYSIS")
|
|
1580
|
-
print("-"*80)
|
|
1581
|
-
|
|
1582
|
-
print(f"Stop-Loss Trades: {m['stoploss_count']:>15} ({m['stoploss_pct']:.1f}% of total)")
|
|
1583
|
-
print(f"Profit Target Trades: {m['profit_target_count']:>15} ({m['profit_target_pct']:.1f}% of total)")
|
|
1584
|
-
|
|
1585
|
-
print(f"Avg Stop-Loss P&L: ${m['avg_stoploss_pnl']:>15,.2f}")
|
|
1586
|
-
print(f"Total Loss from SL: ${m['total_stoploss_loss']:>15,.2f}")
|
|
1587
|
-
print(f"Worst Stop-Loss: ${m['worst_stoploss']:>15,.2f}")
|
|
1588
|
-
print(f"Avg SL Return: {m['avg_stoploss_return_pct']:>15.2f}%")
|
|
1589
|
-
|
|
1590
|
-
if m['avg_days_to_stoploss'] > 0:
|
|
1591
|
-
print(f"Avg Days to SL: {m['avg_days_to_stoploss']:>15.1f}")
|
|
1592
|
-
print(f"Min/Max Days to SL: {m['min_days_to_stoploss']:>7} / {m['max_days_to_stoploss']:<7}")
|
|
1593
|
-
|
|
1594
|
-
if m['profit_to_loss_ratio'] > 0:
|
|
1595
|
-
print(f"Profit/Loss Ratio: {m['profit_to_loss_ratio']:>15.2f} (avg profit target / avg stop-loss)")
|
|
1596
|
-
|
|
1597
|
-
if m['early_exit_count'] > 0:
|
|
1598
|
-
print(f"Early Exits: {m['early_exit_count']:>15} ({m['early_exit_pct']:.1f}% of SL trades)")
|
|
1599
|
-
print(f"Avg Missed Profit: ${m['avg_missed_profit']:>15,.2f} (profit before stop triggered)")
|
|
1600
|
-
|
|
1601
|
-
if m['stoploss_by_type']:
|
|
1602
|
-
print(f"\nStop-Loss Types:")
|
|
1603
|
-
for stop_type, count in m['stoploss_by_type'].items():
|
|
1604
|
-
pct = (count / m['stoploss_count']) * 100
|
|
1605
|
-
print(f" {stop_type:20s} {count:>5} trades ({pct:.1f}%)")
|
|
1606
|
-
|
|
1607
|
-
if m.get('exit_reasons'):
|
|
1608
|
-
print(f"\nExit Reasons Distribution:")
|
|
1609
|
-
total_trades = sum(m['exit_reasons'].values())
|
|
1610
|
-
for reason, count in sorted(m['exit_reasons'].items(), key=lambda x: x[1], reverse=True):
|
|
1611
|
-
pct = (count / total_trades) * 100
|
|
1612
|
-
print(f" {reason:20s} {count:>5} trades ({pct:.1f}%)")
|
|
1613
|
-
|
|
1614
|
-
print()
|
|
1615
|
-
print("="*80)
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
# ============================================================
|
|
1619
|
-
# CHART GENERATOR (only core charts, optimization charts separate)
|
|
1620
|
-
# ============================================================
|
|
1621
|
-
class ChartGenerator:
|
|
1622
|
-
"""Generate 6 professional charts"""
|
|
1623
|
-
|
|
1624
|
-
@staticmethod
|
|
1625
|
-
def create_all_charts(analyzer, filename='backtest_results.png', show_plots=True):
|
|
1626
|
-
r = analyzer.results
|
|
1627
|
-
|
|
1628
|
-
if len(r.trades) == 0:
|
|
1629
|
-
print("No trades to visualize")
|
|
1630
|
-
return
|
|
1631
|
-
|
|
1632
|
-
trades_df = pd.DataFrame(r.trades)
|
|
1633
|
-
fig, axes = plt.subplots(3, 2, figsize=(18, 14))
|
|
1634
|
-
fig.suptitle('Backtest Results', fontsize=16, fontweight='bold', y=0.995)
|
|
1635
|
-
|
|
1636
|
-
dates = pd.to_datetime(r.equity_dates)
|
|
1637
|
-
equity_array = np.array(r.equity_curve)
|
|
1638
|
-
|
|
1639
|
-
ax1 = axes[0, 0]
|
|
1640
|
-
ax1.plot(dates, equity_array, linewidth=2.5, color='#2196F3')
|
|
1641
|
-
ax1.axhline(y=r.initial_capital, color='gray', linestyle='--', alpha=0.7)
|
|
1642
|
-
ax1.fill_between(dates, r.initial_capital, equity_array,
|
|
1643
|
-
where=(equity_array >= r.initial_capital),
|
|
1644
|
-
alpha=0.3, color='green', interpolate=True)
|
|
1645
|
-
ax1.fill_between(dates, r.initial_capital, equity_array,
|
|
1646
|
-
where=(equity_array < r.initial_capital),
|
|
1647
|
-
alpha=0.3, color='red', interpolate=True)
|
|
1648
|
-
ax1.set_title('Equity Curve', fontsize=12, fontweight='bold')
|
|
1649
|
-
ax1.set_ylabel('Equity ($)')
|
|
1650
|
-
ax1.grid(True, alpha=0.3)
|
|
1651
|
-
|
|
1652
|
-
ax2 = axes[0, 1]
|
|
1653
|
-
running_max = np.maximum.accumulate(equity_array)
|
|
1654
|
-
drawdown = (equity_array - running_max) / running_max * 100
|
|
1655
|
-
ax2.fill_between(dates, 0, drawdown, alpha=0.6, color='#f44336')
|
|
1656
|
-
ax2.plot(dates, drawdown, color='#d32f2f', linewidth=2)
|
|
1657
|
-
ax2.set_title('Drawdown', fontsize=12, fontweight='bold')
|
|
1658
|
-
ax2.set_ylabel('Drawdown (%)')
|
|
1659
|
-
ax2.grid(True, alpha=0.3)
|
|
1660
|
-
|
|
1661
|
-
ax3 = axes[1, 0]
|
|
1662
|
-
pnl_values = trades_df['pnl'].values
|
|
1663
|
-
ax3.hist(pnl_values, bins=40, color='#4CAF50', alpha=0.7, edgecolor='black')
|
|
1664
|
-
ax3.axvline(x=0, color='red', linestyle='--', linewidth=2)
|
|
1665
|
-
ax3.set_title('P&L Distribution', fontsize=12, fontweight='bold')
|
|
1666
|
-
ax3.set_xlabel('P&L ($)')
|
|
1667
|
-
ax3.grid(True, alpha=0.3, axis='y')
|
|
1668
|
-
|
|
1669
|
-
ax4 = axes[1, 1]
|
|
1670
|
-
if 'signal' in trades_df.columns:
|
|
1671
|
-
signal_pnl = trades_df.groupby('signal')['pnl'].sum()
|
|
1672
|
-
colors = ['#4CAF50' if x > 0 else '#f44336' for x in signal_pnl.values]
|
|
1673
|
-
ax4.bar(signal_pnl.index, signal_pnl.values, color=colors, alpha=0.7)
|
|
1674
|
-
ax4.set_title('P&L by Signal', fontsize=12, fontweight='bold')
|
|
1675
|
-
else:
|
|
1676
|
-
ax4.text(0.5, 0.5, 'No signal data', ha='center', va='center', transform=ax4.transAxes)
|
|
1677
|
-
ax4.axhline(y=0, color='black', linewidth=1)
|
|
1678
|
-
ax4.grid(True, alpha=0.3, axis='y')
|
|
1679
|
-
|
|
1680
|
-
ax5 = axes[2, 0]
|
|
1681
|
-
trades_df['exit_date'] = pd.to_datetime(trades_df['exit_date'])
|
|
1682
|
-
trades_df['month'] = trades_df['exit_date'].dt.to_period('M')
|
|
1683
|
-
monthly_pnl = trades_df.groupby('month')['pnl'].sum()
|
|
1684
|
-
colors = ['#4CAF50' if x > 0 else '#f44336' for x in monthly_pnl.values]
|
|
1685
|
-
ax5.bar(range(len(monthly_pnl)), monthly_pnl.values, color=colors, alpha=0.7)
|
|
1686
|
-
ax5.set_title('Monthly P&L', fontsize=12, fontweight='bold')
|
|
1687
|
-
ax5.set_xticks(range(len(monthly_pnl)))
|
|
1688
|
-
ax5.set_xticklabels([str(m) for m in monthly_pnl.index], rotation=45, ha='right')
|
|
1689
|
-
ax5.axhline(y=0, color='black', linewidth=1)
|
|
1690
|
-
ax5.grid(True, alpha=0.3, axis='y')
|
|
1691
|
-
|
|
1692
|
-
ax6 = axes[2, 1]
|
|
1693
|
-
if 'symbol' in trades_df.columns:
|
|
1694
|
-
symbol_pnl = trades_df.groupby('symbol')['pnl'].sum().sort_values(ascending=True).tail(10)
|
|
1695
|
-
colors = ['#4CAF50' if x > 0 else '#f44336' for x in symbol_pnl.values]
|
|
1696
|
-
ax6.barh(range(len(symbol_pnl)), symbol_pnl.values, color=colors, alpha=0.7)
|
|
1697
|
-
ax6.set_yticks(range(len(symbol_pnl)))
|
|
1698
|
-
ax6.set_yticklabels(symbol_pnl.index, fontsize=9)
|
|
1699
|
-
ax6.set_title('Top Symbols', fontsize=12, fontweight='bold')
|
|
1700
|
-
else:
|
|
1701
|
-
ax6.text(0.5, 0.5, 'No symbol data', ha='center', va='center', transform=ax6.transAxes)
|
|
1702
|
-
ax6.axvline(x=0, color='black', linewidth=1)
|
|
1703
|
-
ax6.grid(True, alpha=0.3, axis='x')
|
|
1704
|
-
|
|
1705
|
-
plt.tight_layout()
|
|
1706
|
-
plt.savefig(filename, dpi=300, bbox_inches='tight')
|
|
1707
|
-
|
|
1708
|
-
if show_plots:
|
|
1709
|
-
plt.show()
|
|
1710
|
-
else:
|
|
1711
|
-
plt.close() # Close without displaying
|
|
1712
|
-
|
|
1713
|
-
print(f"Chart saved: {filename}")
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
def create_stoploss_charts(analyzer, filename='stoploss_analysis.png', show_plots=True):
|
|
1717
|
-
"""Create 4 stop-loss specific charts"""
|
|
1718
|
-
r = analyzer.results
|
|
1719
|
-
m = analyzer.metrics
|
|
1720
|
-
|
|
1721
|
-
if m.get('stoploss_count', 0) == 0:
|
|
1722
|
-
print("No stop-loss trades to visualize")
|
|
1723
|
-
return
|
|
1724
|
-
|
|
1725
|
-
trades_df = pd.DataFrame(r.trades)
|
|
1726
|
-
|
|
1727
|
-
if 'exit_reason' not in trades_df.columns:
|
|
1728
|
-
print("No exit_reason data available")
|
|
1729
|
-
return
|
|
1730
|
-
|
|
1731
|
-
fig, axes = plt.subplots(2, 2, figsize=(16, 12))
|
|
1732
|
-
fig.suptitle('Stop-Loss Analysis', fontsize=16, fontweight='bold', y=0.995)
|
|
1733
|
-
|
|
1734
|
-
ax1 = axes[0, 0]
|
|
1735
|
-
if m.get('exit_reasons'):
|
|
1736
|
-
reasons = pd.Series(m['exit_reasons']).sort_values(ascending=True)
|
|
1737
|
-
colors = ['#f44336' if 'stop_loss' in str(r) else '#4CAF50' if r == 'profit_target' else '#2196F3'
|
|
1738
|
-
for r in reasons.index]
|
|
1739
|
-
ax1.barh(range(len(reasons)), reasons.values, color=colors, alpha=0.7, edgecolor='black')
|
|
1740
|
-
ax1.set_yticks(range(len(reasons)))
|
|
1741
|
-
ax1.set_yticklabels([r.replace('_', ' ').title() for r in reasons.index])
|
|
1742
|
-
ax1.set_title('Exit Reasons Distribution', fontsize=12, fontweight='bold')
|
|
1743
|
-
ax1.set_xlabel('Number of Trades')
|
|
1744
|
-
ax1.grid(True, alpha=0.3, axis='x')
|
|
1745
|
-
|
|
1746
|
-
total = sum(reasons.values)
|
|
1747
|
-
for i, v in enumerate(reasons.values):
|
|
1748
|
-
ax1.text(v, i, f' {(v/total)*100:.1f}%', va='center', fontweight='bold')
|
|
1749
|
-
|
|
1750
|
-
ax2 = axes[0, 1]
|
|
1751
|
-
sl_trades = trades_df[trades_df['exit_reason'].str.contains('stop_loss', na=False)]
|
|
1752
|
-
if len(sl_trades) > 0:
|
|
1753
|
-
ax2.hist(sl_trades['pnl'], bins=30, color='#f44336', alpha=0.7, edgecolor='black')
|
|
1754
|
-
ax2.axvline(x=0, color='black', linestyle='--', linewidth=2)
|
|
1755
|
-
ax2.axvline(x=sl_trades['pnl'].mean(), color='yellow', linestyle='--', linewidth=2, label='Mean')
|
|
1756
|
-
ax2.set_title('Stop-Loss P&L Distribution', fontsize=12, fontweight='bold')
|
|
1757
|
-
ax2.set_xlabel('P&L ($)')
|
|
1758
|
-
ax2.set_ylabel('Frequency')
|
|
1759
|
-
ax2.legend()
|
|
1760
|
-
ax2.grid(True, alpha=0.3, axis='y')
|
|
1761
|
-
|
|
1762
|
-
ax3 = axes[1, 0]
|
|
1763
|
-
if len(sl_trades) > 0 and 'entry_date' in sl_trades.columns and 'exit_date' in sl_trades.columns:
|
|
1764
|
-
sl_trades_copy = sl_trades.copy()
|
|
1765
|
-
sl_trades_copy['entry_date'] = pd.to_datetime(sl_trades_copy['entry_date'])
|
|
1766
|
-
sl_trades_copy['exit_date'] = pd.to_datetime(sl_trades_copy['exit_date'])
|
|
1767
|
-
sl_trades_copy['days_held'] = (sl_trades_copy['exit_date'] - sl_trades_copy['entry_date']).dt.days
|
|
1768
|
-
|
|
1769
|
-
ax3.hist(sl_trades_copy['days_held'], bins=30, color='#FF9800', alpha=0.7, edgecolor='black')
|
|
1770
|
-
ax3.axvline(x=sl_trades_copy['days_held'].mean(), color='red', linestyle='--', linewidth=2, label='Mean')
|
|
1771
|
-
ax3.set_title('Days Until Stop-Loss Triggered', fontsize=12, fontweight='bold')
|
|
1772
|
-
ax3.set_xlabel('Days Held')
|
|
1773
|
-
ax3.set_ylabel('Frequency')
|
|
1774
|
-
ax3.legend()
|
|
1775
|
-
ax3.grid(True, alpha=0.3, axis='y')
|
|
1776
|
-
|
|
1777
|
-
ax4 = axes[1, 1]
|
|
1778
|
-
if 'stop_type' in sl_trades.columns:
|
|
1779
|
-
stop_types = sl_trades['stop_type'].value_counts()
|
|
1780
|
-
colors_types = plt.cm.Set3(range(len(stop_types)))
|
|
1781
|
-
wedges, texts, autotexts = ax4.pie(stop_types.values, labels=stop_types.index,
|
|
1782
|
-
autopct='%1.1f%%', colors=colors_types,
|
|
1783
|
-
startangle=90)
|
|
1784
|
-
for autotext in autotexts:
|
|
1785
|
-
autotext.set_color('black')
|
|
1786
|
-
autotext.set_fontweight('bold')
|
|
1787
|
-
ax4.set_title('Stop-Loss Types', fontsize=12, fontweight='bold')
|
|
1788
|
-
else:
|
|
1789
|
-
ax4.text(0.5, 0.5, 'No stop_type data', ha='center', va='center', transform=ax4.transAxes)
|
|
1790
|
-
|
|
1791
|
-
plt.tight_layout()
|
|
1792
|
-
plt.savefig(filename, dpi=300, bbox_inches='tight')
|
|
1793
|
-
|
|
1794
|
-
if show_plots:
|
|
1795
|
-
plt.show()
|
|
1796
|
-
else:
|
|
1797
|
-
plt.close()
|
|
1798
|
-
|
|
1799
|
-
print(f"Stop-loss charts saved: {filename}")
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
# ============================================================
|
|
1803
|
-
# RESULTS EXPORTER (unchanged)
|
|
1804
|
-
# ============================================================
|
|
1805
|
-
class ResultsExporter:
|
|
1806
|
-
"""Export results to CSV"""
|
|
1807
|
-
|
|
1808
|
-
@staticmethod
|
|
1809
|
-
def export_all(analyzer, prefix='backtest'):
|
|
1810
|
-
r = analyzer.results
|
|
1811
|
-
m = analyzer.metrics
|
|
1812
|
-
|
|
1813
|
-
if len(r.trades) == 0:
|
|
1814
|
-
print("No trades to export")
|
|
1815
|
-
return
|
|
1816
|
-
|
|
1817
|
-
trades_df = pd.DataFrame(r.trades)
|
|
1818
|
-
|
|
1819
|
-
trades_df['entry_date'] = pd.to_datetime(trades_df['entry_date']).dt.strftime('%Y-%m-%d')
|
|
1820
|
-
trades_df['exit_date'] = pd.to_datetime(trades_df['exit_date']).dt.strftime('%Y-%m-%d')
|
|
1821
|
-
|
|
1822
|
-
# Round numeric columns to 5 decimal places
|
|
1823
|
-
numeric_columns = trades_df.select_dtypes(include=[np.number]).columns
|
|
1824
|
-
for col in numeric_columns:
|
|
1825
|
-
trades_df[col] = trades_df[col].round(5)
|
|
1826
|
-
|
|
1827
|
-
core_columns = [
|
|
1828
|
-
'entry_date', 'exit_date', 'symbol', 'signal',
|
|
1829
|
-
'pnl', 'return_pct', 'exit_reason', 'stop_type'
|
|
1830
|
-
]
|
|
1831
|
-
|
|
1832
|
-
options_columns = [
|
|
1833
|
-
'short_strike', 'long_strike', 'expiration', 'opt_type',
|
|
1834
|
-
'spread_type', 'contracts'
|
|
1835
|
-
]
|
|
1836
|
-
|
|
1837
|
-
bidask_columns = [
|
|
1838
|
-
'short_entry_bid', 'short_entry_ask', 'short_entry_mid',
|
|
1839
|
-
'long_entry_bid', 'long_entry_ask', 'long_entry_mid',
|
|
1840
|
-
'short_exit_bid', 'short_exit_ask',
|
|
1841
|
-
'long_exit_bid', 'long_exit_ask'
|
|
1842
|
-
]
|
|
1843
|
-
|
|
1844
|
-
underlying_columns = [
|
|
1845
|
-
'underlying_entry_price', 'underlying_exit_price',
|
|
1846
|
-
'underlying_change_pct'
|
|
1847
|
-
]
|
|
1848
|
-
|
|
1849
|
-
stop_columns = [
|
|
1850
|
-
'stop_threshold', 'actual_value'
|
|
1851
|
-
]
|
|
1852
|
-
|
|
1853
|
-
strategy_columns = [
|
|
1854
|
-
'entry_z_score', 'is_short_bias', 'entry_price',
|
|
1855
|
-
'exit_price', 'quantity', 'entry_lean', 'exit_lean',
|
|
1856
|
-
# IV EOD fields
|
|
1857
|
-
'call_iv_entry', 'put_iv_entry', 'call_iv_exit', 'put_iv_exit',
|
|
1858
|
-
'iv_lean_entry', 'iv_lean_exit'
|
|
1859
|
-
]
|
|
1860
|
-
|
|
1861
|
-
# NEW: Intraday stop-loss columns
|
|
1862
|
-
intraday_columns = [
|
|
1863
|
-
'spy_intraday_high', 'spy_intraday_low', 'spy_intraday_close',
|
|
1864
|
-
'spy_stop_trigger_time', 'spy_stop_trigger_price',
|
|
1865
|
-
'spy_stop_trigger_bid', 'spy_stop_trigger_ask', 'spy_stop_trigger_last',
|
|
1866
|
-
'intraday_data_points', 'intraday_data_available', 'stop_triggered_by'
|
|
1867
|
-
]
|
|
1868
|
-
|
|
1869
|
-
ordered_columns = []
|
|
1870
|
-
for col in (core_columns + options_columns + bidask_columns +
|
|
1871
|
-
underlying_columns + stop_columns + strategy_columns + intraday_columns):
|
|
1872
|
-
if col in trades_df.columns:
|
|
1873
|
-
ordered_columns.append(col)
|
|
1874
|
-
|
|
1875
|
-
remaining = [col for col in trades_df.columns if col not in ordered_columns]
|
|
1876
|
-
ordered_columns.extend(remaining)
|
|
1877
|
-
|
|
1878
|
-
trades_df = trades_df[ordered_columns]
|
|
1879
|
-
|
|
1880
|
-
# Round numeric columns to 2 decimals
|
|
1881
|
-
numeric_columns = trades_df.select_dtypes(include=['float64', 'float32', 'float']).columns
|
|
1882
|
-
for col in numeric_columns:
|
|
1883
|
-
trades_df[col] = trades_df[col].round(5)
|
|
1884
|
-
|
|
1885
|
-
trades_df.to_csv(f'{prefix}_trades.csv', index=False)
|
|
1886
|
-
print(f"Exported: {prefix}_trades.csv ({len(ordered_columns)} columns)")
|
|
1887
|
-
|
|
1888
|
-
equity_df = pd.DataFrame({
|
|
1889
|
-
'date': pd.to_datetime(r.equity_dates).strftime('%Y-%m-%d'),
|
|
1890
|
-
'equity': r.equity_curve
|
|
1891
|
-
})
|
|
1892
|
-
equity_df['equity'] = equity_df['equity'].round(5)
|
|
1893
|
-
equity_df.to_csv(f'{prefix}_equity.csv', index=False)
|
|
1894
|
-
print(f"Exported: {prefix}_equity.csv")
|
|
1895
|
-
|
|
1896
|
-
with open(f'{prefix}_summary.txt', 'w') as f:
|
|
1897
|
-
f.write("BACKTEST SUMMARY\n")
|
|
1898
|
-
f.write("="*70 + "\n\n")
|
|
1899
|
-
f.write(f"Strategy: {r.config.get('strategy_name', 'Unknown')}\n")
|
|
1900
|
-
f.write(f"Period: {r.config.get('start_date')} to {r.config.get('end_date')}\n\n")
|
|
1901
|
-
f.write("PERFORMANCE\n")
|
|
1902
|
-
f.write("-"*70 + "\n")
|
|
1903
|
-
f.write(f"Total Return: {m['total_return']:.2f}%\n")
|
|
1904
|
-
f.write(f"Sharpe: {m['sharpe']:.2f}\n")
|
|
1905
|
-
f.write(f"Max DD: {m['max_drawdown']:.2f}%\n")
|
|
1906
|
-
f.write(f"Trades: {m['total_trades']}\n")
|
|
1907
|
-
|
|
1908
|
-
print(f"Exported: {prefix}_summary.txt")
|
|
1909
|
-
|
|
1910
|
-
# Export metrics as JSON with rounded values
|
|
1911
|
-
import json
|
|
1912
|
-
metrics_rounded = {}
|
|
1913
|
-
for key, value in m.items():
|
|
1914
|
-
if isinstance(value, (int, float)):
|
|
1915
|
-
metrics_rounded[key] = round(float(value), 5) if isinstance(value, float) else value
|
|
1916
|
-
else:
|
|
1917
|
-
metrics_rounded[key] = value
|
|
1918
|
-
|
|
1919
|
-
with open(f'{prefix}_metrics.json', 'w') as f:
|
|
1920
|
-
json.dump(metrics_rounded, f, indent=2)
|
|
1921
|
-
|
|
1922
|
-
print(f"Exported: {prefix}_metrics.json")
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
# ============================================================
|
|
1926
|
-
# RUN BACKTEST (unchanged)
|
|
1927
|
-
# ============================================================
|
|
1928
|
-
def run_backtest(strategy_function, config, print_report=True,
|
|
1929
|
-
create_charts=True, export_results=True,
|
|
1930
|
-
chart_filename='backtest_results.png',
|
|
1931
|
-
export_prefix='backtest',
|
|
1932
|
-
progress_context=None):
|
|
1933
|
-
"""Run complete backtest"""
|
|
1934
|
-
|
|
1935
|
-
# Check if running inside optimization
|
|
1936
|
-
is_optimization = progress_context and progress_context.get('is_optimization', False)
|
|
1937
|
-
|
|
1938
|
-
if not progress_context and not is_optimization:
|
|
1939
|
-
print("="*80)
|
|
1940
|
-
print(" "*25 + "STARTING BACKTEST")
|
|
1941
|
-
print("="*80)
|
|
1942
|
-
print(f"Strategy: {config.get('strategy_name', 'Unknown')}")
|
|
1943
|
-
print(f"Period: {config.get('start_date')} to {config.get('end_date')}")
|
|
1944
|
-
print(f"Capital: ${config.get('initial_capital', 0):,.0f}")
|
|
1945
|
-
print("="*80 + "\n")
|
|
1946
|
-
|
|
1947
|
-
if progress_context:
|
|
1948
|
-
config['_progress_context'] = progress_context
|
|
1949
|
-
|
|
1950
|
-
results = strategy_function(config)
|
|
1951
|
-
|
|
1952
|
-
if '_progress_context' in config:
|
|
1953
|
-
del config['_progress_context']
|
|
1954
|
-
|
|
1955
|
-
if not is_optimization:
|
|
1956
|
-
print("\n[*] Calculating metrics...")
|
|
1957
|
-
analyzer = BacktestAnalyzer(results)
|
|
1958
|
-
analyzer.calculate_all_metrics()
|
|
1959
|
-
|
|
1960
|
-
if print_report:
|
|
1961
|
-
print("\n" + "="*80)
|
|
1962
|
-
ResultsReporter.print_full_report(analyzer)
|
|
1963
|
-
|
|
1964
|
-
# Export charts during optimization if requested
|
|
1965
|
-
if create_charts and len(results.trades) > 0:
|
|
1966
|
-
if not is_optimization:
|
|
1967
|
-
print(f"\n[*] Creating charts: {chart_filename}")
|
|
1968
|
-
try:
|
|
1969
|
-
# Don't show plots during optimization, just save them
|
|
1970
|
-
ChartGenerator.create_all_charts(analyzer, chart_filename, show_plots=not is_optimization)
|
|
1971
|
-
except Exception as e:
|
|
1972
|
-
if not is_optimization:
|
|
1973
|
-
print(f"[ERROR] Charts failed: {e}")
|
|
1974
|
-
|
|
1975
|
-
# Export results during optimization if requested
|
|
1976
|
-
if export_results and len(results.trades) > 0:
|
|
1977
|
-
if not is_optimization:
|
|
1978
|
-
print(f"\n[*] Exporting: {export_prefix}_*")
|
|
1979
|
-
try:
|
|
1980
|
-
ResultsExporter.export_all(analyzer, export_prefix)
|
|
1981
|
-
except Exception as e:
|
|
1982
|
-
if not is_optimization:
|
|
1983
|
-
print(f"[ERROR] Export failed: {e}")
|
|
1984
|
-
|
|
1985
|
-
return analyzer
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
def run_backtest_with_stoploss(strategy_function, config, print_report=True,
|
|
1989
|
-
create_charts=True, export_results=True,
|
|
1990
|
-
chart_filename='backtest_results.png',
|
|
1991
|
-
export_prefix='backtest',
|
|
1992
|
-
create_stoploss_report=True,
|
|
1993
|
-
create_stoploss_charts=True,
|
|
1994
|
-
progress_context=None):
|
|
1995
|
-
"""Enhanced run_backtest with stop-loss analysis"""
|
|
1996
|
-
|
|
1997
|
-
analyzer = run_backtest(
|
|
1998
|
-
strategy_function, config,
|
|
1999
|
-
print_report=False,
|
|
2000
|
-
create_charts=create_charts,
|
|
2001
|
-
export_results=export_results,
|
|
2002
|
-
chart_filename=chart_filename,
|
|
2003
|
-
export_prefix=export_prefix,
|
|
2004
|
-
progress_context=progress_context
|
|
2005
|
-
)
|
|
2006
|
-
|
|
2007
|
-
calculate_stoploss_metrics(analyzer)
|
|
2008
|
-
|
|
2009
|
-
if print_report:
|
|
2010
|
-
print("\n" + "="*80)
|
|
2011
|
-
ResultsReporter.print_full_report(analyzer)
|
|
2012
|
-
|
|
2013
|
-
if create_stoploss_report and analyzer.metrics.get('stoploss_count', 0) > 0:
|
|
2014
|
-
print_stoploss_section(analyzer)
|
|
2015
|
-
|
|
2016
|
-
if create_stoploss_charts and analyzer.metrics.get('stoploss_count', 0) > 0:
|
|
2017
|
-
print(f"\n[*] Creating stop-loss analysis charts...")
|
|
2018
|
-
try:
|
|
2019
|
-
stoploss_chart_name = chart_filename.replace('.png', '_stoploss.png') if chart_filename else 'stoploss_analysis.png'
|
|
2020
|
-
create_stoploss_charts(analyzer, stoploss_chart_name)
|
|
2021
|
-
except Exception as e:
|
|
2022
|
-
print(f"[ERROR] Stop-loss charts failed: {e}")
|
|
2023
|
-
|
|
2024
|
-
return analyzer
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
# ============================================================
|
|
2028
|
-
# STOP-LOSS CONFIG (ENHANCED WITH COMBINED)
|
|
2029
|
-
# ============================================================
|
|
2030
|
-
class StopLossConfig:
|
|
2031
|
-
"""
|
|
2032
|
-
Universal stop-loss configuration builder (ENHANCED)
|
|
2033
|
-
|
|
2034
|
-
NEW METHOD:
|
|
2035
|
-
- combined(): Requires BOTH pl_loss AND directional conditions
|
|
2036
|
-
"""
|
|
2037
|
-
|
|
2038
|
-
@staticmethod
|
|
2039
|
-
def _normalize_pct(value):
|
|
2040
|
-
"""Convert any number to decimal (0.30)"""
|
|
2041
|
-
if value >= 1:
|
|
2042
|
-
return value / 100
|
|
2043
|
-
return value
|
|
2044
|
-
|
|
2045
|
-
@staticmethod
|
|
2046
|
-
def _format_pct(value):
|
|
2047
|
-
"""Format percentage for display"""
|
|
2048
|
-
if value >= 1:
|
|
2049
|
-
return f"{value:.0f}%"
|
|
2050
|
-
return f"{value*100:.0f}%"
|
|
2051
|
-
|
|
2052
|
-
@staticmethod
|
|
2053
|
-
def none():
|
|
2054
|
-
"""No stop-loss"""
|
|
2055
|
-
return {
|
|
2056
|
-
'enabled': False,
|
|
2057
|
-
'type': 'none',
|
|
2058
|
-
'value': 0,
|
|
2059
|
-
'name': 'No Stop-Loss',
|
|
2060
|
-
'description': 'No stop-loss protection'
|
|
2061
|
-
}
|
|
2062
|
-
|
|
2063
|
-
@staticmethod
|
|
2064
|
-
def fixed(pct):
|
|
2065
|
-
"""Fixed percentage stop-loss"""
|
|
2066
|
-
decimal = StopLossConfig._normalize_pct(pct)
|
|
2067
|
-
display = StopLossConfig._format_pct(pct)
|
|
2068
|
-
|
|
2069
|
-
return {
|
|
2070
|
-
'enabled': True,
|
|
2071
|
-
'type': 'fixed_pct',
|
|
2072
|
-
'value': decimal,
|
|
2073
|
-
'name': f'Fixed {display}',
|
|
2074
|
-
'description': f'Fixed stop at {display} loss'
|
|
2075
|
-
}
|
|
2076
|
-
|
|
2077
|
-
@staticmethod
|
|
2078
|
-
def trailing(pct, trailing_distance=None):
|
|
2079
|
-
"""Trailing stop-loss"""
|
|
2080
|
-
decimal = StopLossConfig._normalize_pct(pct)
|
|
2081
|
-
display = StopLossConfig._format_pct(pct)
|
|
2082
|
-
|
|
2083
|
-
config = {
|
|
2084
|
-
'enabled': True,
|
|
2085
|
-
'type': 'trailing',
|
|
2086
|
-
'value': decimal,
|
|
2087
|
-
'name': f'Trailing {display}',
|
|
2088
|
-
'description': f'Trailing stop at {display} from peak'
|
|
2089
|
-
}
|
|
2090
|
-
|
|
2091
|
-
if trailing_distance is not None:
|
|
2092
|
-
config['trailing_distance'] = StopLossConfig._normalize_pct(trailing_distance)
|
|
2093
|
-
|
|
2094
|
-
return config
|
|
2095
|
-
|
|
2096
|
-
@staticmethod
|
|
2097
|
-
def time_based(days):
|
|
2098
|
-
"""Time-based stop"""
|
|
2099
|
-
return {
|
|
2100
|
-
'enabled': True,
|
|
2101
|
-
'type': 'time_based',
|
|
2102
|
-
'value': days,
|
|
2103
|
-
'name': f'Time {days}d',
|
|
2104
|
-
'description': f'Exit after {days} days'
|
|
2105
|
-
}
|
|
2106
|
-
|
|
2107
|
-
@staticmethod
|
|
2108
|
-
def volatility(atr_multiplier):
|
|
2109
|
-
"""ATR-based stop"""
|
|
2110
|
-
return {
|
|
2111
|
-
'enabled': True,
|
|
2112
|
-
'type': 'volatility',
|
|
2113
|
-
'value': atr_multiplier,
|
|
2114
|
-
'name': f'ATR {atr_multiplier:.1f}x',
|
|
2115
|
-
'description': f'Stop at {atr_multiplier:.1f}× ATR',
|
|
2116
|
-
'requires_atr': True
|
|
2117
|
-
}
|
|
2118
|
-
|
|
2119
|
-
@staticmethod
|
|
2120
|
-
def pl_loss(pct):
|
|
2121
|
-
"""P&L-based stop using real bid/ask prices"""
|
|
2122
|
-
decimal = StopLossConfig._normalize_pct(pct)
|
|
2123
|
-
display = StopLossConfig._format_pct(pct)
|
|
2124
|
-
|
|
2125
|
-
return {
|
|
2126
|
-
'enabled': True,
|
|
2127
|
-
'type': 'pl_loss',
|
|
2128
|
-
'value': decimal,
|
|
2129
|
-
'name': f'P&L Loss {display}',
|
|
2130
|
-
'description': f'Stop when P&L drops to -{display}'
|
|
2131
|
-
}
|
|
2132
|
-
|
|
2133
|
-
@staticmethod
|
|
2134
|
-
def directional(pct):
|
|
2135
|
-
"""Directional stop based on underlying movement"""
|
|
2136
|
-
decimal = StopLossConfig._normalize_pct(pct)
|
|
2137
|
-
display = StopLossConfig._format_pct(pct)
|
|
2138
|
-
|
|
2139
|
-
return {
|
|
2140
|
-
'enabled': True,
|
|
2141
|
-
'type': 'directional',
|
|
2142
|
-
'value': decimal,
|
|
2143
|
-
'name': f'Directional {display}',
|
|
2144
|
-
'description': f'Stop when underlying moves {display}'
|
|
2145
|
-
}
|
|
2146
|
-
|
|
2147
|
-
# ========================================================
|
|
2148
|
-
# NEW: COMBINED STOP (REQUIRES BOTH CONDITIONS)
|
|
2149
|
-
# ========================================================
|
|
2150
|
-
|
|
2151
|
-
@staticmethod
|
|
2152
|
-
def combined(pl_loss_pct, directional_pct):
|
|
2153
|
-
"""
|
|
2154
|
-
Combined stop: Requires BOTH conditions (from code 2)
|
|
2155
|
-
|
|
2156
|
-
Args:
|
|
2157
|
-
pl_loss_pct: P&L loss threshold (e.g., 5 or 0.05 = -5%)
|
|
2158
|
-
directional_pct: Underlying move threshold (e.g., 3 or 0.03 = 3%)
|
|
2159
|
-
|
|
2160
|
-
Example:
|
|
2161
|
-
StopLossConfig.combined(5, 3)
|
|
2162
|
-
# Triggers only when BOTH:
|
|
2163
|
-
# 1. P&L drops to -5%
|
|
2164
|
-
# 2. Underlying moves 3% adversely
|
|
2165
|
-
"""
|
|
2166
|
-
pl_decimal = StopLossConfig._normalize_pct(pl_loss_pct)
|
|
2167
|
-
dir_decimal = StopLossConfig._normalize_pct(directional_pct)
|
|
2168
|
-
|
|
2169
|
-
pl_display = StopLossConfig._format_pct(pl_loss_pct)
|
|
2170
|
-
dir_display = StopLossConfig._format_pct(directional_pct)
|
|
2171
|
-
|
|
2172
|
-
return {
|
|
2173
|
-
'enabled': True,
|
|
2174
|
-
'type': 'combined',
|
|
2175
|
-
'value': {
|
|
2176
|
-
'pl_loss': pl_decimal,
|
|
2177
|
-
'directional': dir_decimal
|
|
2178
|
-
},
|
|
2179
|
-
'name': f'Combined (P&L {pl_display} + Dir {dir_display})',
|
|
2180
|
-
'description': f'Stop when P&L<-{pl_display} AND underlying moves {dir_display}'
|
|
2181
|
-
}
|
|
2182
|
-
|
|
2183
|
-
# ========================================================
|
|
2184
|
-
# BACKWARD COMPATIBILITY
|
|
2185
|
-
# ========================================================
|
|
2186
|
-
|
|
2187
|
-
@staticmethod
|
|
2188
|
-
def time(days):
|
|
2189
|
-
"""Alias for time_based()"""
|
|
2190
|
-
return StopLossConfig.time_based(days)
|
|
2191
|
-
|
|
2192
|
-
@staticmethod
|
|
2193
|
-
def atr(multiplier):
|
|
2194
|
-
"""Alias for volatility()"""
|
|
2195
|
-
return StopLossConfig.volatility(multiplier)
|
|
2196
|
-
|
|
2197
|
-
# ========================================================
|
|
2198
|
-
# PRESETS (WITH COMBINED STOPS)
|
|
2199
|
-
# ========================================================
|
|
2200
|
-
|
|
2201
|
-
@staticmethod
|
|
2202
|
-
def presets():
|
|
2203
|
-
"""Generate all standard stop-loss presets (UPDATED WITH COMBINED)"""
|
|
2204
|
-
return {
|
|
2205
|
-
'none': StopLossConfig.none(),
|
|
2206
|
-
|
|
2207
|
-
'fixed_20': StopLossConfig.fixed(20),
|
|
2208
|
-
'fixed_30': StopLossConfig.fixed(30),
|
|
2209
|
-
'fixed_40': StopLossConfig.fixed(40),
|
|
2210
|
-
'fixed_50': StopLossConfig.fixed(50),
|
|
2211
|
-
'fixed_70': StopLossConfig.fixed(70),
|
|
2212
|
-
|
|
2213
|
-
'trailing_20': StopLossConfig.trailing(20),
|
|
2214
|
-
'trailing_30': StopLossConfig.trailing(30),
|
|
2215
|
-
'trailing_50': StopLossConfig.trailing(50),
|
|
2216
|
-
|
|
2217
|
-
'time_5d': StopLossConfig.time(5),
|
|
2218
|
-
'time_10d': StopLossConfig.time(10),
|
|
2219
|
-
'time_20d': StopLossConfig.time(20),
|
|
2220
|
-
|
|
2221
|
-
'atr_2x': StopLossConfig.atr(2.0),
|
|
2222
|
-
'atr_3x': StopLossConfig.atr(3.0),
|
|
2223
|
-
|
|
2224
|
-
'pl_loss_5': StopLossConfig.pl_loss(5),
|
|
2225
|
-
'pl_loss_10': StopLossConfig.pl_loss(10),
|
|
2226
|
-
'pl_loss_15': StopLossConfig.pl_loss(15),
|
|
2227
|
-
|
|
2228
|
-
'directional_3': StopLossConfig.directional(3),
|
|
2229
|
-
'directional_5': StopLossConfig.directional(5),
|
|
2230
|
-
'directional_7': StopLossConfig.directional(7),
|
|
2231
|
-
|
|
2232
|
-
# NEW: COMBINED STOPS
|
|
2233
|
-
'combined_5_3': StopLossConfig.combined(5, 3),
|
|
2234
|
-
'combined_7_5': StopLossConfig.combined(7, 5),
|
|
2235
|
-
'combined_10_3': StopLossConfig.combined(10, 3),
|
|
2236
|
-
}
|
|
2237
|
-
|
|
2238
|
-
@staticmethod
|
|
2239
|
-
def apply(base_config, stop_config):
|
|
2240
|
-
"""Apply stop-loss configuration to base config"""
|
|
2241
|
-
merged = base_config.copy()
|
|
2242
|
-
|
|
2243
|
-
merged['stop_loss_enabled'] = stop_config.get('enabled', False)
|
|
2244
|
-
|
|
2245
|
-
if merged['stop_loss_enabled']:
|
|
2246
|
-
sl_config = {
|
|
2247
|
-
'type': stop_config['type'],
|
|
2248
|
-
'value': stop_config['value']
|
|
2249
|
-
}
|
|
2250
|
-
|
|
2251
|
-
if 'trailing_distance' in stop_config:
|
|
2252
|
-
sl_config['trailing_distance'] = stop_config['trailing_distance']
|
|
2253
|
-
|
|
2254
|
-
merged['stop_loss_config'] = sl_config
|
|
2255
|
-
|
|
2256
|
-
return merged
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
def create_stoploss_comparison_chart(results, filename='stoploss_comparison.png', show_plots=True):
|
|
2260
|
-
"""Create comparison chart"""
|
|
2261
|
-
try:
|
|
2262
|
-
fig, axes = plt.subplots(2, 2, figsize=(16, 12))
|
|
2263
|
-
fig.suptitle('Stop-Loss Configuration Comparison', fontsize=16, fontweight='bold')
|
|
2264
|
-
|
|
2265
|
-
names = [r['config']['name'] for r in results.values()]
|
|
2266
|
-
returns = [r['total_return'] for r in results.values()]
|
|
2267
|
-
sharpes = [r['sharpe'] for r in results.values()]
|
|
2268
|
-
drawdowns = [r['max_drawdown'] for r in results.values()]
|
|
2269
|
-
stop_counts = [r['stoploss_count'] for r in results.values()]
|
|
2270
|
-
|
|
2271
|
-
ax1 = axes[0, 0]
|
|
2272
|
-
colors = ['#4CAF50' if r > 0 else '#f44336' for r in returns]
|
|
2273
|
-
ax1.barh(range(len(names)), returns, color=colors, alpha=0.7, edgecolor='black')
|
|
2274
|
-
ax1.set_yticks(range(len(names)))
|
|
2275
|
-
ax1.set_yticklabels(names, fontsize=9)
|
|
2276
|
-
ax1.set_xlabel('Total Return (%)')
|
|
2277
|
-
ax1.set_title('Total Return by Stop-Loss Type', fontsize=12, fontweight='bold')
|
|
2278
|
-
ax1.axvline(x=0, color='black', linestyle='-', linewidth=1)
|
|
2279
|
-
ax1.grid(True, alpha=0.3, axis='x')
|
|
2280
|
-
|
|
2281
|
-
ax2 = axes[0, 1]
|
|
2282
|
-
colors_sharpe = ['#4CAF50' if s > 1 else '#FF9800' if s > 0 else '#f44336' for s in sharpes]
|
|
2283
|
-
ax2.barh(range(len(names)), sharpes, color=colors_sharpe, alpha=0.7, edgecolor='black')
|
|
2284
|
-
ax2.set_yticks(range(len(names)))
|
|
2285
|
-
ax2.set_yticklabels(names, fontsize=9)
|
|
2286
|
-
ax2.set_xlabel('Sharpe Ratio')
|
|
2287
|
-
ax2.set_title('Sharpe Ratio by Stop-Loss Type', fontsize=12, fontweight='bold')
|
|
2288
|
-
ax2.axvline(x=1, color='green', linestyle='--', linewidth=1, label='Good (>1)')
|
|
2289
|
-
ax2.axvline(x=0, color='black', linestyle='-', linewidth=1)
|
|
2290
|
-
ax2.legend()
|
|
2291
|
-
ax2.grid(True, alpha=0.3, axis='x')
|
|
2292
|
-
|
|
2293
|
-
ax3 = axes[1, 0]
|
|
2294
|
-
ax3.barh(range(len(names)), drawdowns, color='#f44336', alpha=0.7, edgecolor='black')
|
|
2295
|
-
ax3.set_yticks(range(len(names)))
|
|
2296
|
-
ax3.set_yticklabels(names, fontsize=9)
|
|
2297
|
-
ax3.set_xlabel('Maximum Drawdown (%)')
|
|
2298
|
-
ax3.set_title('Maximum Drawdown (Lower is Better)', fontsize=12, fontweight='bold')
|
|
2299
|
-
ax3.grid(True, alpha=0.3, axis='x')
|
|
2300
|
-
|
|
2301
|
-
ax4 = axes[1, 1]
|
|
2302
|
-
ax4.barh(range(len(names)), stop_counts, color='#2196F3', alpha=0.7, edgecolor='black')
|
|
2303
|
-
ax4.set_yticks(range(len(names)))
|
|
2304
|
-
ax4.set_yticklabels(names, fontsize=9)
|
|
2305
|
-
ax4.set_xlabel('Number of Stop-Loss Exits')
|
|
2306
|
-
ax4.set_title('Stop-Loss Frequency', fontsize=12, fontweight='bold')
|
|
2307
|
-
ax4.grid(True, alpha=0.3, axis='x')
|
|
2308
|
-
|
|
2309
|
-
plt.tight_layout()
|
|
2310
|
-
plt.savefig(filename, dpi=300, bbox_inches='tight')
|
|
2311
|
-
|
|
2312
|
-
if show_plots:
|
|
2313
|
-
plt.show()
|
|
2314
|
-
else:
|
|
2315
|
-
plt.close()
|
|
2316
|
-
|
|
2317
|
-
print(f"Comparison chart saved: {filename}")
|
|
2318
|
-
|
|
2319
|
-
except Exception as e:
|
|
2320
|
-
print(f"Failed to create comparison chart: {e}")
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
|
|
2324
|
-
# ============================================================
|
|
2325
|
-
# DATA PRELOADING FUNCTION (FOR OPTIMIZATION)
|
|
2326
|
-
# ============================================================
|
|
2327
|
-
def preload_options_data(config, progress_widgets=None):
|
|
2328
|
-
"""
|
|
2329
|
-
Preload options data for optimization.
|
|
2330
|
-
Loads data ONCE and returns cache.
|
|
2331
|
-
|
|
2332
|
-
Returns:
|
|
2333
|
-
tuple: (lean_df, options_cache)
|
|
2334
|
-
- lean_df: DataFrame with IV lean history
|
|
2335
|
-
- options_cache: dict {date: DataFrame} with options data
|
|
2336
|
-
"""
|
|
2337
|
-
if progress_widgets:
|
|
2338
|
-
progress_bar, status_label, monitor, start_time = progress_widgets
|
|
2339
|
-
status_label.value = "<b style='color:#0066cc'>🔄 Preloading options data (ONCE)...</b>"
|
|
2340
|
-
progress_bar.value = 5
|
|
2341
|
-
|
|
2342
|
-
# Extract config
|
|
2343
|
-
from datetime import datetime, timedelta
|
|
2344
|
-
import pandas as pd
|
|
2345
|
-
import numpy as np
|
|
2346
|
-
import gc
|
|
2347
|
-
|
|
2348
|
-
start_date = datetime.strptime(config['start_date'], '%Y-%m-%d').date()
|
|
2349
|
-
end_date = datetime.strptime(config['end_date'], '%Y-%m-%d').date()
|
|
2350
|
-
symbol = config['symbol']
|
|
2351
|
-
dte_target = config.get('dte_target', 30)
|
|
2352
|
-
lookback_period = config.get('lookback_period', 60)
|
|
2353
|
-
chunk_months = config.get('chunk_months', 3)
|
|
2354
|
-
|
|
2355
|
-
# Calculate date chunks
|
|
2356
|
-
data_start = start_date - timedelta(days=lookback_period + 60)
|
|
2357
|
-
|
|
2358
|
-
date_chunks = []
|
|
2359
|
-
current_chunk_start = data_start
|
|
2360
|
-
while current_chunk_start <= end_date:
|
|
2361
|
-
chunk_end = min(
|
|
2362
|
-
current_chunk_start + timedelta(days=chunk_months * 31),
|
|
2363
|
-
end_date
|
|
2364
|
-
)
|
|
2365
|
-
date_chunks.append((current_chunk_start, chunk_end))
|
|
2366
|
-
current_chunk_start = chunk_end + timedelta(days=1)
|
|
2367
|
-
|
|
2368
|
-
# Store lean calculations
|
|
2369
|
-
lean_history = []
|
|
2370
|
-
all_options_data = [] # List to collect all options DataFrames
|
|
2371
|
-
|
|
2372
|
-
# Track time for ETA
|
|
2373
|
-
preload_start_time = time.time()
|
|
2374
|
-
|
|
2375
|
-
try:
|
|
2376
|
-
# Use api_call with caching instead of direct ivol API
|
|
2377
|
-
cache_config = config.get('cache_config')
|
|
2378
|
-
|
|
2379
|
-
# Process each chunk
|
|
2380
|
-
for chunk_idx, (chunk_start, chunk_end) in enumerate(date_chunks):
|
|
2381
|
-
if progress_widgets:
|
|
2382
|
-
# Use update_progress for full display with ETA, CPU, RAM
|
|
2383
|
-
update_progress(
|
|
2384
|
-
progress_bar, status_label, monitor,
|
|
2385
|
-
current=chunk_idx + 1,
|
|
2386
|
-
total=len(date_chunks),
|
|
2387
|
-
start_time=preload_start_time,
|
|
2388
|
-
message=f"🔄 Loading chunk {chunk_idx+1}/{len(date_chunks)}"
|
|
2389
|
-
)
|
|
2390
|
-
|
|
2391
|
-
# Use api_call with caching (supports disk + memory cache)
|
|
2392
|
-
raw_data = api_call(
|
|
2393
|
-
'/equities/eod/options-rawiv',
|
|
2394
|
-
cache_config,
|
|
2395
|
-
symbol=symbol,
|
|
2396
|
-
from_=chunk_start.strftime('%Y-%m-%d'),
|
|
2397
|
-
to=chunk_end.strftime('%Y-%m-%d'),
|
|
2398
|
-
debug=cache_config.get('debug', False) if cache_config else False
|
|
2399
|
-
)
|
|
2400
|
-
|
|
2401
|
-
if raw_data is None:
|
|
2402
|
-
continue
|
|
2403
|
-
|
|
2404
|
-
# api_call returns dict with 'data' key
|
|
2405
|
-
if isinstance(raw_data, dict) and 'data' in raw_data:
|
|
2406
|
-
df = pd.DataFrame(raw_data['data'])
|
|
2407
|
-
else:
|
|
2408
|
-
df = pd.DataFrame(raw_data)
|
|
2409
|
-
|
|
2410
|
-
if df.empty:
|
|
2411
|
-
continue
|
|
2412
|
-
|
|
2413
|
-
# Essential columns
|
|
2414
|
-
essential_cols = ['date', 'expiration', 'strike', 'Call/Put', 'iv', 'Adjusted close']
|
|
2415
|
-
if 'bid' in df.columns:
|
|
2416
|
-
essential_cols.append('bid')
|
|
2417
|
-
if 'ask' in df.columns:
|
|
2418
|
-
essential_cols.append('ask')
|
|
2419
|
-
|
|
2420
|
-
df = df[essential_cols].copy()
|
|
2421
|
-
|
|
2422
|
-
# Process bid/ask
|
|
2423
|
-
if 'bid' in df.columns:
|
|
2424
|
-
df['bid'] = pd.to_numeric(df['bid'], errors='coerce').astype('float32')
|
|
2425
|
-
else:
|
|
2426
|
-
df['bid'] = np.nan
|
|
2427
|
-
|
|
2428
|
-
if 'ask' in df.columns:
|
|
2429
|
-
df['ask'] = pd.to_numeric(df['ask'], errors='coerce').astype('float32')
|
|
2430
|
-
else:
|
|
2431
|
-
df['ask'] = np.nan
|
|
2432
|
-
|
|
2433
|
-
# Calculate mid price
|
|
2434
|
-
df['mid'] = (df['bid'] + df['ask']) / 2
|
|
2435
|
-
df['mid'] = df['mid'].fillna(df['iv'])
|
|
2436
|
-
|
|
2437
|
-
df['date'] = pd.to_datetime(df['date']).dt.date
|
|
2438
|
-
df['expiration'] = pd.to_datetime(df['expiration']).dt.date
|
|
2439
|
-
df['strike'] = pd.to_numeric(df['strike'], errors='coerce').astype('float32')
|
|
2440
|
-
df['iv'] = pd.to_numeric(df['iv'], errors='coerce').astype('float32')
|
|
2441
|
-
df['Adjusted close'] = pd.to_numeric(df['Adjusted close'], errors='coerce').astype('float32')
|
|
2442
|
-
|
|
2443
|
-
df['dte'] = (pd.to_datetime(df['expiration']) - pd.to_datetime(df['date'])).dt.days
|
|
2444
|
-
df['dte'] = df['dte'].astype('int16')
|
|
2445
|
-
|
|
2446
|
-
df = df.dropna(subset=['strike', 'iv', 'Adjusted close'])
|
|
2447
|
-
|
|
2448
|
-
if df.empty:
|
|
2449
|
-
del df
|
|
2450
|
-
gc.collect()
|
|
2451
|
-
continue
|
|
2452
|
-
|
|
2453
|
-
# Collect all options data
|
|
2454
|
-
all_options_data.append(df.copy())
|
|
2455
|
-
|
|
2456
|
-
# Calculate lean for this chunk
|
|
2457
|
-
trading_dates = sorted(df['date'].unique())
|
|
2458
|
-
|
|
2459
|
-
for current_date in trading_dates:
|
|
2460
|
-
day_data = df[df['date'] == current_date]
|
|
2461
|
-
|
|
2462
|
-
if day_data.empty:
|
|
2463
|
-
continue
|
|
2464
|
-
|
|
2465
|
-
stock_price = float(day_data['Adjusted close'].iloc[0])
|
|
2466
|
-
|
|
2467
|
-
dte_filtered = day_data[
|
|
2468
|
-
(day_data['dte'] >= dte_target - 7) &
|
|
2469
|
-
(day_data['dte'] <= dte_target + 7)
|
|
2470
|
-
]
|
|
2471
|
-
|
|
2472
|
-
if dte_filtered.empty:
|
|
2473
|
-
continue
|
|
2474
|
-
|
|
2475
|
-
dte_filtered = dte_filtered.copy()
|
|
2476
|
-
dte_filtered['strike_diff'] = abs(dte_filtered['strike'] - stock_price)
|
|
2477
|
-
atm_idx = dte_filtered['strike_diff'].idxmin()
|
|
2478
|
-
atm_strike = float(dte_filtered.loc[atm_idx, 'strike'])
|
|
2479
|
-
|
|
2480
|
-
atm_options = dte_filtered[dte_filtered['strike'] == atm_strike]
|
|
2481
|
-
atm_call = atm_options[atm_options['Call/Put'] == 'C']
|
|
2482
|
-
atm_put = atm_options[atm_options['Call/Put'] == 'P']
|
|
2483
|
-
|
|
2484
|
-
if not atm_call.empty and not atm_put.empty:
|
|
2485
|
-
call_iv = float(atm_call['iv'].iloc[0])
|
|
2486
|
-
put_iv = float(atm_put['iv'].iloc[0])
|
|
2487
|
-
|
|
2488
|
-
if pd.notna(call_iv) and pd.notna(put_iv) and call_iv > 0 and put_iv > 0:
|
|
2489
|
-
iv_lean = call_iv - put_iv
|
|
2490
|
-
|
|
2491
|
-
lean_history.append({
|
|
2492
|
-
'date': current_date,
|
|
2493
|
-
'stock_price': stock_price,
|
|
2494
|
-
'iv_lean': iv_lean
|
|
2495
|
-
})
|
|
2496
|
-
|
|
2497
|
-
del df, raw_data
|
|
2498
|
-
gc.collect()
|
|
2499
|
-
|
|
2500
|
-
lean_df = pd.DataFrame(lean_history)
|
|
2501
|
-
lean_df['stock_price'] = lean_df['stock_price'].astype('float32')
|
|
2502
|
-
lean_df['iv_lean'] = lean_df['iv_lean'].astype('float32')
|
|
2503
|
-
|
|
2504
|
-
# Combine all options data into single DataFrame
|
|
2505
|
-
if all_options_data:
|
|
2506
|
-
options_df = pd.concat(all_options_data, ignore_index=True)
|
|
2507
|
-
# Ensure date column is properly formatted
|
|
2508
|
-
options_df['date'] = pd.to_datetime(options_df['date']).dt.date
|
|
2509
|
-
options_df['expiration'] = pd.to_datetime(options_df['expiration']).dt.date
|
|
2510
|
-
else:
|
|
2511
|
-
options_df = pd.DataFrame()
|
|
2512
|
-
|
|
2513
|
-
del lean_history, all_options_data
|
|
2514
|
-
gc.collect()
|
|
2515
|
-
|
|
2516
|
-
if progress_widgets:
|
|
2517
|
-
status_label.value = f"<b style='color:#00cc00'>✓ Data preloaded: {len(lean_df)} days, {len(options_df)} options records</b>"
|
|
2518
|
-
progress_bar.value = 35
|
|
2519
|
-
|
|
2520
|
-
print(f"✓ Data preloaded: {len(lean_df)} days, {len(options_df)} options records")
|
|
2521
|
-
|
|
2522
|
-
return lean_df, options_df
|
|
2523
|
-
|
|
2524
|
-
except Exception as e:
|
|
2525
|
-
print(f"Error preloading data: {e}")
|
|
2526
|
-
return pd.DataFrame(), {}
|
|
2527
|
-
|
|
2528
|
-
|
|
2529
|
-
# ============================================================
|
|
2530
|
-
# UNIVERSAL DATA PRELOADER V2 (NEW!)
|
|
2531
|
-
# ============================================================
|
|
2532
|
-
def preload_data_universal(config, data_requests=None):
|
|
2533
|
-
"""
|
|
2534
|
-
🚀 TRULY UNIVERSAL DATA PRELOADER - Works with ANY API endpoint!
|
|
2535
|
-
|
|
2536
|
-
Supports:
|
|
2537
|
-
- EOD data: options-rawiv, stock-prices, ivs-by-delta, ivx, etc.
|
|
2538
|
-
- Intraday data: OPTIONS_INTRADAY, stock intraday, etc.
|
|
2539
|
-
- Any custom endpoint with any parameters
|
|
2540
|
-
- Automatic chunking for date ranges
|
|
2541
|
-
- Manual single-date requests
|
|
2542
|
-
|
|
2543
|
-
Args:
|
|
2544
|
-
config: Strategy configuration (start_date, end_date, symbol)
|
|
2545
|
-
data_requests: List of data requests to load. If None, tries auto-detection.
|
|
2546
|
-
|
|
2547
|
-
Format:
|
|
2548
|
-
[
|
|
2549
|
-
{
|
|
2550
|
-
'name': 'options_data', # Your name for this dataset
|
|
2551
|
-
'endpoint': '/equities/eod/options-rawiv',
|
|
2552
|
-
'params': {...}, # Base params (symbol, etc.)
|
|
2553
|
-
'chunking': { # Optional: for date-range data
|
|
2554
|
-
'enabled': True,
|
|
2555
|
-
'date_param': 'from_', # Param name for start date
|
|
2556
|
-
'date_param_to': 'to', # Param name for end date
|
|
2557
|
-
'chunk_days': 90 # Chunk size in days
|
|
2558
|
-
},
|
|
2559
|
-
'post_process': lambda df: df, # Optional: process DataFrame
|
|
2560
|
-
},
|
|
2561
|
-
{
|
|
2562
|
-
'name': 'ivx_data',
|
|
2563
|
-
'endpoint': '/equities/eod/ivx',
|
|
2564
|
-
'params': {
|
|
2565
|
-
'symbol': config['symbol'],
|
|
2566
|
-
'from_': config['start_date'],
|
|
2567
|
-
'to': config['end_date']
|
|
2568
|
-
},
|
|
2569
|
-
'chunking': {'enabled': False} # Single request
|
|
2570
|
-
},
|
|
2571
|
-
{
|
|
2572
|
-
'name': 'options_intraday',
|
|
2573
|
-
'endpoint': '/equities/intraday/options-rawiv',
|
|
2574
|
-
'params': {
|
|
2575
|
-
'symbol': config['symbol']
|
|
2576
|
-
},
|
|
2577
|
-
'date_list': True, # Load for each date separately
|
|
2578
|
-
'date_param': 'date'
|
|
2579
|
-
}
|
|
2580
|
-
]
|
|
2581
|
-
|
|
2582
|
-
Returns:
|
|
2583
|
-
dict: Preloaded data with keys like:
|
|
2584
|
-
{
|
|
2585
|
-
'_preloaded_options_data': DataFrame,
|
|
2586
|
-
'_preloaded_ivx_data': DataFrame,
|
|
2587
|
-
'_preloaded_options_intraday': DataFrame,
|
|
2588
|
-
'_stats': {...}
|
|
2589
|
-
}
|
|
2590
|
-
|
|
2591
|
-
Usage in strategy:
|
|
2592
|
-
# Check for ANY preloaded data
|
|
2593
|
-
if any(k.startswith('_preloaded_') for k in config):
|
|
2594
|
-
options_df = config.get('_preloaded_options_data', pd.DataFrame()).copy()
|
|
2595
|
-
ivx_df = config.get('_preloaded_ivx_data', pd.DataFrame()).copy()
|
|
2596
|
-
else:
|
|
2597
|
-
# Load fresh
|
|
2598
|
-
...
|
|
2599
|
-
"""
|
|
2600
|
-
|
|
2601
|
-
print("\n" + "="*80)
|
|
2602
|
-
print("🚀 UNIVERSAL PRELOADER V2 - Supports ANY endpoint (EOD/Intraday/IVX/etc.)")
|
|
2603
|
-
print("="*80)
|
|
2604
|
-
start_time = time.time()
|
|
2605
|
-
|
|
2606
|
-
# Extract common config
|
|
2607
|
-
start_date = datetime.strptime(config['start_date'], '%Y-%m-%d').date()
|
|
2608
|
-
end_date = datetime.strptime(config['end_date'], '%Y-%m-%d').date()
|
|
2609
|
-
symbol = config['symbol']
|
|
2610
|
-
cache_config = config.get('cache_config', get_cache_config())
|
|
2611
|
-
|
|
2612
|
-
# Auto-detection if not specified
|
|
2613
|
-
if data_requests is None:
|
|
2614
|
-
data_requests = _auto_detect_requests(config)
|
|
2615
|
-
print(f"\n🔍 Auto-detected {len(data_requests)} data requests from config")
|
|
2616
|
-
|
|
2617
|
-
preloaded = {}
|
|
2618
|
-
total_rows = 0
|
|
2619
|
-
|
|
2620
|
-
# Process each data request
|
|
2621
|
-
for req_idx, request in enumerate(data_requests, 1):
|
|
2622
|
-
req_name = request['name']
|
|
2623
|
-
endpoint = request['endpoint']
|
|
2624
|
-
base_params = request.get('params', {})
|
|
2625
|
-
chunking = request.get('chunking', {'enabled': False})
|
|
2626
|
-
post_process = request.get('post_process', None)
|
|
2627
|
-
date_list = request.get('date_list', False)
|
|
2628
|
-
|
|
2629
|
-
print(f"\n[{req_idx}/{len(data_requests)}] 📊 Loading: {req_name}")
|
|
2630
|
-
print(f" Endpoint: {endpoint}")
|
|
2631
|
-
|
|
2632
|
-
all_data = []
|
|
2633
|
-
|
|
2634
|
-
# ========================================================
|
|
2635
|
-
# MODE 1: DATE LIST (one request per date, e.g., intraday)
|
|
2636
|
-
# ========================================================
|
|
2637
|
-
if date_list:
|
|
2638
|
-
date_param = request.get('date_param', 'date')
|
|
2639
|
-
trading_days = pd.bdate_range(start_date, end_date).date
|
|
2640
|
-
|
|
2641
|
-
print(f" Mode: Date list ({len(trading_days)} dates)")
|
|
2642
|
-
|
|
2643
|
-
for day_idx, date in enumerate(trading_days):
|
|
2644
|
-
params = base_params.copy()
|
|
2645
|
-
params[date_param] = date.strftime('%Y-%m-%d')
|
|
2646
|
-
|
|
2647
|
-
if day_idx % max(1, len(trading_days) // 10) == 0:
|
|
2648
|
-
print(f" Progress: {day_idx}/{len(trading_days)} dates...")
|
|
2649
|
-
|
|
2650
|
-
response = api_call(endpoint, cache_config, **params)
|
|
2651
|
-
if response and 'data' in response:
|
|
2652
|
-
df = pd.DataFrame(response['data'])
|
|
2653
|
-
if len(df) > 0:
|
|
2654
|
-
all_data.append(df)
|
|
2655
|
-
|
|
2656
|
-
# ========================================================
|
|
2657
|
-
# MODE 2: CHUNKED LOADING (date ranges in chunks)
|
|
2658
|
-
# ========================================================
|
|
2659
|
-
elif chunking.get('enabled', False):
|
|
2660
|
-
date_param_from = chunking.get('date_param', 'from_')
|
|
2661
|
-
date_param_to = chunking.get('date_param_to', 'to')
|
|
2662
|
-
chunk_days = chunking.get('chunk_days', 90)
|
|
2663
|
-
chunk_size = timedelta(days=chunk_days)
|
|
2664
|
-
|
|
2665
|
-
current = start_date
|
|
2666
|
-
chunks = []
|
|
2667
|
-
while current <= end_date:
|
|
2668
|
-
chunk_end = min(current + chunk_size, end_date)
|
|
2669
|
-
chunks.append((current, chunk_end))
|
|
2670
|
-
current = chunk_end + timedelta(days=1)
|
|
2671
|
-
|
|
2672
|
-
print(f" Mode: Chunked ({len(chunks)} chunks of {chunk_days} days)")
|
|
2673
|
-
|
|
2674
|
-
for chunk_idx, (chunk_start, chunk_end) in enumerate(chunks):
|
|
2675
|
-
params = base_params.copy()
|
|
2676
|
-
params[date_param_from] = chunk_start.strftime('%Y-%m-%d')
|
|
2677
|
-
params[date_param_to] = chunk_end.strftime('%Y-%m-%d')
|
|
2678
|
-
|
|
2679
|
-
if chunk_idx % max(1, len(chunks) // 5) == 0:
|
|
2680
|
-
print(f" Progress: {chunk_idx+1}/{len(chunks)} chunks...")
|
|
2681
|
-
|
|
2682
|
-
response = api_call(endpoint, cache_config, **params)
|
|
2683
|
-
if response and 'data' in response:
|
|
2684
|
-
df = pd.DataFrame(response['data'])
|
|
2685
|
-
if len(df) > 0:
|
|
2686
|
-
all_data.append(df)
|
|
2687
|
-
|
|
2688
|
-
# ========================================================
|
|
2689
|
-
# MODE 3: SINGLE REQUEST (no chunking/date list)
|
|
2690
|
-
# ========================================================
|
|
2691
|
-
else:
|
|
2692
|
-
print(f" Mode: Single request")
|
|
2693
|
-
|
|
2694
|
-
params = base_params.copy()
|
|
2695
|
-
response = api_call(endpoint, cache_config, **params)
|
|
2696
|
-
if response and 'data' in response:
|
|
2697
|
-
df = pd.DataFrame(response['data'])
|
|
2698
|
-
if len(df) > 0:
|
|
2699
|
-
all_data.append(df)
|
|
2700
|
-
|
|
2701
|
-
# ========================================================
|
|
2702
|
-
# COMBINE AND STORE
|
|
2703
|
-
# ========================================================
|
|
2704
|
-
if len(all_data) > 0:
|
|
2705
|
-
combined_df = pd.concat(all_data, ignore_index=True)
|
|
2706
|
-
|
|
2707
|
-
# Apply post-processing if provided
|
|
2708
|
-
if post_process is not None:
|
|
2709
|
-
try:
|
|
2710
|
-
combined_df = post_process(combined_df)
|
|
2711
|
-
except Exception as e:
|
|
2712
|
-
print(f" ⚠️ Post-processing failed: {e}")
|
|
2713
|
-
|
|
2714
|
-
# Auto-process common date columns
|
|
2715
|
-
combined_df = _auto_process_dates(combined_df)
|
|
2716
|
-
|
|
2717
|
-
# Store with standardized key
|
|
2718
|
-
key = f"_preloaded_{req_name}"
|
|
2719
|
-
preloaded[key] = combined_df
|
|
2720
|
-
total_rows += len(combined_df)
|
|
2721
|
-
|
|
2722
|
-
print(f" ✓ Loaded: {len(combined_df):,} rows → {key}")
|
|
2723
|
-
else:
|
|
2724
|
-
print(f" ⚠️ No data returned")
|
|
2725
|
-
|
|
2726
|
-
# ========================================================
|
|
2727
|
-
# SUMMARY
|
|
2728
|
-
# ========================================================
|
|
2729
|
-
elapsed = time.time() - start_time
|
|
2730
|
-
|
|
2731
|
-
# Collect detailed stats for each dataset
|
|
2732
|
-
dataset_details = {}
|
|
2733
|
-
for k in preloaded.keys():
|
|
2734
|
-
if k.startswith('_preloaded_'):
|
|
2735
|
-
dataset_name = k.replace('_preloaded_', '')
|
|
2736
|
-
df = preloaded[k]
|
|
2737
|
-
dataset_details[dataset_name] = {
|
|
2738
|
-
'rows': len(df),
|
|
2739
|
-
'endpoint': None
|
|
2740
|
-
}
|
|
2741
|
-
|
|
2742
|
-
# Map dataset names to endpoints from data_requests
|
|
2743
|
-
if data_requests:
|
|
2744
|
-
for req in data_requests:
|
|
2745
|
-
req_name = req.get('name', 'unknown')
|
|
2746
|
-
if req_name in dataset_details:
|
|
2747
|
-
dataset_details[req_name]['endpoint'] = req.get('endpoint', 'unknown')
|
|
2748
|
-
|
|
2749
|
-
preloaded['_stats'] = {
|
|
2750
|
-
'load_time_seconds': int(elapsed),
|
|
2751
|
-
'total_rows': total_rows,
|
|
2752
|
-
'data_count': len([k for k in preloaded.keys() if k.startswith('_preloaded_')]),
|
|
2753
|
-
'datasets': [k.replace('_preloaded_', '') for k in preloaded.keys() if k.startswith('_preloaded_')],
|
|
2754
|
-
'dataset_details': dataset_details
|
|
2755
|
-
}
|
|
2756
|
-
|
|
2757
|
-
print(f"\n{'='*80}")
|
|
2758
|
-
print(f"✅ PRELOAD COMPLETE:")
|
|
2759
|
-
print(f" • Time: {int(elapsed)}s")
|
|
2760
|
-
print(f" • Total rows: {total_rows:,}")
|
|
2761
|
-
print(f" • Datasets: {preloaded['_stats']['data_count']}")
|
|
2762
|
-
for ds in preloaded['_stats']['datasets']:
|
|
2763
|
-
print(f" - {ds}")
|
|
2764
|
-
print(f" • Cached in RAM for 4-5x speedup! 🚀")
|
|
2765
|
-
print(f"{'='*80}\n")
|
|
2766
|
-
|
|
2767
|
-
return preloaded
|
|
2768
|
-
|
|
2769
|
-
|
|
2770
|
-
def _auto_detect_requests(config):
|
|
2771
|
-
"""Auto-detect what data to load based on config keys"""
|
|
2772
|
-
requests = []
|
|
2773
|
-
|
|
2774
|
-
# Always load options data for options strategies
|
|
2775
|
-
requests.append({
|
|
2776
|
-
'name': 'options',
|
|
2777
|
-
'endpoint': '/equities/eod/options-rawiv',
|
|
2778
|
-
'params': {
|
|
2779
|
-
'symbol': config['symbol']
|
|
2780
|
-
},
|
|
2781
|
-
'chunking': {
|
|
2782
|
-
'enabled': True,
|
|
2783
|
-
'date_param': 'from_',
|
|
2784
|
-
'date_param_to': 'to',
|
|
2785
|
-
'chunk_days': 90
|
|
2786
|
-
},
|
|
2787
|
-
'post_process': lambda df: _process_options_df(df)
|
|
2788
|
-
})
|
|
2789
|
-
|
|
2790
|
-
# Load IV surface if strategy uses term structure
|
|
2791
|
-
if any(k in config for k in ['short_tenor', 'long_tenor', 'delta_target']):
|
|
2792
|
-
requests.append({
|
|
2793
|
-
'name': 'ivs_surface',
|
|
2794
|
-
'endpoint': '/equities/eod/ivs-by-delta',
|
|
2795
|
-
'params': {
|
|
2796
|
-
'symbol': config['symbol'],
|
|
2797
|
-
'deltaFrom': config.get('delta_target', 0.5) - 0.05,
|
|
2798
|
-
'deltaTo': config.get('delta_target', 0.5) + 0.05,
|
|
2799
|
-
'periodFrom': config.get('short_tenor', 30) - 7,
|
|
2800
|
-
'periodTo': config.get('long_tenor', 90) + 7
|
|
2801
|
-
},
|
|
2802
|
-
'chunking': {
|
|
2803
|
-
'enabled': True,
|
|
2804
|
-
'date_param': 'from_',
|
|
2805
|
-
'date_param_to': 'to',
|
|
2806
|
-
'chunk_days': 90
|
|
2807
|
-
}
|
|
2808
|
-
})
|
|
2809
|
-
|
|
2810
|
-
# Load stock prices
|
|
2811
|
-
requests.append({
|
|
2812
|
-
'name': 'stock',
|
|
2813
|
-
'endpoint': '/equities/eod/stock-prices',
|
|
2814
|
-
'params': {
|
|
2815
|
-
'symbol': config['symbol']
|
|
2816
|
-
},
|
|
2817
|
-
'chunking': {
|
|
2818
|
-
'enabled': True,
|
|
2819
|
-
'date_param': 'from_',
|
|
2820
|
-
'date_param_to': 'to',
|
|
2821
|
-
'chunk_days': 365 # Stock data is lightweight
|
|
2822
|
-
}
|
|
2823
|
-
})
|
|
2824
|
-
|
|
2825
|
-
return requests
|
|
2826
|
-
|
|
2827
|
-
|
|
2828
|
-
def _process_options_df(df):
|
|
2829
|
-
"""Process options DataFrame: dates + DTE + OPTIMIZATIONS (5-10x faster!)"""
|
|
2830
|
-
# Basic date processing
|
|
2831
|
-
if 'date' in df.columns:
|
|
2832
|
-
df['date'] = pd.to_datetime(df['date']).dt.date
|
|
2833
|
-
if 'expiration' in df.columns:
|
|
2834
|
-
df['expiration'] = pd.to_datetime(df['expiration']).dt.date
|
|
2835
|
-
|
|
2836
|
-
if 'date' in df.columns and 'expiration' in df.columns:
|
|
2837
|
-
df = df.copy()
|
|
2838
|
-
df['dte'] = (pd.to_datetime(df['expiration']) -
|
|
2839
|
-
pd.to_datetime(df['date'])).dt.days
|
|
2840
|
-
|
|
2841
|
-
# ========================================================
|
|
2842
|
-
# CRITICAL: SORT BY DATE FIRST! (Required for time-series)
|
|
2843
|
-
# ========================================================
|
|
2844
|
-
if 'date' in df.columns:
|
|
2845
|
-
# Check if already sorted (skip if yes, fast!)
|
|
2846
|
-
if not df['date'].is_monotonic_increasing:
|
|
2847
|
-
df = df.sort_values('date') # ✅ Sort only if needed
|
|
2848
|
-
|
|
2849
|
-
# ========================================================
|
|
2850
|
-
# AUTOMATIC OPTIMIZATIONS (applied by library)
|
|
2851
|
-
# ========================================================
|
|
2852
|
-
|
|
2853
|
-
# These optimizations are SAFE to apply automatically:
|
|
2854
|
-
# - Categorical types for low-cardinality columns
|
|
2855
|
-
# - Optimized numeric types (float32/int16 instead of float64/int64)
|
|
2856
|
-
#
|
|
2857
|
-
# NOTE: We do NOT set index on 'date' in library functions because:
|
|
2858
|
-
# - It breaks existing code that uses .loc with non-date indices
|
|
2859
|
-
# - Requires all strategies to handle Series vs scalar results
|
|
2860
|
-
|
|
2861
|
-
# Convert Call/Put to categorical (60% less RAM, 2x faster filtering)
|
|
2862
|
-
if 'Call/Put' in df.columns:
|
|
2863
|
-
df['Call/Put'] = df['Call/Put'].astype('category')
|
|
2864
|
-
|
|
2865
|
-
# Optimize data types (50% less RAM)
|
|
2866
|
-
# float32 for prices (4 bytes instead of 8, enough precision)
|
|
2867
|
-
float32_cols = ['strike', 'bid', 'ask', 'iv', 'price', 'mid', 'delta', 'gamma', 'vega', 'theta']
|
|
2868
|
-
for col in float32_cols:
|
|
2869
|
-
if col in df.columns:
|
|
2870
|
-
df[col] = pd.to_numeric(df[col], errors='coerce').astype('float32')
|
|
2871
|
-
|
|
2872
|
-
# int16 for DTE (2 bytes instead of 8, max 32767 days)
|
|
2873
|
-
if 'dte' in df.columns:
|
|
2874
|
-
df['dte'] = df['dte'].astype('int16')
|
|
2875
|
-
|
|
2876
|
-
return df
|
|
2877
|
-
|
|
2878
|
-
|
|
2879
|
-
def _auto_process_dates(df):
|
|
2880
|
-
"""Auto-process common date columns + SORT BY DATE"""
|
|
2881
|
-
date_columns = ['date', 'expiration', 'trade_date', 'time']
|
|
2882
|
-
|
|
2883
|
-
for col in date_columns:
|
|
2884
|
-
if col in df.columns:
|
|
2885
|
-
try:
|
|
2886
|
-
if col == 'time':
|
|
2887
|
-
# Keep time as string or datetime
|
|
2888
|
-
pass
|
|
2889
|
-
else:
|
|
2890
|
-
df[col] = pd.to_datetime(df[col]).dt.date
|
|
2891
|
-
except:
|
|
2892
|
-
pass # Already in correct format or not a date
|
|
2893
|
-
|
|
2894
|
-
# ========================================================
|
|
2895
|
-
# CRITICAL: SORT BY DATE! (Required for time-series)
|
|
2896
|
-
# ========================================================
|
|
2897
|
-
if 'date' in df.columns:
|
|
2898
|
-
# Check if already sorted (O(1) check vs O(N log N) sort)
|
|
2899
|
-
if not df['date'].is_monotonic_increasing:
|
|
2900
|
-
df = df.sort_values('date') # ✅ Sort only if needed
|
|
2901
|
-
elif 'trade_date' in df.columns:
|
|
2902
|
-
if not df['trade_date'].is_monotonic_increasing:
|
|
2903
|
-
df = df.sort_values('trade_date') # Alternative date column
|
|
2904
|
-
|
|
2905
|
-
return df
|
|
2906
|
-
|
|
2907
|
-
|
|
2908
|
-
# ============================================================
|
|
2909
|
-
# NEW: OPTIMIZATION FRAMEWORK
|
|
2910
|
-
# ============================================================
|
|
2911
|
-
def optimize_parameters(base_config, param_grid, strategy_function,
|
|
2912
|
-
optimization_metric='sharpe', min_trades=5,
|
|
2913
|
-
max_drawdown_limit=None, parallel=False,
|
|
2914
|
-
export_each_combo=True, # ← NEW PARAMETER
|
|
2915
|
-
optimization_config=None, # ← NEW PARAMETER FOR PRESETS
|
|
2916
|
-
results_folder=None # ← NEW: Use existing folder or create new
|
|
2917
|
-
):
|
|
2918
|
-
"""
|
|
2919
|
-
Optimize strategy parameters across multiple combinations
|
|
2920
|
-
|
|
2921
|
-
Args:
|
|
2922
|
-
base_config: Base configuration dict
|
|
2923
|
-
param_grid: Dict of parameters to optimize
|
|
2924
|
-
Example: {'z_score_entry': [1.0, 1.5, 2.0], 'z_score_exit': [0.1, 0.3, 0.5]}
|
|
2925
|
-
strategy_function: Strategy function to run
|
|
2926
|
-
optimization_metric: Metric to optimize ('sharpe', 'total_return', 'total_pnl', 'profit_factor', 'calmar')
|
|
2927
|
-
min_trades: Minimum number of trades required
|
|
2928
|
-
max_drawdown_limit: Maximum acceptable drawdown (e.g., 0.10 for 10%)
|
|
2929
|
-
parallel: Use parallel processing (not implemented yet)
|
|
2930
|
-
export_each_combo: If True, exports files for each combination # ←
|
|
2931
|
-
|
|
2932
|
-
Returns:
|
|
2933
|
-
tuple: (results_df, best_params, results_folder)
|
|
2934
|
-
"""
|
|
2935
|
-
|
|
2936
|
-
# Check if optimization_config has preset and apply it automatically
|
|
2937
|
-
if optimization_config and isinstance(optimization_config, dict) and 'preset' in optimization_config:
|
|
2938
|
-
preset = optimization_config['preset']
|
|
2939
|
-
print(f"🔄 Auto-applying preset: {preset}")
|
|
2940
|
-
apply_optimization_preset(optimization_config, preset)
|
|
2941
|
-
print_preset_info(optimization_config)
|
|
2942
|
-
|
|
2943
|
-
# Use preset parameters for grid and validation criteria
|
|
2944
|
-
param_grid = optimization_config['param_grid']
|
|
2945
|
-
min_trades = optimization_config['min_trades']
|
|
2946
|
-
max_drawdown_limit = optimization_config['max_drawdown_limit']
|
|
2947
|
-
|
|
2948
|
-
# Use optimization_config for optimization_metric if available
|
|
2949
|
-
if 'optimization_metric' in optimization_config:
|
|
2950
|
-
optimization_metric = optimization_config['optimization_metric']
|
|
2951
|
-
|
|
2952
|
-
# Use optimization_config for execution settings if available
|
|
2953
|
-
if 'parallel' in optimization_config:
|
|
2954
|
-
parallel = optimization_config['parallel']
|
|
2955
|
-
if 'export_each_combo' in optimization_config:
|
|
2956
|
-
export_each_combo = optimization_config['export_each_combo']
|
|
2957
|
-
|
|
2958
|
-
# ═══ ADD AT THE BEGINNING OF FUNCTION ═══
|
|
2959
|
-
# Create results folder (or use provided one)
|
|
2960
|
-
if results_folder is None:
|
|
2961
|
-
results_folder = create_optimization_folder()
|
|
2962
|
-
print(f"📊 Results will be saved to: {results_folder}\n")
|
|
2963
|
-
else:
|
|
2964
|
-
print(f"📊 Using existing results folder: {results_folder}\n")
|
|
2965
|
-
|
|
2966
|
-
# Record start time
|
|
2967
|
-
optimization_start_time = datetime.now()
|
|
2968
|
-
start_time_str = optimization_start_time.strftime('%Y-%m-%d %H:%M:%S')
|
|
2969
|
-
|
|
2970
|
-
print("\n" + "="*80)
|
|
2971
|
-
print(" "*20 + "PARAMETER OPTIMIZATION")
|
|
2972
|
-
print("="*80)
|
|
2973
|
-
print(f"Strategy: {base_config.get('strategy_name', 'Unknown')}")
|
|
2974
|
-
print(f"Period: {base_config.get('start_date')} to {base_config.get('end_date')}")
|
|
2975
|
-
print(f"Optimization Metric: {optimization_metric}")
|
|
2976
|
-
print(f"Min Trades: {min_trades}")
|
|
2977
|
-
print(f"🕐 Started: {start_time_str}")
|
|
2978
|
-
if max_drawdown_limit:
|
|
2979
|
-
print(f"Max Drawdown Limit: {max_drawdown_limit*100:.0f}%")
|
|
2980
|
-
print("="*80 + "\n")
|
|
2981
|
-
|
|
2982
|
-
# Generate all combinations
|
|
2983
|
-
param_names = list(param_grid.keys())
|
|
2984
|
-
param_values = list(param_grid.values())
|
|
2985
|
-
all_combinations = list(product(*param_values))
|
|
2986
|
-
|
|
2987
|
-
total_combinations = len(all_combinations)
|
|
2988
|
-
print(f"Testing {total_combinations} parameter combinations...")
|
|
2989
|
-
print(f"Parameters: {param_names}")
|
|
2990
|
-
print(f"Grid: {param_grid}\n")
|
|
2991
|
-
|
|
2992
|
-
# Create SHARED progress context for all backtests
|
|
2993
|
-
try:
|
|
2994
|
-
from IPython.display import display
|
|
2995
|
-
import ipywidgets as widgets
|
|
2996
|
-
|
|
2997
|
-
progress_bar = widgets.FloatProgress(
|
|
2998
|
-
value=0, min=0, max=100,
|
|
2999
|
-
description='Optimizing:',
|
|
3000
|
-
bar_style='info',
|
|
3001
|
-
layout=widgets.Layout(width='100%', height='30px')
|
|
3002
|
-
)
|
|
3003
|
-
|
|
3004
|
-
status_label = widgets.HTML(value="<b>Starting optimization...</b>")
|
|
3005
|
-
display(widgets.VBox([progress_bar, status_label]))
|
|
3006
|
-
|
|
3007
|
-
monitor = ResourceMonitor()
|
|
3008
|
-
opt_start_time = time.time()
|
|
3009
|
-
|
|
3010
|
-
# Create shared progress context (will suppress individual backtest progress)
|
|
3011
|
-
shared_progress = {
|
|
3012
|
-
'progress_widgets': (progress_bar, status_label, monitor, opt_start_time),
|
|
3013
|
-
'is_optimization': True
|
|
3014
|
-
}
|
|
3015
|
-
has_widgets = True
|
|
3016
|
-
except:
|
|
3017
|
-
shared_progress = None
|
|
3018
|
-
has_widgets = False
|
|
3019
|
-
print("Running optimization (no progress bar)...")
|
|
3020
|
-
|
|
3021
|
-
# ═══════════════════════════════════════════════════════════════════════════
|
|
3022
|
-
# PRELOAD DATA ONCE (FOR ALL OPTIMIZATION ITERATIONS)
|
|
3023
|
-
# ═══════════════════════════════════════════════════════════════════════════
|
|
3024
|
-
print("\n" + "="*80)
|
|
3025
|
-
print("📥 PRELOADING OPTIONS DATA (loads ONCE, reused for all combinations)")
|
|
3026
|
-
print("="*80)
|
|
3027
|
-
|
|
3028
|
-
preloaded_lean_df, preloaded_options_df = preload_options_data(
|
|
3029
|
-
base_config,
|
|
3030
|
-
progress_widgets=shared_progress['progress_widgets'] if shared_progress else None
|
|
3031
|
-
)
|
|
3032
|
-
|
|
3033
|
-
if preloaded_lean_df.empty:
|
|
3034
|
-
print("\n❌ ERROR: Failed to preload data. Cannot proceed with optimization.")
|
|
3035
|
-
return pd.DataFrame(), None
|
|
3036
|
-
|
|
3037
|
-
print(f"✓ Preloading complete! Data will be reused for all {total_combinations} combinations")
|
|
3038
|
-
print("="*80 + "\n")
|
|
3039
|
-
|
|
3040
|
-
# ═══════════════════════════════════════════════════════════════════════════
|
|
3041
|
-
# RESET PROGRESS BAR FOR OPTIMIZATION LOOP
|
|
3042
|
-
# ═══════════════════════════════════════════════════════════════════════════
|
|
3043
|
-
if has_widgets:
|
|
3044
|
-
progress_bar.value = 0
|
|
3045
|
-
progress_bar.bar_style = 'info'
|
|
3046
|
-
status_label.value = "<b style='color:#0066cc'>Starting optimization loop...</b>"
|
|
3047
|
-
|
|
3048
|
-
# Run backtests
|
|
3049
|
-
results = []
|
|
3050
|
-
start_time = time.time()
|
|
3051
|
-
|
|
3052
|
-
for idx, param_combo in enumerate(all_combinations, 1):
|
|
3053
|
-
# Create test config
|
|
3054
|
-
test_config = base_config.copy()
|
|
3055
|
-
|
|
3056
|
-
# Update parameters
|
|
3057
|
-
for param_name, param_value in zip(param_names, param_combo):
|
|
3058
|
-
test_config[param_name] = param_value
|
|
3059
|
-
|
|
3060
|
-
# Update name
|
|
3061
|
-
param_str = "_".join([f"{k}={v}" for k, v in zip(param_names, param_combo)])
|
|
3062
|
-
test_config['strategy_name'] = f"{base_config.get('strategy_name', 'Strategy')} [{param_str}]"
|
|
3063
|
-
|
|
3064
|
-
# ═══ ADD PRELOADED DATA TO CONFIG ═══
|
|
3065
|
-
test_config['_preloaded_lean_df'] = preloaded_lean_df
|
|
3066
|
-
test_config['_preloaded_options_cache'] = preloaded_options_df
|
|
3067
|
-
|
|
3068
|
-
# Update progress
|
|
3069
|
-
if has_widgets:
|
|
3070
|
-
# Use update_progress for full display with ETA, CPU, RAM
|
|
3071
|
-
update_progress(
|
|
3072
|
-
progress_bar, status_label, monitor,
|
|
3073
|
-
current=idx,
|
|
3074
|
-
total=total_combinations,
|
|
3075
|
-
start_time=start_time,
|
|
3076
|
-
message=f"Testing: {param_str}"
|
|
3077
|
-
)
|
|
3078
|
-
else:
|
|
3079
|
-
if idx % max(1, total_combinations // 10) == 0:
|
|
3080
|
-
print(f"[{idx}/{total_combinations}] {param_str}")
|
|
3081
|
-
|
|
3082
|
-
# ═══ MODIFY run_backtest CALL (lines ~2240-2248) ═══
|
|
3083
|
-
try:
|
|
3084
|
-
# Create compact parameter string (e.g., Z1.0_E0.1_PT20)
|
|
3085
|
-
param_parts = []
|
|
3086
|
-
for name, value in zip(param_names, param_combo):
|
|
3087
|
-
if 'z_score_entry' in name:
|
|
3088
|
-
param_parts.append(f"Z{value}")
|
|
3089
|
-
elif 'z_score_exit' in name:
|
|
3090
|
-
param_parts.append(f"E{value}")
|
|
3091
|
-
elif 'profit_target' in name:
|
|
3092
|
-
if value is None:
|
|
3093
|
-
param_parts.append("PTNo")
|
|
3094
|
-
else:
|
|
3095
|
-
param_parts.append(f"PT{int(value*100)}")
|
|
3096
|
-
elif 'min_days' in name:
|
|
3097
|
-
param_parts.append(f"D{value}")
|
|
3098
|
-
else:
|
|
3099
|
-
# Generic short name for other params
|
|
3100
|
-
short_name = ''.join([c for c in name if c.isupper() or c.isdigit()])[:3]
|
|
3101
|
-
param_parts.append(f"{short_name}{value}")
|
|
3102
|
-
|
|
3103
|
-
compact_params = "_".join(param_parts)
|
|
3104
|
-
|
|
3105
|
-
# Create combo folder: c01_Z1.0_E0.1_PT20
|
|
3106
|
-
combo_folder = os.path.join(results_folder, f'c{idx:02d}_{compact_params}')
|
|
3107
|
-
os.makedirs(combo_folder, exist_ok=True)
|
|
3108
|
-
|
|
3109
|
-
# File prefix: c01_Z1.0_E0.1_PT20
|
|
3110
|
-
combo_prefix = f"c{idx:02d}_{compact_params}"
|
|
3111
|
-
|
|
3112
|
-
# Run backtest WITH EXPORT AND CHARTS (saved but not displayed)
|
|
3113
|
-
analyzer = run_backtest(
|
|
3114
|
-
strategy_function,
|
|
3115
|
-
test_config,
|
|
3116
|
-
print_report=False,
|
|
3117
|
-
create_charts=export_each_combo, # ← CREATE CHARTS (saved but not displayed)
|
|
3118
|
-
export_results=export_each_combo, # ← MODIFIED
|
|
3119
|
-
progress_context=shared_progress,
|
|
3120
|
-
chart_filename=os.path.join(combo_folder, 'equity_curve.png') if export_each_combo else None, # ← CHARTS SAVED
|
|
3121
|
-
export_prefix=os.path.join(combo_folder, combo_prefix) if export_each_combo else None # ← ADDED
|
|
3122
|
-
)
|
|
3123
|
-
|
|
3124
|
-
# Check validity
|
|
3125
|
-
is_valid = True
|
|
3126
|
-
invalid_reason = ""
|
|
3127
|
-
|
|
3128
|
-
if analyzer.metrics['total_trades'] < min_trades:
|
|
3129
|
-
is_valid = False
|
|
3130
|
-
invalid_reason = f"Too few trades ({analyzer.metrics['total_trades']})"
|
|
3131
|
-
|
|
3132
|
-
if max_drawdown_limit and analyzer.metrics['max_drawdown'] > (max_drawdown_limit * 100):
|
|
3133
|
-
is_valid = False
|
|
3134
|
-
invalid_reason = f"Excessive drawdown ({analyzer.metrics['max_drawdown']:.1f}%)"
|
|
3135
|
-
|
|
3136
|
-
# Print compact statistics for this combination
|
|
3137
|
-
status_symbol = "✓" if is_valid else "✗"
|
|
3138
|
-
status_color = "#00cc00" if is_valid else "#ff6666"
|
|
3139
|
-
|
|
3140
|
-
print(f"\n[{idx}/{total_combinations}] {param_str}")
|
|
3141
|
-
print("-" * 80)
|
|
3142
|
-
if is_valid:
|
|
3143
|
-
print(f" {status_symbol} Return: {analyzer.metrics['total_return']:>7.2f}% | "
|
|
3144
|
-
f"Sharpe: {analyzer.metrics['sharpe']:>6.2f} | "
|
|
3145
|
-
f"Max DD: {analyzer.metrics['max_drawdown']:>6.2f}% | "
|
|
3146
|
-
f"Trades: {analyzer.metrics['total_trades']:>3} | "
|
|
3147
|
-
f"Win Rate: {analyzer.metrics['win_rate']:>5.1f}% | "
|
|
3148
|
-
f"PF: {analyzer.metrics['profit_factor']:>5.2f}")
|
|
3149
|
-
else:
|
|
3150
|
-
print(f" {status_symbol} INVALID: {invalid_reason}")
|
|
3151
|
-
|
|
3152
|
-
# Update widget status with last result
|
|
3153
|
-
if has_widgets:
|
|
3154
|
-
result_text = f"Return: {analyzer.metrics['total_return']:.1f}% | Sharpe: {analyzer.metrics['sharpe']:.2f}" if is_valid else invalid_reason
|
|
3155
|
-
|
|
3156
|
-
# Get resource usage
|
|
3157
|
-
cpu_pct = monitor.get_cpu_percent()
|
|
3158
|
-
mem_info = monitor.get_memory_info()
|
|
3159
|
-
ram_mb = mem_info[0] # process_mb
|
|
3160
|
-
resource_text = f"CPU: {cpu_pct:.0f}% | RAM: {ram_mb:.0f}MB"
|
|
3161
|
-
|
|
3162
|
-
status_label.value = (
|
|
3163
|
-
f"<b style='color:{status_color}'>[{idx}/{total_combinations}] {param_str}</b><br>"
|
|
3164
|
-
f"<span style='color:#666'>{result_text}</span><br>"
|
|
3165
|
-
f"<span style='color:#999;font-size:10px'>{resource_text}</span>"
|
|
3166
|
-
)
|
|
3167
|
-
|
|
3168
|
-
# Store results
|
|
3169
|
-
result = {
|
|
3170
|
-
'combination_id': idx,
|
|
3171
|
-
'is_valid': is_valid,
|
|
3172
|
-
'invalid_reason': invalid_reason,
|
|
3173
|
-
**{name: value for name, value in zip(param_names, param_combo)},
|
|
3174
|
-
'total_return': analyzer.metrics['total_return'],
|
|
3175
|
-
'sharpe': analyzer.metrics['sharpe'],
|
|
3176
|
-
'sortino': analyzer.metrics['sortino'],
|
|
3177
|
-
'calmar': analyzer.metrics['calmar'],
|
|
3178
|
-
'max_drawdown': analyzer.metrics['max_drawdown'],
|
|
3179
|
-
'win_rate': analyzer.metrics['win_rate'],
|
|
3180
|
-
'profit_factor': analyzer.metrics['profit_factor'],
|
|
3181
|
-
'total_trades': analyzer.metrics['total_trades'],
|
|
3182
|
-
'avg_win': analyzer.metrics['avg_win'],
|
|
3183
|
-
'avg_loss': analyzer.metrics['avg_loss'],
|
|
3184
|
-
'volatility': analyzer.metrics['volatility'],
|
|
3185
|
-
}
|
|
3186
|
-
|
|
3187
|
-
results.append(result)
|
|
3188
|
-
|
|
3189
|
-
# Show intermediate summary every 10 combinations (or at end)
|
|
3190
|
-
if idx % 10 == 0 or idx == total_combinations:
|
|
3191
|
-
valid_so_far = [r for r in results if r['is_valid']]
|
|
3192
|
-
if valid_so_far:
|
|
3193
|
-
print("\n" + "="*80)
|
|
3194
|
-
print(f"INTERMEDIATE SUMMARY ({idx}/{total_combinations} tested)")
|
|
3195
|
-
print("="*80)
|
|
3196
|
-
|
|
3197
|
-
# Sort by optimization metric
|
|
3198
|
-
if optimization_metric == 'sharpe':
|
|
3199
|
-
valid_so_far.sort(key=lambda x: x['sharpe'], reverse=True)
|
|
3200
|
-
elif optimization_metric == 'total_return':
|
|
3201
|
-
valid_so_far.sort(key=lambda x: x['total_return'], reverse=True)
|
|
3202
|
-
elif optimization_metric == 'total_pnl':
|
|
3203
|
-
valid_so_far.sort(key=lambda x: x['total_pnl'], reverse=True)
|
|
3204
|
-
elif optimization_metric == 'profit_factor':
|
|
3205
|
-
valid_so_far.sort(key=lambda x: x['profit_factor'], reverse=True)
|
|
3206
|
-
elif optimization_metric == 'calmar':
|
|
3207
|
-
valid_so_far.sort(key=lambda x: x['calmar'], reverse=True)
|
|
3208
|
-
|
|
3209
|
-
# Show top 3
|
|
3210
|
-
print(f"\n🏆 TOP 3 BY {optimization_metric.upper()}:")
|
|
3211
|
-
print("-"*80)
|
|
3212
|
-
for rank, res in enumerate(valid_so_far[:3], 1):
|
|
3213
|
-
params_display = ", ".join([f"{name}={res[name]}" for name in param_names])
|
|
3214
|
-
print(f" {rank}. [{params_display}]")
|
|
3215
|
-
print(f" Return: {res['total_return']:>7.2f}% | "
|
|
3216
|
-
f"Sharpe: {res['sharpe']:>6.2f} | "
|
|
3217
|
-
f"Max DD: {res['max_drawdown']:>6.2f}% | "
|
|
3218
|
-
f"Trades: {res['total_trades']:>3}")
|
|
3219
|
-
|
|
3220
|
-
print(f"\nValid: {len(valid_so_far)}/{idx} | "
|
|
3221
|
-
f"Invalid: {idx - len(valid_so_far)}/{idx}")
|
|
3222
|
-
print("="*80 + "\n")
|
|
3223
|
-
|
|
3224
|
-
except Exception as e:
|
|
3225
|
-
print(f"\n[{idx}/{total_combinations}] {param_str}")
|
|
3226
|
-
print("-" * 80)
|
|
3227
|
-
print(f" ✗ ERROR: {str(e)}")
|
|
3228
|
-
import traceback
|
|
3229
|
-
print(" Full traceback:")
|
|
3230
|
-
traceback.print_exc()
|
|
3231
|
-
|
|
3232
|
-
result = {
|
|
3233
|
-
'combination_id': idx,
|
|
3234
|
-
'is_valid': False,
|
|
3235
|
-
'invalid_reason': f"Error: {str(e)[:50]}",
|
|
3236
|
-
**{name: value for name, value in zip(param_names, param_combo)},
|
|
3237
|
-
'total_return': 0, 'sharpe': 0, 'sortino': 0, 'calmar': 0,
|
|
3238
|
-
'max_drawdown': 0, 'win_rate': 0, 'profit_factor': 0,
|
|
3239
|
-
'total_trades': 0, 'avg_win': 0, 'avg_loss': 0, 'volatility': 0
|
|
3240
|
-
}
|
|
3241
|
-
results.append(result)
|
|
3242
|
-
|
|
3243
|
-
elapsed = time.time() - start_time
|
|
3244
|
-
|
|
3245
|
-
if has_widgets:
|
|
3246
|
-
progress_bar.value = 100
|
|
3247
|
-
progress_bar.bar_style = 'success'
|
|
3248
|
-
status_label.value = f"<b style='color:#00cc00'>✓ Optimization complete in {int(elapsed)}s</b>"
|
|
3249
|
-
|
|
3250
|
-
# Create results DataFrame
|
|
3251
|
-
results_df = pd.DataFrame(results)
|
|
3252
|
-
|
|
3253
|
-
# Round numeric columns to 2 decimals
|
|
3254
|
-
numeric_columns = results_df.select_dtypes(include=['float64', 'float32', 'float']).columns
|
|
3255
|
-
for col in numeric_columns:
|
|
3256
|
-
results_df[col] = results_df[col].round(5)
|
|
3257
|
-
|
|
3258
|
-
# ═══ ADD SUMMARY SAVE TO FOLDER ═══
|
|
3259
|
-
summary_path = os.path.join(results_folder, 'optimization_summary.csv')
|
|
3260
|
-
results_df.to_csv(summary_path, index=False)
|
|
3261
|
-
print(f"\n✓ Summary saved: {summary_path}")
|
|
3262
|
-
|
|
3263
|
-
# Find best parameters
|
|
3264
|
-
valid_results = results_df[results_df['is_valid'] == True].copy()
|
|
3265
|
-
|
|
3266
|
-
if len(valid_results) == 0:
|
|
3267
|
-
print("\n" + "="*80)
|
|
3268
|
-
print("WARNING: No valid combinations found!")
|
|
3269
|
-
print("Try relaxing constraints or checking parameter ranges")
|
|
3270
|
-
print("="*80)
|
|
3271
|
-
return results_df, None, results_folder
|
|
3272
|
-
|
|
3273
|
-
# Select best based on metric
|
|
3274
|
-
if optimization_metric == 'sharpe':
|
|
3275
|
-
best_idx = valid_results['sharpe'].idxmax()
|
|
3276
|
-
elif optimization_metric == 'total_return':
|
|
3277
|
-
best_idx = valid_results['total_return'].idxmax()
|
|
3278
|
-
elif optimization_metric == 'total_pnl':
|
|
3279
|
-
best_idx = valid_results['total_pnl'].idxmax()
|
|
3280
|
-
elif optimization_metric == 'profit_factor':
|
|
3281
|
-
best_idx = valid_results['profit_factor'].idxmax()
|
|
3282
|
-
elif optimization_metric == 'calmar':
|
|
3283
|
-
best_idx = valid_results['calmar'].idxmax()
|
|
3284
|
-
else:
|
|
3285
|
-
best_idx = valid_results['sharpe'].idxmax()
|
|
3286
|
-
|
|
3287
|
-
best_result = valid_results.loc[best_idx]
|
|
3288
|
-
|
|
3289
|
-
# Extract best parameters
|
|
3290
|
-
best_params = {name: best_result[name] for name in param_names}
|
|
3291
|
-
|
|
3292
|
-
# Add stop_loss_pct if it exists in config (it's handled separately in notebook)
|
|
3293
|
-
if 'stop_loss_config' in base_config and base_config['stop_loss_config']:
|
|
3294
|
-
stop_loss_value = base_config['stop_loss_config'].get('value')
|
|
3295
|
-
if stop_loss_value is not None:
|
|
3296
|
-
best_params['stop_loss_pct'] = stop_loss_value
|
|
3297
|
-
|
|
3298
|
-
# Calculate total time
|
|
3299
|
-
optimization_end_time = datetime.now()
|
|
3300
|
-
total_duration = optimization_end_time - optimization_start_time
|
|
3301
|
-
end_time_str = optimization_end_time.strftime('%Y-%m-%d %H:%M:%S')
|
|
3302
|
-
duration_str = format_time(total_duration.total_seconds())
|
|
3303
|
-
|
|
3304
|
-
# Print summary
|
|
3305
|
-
print("\n" + "="*120)
|
|
3306
|
-
print(" "*31 + "🏆 OPTIMIZATION COMPLETE 🏆")
|
|
3307
|
-
print(" "*31 + "=========================")
|
|
3308
|
-
print(f" • Started : {start_time_str}")
|
|
3309
|
-
print(f" • Finished : {end_time_str}")
|
|
3310
|
-
print(f" • Total Duration : {duration_str} ({int(total_duration.total_seconds())} seconds)")
|
|
3311
|
-
print(f" • Average per run : {total_duration.total_seconds() / total_combinations:.1f} seconds")
|
|
3312
|
-
print(f" • Total combinations : {total_combinations}")
|
|
3313
|
-
print(f" • Valid combinations : {len(valid_results)}")
|
|
3314
|
-
print(f" • Invalid combinations : {len(results_df) - len(valid_results)}")
|
|
3315
|
-
|
|
3316
|
-
print(f"\n📈 OPTIMIZATION METRIC:")
|
|
3317
|
-
print(f" • Metric optimized : {optimization_metric.upper()}")
|
|
3318
|
-
|
|
3319
|
-
# Format best parameters in one line (with special formatting for stop_loss_pct)
|
|
3320
|
-
param_parts = []
|
|
3321
|
-
for name, value in best_params.items():
|
|
3322
|
-
if name == 'stop_loss_pct':
|
|
3323
|
-
param_parts.append(f"stop_loss={value*100:.0f}%")
|
|
3324
|
-
else:
|
|
3325
|
-
param_parts.append(f"{name}={value}")
|
|
3326
|
-
param_str = ", ".join(param_parts)
|
|
3327
|
-
print(f" • Best parameters : {param_str}")
|
|
3328
|
-
|
|
3329
|
-
# Add intraday stop-loss info if enabled
|
|
3330
|
-
intraday_stops = base_config.get('intraday_stops', {})
|
|
3331
|
-
if intraday_stops.get('enabled', False):
|
|
3332
|
-
intraday_pct = intraday_stops.get('stop_pct', 0.03) * 100
|
|
3333
|
-
intraday_days = intraday_stops.get('min_days_before_intraday', 3)
|
|
3334
|
-
print(f" • Intraday stop-loss : Enabled ({intraday_pct:.0f}% after {intraday_days} days)")
|
|
3335
|
-
|
|
3336
|
-
print(f"\n🏆 BEST PERFORMANCE:")
|
|
3337
|
-
print(f" • Total Return : {best_result['total_return']:>10.2f}%")
|
|
3338
|
-
print(f" • Sharpe Ratio : {best_result['sharpe']:>10.2f}")
|
|
3339
|
-
print(f" • Max Drawdown : {best_result['max_drawdown']:>10.2f}%")
|
|
3340
|
-
print(f" • Win Rate : {best_result['win_rate']:>10.1f}%")
|
|
3341
|
-
print(f" • Profit Factor : {best_result['profit_factor']:>10.2f}")
|
|
3342
|
-
print(f" • Total Trades : {best_result['total_trades']:>10.0f}")
|
|
3343
|
-
|
|
3344
|
-
print(f"\n🔌 API ENDPOINTS:")
|
|
3345
|
-
# Extract real endpoints from preloaded data stats
|
|
3346
|
-
endpoints_info = []
|
|
3347
|
-
|
|
3348
|
-
if '_stats' in base_config and 'dataset_details' in base_config['_stats']:
|
|
3349
|
-
dataset_details = base_config['_stats']['dataset_details']
|
|
3350
|
-
for dataset_name, info in dataset_details.items():
|
|
3351
|
-
endpoint = info.get('endpoint')
|
|
3352
|
-
rows = info.get('rows', 0)
|
|
3353
|
-
if endpoint:
|
|
3354
|
-
endpoints_info.append((endpoint, rows))
|
|
3355
|
-
|
|
3356
|
-
# Check if intraday stops are enabled
|
|
3357
|
-
intraday_stops = base_config.get('intraday_stops', {})
|
|
3358
|
-
if intraday_stops.get('enabled', False):
|
|
3359
|
-
intraday_endpoint = "/equities/intraday/stock-prices"
|
|
3360
|
-
if not any(ep[0] == intraday_endpoint for ep in endpoints_info):
|
|
3361
|
-
endpoints_info.append((intraday_endpoint, "on-demand"))
|
|
3362
|
-
|
|
3363
|
-
if endpoints_info:
|
|
3364
|
-
for idx, (endpoint, rows) in enumerate(endpoints_info, 1):
|
|
3365
|
-
if isinstance(rows, int):
|
|
3366
|
-
print(f" {idx}. {endpoint:<45} ({rows:>10,} rows)")
|
|
3367
|
-
else:
|
|
3368
|
-
print(f" {idx}. {endpoint:<45} ({rows})")
|
|
3369
|
-
else:
|
|
3370
|
-
# Fallback to static list if no stats available
|
|
3371
|
-
print(f" 1. /equities/eod/options-rawiv")
|
|
3372
|
-
print(f" 2. /equities/eod/stock-prices")
|
|
3373
|
-
if intraday_stops.get('enabled', False):
|
|
3374
|
-
print(f" 3. /equities/intraday/stock-prices")
|
|
3375
|
-
|
|
3376
|
-
print("="*120)
|
|
3377
|
-
|
|
3378
|
-
# ═══════════════════════════════════════════════════════════════════════════
|
|
3379
|
-
# NEW! FULL BACKTEST OF BEST COMBINATION WITH ALL CHARTS
|
|
3380
|
-
# ═══════════════════════════════════════════════════════════════════════════
|
|
3381
|
-
print("\n" + "="*80)
|
|
3382
|
-
print(" "*15 + "RUNNING FULL BACKTEST FOR BEST COMBINATION")
|
|
3383
|
-
print("="*80)
|
|
3384
|
-
print("\n📊 Creating detailed report for best combination...")
|
|
3385
|
-
print(f"Parameters: {', '.join([f'{k}={v}' for k, v in best_params.items()])}\n")
|
|
3386
|
-
|
|
3387
|
-
# Create config for best combination
|
|
3388
|
-
best_config = base_config.copy()
|
|
3389
|
-
best_config.update(best_params)
|
|
3390
|
-
best_config['_preloaded_lean_df'] = preloaded_lean_df
|
|
3391
|
-
best_config['_preloaded_options_cache'] = preloaded_options_df
|
|
3392
|
-
|
|
3393
|
-
# Create folder for best combination
|
|
3394
|
-
best_combo_folder = os.path.join(results_folder, 'best_combination')
|
|
3395
|
-
os.makedirs(best_combo_folder, exist_ok=True)
|
|
3396
|
-
|
|
3397
|
-
# Run FULL backtest with ALL charts and exports
|
|
3398
|
-
# Note: progress_context=None, so plt.show() will be called but fail due to renderer
|
|
3399
|
-
# We'll display charts explicitly afterwards using IPython.display.Image
|
|
3400
|
-
best_analyzer = run_backtest(
|
|
3401
|
-
strategy_function,
|
|
3402
|
-
best_config,
|
|
3403
|
-
print_report=True, # ← SHOW FULL REPORT
|
|
3404
|
-
create_charts=True, # ← CREATE ALL CHARTS
|
|
3405
|
-
export_results=True, # ← EXPORT ALL FILES
|
|
3406
|
-
progress_context=None, # ← Normal mode
|
|
3407
|
-
chart_filename=os.path.join(best_combo_folder, 'equity_curve.png'),
|
|
3408
|
-
export_prefix=os.path.join(best_combo_folder, 'best')
|
|
3409
|
-
)
|
|
3410
|
-
|
|
3411
|
-
# Save detailed metrics to optimization_metrics.csv
|
|
3412
|
-
metrics_data = {
|
|
3413
|
-
'metric': list(best_analyzer.metrics.keys()),
|
|
3414
|
-
'value': list(best_analyzer.metrics.values())
|
|
3415
|
-
}
|
|
3416
|
-
metrics_df = pd.DataFrame(metrics_data)
|
|
3417
|
-
metrics_path = os.path.join(results_folder, 'optimization_metrics.csv')
|
|
3418
|
-
metrics_df.to_csv(metrics_path, index=False)
|
|
3419
|
-
|
|
3420
|
-
print(f"\n✓ Detailed metrics saved: {metrics_path}")
|
|
3421
|
-
print(f"✓ Best combination results saved to: {best_combo_folder}/")
|
|
3422
|
-
|
|
3423
|
-
# ═══════════════════════════════════════════════════════════════════════════
|
|
3424
|
-
# DISPLAY CHARTS FOR BEST COMBINATION IN NOTEBOOK
|
|
3425
|
-
# ═══════════════════════════════════════════════════════════════════════════
|
|
3426
|
-
try:
|
|
3427
|
-
# Charts are displayed in the notebook, not here
|
|
3428
|
-
chart_file = os.path.join(best_combo_folder, 'equity_curve.png')
|
|
3429
|
-
if os.path.exists(chart_file):
|
|
3430
|
-
print(f"\n📈 Best combination charts saved to: {chart_file}")
|
|
3431
|
-
except Exception as e:
|
|
3432
|
-
print(f"\n⚠ Could not display charts (saved to {best_combo_folder}/): {e}")
|
|
3433
|
-
|
|
3434
|
-
# ═══════════════════════════════════════════════════════════════════════════
|
|
3435
|
-
# CREATE OPTIMIZATION COMPARISON CHARTS (save only, display in notebook manually)
|
|
3436
|
-
# ═══════════════════════════════════════════════════════════════════════════
|
|
3437
|
-
print("\n" + "="*80)
|
|
3438
|
-
print(" "*15 + "CREATING OPTIMIZATION COMPARISON CHARTS")
|
|
3439
|
-
print("="*80)
|
|
3440
|
-
try:
|
|
3441
|
-
optimization_chart_path = os.path.join(results_folder, 'optimization_results.png')
|
|
3442
|
-
# Save chart but don't display (show_plot=False) - display will be done in notebook for combined results
|
|
3443
|
-
plot_optimization_results(
|
|
3444
|
-
results_df,
|
|
3445
|
-
param_names,
|
|
3446
|
-
filename=optimization_chart_path,
|
|
3447
|
-
show_plot=False # Don't display here - will be shown in notebook for combined results
|
|
3448
|
-
)
|
|
3449
|
-
print(f"✓ Optimization comparison charts saved to: {optimization_chart_path}")
|
|
3450
|
-
print(" (Chart will be displayed in notebook for combined results)")
|
|
3451
|
-
except Exception as e:
|
|
3452
|
-
print(f"⚠ Could not create optimization charts: {e}")
|
|
3453
|
-
import traceback
|
|
3454
|
-
traceback.print_exc()
|
|
3455
|
-
|
|
3456
|
-
print("="*80 + "\n")
|
|
3457
|
-
|
|
3458
|
-
return results_df, best_params, results_folder
|
|
3459
|
-
|
|
3460
|
-
|
|
3461
|
-
def plot_optimization_results(results_df, param_names, filename='optimization_results.png', show_plot=True):
|
|
3462
|
-
"""
|
|
3463
|
-
Create visualization of optimization results
|
|
3464
|
-
|
|
3465
|
-
Args:
|
|
3466
|
-
results_df: Results DataFrame from optimize_parameters()
|
|
3467
|
-
param_names: List of parameter names
|
|
3468
|
-
filename: Output filename
|
|
3469
|
-
show_plot: If True, display plot in Jupyter notebook (default: True)
|
|
3470
|
-
"""
|
|
3471
|
-
import matplotlib.pyplot as plt
|
|
3472
|
-
import seaborn as sns
|
|
3473
|
-
|
|
3474
|
-
# Handle missing is_valid column (for combined results from multiple optimizations)
|
|
3475
|
-
if 'is_valid' not in results_df.columns:
|
|
3476
|
-
results_df = results_df.copy()
|
|
3477
|
-
results_df['is_valid'] = True
|
|
3478
|
-
|
|
3479
|
-
valid_results = results_df[results_df['is_valid'] == True].copy()
|
|
3480
|
-
|
|
3481
|
-
if valid_results.empty:
|
|
3482
|
-
print("No valid results to plot")
|
|
3483
|
-
return
|
|
3484
|
-
|
|
3485
|
-
sns.set_style("whitegrid")
|
|
3486
|
-
|
|
3487
|
-
fig = plt.figure(figsize=(18, 12))
|
|
3488
|
-
|
|
3489
|
-
# 1. Sharpe vs Total Return scatter
|
|
3490
|
-
ax1 = plt.subplot(2, 3, 1)
|
|
3491
|
-
scatter = ax1.scatter(
|
|
3492
|
-
valid_results['total_return'],
|
|
3493
|
-
valid_results['sharpe'],
|
|
3494
|
-
c=valid_results['max_drawdown'],
|
|
3495
|
-
s=valid_results['total_trades']*10,
|
|
3496
|
-
alpha=0.6,
|
|
3497
|
-
cmap='RdYlGn_r'
|
|
3498
|
-
)
|
|
3499
|
-
ax1.set_xlabel('Total Return (%)', fontsize=10)
|
|
3500
|
-
ax1.set_ylabel('Sharpe Ratio', fontsize=10)
|
|
3501
|
-
ax1.set_title('Sharpe vs Return (size=trades, color=drawdown)', fontsize=11, fontweight='bold')
|
|
3502
|
-
plt.colorbar(scatter, ax=ax1, label='Max Drawdown (%)')
|
|
3503
|
-
ax1.grid(True, alpha=0.3)
|
|
3504
|
-
|
|
3505
|
-
# 2. Parameter heatmap (if 2 parameters)
|
|
3506
|
-
if len(param_names) == 2:
|
|
3507
|
-
ax2 = plt.subplot(2, 3, 2)
|
|
3508
|
-
pivot_data = valid_results.pivot_table(
|
|
3509
|
-
values='sharpe',
|
|
3510
|
-
index=param_names[0],
|
|
3511
|
-
columns=param_names[1],
|
|
3512
|
-
aggfunc='mean'
|
|
3513
|
-
)
|
|
3514
|
-
sns.heatmap(pivot_data, annot=True, fmt='.2f', cmap='RdYlGn', ax=ax2)
|
|
3515
|
-
ax2.set_title(f'Sharpe Ratio Heatmap', fontsize=11, fontweight='bold')
|
|
3516
|
-
else:
|
|
3517
|
-
ax2 = plt.subplot(2, 3, 2)
|
|
3518
|
-
ax2.text(0.5, 0.5, 'Heatmap requires\nexactly 2 parameters',
|
|
3519
|
-
ha='center', va='center', fontsize=12)
|
|
3520
|
-
ax2.axis('off')
|
|
3521
|
-
|
|
3522
|
-
# 3. Win Rate vs Profit Factor
|
|
3523
|
-
ax3 = plt.subplot(2, 3, 3)
|
|
3524
|
-
scatter3 = ax3.scatter(
|
|
3525
|
-
valid_results['win_rate'],
|
|
3526
|
-
valid_results['profit_factor'],
|
|
3527
|
-
c=valid_results['sharpe'],
|
|
3528
|
-
s=100,
|
|
3529
|
-
alpha=0.6,
|
|
3530
|
-
cmap='viridis'
|
|
3531
|
-
)
|
|
3532
|
-
ax3.set_xlabel('Win Rate (%)', fontsize=10)
|
|
3533
|
-
ax3.set_ylabel('Profit Factor', fontsize=10)
|
|
3534
|
-
ax3.set_title('Win Rate vs Profit Factor (color=Sharpe)', fontsize=11, fontweight='bold')
|
|
3535
|
-
plt.colorbar(scatter3, ax=ax3, label='Sharpe Ratio')
|
|
3536
|
-
ax3.grid(True, alpha=0.3)
|
|
3537
|
-
|
|
3538
|
-
# 4. Distribution of Sharpe Ratios
|
|
3539
|
-
ax4 = plt.subplot(2, 3, 4)
|
|
3540
|
-
ax4.hist(valid_results['sharpe'], bins=20, color='steelblue', alpha=0.7, edgecolor='black')
|
|
3541
|
-
ax4.axvline(valid_results['sharpe'].mean(), color='red', linestyle='--', linewidth=2, label='Mean')
|
|
3542
|
-
ax4.axvline(valid_results['sharpe'].median(), color='green', linestyle='--', linewidth=2, label='Median')
|
|
3543
|
-
ax4.set_xlabel('Sharpe Ratio', fontsize=10)
|
|
3544
|
-
ax4.set_ylabel('Frequency', fontsize=10)
|
|
3545
|
-
ax4.set_title('Distribution of Sharpe Ratios', fontsize=11, fontweight='bold')
|
|
3546
|
-
ax4.legend()
|
|
3547
|
-
ax4.grid(True, alpha=0.3, axis='y')
|
|
3548
|
-
|
|
3549
|
-
# 5. Total Trades distribution
|
|
3550
|
-
ax5 = plt.subplot(2, 3, 5)
|
|
3551
|
-
ax5.hist(valid_results['total_trades'], bins=15, color='coral', alpha=0.7, edgecolor='black')
|
|
3552
|
-
ax5.set_xlabel('Total Trades', fontsize=10)
|
|
3553
|
-
ax5.set_ylabel('Frequency', fontsize=10)
|
|
3554
|
-
ax5.set_title('Distribution of Trade Counts', fontsize=11, fontweight='bold')
|
|
3555
|
-
ax5.grid(True, alpha=0.3, axis='y')
|
|
3556
|
-
|
|
3557
|
-
# 6. Top 10 combinations
|
|
3558
|
-
ax6 = plt.subplot(2, 3, 6)
|
|
3559
|
-
if 'combination_id' in valid_results.columns:
|
|
3560
|
-
top_10 = valid_results.nlargest(10, 'sharpe')[['combination_id', 'sharpe']].sort_values('sharpe')
|
|
3561
|
-
ax6.barh(range(len(top_10)), top_10['sharpe'], color='green', alpha=0.7)
|
|
3562
|
-
ax6.set_yticks(range(len(top_10)))
|
|
3563
|
-
ax6.set_yticklabels([f"#{int(x)}" for x in top_10['combination_id']])
|
|
3564
|
-
ax6.set_xlabel('Sharpe Ratio', fontsize=10)
|
|
3565
|
-
ax6.set_title('Top 10 Combinations by Sharpe', fontsize=11, fontweight='bold')
|
|
3566
|
-
else:
|
|
3567
|
-
# Fallback: use index as combination ID
|
|
3568
|
-
top_10 = valid_results.nlargest(10, 'sharpe')['sharpe'].sort_values()
|
|
3569
|
-
ax6.barh(range(len(top_10)), top_10.values, color='green', alpha=0.7)
|
|
3570
|
-
ax6.set_yticks(range(len(top_10)))
|
|
3571
|
-
ax6.set_yticklabels([f"#{i+1}" for i in range(len(top_10))])
|
|
3572
|
-
ax6.set_xlabel('Sharpe Ratio', fontsize=10)
|
|
3573
|
-
ax6.set_title('Top 10 Combinations by Sharpe', fontsize=11, fontweight='bold')
|
|
3574
|
-
ax6.grid(True, alpha=0.3, axis='x')
|
|
3575
|
-
|
|
3576
|
-
plt.tight_layout()
|
|
3577
|
-
plt.savefig(filename, dpi=150, bbox_inches='tight')
|
|
3578
|
-
print(f"\nVisualization saved: {filename}")
|
|
3579
|
-
|
|
3580
|
-
# Display plot if requested
|
|
3581
|
-
if show_plot:
|
|
3582
|
-
try:
|
|
3583
|
-
# First try to use IPython.display.Image (most reliable in Jupyter)
|
|
3584
|
-
from IPython.display import display, Image
|
|
3585
|
-
import os
|
|
3586
|
-
if os.path.exists(filename):
|
|
3587
|
-
display(Image(filename))
|
|
3588
|
-
else:
|
|
3589
|
-
# If file doesn't exist yet, try plt.show()
|
|
3590
|
-
plt.show()
|
|
3591
|
-
except (ImportError, NameError):
|
|
3592
|
-
# Not in Jupyter or IPython not available - try plt.show()
|
|
3593
|
-
try:
|
|
3594
|
-
plt.show()
|
|
3595
|
-
except:
|
|
3596
|
-
plt.close()
|
|
3597
|
-
except Exception:
|
|
3598
|
-
# Any other error - try plt.show() as fallback
|
|
3599
|
-
try:
|
|
3600
|
-
plt.show()
|
|
3601
|
-
except:
|
|
3602
|
-
plt.close()
|
|
3603
|
-
else:
|
|
3604
|
-
plt.close() # Close without displaying
|
|
3605
|
-
|
|
3606
|
-
|
|
3607
|
-
# ============================================================
|
|
3608
|
-
# CACHE CONFIGURATION (integrated from universal_backend_system.py)
|
|
3609
|
-
# ============================================================
|
|
3610
|
-
def get_cache_config(disk_enabled: bool = True, memory_enabled: bool = True,
|
|
3611
|
-
memory_percent: int = 10, max_age_days: int = 7,
|
|
3612
|
-
debug: bool = False, cache_dir: str = 'cache',
|
|
3613
|
-
compression: bool = True, auto_cleanup: bool = True) -> Dict[str, Any]:
|
|
3614
|
-
"""
|
|
3615
|
-
Get cache configuration
|
|
3616
|
-
|
|
3617
|
-
Args:
|
|
3618
|
-
disk_enabled: Enable disk cache
|
|
3619
|
-
memory_enabled: Enable memory cache
|
|
3620
|
-
memory_percent: RAM percentage for cache (default 10%)
|
|
3621
|
-
max_age_days: Maximum cache age in days
|
|
3622
|
-
debug: Debug mode
|
|
3623
|
-
cache_dir: Cache directory
|
|
3624
|
-
compression: Use compression (Parquet + Snappy)
|
|
3625
|
-
auto_cleanup: Automatic cleanup of old cache
|
|
3626
|
-
|
|
3627
|
-
Returns:
|
|
3628
|
-
Dict with cache configuration
|
|
3629
|
-
"""
|
|
3630
|
-
return {
|
|
3631
|
-
'disk_enabled': disk_enabled,
|
|
3632
|
-
'memory_enabled': memory_enabled,
|
|
3633
|
-
'memory_percent': memory_percent,
|
|
3634
|
-
'max_age_days': max_age_days,
|
|
3635
|
-
'debug': debug,
|
|
3636
|
-
'cache_dir': cache_dir,
|
|
3637
|
-
'compression': compression,
|
|
3638
|
-
'auto_cleanup': auto_cleanup
|
|
3639
|
-
}
|
|
3640
|
-
|
|
3641
|
-
|
|
3642
|
-
# ============================================================
|
|
3643
|
-
# UNIVERSAL CACHE MANAGER (integrated from universal_backend_system.py)
|
|
3644
|
-
# ============================================================
|
|
3645
|
-
class UniversalCacheManager:
|
|
3646
|
-
"""Universal cache manager for any data types"""
|
|
3647
|
-
|
|
3648
|
-
# Mapping data types to cache directories
|
|
3649
|
-
DATA_TYPE_MAP = {
|
|
3650
|
-
'stock_eod': 'STOCK_EOD',
|
|
3651
|
-
'stock_intraday': 'STOCK_INTRADAY',
|
|
3652
|
-
'options_eod': 'OPTIONS_EOD',
|
|
3653
|
-
'options_intraday': 'OPTIONS_INTRADAY',
|
|
3654
|
-
# Backward compatibility (old naming):
|
|
3655
|
-
'stock': 'STOCK_EOD',
|
|
3656
|
-
'options': 'OPTIONS_EOD',
|
|
3657
|
-
'intraday': 'OPTIONS_INTRADAY', # Default intraday = options
|
|
3658
|
-
}
|
|
3659
|
-
|
|
3660
|
-
def __init__(self, cache_config: Dict[str, Any]):
|
|
3661
|
-
self.cache_config = cache_config
|
|
3662
|
-
self.disk_enabled = cache_config.get('disk_enabled', True)
|
|
3663
|
-
self.memory_enabled = cache_config.get('memory_enabled', True)
|
|
3664
|
-
self.memory_percent = cache_config.get('memory_percent', 10)
|
|
3665
|
-
self.max_age_days = cache_config.get('max_age_days', 7)
|
|
3666
|
-
self.debug = cache_config.get('debug', False)
|
|
3667
|
-
self.cache_dir = cache_config.get('cache_dir', 'cache')
|
|
3668
|
-
self.compression = cache_config.get('compression', True)
|
|
3669
|
-
self.auto_cleanup = cache_config.get('auto_cleanup', True)
|
|
3670
|
-
|
|
3671
|
-
# Calculate cache size in RAM
|
|
3672
|
-
if self.memory_enabled:
|
|
3673
|
-
total_memory = psutil.virtual_memory().total
|
|
3674
|
-
self.max_memory_bytes = int(total_memory * self.memory_percent / 100)
|
|
3675
|
-
self.memory_cache = {}
|
|
3676
|
-
self.cache_order = []
|
|
3677
|
-
else:
|
|
3678
|
-
self.max_memory_bytes = 0
|
|
3679
|
-
self.memory_cache = {}
|
|
3680
|
-
self.cache_order = []
|
|
3681
|
-
|
|
3682
|
-
# Create cache directories
|
|
3683
|
-
if self.disk_enabled:
|
|
3684
|
-
os.makedirs(self.cache_dir, exist_ok=True)
|
|
3685
|
-
|
|
3686
|
-
def get(self, key: str, data_type: str = 'default') -> Optional[Any]:
|
|
3687
|
-
"""Get data from cache"""
|
|
3688
|
-
try:
|
|
3689
|
-
# Check memory
|
|
3690
|
-
if self.memory_enabled and key in self.memory_cache:
|
|
3691
|
-
if self.debug:
|
|
3692
|
-
print(f"[CACHE] 🧠 Memory hit: {key}")
|
|
3693
|
-
return self.memory_cache[key]
|
|
3694
|
-
|
|
3695
|
-
# Check disk
|
|
3696
|
-
if self.disk_enabled:
|
|
3697
|
-
# Map data_type to proper directory structure using DATA_TYPE_MAP
|
|
3698
|
-
dir_name = self.DATA_TYPE_MAP.get(data_type, data_type.upper())
|
|
3699
|
-
data_dir = f"{self.cache_dir}/{dir_name}"
|
|
3700
|
-
|
|
3701
|
-
cache_file = os.path.join(data_dir, f"{key}.parquet")
|
|
3702
|
-
if os.path.exists(cache_file):
|
|
3703
|
-
if self._is_cache_valid(cache_file):
|
|
3704
|
-
data = self._load_from_disk(cache_file)
|
|
3705
|
-
if data is not None:
|
|
3706
|
-
# Save to memory
|
|
3707
|
-
if self.memory_enabled:
|
|
3708
|
-
self._save_to_memory(key, data)
|
|
3709
|
-
if self.debug:
|
|
3710
|
-
print(f"[CACHE] 💾 Disk hit: {key}")
|
|
3711
|
-
return data
|
|
3712
|
-
|
|
3713
|
-
# NEW: If exact match not found, search for overlapping cache
|
|
3714
|
-
# Only for date-range based cache types
|
|
3715
|
-
if data_type in ['stock_eod', 'options_eod', 'stock_intraday', 'options_intraday']:
|
|
3716
|
-
overlapping_data = self._find_overlapping_cache(key, data_type, data_dir)
|
|
3717
|
-
if overlapping_data is not None:
|
|
3718
|
-
# Save to memory for fast access
|
|
3719
|
-
if self.memory_enabled:
|
|
3720
|
-
self._save_to_memory(key, overlapping_data)
|
|
3721
|
-
return overlapping_data
|
|
3722
|
-
|
|
3723
|
-
if self.debug:
|
|
3724
|
-
print(f"[CACHE] ❌ Cache miss: {key}")
|
|
3725
|
-
return None
|
|
3726
|
-
|
|
3727
|
-
except Exception as e:
|
|
3728
|
-
if self.debug:
|
|
3729
|
-
print(f"[CACHE] ❌ Error getting {key}: {e}")
|
|
3730
|
-
return None
|
|
3731
|
-
|
|
3732
|
-
def set(self, key: str, data: Any, data_type: str = 'default') -> bool:
|
|
3733
|
-
"""Save data to cache"""
|
|
3734
|
-
try:
|
|
3735
|
-
# Save to memory
|
|
3736
|
-
if self.memory_enabled:
|
|
3737
|
-
self._save_to_memory(key, data)
|
|
3738
|
-
|
|
3739
|
-
# Save to disk
|
|
3740
|
-
if self.disk_enabled:
|
|
3741
|
-
# Map data_type to proper directory structure using DATA_TYPE_MAP
|
|
3742
|
-
dir_name = self.DATA_TYPE_MAP.get(data_type, data_type.upper())
|
|
3743
|
-
data_dir = f"{self.cache_dir}/{dir_name}"
|
|
3744
|
-
|
|
3745
|
-
# Create directory if it doesn't exist
|
|
3746
|
-
os.makedirs(data_dir, exist_ok=True)
|
|
3747
|
-
|
|
3748
|
-
cache_file = os.path.join(data_dir, f"{key}.parquet")
|
|
3749
|
-
self._save_to_disk(cache_file, data)
|
|
3750
|
-
|
|
3751
|
-
if self.debug:
|
|
3752
|
-
# Count records for reporting
|
|
3753
|
-
record_count = len(data) if hasattr(data, '__len__') else '?'
|
|
3754
|
-
print(f"[CACHE] 💾 Saved: {key}")
|
|
3755
|
-
print(f"[CACHE] 💾 Saved to cache: {data_type.upper()} ({record_count} records)")
|
|
3756
|
-
return True
|
|
3757
|
-
|
|
3758
|
-
except Exception as e:
|
|
3759
|
-
if self.debug:
|
|
3760
|
-
print(f"[CACHE] ❌ Error saving {key}: {e}")
|
|
3761
|
-
return False
|
|
3762
|
-
|
|
3763
|
-
def _save_to_memory(self, key: str, data: Any):
|
|
3764
|
-
"""Save to memory with LRU logic"""
|
|
3765
|
-
if key in self.memory_cache:
|
|
3766
|
-
self.cache_order.remove(key)
|
|
3767
|
-
else:
|
|
3768
|
-
# Check cache size
|
|
3769
|
-
while len(self.memory_cache) > 0 and self._get_memory_usage() > self.max_memory_bytes:
|
|
3770
|
-
oldest_key = self.cache_order.pop(0)
|
|
3771
|
-
del self.memory_cache[oldest_key]
|
|
3772
|
-
|
|
3773
|
-
self.memory_cache[key] = data
|
|
3774
|
-
self.cache_order.append(key)
|
|
3775
|
-
|
|
3776
|
-
def _save_to_disk(self, file_path: str, data: Any):
|
|
3777
|
-
"""Save to disk"""
|
|
3778
|
-
try:
|
|
3779
|
-
# Ensure directory exists
|
|
3780
|
-
file_dir = os.path.dirname(file_path)
|
|
3781
|
-
if file_dir and not os.path.exists(file_dir):
|
|
3782
|
-
os.makedirs(file_dir, exist_ok=True)
|
|
3783
|
-
|
|
3784
|
-
if isinstance(data, pd.DataFrame):
|
|
3785
|
-
if self.compression:
|
|
3786
|
-
data.to_parquet(file_path, compression='snappy')
|
|
3787
|
-
else:
|
|
3788
|
-
data.to_parquet(file_path)
|
|
3789
|
-
elif isinstance(data, dict):
|
|
3790
|
-
# Convert dict to DataFrame
|
|
3791
|
-
df = pd.DataFrame([data])
|
|
3792
|
-
if self.compression:
|
|
3793
|
-
df.to_parquet(file_path, compression='snappy')
|
|
3794
|
-
else:
|
|
3795
|
-
df.to_parquet(file_path)
|
|
3796
|
-
else:
|
|
3797
|
-
# Try to convert to DataFrame
|
|
3798
|
-
df = pd.DataFrame(data)
|
|
3799
|
-
if self.compression:
|
|
3800
|
-
df.to_parquet(file_path, compression='snappy')
|
|
3801
|
-
else:
|
|
3802
|
-
df.to_parquet(file_path)
|
|
3803
|
-
except Exception as e:
|
|
3804
|
-
if self.debug:
|
|
3805
|
-
print(f"[CACHE] ❌ Error saving to disk: {e}")
|
|
3806
|
-
|
|
3807
|
-
def _load_from_disk(self, file_path: str) -> Optional[Any]:
|
|
3808
|
-
"""Load from disk"""
|
|
3809
|
-
try:
|
|
3810
|
-
return pd.read_parquet(file_path)
|
|
3811
|
-
except Exception as e:
|
|
3812
|
-
if self.debug:
|
|
3813
|
-
print(f"[CACHE] ❌ Error loading from disk: {e}")
|
|
3814
|
-
return None
|
|
3815
|
-
|
|
3816
|
-
def _is_cache_valid(self, file_path: str) -> bool:
|
|
3817
|
-
"""Check cache validity"""
|
|
3818
|
-
if not os.path.exists(file_path):
|
|
3819
|
-
return False
|
|
3820
|
-
|
|
3821
|
-
file_age = time.time() - os.path.getmtime(file_path)
|
|
3822
|
-
max_age_seconds = self.max_age_days * 24 * 3600
|
|
3823
|
-
|
|
3824
|
-
return file_age < max_age_seconds
|
|
3825
|
-
|
|
3826
|
-
def _get_memory_usage(self) -> int:
|
|
3827
|
-
"""Get memory usage"""
|
|
3828
|
-
total_size = 0
|
|
3829
|
-
for key, value in self.memory_cache.items():
|
|
3830
|
-
try:
|
|
3831
|
-
if hasattr(value, 'memory_usage'):
|
|
3832
|
-
total_size += value.memory_usage(deep=True).sum()
|
|
3833
|
-
else:
|
|
3834
|
-
total_size += sys.getsizeof(value)
|
|
3835
|
-
except:
|
|
3836
|
-
total_size += sys.getsizeof(value)
|
|
3837
|
-
return total_size
|
|
3838
|
-
|
|
3839
|
-
def _find_overlapping_cache(self, key: str, data_type: str, data_dir: str) -> Optional[Any]:
|
|
3840
|
-
"""
|
|
3841
|
-
Find cache files with overlapping date ranges
|
|
3842
|
-
|
|
3843
|
-
Args:
|
|
3844
|
-
key: Cache key (format: SYMBOL_START_END or SYMBOL_DATE)
|
|
3845
|
-
data_type: Data type (stock_eod, options_eod, etc.)
|
|
3846
|
-
data_dir: Cache directory
|
|
3847
|
-
|
|
3848
|
-
Returns:
|
|
3849
|
-
Filtered data if overlapping cache found, None otherwise
|
|
3850
|
-
"""
|
|
3851
|
-
try:
|
|
3852
|
-
import re
|
|
3853
|
-
import glob
|
|
3854
|
-
from datetime import datetime
|
|
3855
|
-
|
|
3856
|
-
# Parse symbol and dates from key
|
|
3857
|
-
# Format: "SPY_2024-07-01_2025-10-29" or "SPY_2024-07-01"
|
|
3858
|
-
match = re.search(r'^([A-Z]+)_(\d{4}-\d{2}-\d{2})(?:_(\d{4}-\d{2}-\d{2}))?$', key)
|
|
3859
|
-
if not match:
|
|
3860
|
-
return None
|
|
3861
|
-
|
|
3862
|
-
symbol = match.group(1)
|
|
3863
|
-
start_date_str = match.group(2)
|
|
3864
|
-
end_date_str = match.group(3) if match.group(3) else start_date_str
|
|
3865
|
-
|
|
3866
|
-
# Parse dates
|
|
3867
|
-
start_date = datetime.strptime(start_date_str, '%Y-%m-%d').date()
|
|
3868
|
-
end_date = datetime.strptime(end_date_str, '%Y-%m-%d').date()
|
|
3869
|
-
|
|
3870
|
-
# Find all cache files for this symbol
|
|
3871
|
-
if not os.path.exists(data_dir):
|
|
3872
|
-
return None
|
|
3873
|
-
|
|
3874
|
-
pattern = os.path.join(data_dir, f"{symbol}_*.parquet")
|
|
3875
|
-
cache_files = glob.glob(pattern)
|
|
3876
|
-
|
|
3877
|
-
if not cache_files:
|
|
3878
|
-
return None
|
|
3879
|
-
|
|
3880
|
-
# Search for best overlapping cache
|
|
3881
|
-
best_match = None
|
|
3882
|
-
best_size = float('inf') # Prefer smallest file that covers range
|
|
3883
|
-
|
|
3884
|
-
for cache_file in cache_files:
|
|
3885
|
-
# Skip if cache is not valid
|
|
3886
|
-
if not self._is_cache_valid(cache_file):
|
|
3887
|
-
continue
|
|
3888
|
-
|
|
3889
|
-
# Parse dates from filename
|
|
3890
|
-
filename = os.path.basename(cache_file)
|
|
3891
|
-
file_match = re.search(r'(\d{4}-\d{2}-\d{2})(?:_(\d{4}-\d{2}-\d{2}))?', filename)
|
|
3892
|
-
|
|
3893
|
-
if not file_match:
|
|
3894
|
-
continue
|
|
3895
|
-
|
|
3896
|
-
cached_start_str = file_match.group(1)
|
|
3897
|
-
cached_end_str = file_match.group(2) if file_match.group(2) else cached_start_str
|
|
3898
|
-
|
|
3899
|
-
cached_start = datetime.strptime(cached_start_str, '%Y-%m-%d').date()
|
|
3900
|
-
cached_end = datetime.strptime(cached_end_str, '%Y-%m-%d').date()
|
|
3901
|
-
|
|
3902
|
-
# Check if cached range CONTAINS requested range
|
|
3903
|
-
if cached_start <= start_date and cached_end >= end_date:
|
|
3904
|
-
# Calculate file size (prefer smaller files)
|
|
3905
|
-
file_size = os.path.getsize(cache_file)
|
|
3906
|
-
|
|
3907
|
-
if file_size < best_size:
|
|
3908
|
-
best_match = cache_file
|
|
3909
|
-
best_size = file_size
|
|
3910
|
-
|
|
3911
|
-
if best_match:
|
|
3912
|
-
if self.debug:
|
|
3913
|
-
print(f"[CACHE] 🔍 Found overlapping cache: {os.path.basename(best_match)}")
|
|
3914
|
-
print(f"[CACHE] Requested: {start_date_str} → {end_date_str}")
|
|
3915
|
-
print(f"[CACHE] Filtering and loading...")
|
|
3916
|
-
|
|
3917
|
-
# Load and filter data
|
|
3918
|
-
df = pd.read_parquet(best_match)
|
|
3919
|
-
|
|
3920
|
-
# Ensure date column is in correct format
|
|
3921
|
-
if 'date' in df.columns:
|
|
3922
|
-
if df['date'].dtype == 'object':
|
|
3923
|
-
df['date'] = pd.to_datetime(df['date']).dt.date
|
|
3924
|
-
elif pd.api.types.is_datetime64_any_dtype(df['date']):
|
|
3925
|
-
df['date'] = df['date'].dt.date
|
|
3926
|
-
|
|
3927
|
-
# Filter by date range
|
|
3928
|
-
filtered = df[(df['date'] >= start_date) & (df['date'] <= end_date)].copy()
|
|
3929
|
-
|
|
3930
|
-
if self.debug:
|
|
3931
|
-
print(f"[CACHE] ✓ Overlapping cache hit: {len(filtered)} records (filtered from {len(df)})")
|
|
3932
|
-
|
|
3933
|
-
return filtered
|
|
3934
|
-
else:
|
|
3935
|
-
# No date column to filter - return as is
|
|
3936
|
-
if self.debug:
|
|
3937
|
-
print(f"[CACHE] ✓ Overlapping cache hit: {len(df)} records (no date filtering)")
|
|
3938
|
-
return df
|
|
3939
|
-
|
|
3940
|
-
return None
|
|
3941
|
-
|
|
3942
|
-
except Exception as e:
|
|
3943
|
-
if self.debug:
|
|
3944
|
-
print(f"[CACHE] ⚠️ Error searching for overlapping cache: {e}")
|
|
3945
|
-
return None
|
|
3946
|
-
|
|
3947
|
-
|
|
3948
|
-
# Export all
|
|
3949
|
-
__all__ = [
|
|
3950
|
-
'BacktestResults', 'BacktestAnalyzer', 'ResultsReporter',
|
|
3951
|
-
'ChartGenerator', 'ResultsExporter', 'run_backtest', 'run_backtest_with_stoploss',
|
|
3952
|
-
'init_api', 'api_call', 'APIHelper', 'APIManager',
|
|
3953
|
-
'ResourceMonitor', 'create_progress_bar', 'update_progress', 'format_time',
|
|
3954
|
-
'StopLossManager', 'PositionManager', 'StopLossConfig',
|
|
3955
|
-
'calculate_stoploss_metrics', 'print_stoploss_section', 'create_stoploss_charts',
|
|
3956
|
-
'create_stoploss_comparison_chart',
|
|
3957
|
-
'optimize_parameters', 'plot_optimization_results',
|
|
3958
|
-
'create_optimization_folder',
|
|
3959
|
-
'preload_options_data',
|
|
3960
|
-
'preload_data_universal', # NEW: Universal preloader V2
|
|
3961
|
-
# New caching functions
|
|
3962
|
-
# Optimization preset functions
|
|
3963
|
-
'apply_optimization_preset', 'list_optimization_presets',
|
|
3964
|
-
'calculate_combinations_count', 'print_preset_info',
|
|
3965
|
-
'get_cache_config', 'UniversalCacheManager'
|
|
3966
|
-
]
|
|
3967
|
-
|
|
3968
|
-
|
|
3969
|
-
# ============================================================
|
|
3970
|
-
# OPTIMIZATION PRESET FUNCTIONS
|
|
3971
|
-
# ============================================================
|
|
3972
|
-
|
|
3973
|
-
def apply_optimization_preset(config, preset='default'):
|
|
3974
|
-
"""
|
|
3975
|
-
Apply built-in optimization preset to config
|
|
3976
|
-
|
|
3977
|
-
Args:
|
|
3978
|
-
config: Configuration dictionary (will be updated)
|
|
3979
|
-
preset: Preset name ('default', 'quick_test', 'aggressive', 'conservative')
|
|
3980
|
-
|
|
3981
|
-
Returns:
|
|
3982
|
-
dict: Updated configuration
|
|
3983
|
-
"""
|
|
3984
|
-
presets = {
|
|
3985
|
-
'default': {
|
|
3986
|
-
'param_grid': {
|
|
3987
|
-
'z_score_entry': [0.8, 1.0, 1.2, 1.5],
|
|
3988
|
-
'z_score_exit': [0.05, 0.1, 0.15],
|
|
3989
|
-
'lookback_period': [45, 60, 90],
|
|
3990
|
-
'dte_target': [30, 45, 60]
|
|
3991
|
-
},
|
|
3992
|
-
'optimization_metric': 'sharpe',
|
|
3993
|
-
'min_trades': 5,
|
|
3994
|
-
'max_drawdown_limit': 0.50,
|
|
3995
|
-
'parallel': False,
|
|
3996
|
-
# 'export_each_combo': True, # ← Убрано, будет использоваться из основного конфига
|
|
3997
|
-
'results_folder_prefix': 'optimization',
|
|
3998
|
-
'chart_filename': 'optimization_analysis.png',
|
|
3999
|
-
'show_progress': True,
|
|
4000
|
-
'verbose': True
|
|
4001
|
-
},
|
|
4002
|
-
'quick_test': {
|
|
4003
|
-
'param_grid': {
|
|
4004
|
-
'z_score_entry': [1.0, 1.5],
|
|
4005
|
-
'z_score_exit': [0.1],
|
|
4006
|
-
'lookback_period': [60],
|
|
4007
|
-
'dte_target': [45]
|
|
4008
|
-
},
|
|
4009
|
-
'optimization_metric': 'sharpe',
|
|
4010
|
-
'min_trades': 3,
|
|
4011
|
-
'max_drawdown_limit': 0.40,
|
|
4012
|
-
'parallel': False,
|
|
4013
|
-
# 'export_each_combo': False, # ← Убрано, будет использоваться из основного конфига
|
|
4014
|
-
'results_folder_prefix': 'quick_test',
|
|
4015
|
-
'chart_filename': 'quick_test_analysis.png',
|
|
4016
|
-
'show_progress': True,
|
|
4017
|
-
'verbose': False
|
|
4018
|
-
},
|
|
4019
|
-
'aggressive': {
|
|
4020
|
-
'param_grid': {
|
|
4021
|
-
'z_score_entry': [1.5, 2.0, 2.5],
|
|
4022
|
-
'z_score_exit': [0.05, 0.1],
|
|
4023
|
-
'lookback_period': [30, 45, 60],
|
|
4024
|
-
'dte_target': [30, 45]
|
|
4025
|
-
},
|
|
4026
|
-
'optimization_metric': 'total_return',
|
|
4027
|
-
'min_trades': 10,
|
|
4028
|
-
'max_drawdown_limit': 0.60,
|
|
4029
|
-
'parallel': False,
|
|
4030
|
-
# 'export_each_combo': True, # ← Убрано, будет использоваться из основного конфига
|
|
4031
|
-
'results_folder_prefix': 'aggressive',
|
|
4032
|
-
'chart_filename': 'aggressive_analysis.png',
|
|
4033
|
-
'show_progress': True,
|
|
4034
|
-
'verbose': True
|
|
4035
|
-
},
|
|
4036
|
-
'conservative': {
|
|
4037
|
-
'param_grid': {
|
|
4038
|
-
'z_score_entry': [0.8, 1.0],
|
|
4039
|
-
'z_score_exit': [0.1, 0.15, 0.2],
|
|
4040
|
-
'lookback_period': [60, 90, 120],
|
|
4041
|
-
'dte_target': [45, 60, 90]
|
|
4042
|
-
},
|
|
4043
|
-
'optimization_metric': 'calmar',
|
|
4044
|
-
'min_trades': 8,
|
|
4045
|
-
'max_drawdown_limit': 0.25,
|
|
4046
|
-
'parallel': False,
|
|
4047
|
-
# 'export_each_combo': True, # ← Убрано, будет использоваться из основного конфига
|
|
4048
|
-
'results_folder_prefix': 'conservative',
|
|
4049
|
-
'chart_filename': 'conservative_analysis.png',
|
|
4050
|
-
'show_progress': True,
|
|
4051
|
-
'verbose': True
|
|
4052
|
-
}
|
|
4053
|
-
}
|
|
4054
|
-
|
|
4055
|
-
if preset not in presets:
|
|
4056
|
-
available = list(presets.keys())
|
|
4057
|
-
raise ValueError(f"Preset '{preset}' not found. Available: {available}")
|
|
4058
|
-
|
|
4059
|
-
# Update only specific fields from preset
|
|
4060
|
-
preset_data = presets[preset]
|
|
4061
|
-
|
|
4062
|
-
# Save user-defined param_grid if it exists (user override has priority)
|
|
4063
|
-
user_param_grid = config.get('param_grid')
|
|
4064
|
-
|
|
4065
|
-
fields_to_update = [
|
|
4066
|
-
'param_grid', 'min_trades', 'max_drawdown_limit',
|
|
4067
|
-
'optimization_metric', 'parallel', 'export_each_combo',
|
|
4068
|
-
'results_folder_prefix', 'chart_filename',
|
|
4069
|
-
'show_progress', 'verbose'
|
|
4070
|
-
]
|
|
4071
|
-
|
|
4072
|
-
for field in fields_to_update:
|
|
4073
|
-
if field in preset_data:
|
|
4074
|
-
# Special handling for param_grid: preserve user's param_grid if provided
|
|
4075
|
-
if field == 'param_grid' and user_param_grid is not None:
|
|
4076
|
-
# User defined param_grid - don't override with preset
|
|
4077
|
-
continue
|
|
4078
|
-
config[field] = preset_data[field]
|
|
4079
|
-
|
|
4080
|
-
# Restore user's param_grid if it was saved (preserve user override)
|
|
4081
|
-
if user_param_grid is not None:
|
|
4082
|
-
config['param_grid'] = user_param_grid
|
|
4083
|
-
|
|
4084
|
-
print(f"✓ Applied preset: {preset}")
|
|
4085
|
-
if user_param_grid is not None:
|
|
4086
|
-
print(f" (Preserved user-defined param_grid)")
|
|
4087
|
-
|
|
4088
|
-
return config
|
|
4089
|
-
|
|
4090
|
-
|
|
4091
|
-
def calculate_combinations_count(param_grid):
|
|
4092
|
-
"""
|
|
4093
|
-
Calculate total number of parameter combinations
|
|
4094
|
-
|
|
4095
|
-
Args:
|
|
4096
|
-
param_grid: Dictionary with parameter lists
|
|
4097
|
-
|
|
4098
|
-
Returns:
|
|
4099
|
-
int: Total number of combinations
|
|
4100
|
-
"""
|
|
4101
|
-
import math
|
|
4102
|
-
return math.prod(len(values) for values in param_grid.values())
|
|
4103
|
-
|
|
4104
|
-
|
|
4105
|
-
def print_preset_info(config):
|
|
4106
|
-
"""
|
|
4107
|
-
Print preset information and combination count
|
|
4108
|
-
|
|
4109
|
-
Args:
|
|
4110
|
-
config: Configuration dictionary with preset applied
|
|
4111
|
-
"""
|
|
4112
|
-
preset = config.get('preset', 'unknown')
|
|
4113
|
-
combinations = calculate_combinations_count(config['param_grid'])
|
|
4114
|
-
|
|
4115
|
-
print(f"\n{'='*60}")
|
|
4116
|
-
print(f"OPTIMIZATION PRESET: {preset.upper()}")
|
|
4117
|
-
print(f"{'='*60}")
|
|
4118
|
-
print(f"Total combinations: {combinations}")
|
|
4119
|
-
print(f"Optimization metric: {config.get('optimization_metric', 'sharpe')}")
|
|
4120
|
-
print(f"Min trades required: {config.get('min_trades', 10)}")
|
|
4121
|
-
print(f"Max drawdown limit: {config.get('max_drawdown_limit', 0.50)}")
|
|
4122
|
-
print(f"Parallel execution: {config.get('parallel', True)}")
|
|
4123
|
-
print(f"Export each combo: {config.get('export_each_combo', False)}")
|
|
4124
|
-
print(f"{'='*60}\n")
|
|
4125
|
-
|
|
4126
|
-
|
|
4127
|
-
def list_optimization_presets():
|
|
4128
|
-
"""Show available built-in presets"""
|
|
4129
|
-
presets = {
|
|
4130
|
-
'default': 'Standard configuration (4×3×3×3 = 108 combinations)',
|
|
4131
|
-
'quick_test': 'Quick test (2×1×1×1 = 2 combinations)',
|
|
4132
|
-
'aggressive': 'Aggressive strategy (3×2×3×2 = 36 combinations)',
|
|
4133
|
-
'conservative': 'Conservative strategy (2×3×3×3 = 54 combinations)'
|
|
4134
|
-
}
|
|
4135
|
-
|
|
4136
|
-
print("\n📋 AVAILABLE OPTIMIZATION PRESETS:")
|
|
4137
|
-
print("-" * 60)
|
|
4138
|
-
for name, desc in presets.items():
|
|
4139
|
-
print(f" {name:<12} | {desc}")
|
|
4140
|
-
print("-" * 60)
|
|
4141
|
-
|
|
4142
|
-
|
|
4143
|
-
|
|
4144
|
-
|
|
4145
|
-
|
|
4146
|
-
|
|
4147
|
-
|