ivolatility-backtesting 1.6.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.

@@ -1,2973 +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
15
- import ivolatility as ivol
16
- import os
17
- import time
18
- import psutil
19
- import warnings
20
- from itertools import product
21
- warnings.filterwarnings('ignore', category=pd.errors.SettingWithCopyWarning)
22
- warnings.filterwarnings('ignore', message='.*SettingWithCopyWarning.*')
23
- warnings.filterwarnings('ignore', category=FutureWarning)
24
- warnings.filterwarnings('ignore', category=DeprecationWarning)
25
-
26
- sns.set_style('darkgrid')
27
- plt.rcParams['figure.figsize'] = (15, 8)
28
-
29
- def create_optimization_folder(base_dir='optimization_results'):
30
- """
31
- Create timestamped folder for optimization run
32
- Returns: folder path (e.g., 'optimization_results/20250122_143025')
33
- """
34
- from pathlib import Path
35
- timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
36
- folder_path = Path(base_dir) / timestamp
37
- folder_path.mkdir(parents=True, exist_ok=True)
38
- print(f"\n📁 Created optimization folder: {folder_path}")
39
- return str(folder_path)
40
-
41
- # ============================================================
42
- # RESOURCE MONITOR
43
- # ============================================================
44
- class ResourceMonitor:
45
- """Monitor CPU and RAM with container support"""
46
-
47
- def __init__(self, show_container_total=False):
48
- self.process = psutil.Process()
49
- self.cpu_count = psutil.cpu_count()
50
- self.last_cpu_time = None
51
- self.last_check_time = None
52
- self.use_cgroups = self._check_cgroups_v2()
53
- self.show_container_total = show_container_total
54
- self.cpu_history = []
55
- self.cpu_history_max = 5
56
-
57
- if self.use_cgroups:
58
- quota = self._read_cpu_quota()
59
- if quota and quota > 0:
60
- self.cpu_count = quota
61
-
62
- self.context = "Container" if self.use_cgroups else "Host"
63
-
64
- def _read_cpu_quota(self):
65
- try:
66
- with open('/sys/fs/cgroup/cpu.max', 'r') as f:
67
- line = f.read().strip()
68
- if line == 'max':
69
- return None
70
- parts = line.split()
71
- if len(parts) == 2:
72
- quota = int(parts[0])
73
- period = int(parts[1])
74
- return quota / period
75
- except:
76
- pass
77
- return None
78
-
79
- def get_context_info(self):
80
- if self.use_cgroups:
81
- current, max_mem = self._read_cgroup_memory()
82
- ram_info = ""
83
- if max_mem:
84
- max_mem_gb = max_mem / (1024**3)
85
- ram_info = f", {max_mem_gb:.1f}GB limit"
86
-
87
- mem_type = "container total" if self.show_container_total else "process only"
88
- return f"Container (CPU: {self.cpu_count:.1f} cores{ram_info}) - RAM: {mem_type}"
89
- else:
90
- total_ram_gb = psutil.virtual_memory().total / (1024**3)
91
- return f"Host ({self.cpu_count} cores, {total_ram_gb:.0f}GB RAM) - RAM: process"
92
-
93
- def _check_cgroups_v2(self):
94
- try:
95
- return os.path.exists('/sys/fs/cgroup/cpu.stat') and \
96
- os.path.exists('/sys/fs/cgroup/memory.current')
97
- except:
98
- return False
99
-
100
- def _read_cgroup_cpu(self):
101
- try:
102
- with open('/sys/fs/cgroup/cpu.stat', 'r') as f:
103
- for line in f:
104
- if line.startswith('usage_usec'):
105
- return int(line.split()[1])
106
- except:
107
- pass
108
- return None
109
-
110
- def _read_cgroup_memory(self):
111
- try:
112
- with open('/sys/fs/cgroup/memory.current', 'r') as f:
113
- current = int(f.read().strip())
114
- with open('/sys/fs/cgroup/memory.max', 'r') as f:
115
- max_mem = f.read().strip()
116
- if max_mem == 'max':
117
- max_mem = psutil.virtual_memory().total
118
- else:
119
- max_mem = int(max_mem)
120
- return current, max_mem
121
- except:
122
- pass
123
- return None, None
124
-
125
- def get_cpu_percent(self):
126
- if self.use_cgroups:
127
- current_time = time.time()
128
- current_cpu = self._read_cgroup_cpu()
129
-
130
- if current_cpu and self.last_cpu_time and self.last_check_time:
131
- time_delta = current_time - self.last_check_time
132
- cpu_delta = current_cpu - self.last_cpu_time
133
-
134
- if time_delta > 0:
135
- cpu_percent = (cpu_delta / (time_delta * 1_000_000)) * 100
136
- cpu_percent = min(cpu_percent, 100 * self.cpu_count)
137
-
138
- self.cpu_history.append(cpu_percent)
139
- if len(self.cpu_history) > self.cpu_history_max:
140
- self.cpu_history.pop(0)
141
-
142
- self.last_cpu_time = current_cpu
143
- self.last_check_time = current_time
144
-
145
- return round(sum(self.cpu_history) / len(self.cpu_history), 1)
146
-
147
- self.last_cpu_time = current_cpu
148
- self.last_check_time = current_time
149
-
150
- try:
151
- cpu = self.process.cpu_percent(interval=0.1)
152
- if cpu == 0:
153
- cpu = psutil.cpu_percent(interval=0.1)
154
-
155
- self.cpu_history.append(cpu)
156
- if len(self.cpu_history) > self.cpu_history_max:
157
- self.cpu_history.pop(0)
158
-
159
- return round(sum(self.cpu_history) / len(self.cpu_history), 1)
160
- except:
161
- return 0.0
162
-
163
- def get_memory_info(self):
164
- try:
165
- mem = self.process.memory_info()
166
- process_mb = mem.rss / (1024 * 1024)
167
-
168
- if self.use_cgroups:
169
- current, max_mem = self._read_cgroup_memory()
170
- if max_mem:
171
- process_percent = (mem.rss / max_mem) * 100
172
-
173
- if current:
174
- container_mb = current / (1024 * 1024)
175
- container_percent = (current / max_mem) * 100
176
- return (
177
- round(process_mb, 1),
178
- round(process_percent, 1),
179
- round(container_mb, 1),
180
- round(container_percent, 1)
181
- )
182
-
183
- return (
184
- round(process_mb, 1),
185
- round(process_percent, 1),
186
- round(process_mb, 1),
187
- round(process_percent, 1)
188
- )
189
-
190
- total = psutil.virtual_memory().total
191
- percent = (mem.rss / total) * 100
192
-
193
- return (
194
- round(process_mb, 1),
195
- round(percent, 1),
196
- round(process_mb, 1),
197
- round(percent, 1)
198
- )
199
-
200
- except:
201
- return 0.0, 0.0, 0.0, 0.0
202
-
203
-
204
- def create_progress_bar(reuse_existing=None):
205
- """Create or reuse enhanced progress bar"""
206
- if reuse_existing is not None:
207
- progress_bar, status_label, monitor, start_time = reuse_existing
208
- progress_bar.value = 0
209
- progress_bar.bar_style = 'info'
210
- status_label.value = "<b style='color:#0066cc'>Starting...</b>"
211
- return progress_bar, status_label, monitor, time.time()
212
-
213
- try:
214
- from IPython.display import display
215
- import ipywidgets as widgets
216
-
217
- progress_bar = widgets.FloatProgress(
218
- value=0, min=0, max=100,
219
- description='Progress:',
220
- bar_style='info',
221
- style={'bar_color': '#00ff00'},
222
- layout=widgets.Layout(width='100%', height='30px')
223
- )
224
-
225
- status_label = widgets.HTML(
226
- value="<b style='color:#0066cc'>Starting...</b>"
227
- )
228
-
229
- display(widgets.VBox([progress_bar, status_label]))
230
-
231
- monitor = ResourceMonitor()
232
- start_time = time.time()
233
-
234
- return progress_bar, status_label, monitor, start_time
235
- except ImportError:
236
- print("Warning: ipywidgets not available. Progress bar disabled.")
237
- return None, None, ResourceMonitor(), time.time()
238
-
239
-
240
- def update_progress(progress_bar, status_label, monitor, current, total, start_time, message="Processing"):
241
- """Update progress bar with ETA, CPU%, RAM"""
242
- if progress_bar is None or status_label is None:
243
- return
244
-
245
- progress = (current / total) * 100
246
- progress_bar.value = progress
247
-
248
- elapsed = time.time() - start_time
249
- if current > 0:
250
- eta_seconds = (elapsed / current) * (total - current)
251
- eta_str = format_time(eta_seconds)
252
- else:
253
- eta_str = "calculating..."
254
-
255
- cpu = monitor.get_cpu_percent()
256
- process_mb, process_pct, container_mb, container_pct = monitor.get_memory_info()
257
-
258
- if abs(container_mb - process_mb) > 10:
259
- ram_display = (
260
- f"RAM: <span style='color:#4CAF50'>{process_mb}MB ({process_pct}%)</span> Python | "
261
- f"<span style='color:#2196F3'>{container_mb}MB ({container_pct}%)</span> Container"
262
- )
263
- else:
264
- ram_display = f"RAM: {process_mb}MB ({process_pct}%)"
265
-
266
- context_info = monitor.get_context_info()
267
-
268
- elapsed_str = format_time(elapsed)
269
- start_time_str = datetime.fromtimestamp(start_time).strftime('%H:%M:%S')
270
-
271
- status_label.value = (
272
- f"<b style='color:#0066cc'>{message} ({current}/{total})</b><br>"
273
- f"<span style='color:#666'>⏱️ Elapsed: {elapsed_str} | ETA: {eta_str} | Started: {start_time_str}</span><br>"
274
- f"<span style='color:#666'>CPU: {cpu}% | {ram_display}</span><br>"
275
- f"<span style='color:#999;font-size:10px'>{context_info}</span>"
276
- )
277
-
278
-
279
- def format_time(seconds):
280
- """Format seconds to human readable time"""
281
- if seconds < 60:
282
- return f"{int(seconds)}s"
283
- elif seconds < 3600:
284
- return f"{int(seconds // 60)}m {int(seconds % 60)}s"
285
- else:
286
- hours = int(seconds // 3600)
287
- minutes = int((seconds % 3600) // 60)
288
- return f"{hours}h {minutes}m"
289
-
290
-
291
- # ============================================================
292
- # API HELPER
293
- # ============================================================
294
- class APIHelper:
295
- """Normalizes API responses"""
296
-
297
- @staticmethod
298
- def normalize_response(response, debug=False):
299
- if response is None:
300
- if debug:
301
- print("[APIHelper] Response is None")
302
- return None
303
-
304
- if isinstance(response, dict):
305
- if 'data' in response:
306
- if debug:
307
- print(f"[APIHelper] Dict response: {len(response['data'])} records")
308
- return response
309
- else:
310
- if debug:
311
- print("[APIHelper] Dict without 'data' key")
312
- return None
313
-
314
- if isinstance(response, pd.DataFrame):
315
- if response.empty:
316
- if debug:
317
- print("[APIHelper] Empty DataFrame")
318
- return None
319
-
320
- records = response.to_dict('records')
321
- if debug:
322
- print(f"[APIHelper] DataFrame converted: {len(records)} records")
323
- return {'data': records, 'status': 'success'}
324
-
325
- if debug:
326
- print(f"[APIHelper] Unexpected type: {type(response)}")
327
- return None
328
-
329
-
330
- class APIManager:
331
- """Centralized API key management"""
332
- _api_key = None
333
- _methods = {}
334
-
335
- @classmethod
336
- def initialize(cls, api_key):
337
- if not api_key:
338
- raise ValueError("API key cannot be empty")
339
- cls._api_key = api_key
340
- ivol.setLoginParams(apiKey=api_key)
341
- print(f"[API] Initialized: {api_key[:10]}...{api_key[-5:]}")
342
-
343
- @classmethod
344
- def get_method(cls, endpoint):
345
- if cls._api_key is None:
346
- api_key = os.getenv("API_KEY")
347
- if not api_key:
348
- raise ValueError("API key not set. Call init_api(key) first")
349
- cls.initialize(api_key)
350
-
351
- if endpoint not in cls._methods:
352
- ivol.setLoginParams(apiKey=cls._api_key)
353
- cls._methods[endpoint] = ivol.setMethod(endpoint)
354
-
355
- return cls._methods[endpoint]
356
-
357
-
358
- def init_api(api_key=None):
359
- """Initialize IVolatility API"""
360
- if api_key is None:
361
- api_key = os.getenv("API_KEY")
362
- APIManager.initialize(api_key)
363
-
364
-
365
- def api_call(endpoint, debug=False, **kwargs):
366
- """Make API call with automatic response normalization"""
367
- try:
368
- if debug and APIManager._api_key:
369
- base_url = "https://restapi.ivolatility.com"
370
- url_params = {}
371
- for key, value in kwargs.items():
372
- clean_key = key.rstrip('_') if key.endswith('_') else key
373
- url_params[clean_key] = value
374
-
375
- params_str = "&".join([f"{k}={v}" for k, v in url_params.items()])
376
- full_url = f"{base_url}{endpoint}?apiKey={APIManager._api_key}&{params_str}"
377
- print(f"\n[API] Full URL:")
378
- print(f"[API] {full_url}\n")
379
-
380
- method = APIManager.get_method(endpoint)
381
- response = method(**kwargs)
382
-
383
- normalized = APIHelper.normalize_response(response, debug=debug)
384
-
385
- if normalized is None and debug:
386
- print(f"[api_call] Failed to get data")
387
- print(f"[api_call] Endpoint: {endpoint}")
388
- print(f"[api_call] Params: {kwargs}")
389
-
390
- return normalized
391
-
392
- except Exception as e:
393
- if debug:
394
- print(f"[api_call] Exception: {e}")
395
- print(f"[api_call] Endpoint: {endpoint}")
396
- print(f"[api_call] Params: {kwargs}")
397
- return None
398
-
399
-
400
- # ============================================================
401
- # BACKTEST RESULTS
402
- # ============================================================
403
- class BacktestResults:
404
- """Universal container for backtest results"""
405
-
406
- def __init__(self, equity_curve, equity_dates, trades, initial_capital,
407
- config, benchmark_prices=None, benchmark_symbol='SPY',
408
- daily_returns=None, debug_info=None):
409
-
410
- self.equity_curve = equity_curve
411
- self.equity_dates = equity_dates
412
- self.trades = trades
413
- self.initial_capital = initial_capital
414
- self.final_capital = equity_curve[-1] if len(equity_curve) > 0 else initial_capital
415
- self.config = config
416
- self.benchmark_prices = benchmark_prices
417
- self.benchmark_symbol = benchmark_symbol
418
- self.debug_info = debug_info if debug_info else []
419
-
420
- if daily_returns is None and len(equity_curve) > 1:
421
- self.daily_returns = [
422
- (equity_curve[i] - equity_curve[i-1]) / equity_curve[i-1]
423
- for i in range(1, len(equity_curve))
424
- ]
425
- else:
426
- self.daily_returns = daily_returns if daily_returns else []
427
-
428
- self.max_drawdown = self._calculate_max_drawdown()
429
-
430
- def _calculate_max_drawdown(self):
431
- if len(self.equity_curve) < 2:
432
- return 0
433
- running_max = np.maximum.accumulate(self.equity_curve)
434
- drawdowns = (np.array(self.equity_curve) - running_max) / running_max * 100
435
- return abs(np.min(drawdowns))
436
-
437
-
438
- # ============================================================
439
- # STOP-LOSS MANAGER (ENHANCED VERSION WITH COMBINED STOP)
440
- # ============================================================
441
- class StopLossManager:
442
- """
443
- Enhanced stop-loss manager with COMBINED STOP support
444
-
445
- NEW STOP TYPE:
446
- - combined: Requires BOTH pl_loss AND directional conditions (from code 2)
447
- """
448
-
449
- def __init__(self):
450
- self.positions = {}
451
-
452
- def add_position(self, position_id, entry_price, entry_date, stop_type='fixed_pct',
453
- stop_value=0.05, atr=None, trailing_distance=None, use_pnl_pct=False,
454
- is_short_bias=False, **kwargs):
455
- """
456
- Add position with stop-loss
457
-
458
- NEW for combined stop:
459
- stop_type='combined'
460
- stop_value={'pl_loss': 0.05, 'directional': 0.03}
461
- """
462
- self.positions[position_id] = {
463
- 'entry_price': entry_price,
464
- 'entry_date': entry_date,
465
- 'stop_type': stop_type,
466
- 'stop_value': stop_value,
467
- 'atr': atr,
468
- 'trailing_distance': trailing_distance,
469
- 'highest_price': entry_price if not use_pnl_pct else 0,
470
- 'lowest_price': entry_price if not use_pnl_pct else 0,
471
- 'max_profit': 0,
472
- 'use_pnl_pct': use_pnl_pct,
473
- 'is_short_bias': is_short_bias,
474
- **kwargs # Store additional parameters for combined stop
475
- }
476
-
477
- def check_stop(self, position_id, current_price, current_date, position_type='LONG', **kwargs):
478
- """
479
- Check if stop-loss triggered
480
-
481
- NEW: Supports 'combined' stop type
482
- """
483
- if position_id not in self.positions:
484
- return False, None, None
485
-
486
- pos = self.positions[position_id]
487
- stop_type = pos['stop_type']
488
- use_pnl_pct = pos.get('use_pnl_pct', False)
489
-
490
- # Update tracking
491
- if use_pnl_pct:
492
- pnl_pct = current_price
493
- pos['highest_price'] = max(pos['highest_price'], pnl_pct)
494
- pos['lowest_price'] = min(pos['lowest_price'], pnl_pct)
495
- pos['max_profit'] = max(pos['max_profit'], pnl_pct)
496
- else:
497
- if position_type == 'LONG':
498
- pos['highest_price'] = max(pos['highest_price'], current_price)
499
- current_profit = current_price - pos['entry_price']
500
- else:
501
- pos['lowest_price'] = min(pos['lowest_price'], current_price)
502
- current_profit = pos['entry_price'] - current_price
503
-
504
- pos['max_profit'] = max(pos['max_profit'], current_profit)
505
-
506
- # Route to appropriate check method
507
- if stop_type == 'fixed_pct':
508
- if use_pnl_pct:
509
- return self._check_fixed_pct_stop_pnl(pos, current_price)
510
- else:
511
- return self._check_fixed_pct_stop(pos, current_price, position_type)
512
-
513
- elif stop_type == 'trailing':
514
- if use_pnl_pct:
515
- return self._check_trailing_stop_pnl(pos, current_price)
516
- else:
517
- return self._check_trailing_stop(pos, current_price, position_type)
518
-
519
- elif stop_type == 'time_based':
520
- return self._check_time_stop(pos, current_date)
521
-
522
- elif stop_type == 'volatility':
523
- return self._check_volatility_stop(pos, current_price, position_type)
524
-
525
- elif stop_type == 'pl_loss':
526
- return self._check_pl_loss_stop(pos, kwargs)
527
-
528
- elif stop_type == 'directional':
529
- return self._check_directional_stop(pos, kwargs)
530
-
531
- # NEW: COMBINED STOP (requires BOTH conditions)
532
- elif stop_type == 'combined':
533
- return self._check_combined_stop(pos, kwargs)
534
-
535
- else:
536
- return False, None, None
537
-
538
- # ========================================================
539
- # EXISTING STOP METHODS (unchanged)
540
- # ========================================================
541
-
542
- def _check_fixed_pct_stop(self, pos, current_price, position_type):
543
- """Fixed percentage stop-loss (price-based)"""
544
- entry = pos['entry_price']
545
- stop_pct = pos['stop_value']
546
-
547
- if position_type == 'LONG':
548
- stop_level = entry * (1 - stop_pct)
549
- triggered = current_price <= stop_level
550
- else:
551
- stop_level = entry * (1 + stop_pct)
552
- triggered = current_price >= stop_level
553
-
554
- return triggered, stop_level, 'fixed_pct'
555
-
556
- def _check_fixed_pct_stop_pnl(self, pos, pnl_pct):
557
- """Fixed percentage stop-loss (P&L%-based for options)"""
558
- stop_pct = pos['stop_value']
559
- stop_level = -stop_pct * 100
560
-
561
- triggered = pnl_pct <= stop_level
562
-
563
- return triggered, stop_level, 'fixed_pct'
564
-
565
- def _check_trailing_stop(self, pos, current_price, position_type):
566
- """Trailing stop-loss (price-based)"""
567
- if pos['trailing_distance'] is None:
568
- pos['trailing_distance'] = pos['stop_value']
569
-
570
- distance = pos['trailing_distance']
571
-
572
- if position_type == 'LONG':
573
- stop_level = pos['highest_price'] * (1 - distance)
574
- triggered = current_price <= stop_level
575
- else:
576
- stop_level = pos['lowest_price'] * (1 + distance)
577
- triggered = current_price >= stop_level
578
-
579
- return triggered, stop_level, 'trailing'
580
-
581
- def _check_trailing_stop_pnl(self, pos, pnl_pct):
582
- """Trailing stop-loss (P&L%-based for options)"""
583
- if pos['trailing_distance'] is None:
584
- pos['trailing_distance'] = pos['stop_value']
585
-
586
- distance = pos['trailing_distance'] * 100
587
-
588
- stop_level = pos['highest_price'] - distance
589
-
590
- triggered = pnl_pct <= stop_level
591
-
592
- return triggered, stop_level, 'trailing'
593
-
594
- def _check_time_stop(self, pos, current_date):
595
- """Time-based stop"""
596
- days_held = (current_date - pos['entry_date']).days
597
- max_days = pos['stop_value']
598
-
599
- triggered = days_held >= max_days
600
- return triggered, None, 'time_based'
601
-
602
- def _check_volatility_stop(self, pos, current_price, position_type):
603
- """ATR-based stop"""
604
- if pos['atr'] is None:
605
- return False, None, None
606
-
607
- entry = pos['entry_price']
608
- atr_multiplier = pos['stop_value']
609
- stop_distance = pos['atr'] * atr_multiplier
610
-
611
- if position_type == 'LONG':
612
- stop_level = entry - stop_distance
613
- triggered = current_price <= stop_level
614
- else:
615
- stop_level = entry + stop_distance
616
- triggered = current_price >= stop_level
617
-
618
- return triggered, stop_level, 'volatility'
619
-
620
- def _check_pl_loss_stop(self, pos, kwargs):
621
- """Stop-loss based on actual P&L"""
622
- pnl_pct = kwargs.get('pnl_pct')
623
-
624
- if pnl_pct is None:
625
- current_pnl = kwargs.get('current_pnl', 0)
626
- total_cost = kwargs.get('total_cost', pos.get('total_cost', 1))
627
-
628
- if total_cost > 0:
629
- pnl_pct = (current_pnl / total_cost) * 100
630
- else:
631
- pnl_pct = 0
632
-
633
- stop_threshold = -pos['stop_value'] * 100
634
- triggered = pnl_pct <= stop_threshold
635
-
636
- return triggered, stop_threshold, 'pl_loss'
637
-
638
- def _check_directional_stop(self, pos, kwargs):
639
- """Stop-loss based on underlying price movement"""
640
- underlying_change_pct = kwargs.get('underlying_change_pct')
641
-
642
- if underlying_change_pct is None:
643
- current = kwargs.get('underlying_price')
644
- entry = kwargs.get('underlying_entry_price', pos.get('underlying_entry_price'))
645
-
646
- if current is not None and entry is not None and entry != 0:
647
- underlying_change_pct = ((current - entry) / entry) * 100
648
- else:
649
- underlying_change_pct = 0
650
-
651
- threshold = pos['stop_value'] * 100
652
- is_short_bias = pos.get('is_short_bias', False)
653
-
654
- if is_short_bias:
655
- triggered = underlying_change_pct >= threshold
656
- else:
657
- triggered = underlying_change_pct <= -threshold
658
-
659
- return triggered, threshold, 'directional'
660
-
661
- # ========================================================
662
- # NEW: COMBINED STOP (REQUIRES BOTH CONDITIONS)
663
- # ========================================================
664
-
665
- def _check_combined_stop(self, pos, kwargs):
666
- """
667
- Combined stop: Requires BOTH pl_loss AND directional conditions
668
-
669
- This is the key feature from code 2:
670
- - Must have P&L loss > threshold
671
- - AND underlying must move adversely > threshold
672
-
673
- Args:
674
- pos: Position dict with stop_value = {'pl_loss': 0.05, 'directional': 0.03}
675
- kwargs: Must contain pnl_pct and underlying_change_pct
676
-
677
- Returns:
678
- tuple: (triggered, thresholds_dict, 'combined')
679
- """
680
- stop_config = pos['stop_value']
681
-
682
- if not isinstance(stop_config, dict):
683
- # Fallback: treat as simple fixed stop
684
- return False, None, 'combined'
685
-
686
- pl_threshold = stop_config.get('pl_loss', 0.05)
687
- dir_threshold = stop_config.get('directional', 0.03)
688
-
689
- # Check P&L condition
690
- pnl_pct = kwargs.get('pnl_pct', 0)
691
- is_losing = pnl_pct <= (-pl_threshold * 100)
692
-
693
- # Check directional condition
694
- underlying_change_pct = kwargs.get('underlying_change_pct')
695
-
696
- if underlying_change_pct is None:
697
- current = kwargs.get('underlying_price')
698
- entry = kwargs.get('underlying_entry_price', pos.get('underlying_entry_price'))
699
-
700
- if current is not None and entry is not None and entry != 0:
701
- underlying_change_pct = ((current - entry) / entry) * 100
702
- else:
703
- underlying_change_pct = 0
704
-
705
- is_short_bias = pos.get('is_short_bias', False)
706
-
707
- if is_short_bias:
708
- # Bearish position: adverse move is UP
709
- adverse_move = underlying_change_pct >= (dir_threshold * 100)
710
- else:
711
- # Bullish position: adverse move is DOWN
712
- adverse_move = underlying_change_pct <= (-dir_threshold * 100)
713
-
714
- # CRITICAL: Both conditions must be true
715
- triggered = is_losing and adverse_move
716
-
717
- # Return detailed thresholds for reporting
718
- thresholds = {
719
- 'pl_threshold': -pl_threshold * 100,
720
- 'dir_threshold': dir_threshold * 100,
721
- 'actual_pnl_pct': pnl_pct,
722
- 'actual_underlying_change': underlying_change_pct,
723
- 'pl_condition': is_losing,
724
- 'dir_condition': adverse_move
725
- }
726
-
727
- return triggered, thresholds, 'combined'
728
-
729
- # ========================================================
730
- # UTILITY METHODS
731
- # ========================================================
732
-
733
- def remove_position(self, position_id):
734
- """Remove position from tracking"""
735
- if position_id in self.positions:
736
- del self.positions[position_id]
737
-
738
- def get_position_info(self, position_id):
739
- """Get position stop-loss info"""
740
- if position_id not in self.positions:
741
- return None
742
-
743
- pos = self.positions[position_id]
744
- return {
745
- 'stop_type': pos['stop_type'],
746
- 'stop_value': pos['stop_value'],
747
- 'max_profit_before_stop': pos['max_profit']
748
- }
749
-
750
-
751
- # ============================================================
752
- # POSITION MANAGER (unchanged but compatible with combined stop)
753
- # ============================================================
754
- class PositionManager:
755
- """Universal Position Manager with automatic mode detection"""
756
-
757
- def __init__(self, config, debug=False):
758
- self.positions = {}
759
- self.closed_trades = []
760
- self.config = config
761
- self.debug = debug
762
-
763
- self.sl_enabled = config.get('stop_loss_enabled', False)
764
- if self.sl_enabled:
765
- self.sl_config = config.get('stop_loss_config', {})
766
- self.sl_manager = StopLossManager()
767
- else:
768
- self.sl_config = None
769
- self.sl_manager = None
770
-
771
- def open_position(self, position_id, symbol, entry_date, entry_price,
772
- quantity, position_type='LONG', **kwargs):
773
- """Open position with automatic stop-loss"""
774
-
775
- if entry_price == 0 and self.sl_enabled:
776
- if 'total_cost' not in kwargs or kwargs['total_cost'] == 0:
777
- raise ValueError(
778
- f"\n{'='*70}\n"
779
- f"ERROR: P&L% mode requires 'total_cost' parameter\n"
780
- f"{'='*70}\n"
781
- )
782
-
783
- position = {
784
- 'id': position_id,
785
- 'symbol': symbol,
786
- 'entry_date': entry_date,
787
- 'entry_price': entry_price,
788
- 'quantity': quantity,
789
- 'type': position_type,
790
- 'highest_price': entry_price,
791
- 'lowest_price': entry_price,
792
- **kwargs
793
- }
794
-
795
- self.positions[position_id] = position
796
-
797
- if self.sl_enabled and self.sl_manager:
798
- sl_type = self.sl_config.get('type', 'fixed_pct')
799
- sl_value = self.sl_config.get('value', 0.05)
800
-
801
- use_pnl_pct = (entry_price == 0)
802
- is_short_bias = kwargs.get('is_short_bias', False)
803
-
804
- # Pass underlying_entry_price for combined stop
805
- self.sl_manager.add_position(
806
- position_id=position_id,
807
- entry_price=entry_price,
808
- entry_date=entry_date,
809
- stop_type=sl_type,
810
- stop_value=sl_value,
811
- atr=kwargs.get('atr', None),
812
- trailing_distance=self.sl_config.get('trailing_distance', None),
813
- use_pnl_pct=use_pnl_pct,
814
- is_short_bias=is_short_bias,
815
- underlying_entry_price=kwargs.get('entry_stock_price') # For combined stop
816
- )
817
-
818
- if self.debug:
819
- mode = "P&L%" if entry_price == 0 else "Price"
820
- bias = " (SHORT BIAS)" if kwargs.get('is_short_bias') else ""
821
- print(f"[PositionManager] OPEN {position_id}: {symbol} @ {entry_price} (Mode: {mode}{bias})")
822
-
823
- return position
824
-
825
- def check_positions(self, current_date, price_data):
826
- """Check all positions for stop-loss triggers"""
827
- if not self.sl_enabled:
828
- return []
829
-
830
- to_close = []
831
-
832
- for position_id, position in self.positions.items():
833
- if position_id not in price_data:
834
- continue
835
-
836
- if isinstance(price_data[position_id], dict):
837
- data = price_data[position_id]
838
- current_price = data.get('price', position['entry_price'])
839
- current_pnl = data.get('pnl', 0)
840
- current_pnl_pct = data.get('pnl_pct', 0)
841
-
842
- # NEW: Pass underlying data for combined stop
843
- underlying_price = data.get('underlying_price')
844
- underlying_entry_price = data.get('underlying_entry_price')
845
- underlying_change_pct = data.get('underlying_change_pct')
846
- else:
847
- current_price = price_data[position_id]
848
- current_pnl = (current_price - position['entry_price']) * position['quantity']
849
- current_pnl_pct = (current_price - position['entry_price']) / position['entry_price'] if position['entry_price'] != 0 else 0
850
- underlying_price = None
851
- underlying_entry_price = None
852
- underlying_change_pct = None
853
-
854
- position['highest_price'] = max(position['highest_price'], current_price)
855
- position['lowest_price'] = min(position['lowest_price'], current_price)
856
-
857
- if position['entry_price'] == 0:
858
- check_value = current_pnl_pct
859
- else:
860
- check_value = current_price
861
-
862
- # Pass all data to stop manager
863
- stop_kwargs = {
864
- 'pnl_pct': current_pnl_pct,
865
- 'current_pnl': current_pnl,
866
- 'total_cost': position.get('total_cost', 1),
867
- 'underlying_price': underlying_price,
868
- 'underlying_entry_price': underlying_entry_price or position.get('entry_stock_price'),
869
- 'underlying_change_pct': underlying_change_pct
870
- }
871
-
872
- triggered, stop_level, stop_type = self.sl_manager.check_stop(
873
- position_id=position_id,
874
- current_price=check_value,
875
- current_date=current_date,
876
- position_type=position['type'],
877
- **stop_kwargs
878
- )
879
-
880
- if triggered:
881
- to_close.append({
882
- 'position_id': position_id,
883
- 'symbol': position['symbol'],
884
- 'stop_type': stop_type,
885
- 'stop_level': stop_level,
886
- 'current_price': current_price,
887
- 'pnl': current_pnl,
888
- 'pnl_pct': current_pnl_pct
889
- })
890
-
891
- if self.debug:
892
- mode = "P&L%" if position['entry_price'] == 0 else "Price"
893
- print(f"[PositionManager] STOP-LOSS: {position_id} ({stop_type}, {mode}) @ {check_value:.2f}")
894
-
895
- return to_close
896
-
897
- def close_position(self, position_id, exit_date, exit_price,
898
- close_reason='manual', pnl=None, **kwargs):
899
- """Close position"""
900
- if position_id not in self.positions:
901
- if self.debug:
902
- print(f"[PositionManager] WARNING: Position {position_id} not found")
903
- return None
904
-
905
- position = self.positions.pop(position_id)
906
-
907
- if pnl is None:
908
- pnl = (exit_price - position['entry_price']) * position['quantity']
909
-
910
- if position['entry_price'] != 0:
911
- pnl_pct = (exit_price - position['entry_price']) / position['entry_price'] * 100
912
- else:
913
- if 'total_cost' in position and position['total_cost'] != 0:
914
- pnl_pct = (pnl / position['total_cost']) * 100
915
- elif 'total_cost' in kwargs and kwargs['total_cost'] != 0:
916
- pnl_pct = (pnl / kwargs['total_cost']) * 100
917
- else:
918
- pnl_pct = 0.0
919
-
920
- trade = {
921
- 'entry_date': position['entry_date'],
922
- 'exit_date': exit_date,
923
- 'symbol': position['symbol'],
924
- 'signal': position['type'],
925
- 'entry_price': position['entry_price'],
926
- 'exit_price': exit_price,
927
- 'quantity': position['quantity'],
928
- 'pnl': pnl,
929
- 'return_pct': pnl_pct,
930
- 'exit_reason': close_reason,
931
- 'stop_type': self.sl_config.get('type', 'none') if self.sl_enabled else 'none',
932
- **kwargs
933
- }
934
-
935
- for key in ['call_strike', 'put_strike', 'expiration', 'contracts',
936
- 'short_strike', 'long_strike', 'opt_type', 'spread_type',
937
- 'entry_z_score', 'is_short_bias', 'entry_lean', 'exit_lean',
938
- 'call_iv_entry', 'put_iv_entry', 'iv_lean_entry']:
939
- if key in position:
940
- trade[key] = position[key]
941
-
942
- for key in ['short_entry_bid', 'short_entry_ask', 'short_entry_mid',
943
- 'long_entry_bid', 'long_entry_ask', 'long_entry_mid',
944
- 'underlying_entry_price']:
945
- if key in position:
946
- trade[key] = position[key]
947
-
948
- for key in ['short_exit_bid', 'short_exit_ask',
949
- 'long_exit_bid', 'long_exit_ask',
950
- 'underlying_exit_price', 'underlying_change_pct',
951
- 'stop_threshold', 'actual_value',
952
- 'call_iv_exit', 'put_iv_exit', 'iv_lean_exit',
953
- 'spy_intraday_high', 'spy_intraday_low', 'spy_intraday_close',
954
- 'spy_stop_trigger_time', 'spy_stop_trigger_price',
955
- 'spy_stop_trigger_bid', 'spy_stop_trigger_ask', 'spy_stop_trigger_last',
956
- 'intraday_data_points', 'intraday_data_available', 'stop_triggered_by']:
957
- if key in kwargs:
958
- trade[key] = kwargs[key]
959
-
960
- self.closed_trades.append(trade)
961
-
962
- if self.sl_enabled and self.sl_manager:
963
- self.sl_manager.remove_position(position_id)
964
-
965
- if self.debug:
966
- print(f"[PositionManager] CLOSE {position_id}: P&L=${pnl:.2f} ({pnl_pct:.2f}%) - {close_reason}")
967
-
968
- return trade
969
-
970
- def get_open_positions(self):
971
- return list(self.positions.values())
972
-
973
- def get_closed_trades(self):
974
- return self.closed_trades
975
-
976
- def close_all_positions(self, final_date, price_data, reason='end_of_backtest'):
977
- """Close all open positions at end of backtest"""
978
- for position_id in list(self.positions.keys()):
979
- if position_id in price_data:
980
- position = self.positions[position_id]
981
-
982
- if isinstance(price_data[position_id], dict):
983
- data = price_data[position_id]
984
- exit_price = data.get('price', position['entry_price'])
985
- pnl = data.get('pnl', None)
986
- else:
987
- exit_price = price_data[position_id]
988
- pnl = None
989
-
990
- if pnl is None and position['entry_price'] == 0:
991
- if isinstance(price_data[position_id], dict) and 'pnl' in price_data[position_id]:
992
- pnl = price_data[position_id]['pnl']
993
-
994
- self.close_position(
995
- position_id=position_id,
996
- exit_date=final_date,
997
- exit_price=exit_price,
998
- close_reason=reason,
999
- pnl=pnl
1000
- )
1001
-
1002
-
1003
- # ============================================================
1004
- # BACKTEST ANALYZER (unchanged)
1005
- # ============================================================
1006
- class BacktestAnalyzer:
1007
- """Calculate all metrics from BacktestResults"""
1008
-
1009
- def __init__(self, results):
1010
- self.results = results
1011
- self.metrics = {}
1012
-
1013
- def calculate_all_metrics(self):
1014
- r = self.results
1015
-
1016
- self.metrics['initial_capital'] = r.initial_capital
1017
- self.metrics['final_equity'] = r.final_capital
1018
-
1019
- self.metrics['total_pnl'] = r.final_capital - r.initial_capital
1020
- self.metrics['total_return'] = (self.metrics['total_pnl'] / r.initial_capital) * 100
1021
-
1022
- if len(r.equity_dates) > 0:
1023
- start_date = min(r.equity_dates)
1024
- end_date = max(r.equity_dates)
1025
- days_diff = (end_date - start_date).days
1026
-
1027
- if days_diff <= 0:
1028
- self.metrics['cagr'] = 0
1029
- self.metrics['show_cagr'] = False
1030
- else:
1031
- years = days_diff / 365.25
1032
- if years >= 1.0:
1033
- self.metrics['cagr'] = ((r.final_capital / r.initial_capital) ** (1/years) - 1) * 100
1034
- self.metrics['show_cagr'] = True
1035
- else:
1036
- self.metrics['cagr'] = self.metrics['total_return'] * (365.25 / days_diff)
1037
- self.metrics['show_cagr'] = False
1038
- else:
1039
- self.metrics['cagr'] = 0
1040
- self.metrics['show_cagr'] = False
1041
-
1042
- self.metrics['sharpe'] = self._sharpe_ratio(r.daily_returns)
1043
- self.metrics['sortino'] = self._sortino_ratio(r.daily_returns)
1044
- self.metrics['max_drawdown'] = r.max_drawdown
1045
- self.metrics['volatility'] = np.std(r.daily_returns) * np.sqrt(252) * 100 if len(r.daily_returns) > 0 else 0
1046
- self.metrics['calmar'] = abs(self.metrics['total_return'] / r.max_drawdown) if r.max_drawdown > 0 else 0
1047
- self.metrics['omega'] = self._omega_ratio(r.daily_returns)
1048
- self.metrics['ulcer'] = self._ulcer_index(r.equity_curve)
1049
-
1050
- self.metrics['var_95'], self.metrics['var_95_pct'] = self._calculate_var(r.daily_returns, 0.95)
1051
- self.metrics['var_99'], self.metrics['var_99_pct'] = self._calculate_var(r.daily_returns, 0.99)
1052
- self.metrics['cvar_95'], self.metrics['cvar_95_pct'] = self._calculate_cvar(r.daily_returns, 0.95)
1053
-
1054
- avg_equity = np.mean(r.equity_curve) if len(r.equity_curve) > 0 else r.initial_capital
1055
- self.metrics['var_95_dollar'] = self.metrics['var_95'] * avg_equity
1056
- self.metrics['var_99_dollar'] = self.metrics['var_99'] * avg_equity
1057
- self.metrics['cvar_95_dollar'] = self.metrics['cvar_95'] * avg_equity
1058
-
1059
- self.metrics['tail_ratio'] = self._tail_ratio(r.daily_returns)
1060
- self.metrics['skewness'], self.metrics['kurtosis'] = self._skewness_kurtosis(r.daily_returns)
1061
-
1062
- self.metrics['alpha'], self.metrics['beta'], self.metrics['r_squared'] = self._alpha_beta(r)
1063
-
1064
- if len(r.trades) > 0:
1065
- self._calculate_trading_stats(r.trades)
1066
- else:
1067
- self._set_empty_trading_stats()
1068
-
1069
- running_max = np.maximum.accumulate(r.equity_curve)
1070
- max_dd_dollars = np.min(np.array(r.equity_curve) - running_max)
1071
- self.metrics['recovery_factor'] = self.metrics['total_pnl'] / abs(max_dd_dollars) if max_dd_dollars != 0 else 0
1072
-
1073
- if len(r.trades) > 0 and 'start_date' in r.config and 'end_date' in r.config:
1074
- total_days = (pd.to_datetime(r.config['end_date']) - pd.to_datetime(r.config['start_date'])).days
1075
- self.metrics['exposure_time'] = self._exposure_time(r.trades, total_days)
1076
- else:
1077
- self.metrics['exposure_time'] = 0
1078
-
1079
- return self.metrics
1080
-
1081
- def _calculate_trading_stats(self, trades):
1082
- trades_df = pd.DataFrame(trades)
1083
- winning = trades_df[trades_df['pnl'] > 0]
1084
- losing = trades_df[trades_df['pnl'] <= 0]
1085
-
1086
- self.metrics['total_trades'] = len(trades_df)
1087
- self.metrics['winning_trades'] = len(winning)
1088
- self.metrics['losing_trades'] = len(losing)
1089
- self.metrics['win_rate'] = (len(winning) / len(trades_df)) * 100 if len(trades_df) > 0 else 0
1090
-
1091
- wins_sum = winning['pnl'].sum() if len(winning) > 0 else 0
1092
- losses_sum = abs(losing['pnl'].sum()) if len(losing) > 0 else 0
1093
- self.metrics['profit_factor'] = wins_sum / losses_sum if losses_sum > 0 else float('inf')
1094
-
1095
- self.metrics['avg_win'] = winning['pnl'].mean() if len(winning) > 0 else 0
1096
- self.metrics['avg_loss'] = losing['pnl'].mean() if len(losing) > 0 else 0
1097
- self.metrics['best_trade'] = trades_df['pnl'].max()
1098
- self.metrics['worst_trade'] = trades_df['pnl'].min()
1099
-
1100
- if len(winning) > 0 and len(losing) > 0:
1101
- self.metrics['avg_win_loss_ratio'] = abs(self.metrics['avg_win'] / self.metrics['avg_loss'])
1102
- else:
1103
- self.metrics['avg_win_loss_ratio'] = 0
1104
-
1105
- self.metrics['max_win_streak'], self.metrics['max_loss_streak'] = self._win_loss_streaks(trades)
1106
-
1107
- def _set_empty_trading_stats(self):
1108
- self.metrics.update({
1109
- 'total_trades': 0, 'winning_trades': 0, 'losing_trades': 0,
1110
- 'win_rate': 0, 'profit_factor': 0, 'avg_win': 0, 'avg_loss': 0,
1111
- 'best_trade': 0, 'worst_trade': 0, 'avg_win_loss_ratio': 0,
1112
- 'max_win_streak': 0, 'max_loss_streak': 0
1113
- })
1114
-
1115
- def _sharpe_ratio(self, returns):
1116
- if len(returns) < 2:
1117
- return 0
1118
- return np.sqrt(252) * np.mean(returns) / np.std(returns) if np.std(returns) > 0 else 0
1119
-
1120
- def _sortino_ratio(self, returns):
1121
- if len(returns) < 2:
1122
- return 0
1123
- returns_array = np.array(returns)
1124
- downside = returns_array[returns_array < 0]
1125
- if len(downside) == 0 or np.std(downside) == 0:
1126
- return 0
1127
- return np.sqrt(252) * np.mean(returns_array) / np.std(downside)
1128
-
1129
- def _omega_ratio(self, returns, threshold=0):
1130
- if len(returns) < 2:
1131
- return 0
1132
- returns_array = np.array(returns)
1133
- gains = np.sum(np.maximum(returns_array - threshold, 0))
1134
- losses = np.sum(np.maximum(threshold - returns_array, 0))
1135
- return gains / losses if losses > 0 else float('inf')
1136
-
1137
- def _ulcer_index(self, equity_curve):
1138
- if len(equity_curve) < 2:
1139
- return 0
1140
- equity_array = np.array(equity_curve)
1141
- running_max = np.maximum.accumulate(equity_array)
1142
- drawdown = (equity_array - running_max) / running_max
1143
- return np.sqrt(np.mean(drawdown ** 2)) * 100
1144
-
1145
- def _calculate_var(self, returns, confidence=0.95):
1146
- if len(returns) < 10:
1147
- return 0, 0
1148
- returns_array = np.array(returns)
1149
- returns_array = returns_array[~np.isnan(returns_array)]
1150
- if len(returns_array) < 10:
1151
- return 0, 0
1152
- var_percentile = (1 - confidence) * 100
1153
- var_return = np.percentile(returns_array, var_percentile)
1154
- return var_return, var_return * 100
1155
-
1156
- def _calculate_cvar(self, returns, confidence=0.95):
1157
- if len(returns) < 10:
1158
- return 0, 0
1159
- returns_array = np.array(returns)
1160
- returns_array = returns_array[~np.isnan(returns_array)]
1161
- if len(returns_array) < 10:
1162
- return 0, 0
1163
- var_percentile = (1 - confidence) * 100
1164
- var_threshold = np.percentile(returns_array, var_percentile)
1165
- tail_losses = returns_array[returns_array <= var_threshold]
1166
- if len(tail_losses) == 0:
1167
- return 0, 0
1168
- cvar_return = np.mean(tail_losses)
1169
- return cvar_return, cvar_return * 100
1170
-
1171
- def _tail_ratio(self, returns):
1172
- if len(returns) < 20:
1173
- return 0
1174
- returns_array = np.array(returns)
1175
- right = np.percentile(returns_array, 95)
1176
- left = abs(np.percentile(returns_array, 5))
1177
- return right / left if left > 0 else 0
1178
-
1179
- def _skewness_kurtosis(self, returns):
1180
- if len(returns) < 10:
1181
- return 0, 0
1182
- returns_array = np.array(returns)
1183
- mean = np.mean(returns_array)
1184
- std = np.std(returns_array)
1185
- if std == 0:
1186
- return 0, 0
1187
- skew = np.mean(((returns_array - mean) / std) ** 3)
1188
- kurt = np.mean(((returns_array - mean) / std) ** 4) - 3
1189
- return skew, kurt
1190
-
1191
- def _alpha_beta(self, results):
1192
- if not hasattr(results, 'benchmark_prices') or not results.benchmark_prices:
1193
- return 0, 0, 0
1194
- if len(results.equity_dates) < 10:
1195
- return 0, 0, 0
1196
-
1197
- benchmark_returns = []
1198
- sorted_dates = sorted(results.equity_dates)
1199
-
1200
- for i in range(1, len(sorted_dates)):
1201
- prev_date = sorted_dates[i-1]
1202
- curr_date = sorted_dates[i]
1203
-
1204
- if prev_date in results.benchmark_prices and curr_date in results.benchmark_prices:
1205
- prev_price = results.benchmark_prices[prev_date]
1206
- curr_price = results.benchmark_prices[curr_date]
1207
- bench_return = (curr_price - prev_price) / prev_price
1208
- benchmark_returns.append(bench_return)
1209
- else:
1210
- benchmark_returns.append(0)
1211
-
1212
- if len(benchmark_returns) != len(results.daily_returns):
1213
- return 0, 0, 0
1214
-
1215
- port_ret = np.array(results.daily_returns)
1216
- bench_ret = np.array(benchmark_returns)
1217
-
1218
- bench_mean = np.mean(bench_ret)
1219
- port_mean = np.mean(port_ret)
1220
-
1221
- covariance = np.mean((bench_ret - bench_mean) * (port_ret - port_mean))
1222
- benchmark_variance = np.mean((bench_ret - bench_mean) ** 2)
1223
-
1224
- if benchmark_variance == 0:
1225
- return 0, 0, 0
1226
-
1227
- beta = covariance / benchmark_variance
1228
- alpha_daily = port_mean - beta * bench_mean
1229
- alpha_annualized = alpha_daily * 252 * 100
1230
-
1231
- ss_res = np.sum((port_ret - (alpha_daily + beta * bench_ret)) ** 2)
1232
- ss_tot = np.sum((port_ret - port_mean) ** 2)
1233
- r_squared = 1 - (ss_res / ss_tot) if ss_tot > 0 else 0
1234
-
1235
- return alpha_annualized, beta, r_squared
1236
-
1237
- def _win_loss_streaks(self, trades):
1238
- if len(trades) == 0:
1239
- return 0, 0
1240
- max_win = max_loss = current_win = current_loss = 0
1241
- for trade in trades:
1242
- if trade['pnl'] > 0:
1243
- current_win += 1
1244
- current_loss = 0
1245
- max_win = max(max_win, current_win)
1246
- else:
1247
- current_loss += 1
1248
- current_win = 0
1249
- max_loss = max(max_loss, current_loss)
1250
- return max_win, max_loss
1251
-
1252
- def _exposure_time(self, trades, total_days):
1253
- if total_days <= 0 or len(trades) == 0:
1254
- return 0
1255
- days_with_positions = set()
1256
- for trade in trades:
1257
- entry = pd.to_datetime(trade['entry_date'])
1258
- exit_ = pd.to_datetime(trade['exit_date'])
1259
- date_range = pd.date_range(start=entry, end=exit_, freq='D')
1260
- days_with_positions.update(date_range.date)
1261
- exposure_pct = (len(days_with_positions) / total_days) * 100
1262
- return min(exposure_pct, 100.0)
1263
-
1264
-
1265
- # ============================================================
1266
- # STOP-LOSS METRICS (unchanged)
1267
- # ============================================================
1268
- def calculate_stoploss_metrics(analyzer):
1269
- """Calculate stop-loss specific metrics"""
1270
- if len(analyzer.results.trades) == 0:
1271
- _set_empty_stoploss_metrics(analyzer)
1272
- return analyzer.metrics
1273
-
1274
- trades_df = pd.DataFrame(analyzer.results.trades)
1275
-
1276
- if 'exit_reason' not in trades_df.columns:
1277
- _set_empty_stoploss_metrics(analyzer)
1278
- return analyzer.metrics
1279
-
1280
- sl_trades = trades_df[trades_df['exit_reason'].str.contains('stop_loss', na=False)]
1281
- profit_target_trades = trades_df[trades_df['exit_reason'] == 'profit_target']
1282
-
1283
- analyzer.metrics['stoploss_count'] = len(sl_trades)
1284
- analyzer.metrics['stoploss_pct'] = (len(sl_trades) / len(trades_df)) * 100 if len(trades_df) > 0 else 0
1285
- analyzer.metrics['profit_target_count'] = len(profit_target_trades)
1286
- analyzer.metrics['profit_target_pct'] = (len(profit_target_trades) / len(trades_df)) * 100 if len(trades_df) > 0 else 0
1287
-
1288
- if len(sl_trades) > 0:
1289
- analyzer.metrics['avg_stoploss_pnl'] = sl_trades['pnl'].mean()
1290
- analyzer.metrics['total_stoploss_loss'] = sl_trades['pnl'].sum()
1291
- analyzer.metrics['worst_stoploss'] = sl_trades['pnl'].min()
1292
-
1293
- if 'return_pct' in sl_trades.columns:
1294
- analyzer.metrics['avg_stoploss_return_pct'] = sl_trades['return_pct'].mean()
1295
- else:
1296
- analyzer.metrics['avg_stoploss_return_pct'] = 0
1297
-
1298
- if 'entry_date' in sl_trades.columns and 'exit_date' in sl_trades.columns:
1299
- sl_trades_copy = sl_trades.copy()
1300
- sl_trades_copy['entry_date'] = pd.to_datetime(sl_trades_copy['entry_date'])
1301
- sl_trades_copy['exit_date'] = pd.to_datetime(sl_trades_copy['exit_date'])
1302
- sl_trades_copy['days_held'] = (sl_trades_copy['exit_date'] - sl_trades_copy['entry_date']).dt.days
1303
- analyzer.metrics['avg_days_to_stoploss'] = sl_trades_copy['days_held'].mean()
1304
- analyzer.metrics['min_days_to_stoploss'] = sl_trades_copy['days_held'].min()
1305
- analyzer.metrics['max_days_to_stoploss'] = sl_trades_copy['days_held'].max()
1306
- else:
1307
- analyzer.metrics['avg_days_to_stoploss'] = 0
1308
- analyzer.metrics['min_days_to_stoploss'] = 0
1309
- analyzer.metrics['max_days_to_stoploss'] = 0
1310
-
1311
- if 'stop_type' in sl_trades.columns:
1312
- stop_types = sl_trades['stop_type'].value_counts().to_dict()
1313
- analyzer.metrics['stoploss_by_type'] = stop_types
1314
- else:
1315
- analyzer.metrics['stoploss_by_type'] = {}
1316
- else:
1317
- analyzer.metrics['avg_stoploss_pnl'] = 0
1318
- analyzer.metrics['total_stoploss_loss'] = 0
1319
- analyzer.metrics['worst_stoploss'] = 0
1320
- analyzer.metrics['avg_stoploss_return_pct'] = 0
1321
- analyzer.metrics['avg_days_to_stoploss'] = 0
1322
- analyzer.metrics['min_days_to_stoploss'] = 0
1323
- analyzer.metrics['max_days_to_stoploss'] = 0
1324
- analyzer.metrics['stoploss_by_type'] = {}
1325
-
1326
- if len(profit_target_trades) > 0 and len(sl_trades) > 0:
1327
- avg_profit_target = profit_target_trades['pnl'].mean()
1328
- avg_stoploss = abs(sl_trades['pnl'].mean())
1329
- analyzer.metrics['profit_to_loss_ratio'] = avg_profit_target / avg_stoploss if avg_stoploss > 0 else 0
1330
- else:
1331
- analyzer.metrics['profit_to_loss_ratio'] = 0
1332
-
1333
- if 'max_profit_before_stop' in sl_trades.columns:
1334
- early_exits = sl_trades[sl_trades['max_profit_before_stop'] > 0]
1335
- analyzer.metrics['early_exit_count'] = len(early_exits)
1336
- analyzer.metrics['early_exit_pct'] = (len(early_exits) / len(sl_trades)) * 100 if len(sl_trades) > 0 else 0
1337
- if len(early_exits) > 0:
1338
- analyzer.metrics['avg_missed_profit'] = early_exits['max_profit_before_stop'].mean()
1339
- else:
1340
- analyzer.metrics['avg_missed_profit'] = 0
1341
- else:
1342
- analyzer.metrics['early_exit_count'] = 0
1343
- analyzer.metrics['early_exit_pct'] = 0
1344
- analyzer.metrics['avg_missed_profit'] = 0
1345
-
1346
- exit_reasons = trades_df['exit_reason'].value_counts().to_dict()
1347
- analyzer.metrics['exit_reasons'] = exit_reasons
1348
-
1349
- return analyzer.metrics
1350
-
1351
-
1352
- def _set_empty_stoploss_metrics(analyzer):
1353
- analyzer.metrics.update({
1354
- 'stoploss_count': 0, 'stoploss_pct': 0,
1355
- 'profit_target_count': 0, 'profit_target_pct': 0,
1356
- 'avg_stoploss_pnl': 0, 'total_stoploss_loss': 0,
1357
- 'worst_stoploss': 0, 'avg_stoploss_return_pct': 0,
1358
- 'avg_days_to_stoploss': 0, 'min_days_to_stoploss': 0,
1359
- 'max_days_to_stoploss': 0, 'stoploss_by_type': {},
1360
- 'profit_to_loss_ratio': 0, 'early_exit_count': 0,
1361
- 'early_exit_pct': 0, 'avg_missed_profit': 0,
1362
- 'exit_reasons': {}
1363
- })
1364
-
1365
-
1366
- # ============================================================
1367
- # RESULTS REPORTER (unchanged)
1368
- # ============================================================
1369
- class ResultsReporter:
1370
- """Print comprehensive metrics report"""
1371
-
1372
- @staticmethod
1373
- def print_full_report(analyzer):
1374
- m = analyzer.metrics
1375
- r = analyzer.results
1376
-
1377
- print("="*80)
1378
- print(" "*25 + "BACKTEST RESULTS")
1379
- print("="*80)
1380
- print()
1381
-
1382
- print("PROFITABILITY METRICS")
1383
- print("-"*80)
1384
- print(f"Initial Capital: ${r.initial_capital:>15,.2f}")
1385
- print(f"Final Equity: ${r.final_capital:>15,.2f}")
1386
- print(f"Total P&L: ${m['total_pnl']:>15,.2f} (absolute profit/loss)")
1387
- print(f"Total Return: {m['total_return']:>15.2f}% (% gain/loss)")
1388
- if m['cagr'] != 0:
1389
- if m['show_cagr']:
1390
- print(f"CAGR: {m['cagr']:>15.2f}% (annualized compound growth)")
1391
- else:
1392
- print(f"Annualized Return: {m['cagr']:>15.2f}% (extrapolated to 1 year)")
1393
- print()
1394
-
1395
- print("RISK METRICS")
1396
- print("-"*80)
1397
- print(f"Sharpe Ratio: {m['sharpe']:>15.2f} (>1 good, >2 excellent)")
1398
- print(f"Sortino Ratio: {m['sortino']:>15.2f} (downside risk, >2 good)")
1399
- print(f"Calmar Ratio: {m['calmar']:>15.2f} (return/drawdown, >3 good)")
1400
- if m['omega'] != 0:
1401
- omega_display = f"{m['omega']:.2f}" if m['omega'] < 999 else "∞"
1402
- print(f"Omega Ratio: {omega_display:>15s} (gains/losses, >1 good)")
1403
- print(f"Maximum Drawdown: {m['max_drawdown']:>15.2f}% (peak to trough)")
1404
- if m['ulcer'] != 0:
1405
- print(f"Ulcer Index: {m['ulcer']:>15.2f}% (pain of drawdowns, lower better)")
1406
- print(f"Volatility (ann.): {m['volatility']:>15.2f}% (annualized std dev)")
1407
-
1408
- if len(r.daily_returns) >= 10:
1409
- print(f"VaR (95%, 1-day): {m['var_95_pct']:>15.2f}% (${m['var_95_dollar']:>,.0f}) (max loss 95% confidence)")
1410
- print(f"VaR (99%, 1-day): {m['var_99_pct']:>15.2f}% (${m['var_99_dollar']:>,.0f}) (max loss 99% confidence)")
1411
- print(f"CVaR (95%, 1-day): {m['cvar_95_pct']:>15.2f}% (${m['cvar_95_dollar']:>,.0f}) (avg loss in worst 5%)")
1412
-
1413
- if m['tail_ratio'] != 0:
1414
- print(f"Tail Ratio (95/5): {m['tail_ratio']:>15.2f} (big wins/losses, >1 good)")
1415
-
1416
- if m['skewness'] != 0 or m['kurtosis'] != 0:
1417
- print(f"Skewness: {m['skewness']:>15.2f} (>0 positive tail)")
1418
- print(f"Kurtosis (excess): {m['kurtosis']:>15.2f} (>0 fat tails)")
1419
-
1420
- if m['beta'] != 0 or m['alpha'] != 0:
1421
- print(f"Alpha (vs {r.benchmark_symbol}): {m['alpha']:>15.2f}% (excess return)")
1422
- print(f"Beta (vs {r.benchmark_symbol}): {m['beta']:>15.2f} (<1 defensive, >1 aggressive)")
1423
- print(f"R² (vs {r.benchmark_symbol}): {m['r_squared']:>15.2f} (market correlation 0-1)")
1424
-
1425
- if abs(m['total_return']) > 200 or m['volatility'] > 150:
1426
- print()
1427
- print("WARNING: UNREALISTIC RESULTS DETECTED")
1428
- if abs(m['total_return']) > 200:
1429
- print(f" Total return {m['total_return']:.1f}% is extremely high")
1430
- if m['volatility'] > 150:
1431
- print(f" Volatility {m['volatility']:.1f}% is higher than leveraged ETFs")
1432
- print(" Review configuration before trusting results")
1433
-
1434
- print()
1435
-
1436
- print("EFFICIENCY METRICS")
1437
- print("-"*80)
1438
- if m['recovery_factor'] != 0:
1439
- print(f"Recovery Factor: {m['recovery_factor']:>15.2f} (profit/max DD, >3 good)")
1440
- if m['exposure_time'] != 0:
1441
- print(f"Exposure Time: {m['exposure_time']:>15.1f}% (time in market)")
1442
- print()
1443
-
1444
- print("TRADING STATISTICS")
1445
- print("-"*80)
1446
- print(f"Total Trades: {m['total_trades']:>15}")
1447
- print(f"Winning Trades: {m['winning_trades']:>15}")
1448
- print(f"Losing Trades: {m['losing_trades']:>15}")
1449
- print(f"Win Rate: {m['win_rate']:>15.2f}% (% profitable trades)")
1450
- print(f"Profit Factor: {m['profit_factor']:>15.2f} (gross profit/loss, >1.5 good)")
1451
- if m['max_win_streak'] > 0 or m['max_loss_streak'] > 0:
1452
- print(f"Max Win Streak: {m['max_win_streak']:>15} (consecutive wins)")
1453
- print(f"Max Loss Streak: {m['max_loss_streak']:>15} (consecutive losses)")
1454
- print(f"Average Win: ${m['avg_win']:>15,.2f}")
1455
- print(f"Average Loss: ${m['avg_loss']:>15,.2f}")
1456
- print(f"Best Trade: ${m['best_trade']:>15,.2f}")
1457
- print(f"Worst Trade: ${m['worst_trade']:>15,.2f}")
1458
- if m['avg_win_loss_ratio'] != 0:
1459
- print(f"Avg Win/Loss Ratio: {m['avg_win_loss_ratio']:>15.2f} (avg win / avg loss)")
1460
- print()
1461
- print("="*80)
1462
-
1463
-
1464
- def print_stoploss_section(analyzer):
1465
- """Print stop-loss analysis section"""
1466
- m = analyzer.metrics
1467
-
1468
- if m.get('stoploss_count', 0) == 0:
1469
- return
1470
-
1471
- print("STOP-LOSS ANALYSIS")
1472
- print("-"*80)
1473
-
1474
- print(f"Stop-Loss Trades: {m['stoploss_count']:>15} ({m['stoploss_pct']:.1f}% of total)")
1475
- print(f"Profit Target Trades: {m['profit_target_count']:>15} ({m['profit_target_pct']:.1f}% of total)")
1476
-
1477
- print(f"Avg Stop-Loss P&L: ${m['avg_stoploss_pnl']:>15,.2f}")
1478
- print(f"Total Loss from SL: ${m['total_stoploss_loss']:>15,.2f}")
1479
- print(f"Worst Stop-Loss: ${m['worst_stoploss']:>15,.2f}")
1480
- print(f"Avg SL Return: {m['avg_stoploss_return_pct']:>15.2f}%")
1481
-
1482
- if m['avg_days_to_stoploss'] > 0:
1483
- print(f"Avg Days to SL: {m['avg_days_to_stoploss']:>15.1f}")
1484
- print(f"Min/Max Days to SL: {m['min_days_to_stoploss']:>7} / {m['max_days_to_stoploss']:<7}")
1485
-
1486
- if m['profit_to_loss_ratio'] > 0:
1487
- print(f"Profit/Loss Ratio: {m['profit_to_loss_ratio']:>15.2f} (avg profit target / avg stop-loss)")
1488
-
1489
- if m['early_exit_count'] > 0:
1490
- print(f"Early Exits: {m['early_exit_count']:>15} ({m['early_exit_pct']:.1f}% of SL trades)")
1491
- print(f"Avg Missed Profit: ${m['avg_missed_profit']:>15,.2f} (profit before stop triggered)")
1492
-
1493
- if m['stoploss_by_type']:
1494
- print(f"\nStop-Loss Types:")
1495
- for stop_type, count in m['stoploss_by_type'].items():
1496
- pct = (count / m['stoploss_count']) * 100
1497
- print(f" {stop_type:20s} {count:>5} trades ({pct:.1f}%)")
1498
-
1499
- if m.get('exit_reasons'):
1500
- print(f"\nExit Reasons Distribution:")
1501
- total_trades = sum(m['exit_reasons'].values())
1502
- for reason, count in sorted(m['exit_reasons'].items(), key=lambda x: x[1], reverse=True):
1503
- pct = (count / total_trades) * 100
1504
- print(f" {reason:20s} {count:>5} trades ({pct:.1f}%)")
1505
-
1506
- print()
1507
- print("="*80)
1508
-
1509
-
1510
- # ============================================================
1511
- # CHART GENERATOR (only core charts, optimization charts separate)
1512
- # ============================================================
1513
- class ChartGenerator:
1514
- """Generate 6 professional charts"""
1515
-
1516
- @staticmethod
1517
- def create_all_charts(analyzer, filename='backtest_results.png', show_plots=True):
1518
- r = analyzer.results
1519
-
1520
- if len(r.trades) == 0:
1521
- print("No trades to visualize")
1522
- return
1523
-
1524
- trades_df = pd.DataFrame(r.trades)
1525
- fig, axes = plt.subplots(3, 2, figsize=(18, 14))
1526
- fig.suptitle('Backtest Results', fontsize=16, fontweight='bold', y=0.995)
1527
-
1528
- dates = pd.to_datetime(r.equity_dates)
1529
- equity_array = np.array(r.equity_curve)
1530
-
1531
- ax1 = axes[0, 0]
1532
- ax1.plot(dates, equity_array, linewidth=2.5, color='#2196F3')
1533
- ax1.axhline(y=r.initial_capital, color='gray', linestyle='--', alpha=0.7)
1534
- ax1.fill_between(dates, r.initial_capital, equity_array,
1535
- where=(equity_array >= r.initial_capital),
1536
- alpha=0.3, color='green', interpolate=True)
1537
- ax1.fill_between(dates, r.initial_capital, equity_array,
1538
- where=(equity_array < r.initial_capital),
1539
- alpha=0.3, color='red', interpolate=True)
1540
- ax1.set_title('Equity Curve', fontsize=12, fontweight='bold')
1541
- ax1.set_ylabel('Equity ($)')
1542
- ax1.grid(True, alpha=0.3)
1543
-
1544
- ax2 = axes[0, 1]
1545
- running_max = np.maximum.accumulate(equity_array)
1546
- drawdown = (equity_array - running_max) / running_max * 100
1547
- ax2.fill_between(dates, 0, drawdown, alpha=0.6, color='#f44336')
1548
- ax2.plot(dates, drawdown, color='#d32f2f', linewidth=2)
1549
- ax2.set_title('Drawdown', fontsize=12, fontweight='bold')
1550
- ax2.set_ylabel('Drawdown (%)')
1551
- ax2.grid(True, alpha=0.3)
1552
-
1553
- ax3 = axes[1, 0]
1554
- pnl_values = trades_df['pnl'].values
1555
- ax3.hist(pnl_values, bins=40, color='#4CAF50', alpha=0.7, edgecolor='black')
1556
- ax3.axvline(x=0, color='red', linestyle='--', linewidth=2)
1557
- ax3.set_title('P&L Distribution', fontsize=12, fontweight='bold')
1558
- ax3.set_xlabel('P&L ($)')
1559
- ax3.grid(True, alpha=0.3, axis='y')
1560
-
1561
- ax4 = axes[1, 1]
1562
- if 'signal' in trades_df.columns:
1563
- signal_pnl = trades_df.groupby('signal')['pnl'].sum()
1564
- colors = ['#4CAF50' if x > 0 else '#f44336' for x in signal_pnl.values]
1565
- ax4.bar(signal_pnl.index, signal_pnl.values, color=colors, alpha=0.7)
1566
- ax4.set_title('P&L by Signal', fontsize=12, fontweight='bold')
1567
- else:
1568
- ax4.text(0.5, 0.5, 'No signal data', ha='center', va='center', transform=ax4.transAxes)
1569
- ax4.axhline(y=0, color='black', linewidth=1)
1570
- ax4.grid(True, alpha=0.3, axis='y')
1571
-
1572
- ax5 = axes[2, 0]
1573
- trades_df['exit_date'] = pd.to_datetime(trades_df['exit_date'])
1574
- trades_df['month'] = trades_df['exit_date'].dt.to_period('M')
1575
- monthly_pnl = trades_df.groupby('month')['pnl'].sum()
1576
- colors = ['#4CAF50' if x > 0 else '#f44336' for x in monthly_pnl.values]
1577
- ax5.bar(range(len(monthly_pnl)), monthly_pnl.values, color=colors, alpha=0.7)
1578
- ax5.set_title('Monthly P&L', fontsize=12, fontweight='bold')
1579
- ax5.set_xticks(range(len(monthly_pnl)))
1580
- ax5.set_xticklabels([str(m) for m in monthly_pnl.index], rotation=45, ha='right')
1581
- ax5.axhline(y=0, color='black', linewidth=1)
1582
- ax5.grid(True, alpha=0.3, axis='y')
1583
-
1584
- ax6 = axes[2, 1]
1585
- if 'symbol' in trades_df.columns:
1586
- symbol_pnl = trades_df.groupby('symbol')['pnl'].sum().sort_values(ascending=True).tail(10)
1587
- colors = ['#4CAF50' if x > 0 else '#f44336' for x in symbol_pnl.values]
1588
- ax6.barh(range(len(symbol_pnl)), symbol_pnl.values, color=colors, alpha=0.7)
1589
- ax6.set_yticks(range(len(symbol_pnl)))
1590
- ax6.set_yticklabels(symbol_pnl.index, fontsize=9)
1591
- ax6.set_title('Top Symbols', fontsize=12, fontweight='bold')
1592
- else:
1593
- ax6.text(0.5, 0.5, 'No symbol data', ha='center', va='center', transform=ax6.transAxes)
1594
- ax6.axvline(x=0, color='black', linewidth=1)
1595
- ax6.grid(True, alpha=0.3, axis='x')
1596
-
1597
- plt.tight_layout()
1598
- plt.savefig(filename, dpi=300, bbox_inches='tight')
1599
-
1600
- if show_plots:
1601
- plt.show()
1602
- else:
1603
- plt.close() # Закрываем без показа
1604
-
1605
- print(f"Chart saved: {filename}")
1606
-
1607
-
1608
- def create_stoploss_charts(analyzer, filename='stoploss_analysis.png', show_plots=True):
1609
- """Create 4 stop-loss specific charts"""
1610
- r = analyzer.results
1611
- m = analyzer.metrics
1612
-
1613
- if m.get('stoploss_count', 0) == 0:
1614
- print("No stop-loss trades to visualize")
1615
- return
1616
-
1617
- trades_df = pd.DataFrame(r.trades)
1618
-
1619
- if 'exit_reason' not in trades_df.columns:
1620
- print("No exit_reason data available")
1621
- return
1622
-
1623
- fig, axes = plt.subplots(2, 2, figsize=(16, 12))
1624
- fig.suptitle('Stop-Loss Analysis', fontsize=16, fontweight='bold', y=0.995)
1625
-
1626
- ax1 = axes[0, 0]
1627
- if m.get('exit_reasons'):
1628
- reasons = pd.Series(m['exit_reasons']).sort_values(ascending=True)
1629
- colors = ['#f44336' if 'stop_loss' in str(r) else '#4CAF50' if r == 'profit_target' else '#2196F3'
1630
- for r in reasons.index]
1631
- ax1.barh(range(len(reasons)), reasons.values, color=colors, alpha=0.7, edgecolor='black')
1632
- ax1.set_yticks(range(len(reasons)))
1633
- ax1.set_yticklabels([r.replace('_', ' ').title() for r in reasons.index])
1634
- ax1.set_title('Exit Reasons Distribution', fontsize=12, fontweight='bold')
1635
- ax1.set_xlabel('Number of Trades')
1636
- ax1.grid(True, alpha=0.3, axis='x')
1637
-
1638
- total = sum(reasons.values)
1639
- for i, v in enumerate(reasons.values):
1640
- ax1.text(v, i, f' {(v/total)*100:.1f}%', va='center', fontweight='bold')
1641
-
1642
- ax2 = axes[0, 1]
1643
- sl_trades = trades_df[trades_df['exit_reason'].str.contains('stop_loss', na=False)]
1644
- if len(sl_trades) > 0:
1645
- ax2.hist(sl_trades['pnl'], bins=30, color='#f44336', alpha=0.7, edgecolor='black')
1646
- ax2.axvline(x=0, color='black', linestyle='--', linewidth=2)
1647
- ax2.axvline(x=sl_trades['pnl'].mean(), color='yellow', linestyle='--', linewidth=2, label='Mean')
1648
- ax2.set_title('Stop-Loss P&L Distribution', fontsize=12, fontweight='bold')
1649
- ax2.set_xlabel('P&L ($)')
1650
- ax2.set_ylabel('Frequency')
1651
- ax2.legend()
1652
- ax2.grid(True, alpha=0.3, axis='y')
1653
-
1654
- ax3 = axes[1, 0]
1655
- if len(sl_trades) > 0 and 'entry_date' in sl_trades.columns and 'exit_date' in sl_trades.columns:
1656
- sl_trades_copy = sl_trades.copy()
1657
- sl_trades_copy['entry_date'] = pd.to_datetime(sl_trades_copy['entry_date'])
1658
- sl_trades_copy['exit_date'] = pd.to_datetime(sl_trades_copy['exit_date'])
1659
- sl_trades_copy['days_held'] = (sl_trades_copy['exit_date'] - sl_trades_copy['entry_date']).dt.days
1660
-
1661
- ax3.hist(sl_trades_copy['days_held'], bins=30, color='#FF9800', alpha=0.7, edgecolor='black')
1662
- ax3.axvline(x=sl_trades_copy['days_held'].mean(), color='red', linestyle='--', linewidth=2, label='Mean')
1663
- ax3.set_title('Days Until Stop-Loss Triggered', fontsize=12, fontweight='bold')
1664
- ax3.set_xlabel('Days Held')
1665
- ax3.set_ylabel('Frequency')
1666
- ax3.legend()
1667
- ax3.grid(True, alpha=0.3, axis='y')
1668
-
1669
- ax4 = axes[1, 1]
1670
- if 'stop_type' in sl_trades.columns:
1671
- stop_types = sl_trades['stop_type'].value_counts()
1672
- colors_types = plt.cm.Set3(range(len(stop_types)))
1673
- wedges, texts, autotexts = ax4.pie(stop_types.values, labels=stop_types.index,
1674
- autopct='%1.1f%%', colors=colors_types,
1675
- startangle=90)
1676
- for autotext in autotexts:
1677
- autotext.set_color('black')
1678
- autotext.set_fontweight('bold')
1679
- ax4.set_title('Stop-Loss Types', fontsize=12, fontweight='bold')
1680
- else:
1681
- ax4.text(0.5, 0.5, 'No stop_type data', ha='center', va='center', transform=ax4.transAxes)
1682
-
1683
- plt.tight_layout()
1684
- plt.savefig(filename, dpi=300, bbox_inches='tight')
1685
-
1686
- if show_plots:
1687
- plt.show()
1688
- else:
1689
- plt.close()
1690
-
1691
- print(f"Stop-loss charts saved: {filename}")
1692
-
1693
-
1694
- # ============================================================
1695
- # RESULTS EXPORTER (unchanged)
1696
- # ============================================================
1697
- class ResultsExporter:
1698
- """Export results to CSV"""
1699
-
1700
- @staticmethod
1701
- def export_all(analyzer, prefix='backtest'):
1702
- r = analyzer.results
1703
- m = analyzer.metrics
1704
-
1705
- if len(r.trades) == 0:
1706
- print("No trades to export")
1707
- return
1708
-
1709
- trades_df = pd.DataFrame(r.trades)
1710
-
1711
- trades_df['entry_date'] = pd.to_datetime(trades_df['entry_date']).dt.strftime('%Y-%m-%d')
1712
- trades_df['exit_date'] = pd.to_datetime(trades_df['exit_date']).dt.strftime('%Y-%m-%d')
1713
-
1714
- # Round numeric columns to 5 decimal places
1715
- numeric_columns = trades_df.select_dtypes(include=[np.number]).columns
1716
- for col in numeric_columns:
1717
- trades_df[col] = trades_df[col].round(5)
1718
-
1719
- core_columns = [
1720
- 'entry_date', 'exit_date', 'symbol', 'signal',
1721
- 'pnl', 'return_pct', 'exit_reason', 'stop_type'
1722
- ]
1723
-
1724
- options_columns = [
1725
- 'short_strike', 'long_strike', 'expiration', 'opt_type',
1726
- 'spread_type', 'contracts'
1727
- ]
1728
-
1729
- bidask_columns = [
1730
- 'short_entry_bid', 'short_entry_ask', 'short_entry_mid',
1731
- 'long_entry_bid', 'long_entry_ask', 'long_entry_mid',
1732
- 'short_exit_bid', 'short_exit_ask',
1733
- 'long_exit_bid', 'long_exit_ask'
1734
- ]
1735
-
1736
- underlying_columns = [
1737
- 'underlying_entry_price', 'underlying_exit_price',
1738
- 'underlying_change_pct'
1739
- ]
1740
-
1741
- stop_columns = [
1742
- 'stop_threshold', 'actual_value'
1743
- ]
1744
-
1745
- strategy_columns = [
1746
- 'entry_z_score', 'is_short_bias', 'entry_price',
1747
- 'exit_price', 'quantity', 'entry_lean', 'exit_lean',
1748
- # IV EOD fields
1749
- 'call_iv_entry', 'put_iv_entry', 'call_iv_exit', 'put_iv_exit',
1750
- 'iv_lean_entry', 'iv_lean_exit'
1751
- ]
1752
-
1753
- # NEW: Intraday stop-loss columns
1754
- intraday_columns = [
1755
- 'spy_intraday_high', 'spy_intraday_low', 'spy_intraday_close',
1756
- 'spy_stop_trigger_time', 'spy_stop_trigger_price',
1757
- 'spy_stop_trigger_bid', 'spy_stop_trigger_ask', 'spy_stop_trigger_last',
1758
- 'intraday_data_points', 'intraday_data_available', 'stop_triggered_by'
1759
- ]
1760
-
1761
- ordered_columns = []
1762
- for col in (core_columns + options_columns + bidask_columns +
1763
- underlying_columns + stop_columns + strategy_columns + intraday_columns):
1764
- if col in trades_df.columns:
1765
- ordered_columns.append(col)
1766
-
1767
- remaining = [col for col in trades_df.columns if col not in ordered_columns]
1768
- ordered_columns.extend(remaining)
1769
-
1770
- trades_df = trades_df[ordered_columns]
1771
-
1772
- # Round numeric columns to 2 decimals
1773
- numeric_columns = trades_df.select_dtypes(include=['float64', 'float32', 'float']).columns
1774
- for col in numeric_columns:
1775
- trades_df[col] = trades_df[col].round(5)
1776
-
1777
- trades_df.to_csv(f'{prefix}_trades.csv', index=False)
1778
- print(f"Exported: {prefix}_trades.csv ({len(ordered_columns)} columns)")
1779
-
1780
- equity_df = pd.DataFrame({
1781
- 'date': pd.to_datetime(r.equity_dates).strftime('%Y-%m-%d'),
1782
- 'equity': r.equity_curve
1783
- })
1784
- equity_df['equity'] = equity_df['equity'].round(5)
1785
- equity_df.to_csv(f'{prefix}_equity.csv', index=False)
1786
- print(f"Exported: {prefix}_equity.csv")
1787
-
1788
- with open(f'{prefix}_summary.txt', 'w') as f:
1789
- f.write("BACKTEST SUMMARY\n")
1790
- f.write("="*70 + "\n\n")
1791
- f.write(f"Strategy: {r.config.get('strategy_name', 'Unknown')}\n")
1792
- f.write(f"Period: {r.config.get('start_date')} to {r.config.get('end_date')}\n\n")
1793
- f.write("PERFORMANCE\n")
1794
- f.write("-"*70 + "\n")
1795
- f.write(f"Total Return: {m['total_return']:.2f}%\n")
1796
- f.write(f"Sharpe: {m['sharpe']:.2f}\n")
1797
- f.write(f"Max DD: {m['max_drawdown']:.2f}%\n")
1798
- f.write(f"Trades: {m['total_trades']}\n")
1799
-
1800
- print(f"Exported: {prefix}_summary.txt")
1801
-
1802
- # Export metrics as JSON with rounded values
1803
- import json
1804
- metrics_rounded = {}
1805
- for key, value in m.items():
1806
- if isinstance(value, (int, float)):
1807
- metrics_rounded[key] = round(float(value), 5) if isinstance(value, float) else value
1808
- else:
1809
- metrics_rounded[key] = value
1810
-
1811
- with open(f'{prefix}_metrics.json', 'w') as f:
1812
- json.dump(metrics_rounded, f, indent=2)
1813
-
1814
- print(f"Exported: {prefix}_metrics.json")
1815
-
1816
-
1817
- # ============================================================
1818
- # RUN BACKTEST (unchanged)
1819
- # ============================================================
1820
- def run_backtest(strategy_function, config, print_report=True,
1821
- create_charts=True, export_results=True,
1822
- chart_filename='backtest_results.png',
1823
- export_prefix='backtest',
1824
- progress_context=None):
1825
- """Run complete backtest"""
1826
-
1827
- # Check if running inside optimization
1828
- is_optimization = progress_context and progress_context.get('is_optimization', False)
1829
-
1830
- if not progress_context and not is_optimization:
1831
- print("="*80)
1832
- print(" "*25 + "STARTING BACKTEST")
1833
- print("="*80)
1834
- print(f"Strategy: {config.get('strategy_name', 'Unknown')}")
1835
- print(f"Period: {config.get('start_date')} to {config.get('end_date')}")
1836
- print(f"Capital: ${config.get('initial_capital', 0):,.0f}")
1837
- print("="*80 + "\n")
1838
-
1839
- if progress_context:
1840
- config['_progress_context'] = progress_context
1841
-
1842
- results = strategy_function(config)
1843
-
1844
- if '_progress_context' in config:
1845
- del config['_progress_context']
1846
-
1847
- if not is_optimization:
1848
- print("\n[*] Calculating metrics...")
1849
- analyzer = BacktestAnalyzer(results)
1850
- analyzer.calculate_all_metrics()
1851
-
1852
- if print_report:
1853
- print("\n" + "="*80)
1854
- ResultsReporter.print_full_report(analyzer)
1855
-
1856
- # Export charts during optimization if requested
1857
- if create_charts and len(results.trades) > 0:
1858
- if not is_optimization:
1859
- print(f"\n[*] Creating charts: {chart_filename}")
1860
- try:
1861
- # Don't show plots during optimization, just save them
1862
- ChartGenerator.create_all_charts(analyzer, chart_filename, show_plots=not is_optimization)
1863
- except Exception as e:
1864
- if not is_optimization:
1865
- print(f"[ERROR] Charts failed: {e}")
1866
-
1867
- # Export results during optimization if requested
1868
- if export_results and len(results.trades) > 0:
1869
- if not is_optimization:
1870
- print(f"\n[*] Exporting: {export_prefix}_*")
1871
- try:
1872
- ResultsExporter.export_all(analyzer, export_prefix)
1873
- except Exception as e:
1874
- if not is_optimization:
1875
- print(f"[ERROR] Export failed: {e}")
1876
-
1877
- return analyzer
1878
-
1879
-
1880
- def run_backtest_with_stoploss(strategy_function, config, print_report=True,
1881
- create_charts=True, export_results=True,
1882
- chart_filename='backtest_results.png',
1883
- export_prefix='backtest',
1884
- create_stoploss_report=True,
1885
- create_stoploss_charts=True,
1886
- progress_context=None):
1887
- """Enhanced run_backtest with stop-loss analysis"""
1888
-
1889
- analyzer = run_backtest(
1890
- strategy_function, config,
1891
- print_report=False,
1892
- create_charts=create_charts,
1893
- export_results=export_results,
1894
- chart_filename=chart_filename,
1895
- export_prefix=export_prefix,
1896
- progress_context=progress_context
1897
- )
1898
-
1899
- calculate_stoploss_metrics(analyzer)
1900
-
1901
- if print_report:
1902
- print("\n" + "="*80)
1903
- ResultsReporter.print_full_report(analyzer)
1904
-
1905
- if create_stoploss_report and analyzer.metrics.get('stoploss_count', 0) > 0:
1906
- print_stoploss_section(analyzer)
1907
-
1908
- if create_stoploss_charts and analyzer.metrics.get('stoploss_count', 0) > 0:
1909
- print(f"\n[*] Creating stop-loss analysis charts...")
1910
- try:
1911
- stoploss_chart_name = chart_filename.replace('.png', '_stoploss.png') if chart_filename else 'stoploss_analysis.png'
1912
- create_stoploss_charts(analyzer, stoploss_chart_name)
1913
- except Exception as e:
1914
- print(f"[ERROR] Stop-loss charts failed: {e}")
1915
-
1916
- return analyzer
1917
-
1918
-
1919
- # ============================================================
1920
- # STOP-LOSS CONFIG (ENHANCED WITH COMBINED)
1921
- # ============================================================
1922
- class StopLossConfig:
1923
- """
1924
- Universal stop-loss configuration builder (ENHANCED)
1925
-
1926
- NEW METHOD:
1927
- - combined(): Requires BOTH pl_loss AND directional conditions
1928
- """
1929
-
1930
- @staticmethod
1931
- def _normalize_pct(value):
1932
- """Convert any number to decimal (0.30)"""
1933
- if value >= 1:
1934
- return value / 100
1935
- return value
1936
-
1937
- @staticmethod
1938
- def _format_pct(value):
1939
- """Format percentage for display"""
1940
- if value >= 1:
1941
- return f"{value:.0f}%"
1942
- return f"{value*100:.0f}%"
1943
-
1944
- @staticmethod
1945
- def none():
1946
- """No stop-loss"""
1947
- return {
1948
- 'enabled': False,
1949
- 'type': 'none',
1950
- 'value': 0,
1951
- 'name': 'No Stop-Loss',
1952
- 'description': 'No stop-loss protection'
1953
- }
1954
-
1955
- @staticmethod
1956
- def fixed(pct):
1957
- """Fixed percentage stop-loss"""
1958
- decimal = StopLossConfig._normalize_pct(pct)
1959
- display = StopLossConfig._format_pct(pct)
1960
-
1961
- return {
1962
- 'enabled': True,
1963
- 'type': 'fixed_pct',
1964
- 'value': decimal,
1965
- 'name': f'Fixed {display}',
1966
- 'description': f'Fixed stop at {display} loss'
1967
- }
1968
-
1969
- @staticmethod
1970
- def trailing(pct, trailing_distance=None):
1971
- """Trailing stop-loss"""
1972
- decimal = StopLossConfig._normalize_pct(pct)
1973
- display = StopLossConfig._format_pct(pct)
1974
-
1975
- config = {
1976
- 'enabled': True,
1977
- 'type': 'trailing',
1978
- 'value': decimal,
1979
- 'name': f'Trailing {display}',
1980
- 'description': f'Trailing stop at {display} from peak'
1981
- }
1982
-
1983
- if trailing_distance is not None:
1984
- config['trailing_distance'] = StopLossConfig._normalize_pct(trailing_distance)
1985
-
1986
- return config
1987
-
1988
- @staticmethod
1989
- def time_based(days):
1990
- """Time-based stop"""
1991
- return {
1992
- 'enabled': True,
1993
- 'type': 'time_based',
1994
- 'value': days,
1995
- 'name': f'Time {days}d',
1996
- 'description': f'Exit after {days} days'
1997
- }
1998
-
1999
- @staticmethod
2000
- def volatility(atr_multiplier):
2001
- """ATR-based stop"""
2002
- return {
2003
- 'enabled': True,
2004
- 'type': 'volatility',
2005
- 'value': atr_multiplier,
2006
- 'name': f'ATR {atr_multiplier:.1f}x',
2007
- 'description': f'Stop at {atr_multiplier:.1f}× ATR',
2008
- 'requires_atr': True
2009
- }
2010
-
2011
- @staticmethod
2012
- def pl_loss(pct):
2013
- """P&L-based stop using real bid/ask prices"""
2014
- decimal = StopLossConfig._normalize_pct(pct)
2015
- display = StopLossConfig._format_pct(pct)
2016
-
2017
- return {
2018
- 'enabled': True,
2019
- 'type': 'pl_loss',
2020
- 'value': decimal,
2021
- 'name': f'P&L Loss {display}',
2022
- 'description': f'Stop when P&L drops to -{display}'
2023
- }
2024
-
2025
- @staticmethod
2026
- def directional(pct):
2027
- """Directional stop based on underlying movement"""
2028
- decimal = StopLossConfig._normalize_pct(pct)
2029
- display = StopLossConfig._format_pct(pct)
2030
-
2031
- return {
2032
- 'enabled': True,
2033
- 'type': 'directional',
2034
- 'value': decimal,
2035
- 'name': f'Directional {display}',
2036
- 'description': f'Stop when underlying moves {display}'
2037
- }
2038
-
2039
- # ========================================================
2040
- # NEW: COMBINED STOP (REQUIRES BOTH CONDITIONS)
2041
- # ========================================================
2042
-
2043
- @staticmethod
2044
- def combined(pl_loss_pct, directional_pct):
2045
- """
2046
- Combined stop: Requires BOTH conditions (from code 2)
2047
-
2048
- Args:
2049
- pl_loss_pct: P&L loss threshold (e.g., 5 or 0.05 = -5%)
2050
- directional_pct: Underlying move threshold (e.g., 3 or 0.03 = 3%)
2051
-
2052
- Example:
2053
- StopLossConfig.combined(5, 3)
2054
- # Triggers only when BOTH:
2055
- # 1. P&L drops to -5%
2056
- # 2. Underlying moves 3% adversely
2057
- """
2058
- pl_decimal = StopLossConfig._normalize_pct(pl_loss_pct)
2059
- dir_decimal = StopLossConfig._normalize_pct(directional_pct)
2060
-
2061
- pl_display = StopLossConfig._format_pct(pl_loss_pct)
2062
- dir_display = StopLossConfig._format_pct(directional_pct)
2063
-
2064
- return {
2065
- 'enabled': True,
2066
- 'type': 'combined',
2067
- 'value': {
2068
- 'pl_loss': pl_decimal,
2069
- 'directional': dir_decimal
2070
- },
2071
- 'name': f'Combined (P&L {pl_display} + Dir {dir_display})',
2072
- 'description': f'Stop when P&L<-{pl_display} AND underlying moves {dir_display}'
2073
- }
2074
-
2075
- # ========================================================
2076
- # BACKWARD COMPATIBILITY
2077
- # ========================================================
2078
-
2079
- @staticmethod
2080
- def time(days):
2081
- """Alias for time_based()"""
2082
- return StopLossConfig.time_based(days)
2083
-
2084
- @staticmethod
2085
- def atr(multiplier):
2086
- """Alias for volatility()"""
2087
- return StopLossConfig.volatility(multiplier)
2088
-
2089
- # ========================================================
2090
- # PRESETS (WITH COMBINED STOPS)
2091
- # ========================================================
2092
-
2093
- @staticmethod
2094
- def presets():
2095
- """Generate all standard stop-loss presets (UPDATED WITH COMBINED)"""
2096
- return {
2097
- 'none': StopLossConfig.none(),
2098
-
2099
- 'fixed_20': StopLossConfig.fixed(20),
2100
- 'fixed_30': StopLossConfig.fixed(30),
2101
- 'fixed_40': StopLossConfig.fixed(40),
2102
- 'fixed_50': StopLossConfig.fixed(50),
2103
- 'fixed_70': StopLossConfig.fixed(70),
2104
-
2105
- 'trailing_20': StopLossConfig.trailing(20),
2106
- 'trailing_30': StopLossConfig.trailing(30),
2107
- 'trailing_50': StopLossConfig.trailing(50),
2108
-
2109
- 'time_5d': StopLossConfig.time(5),
2110
- 'time_10d': StopLossConfig.time(10),
2111
- 'time_20d': StopLossConfig.time(20),
2112
-
2113
- 'atr_2x': StopLossConfig.atr(2.0),
2114
- 'atr_3x': StopLossConfig.atr(3.0),
2115
-
2116
- 'pl_loss_5': StopLossConfig.pl_loss(5),
2117
- 'pl_loss_10': StopLossConfig.pl_loss(10),
2118
- 'pl_loss_15': StopLossConfig.pl_loss(15),
2119
-
2120
- 'directional_3': StopLossConfig.directional(3),
2121
- 'directional_5': StopLossConfig.directional(5),
2122
- 'directional_7': StopLossConfig.directional(7),
2123
-
2124
- # NEW: COMBINED STOPS
2125
- 'combined_5_3': StopLossConfig.combined(5, 3),
2126
- 'combined_7_5': StopLossConfig.combined(7, 5),
2127
- 'combined_10_3': StopLossConfig.combined(10, 3),
2128
- }
2129
-
2130
- @staticmethod
2131
- def apply(base_config, stop_config):
2132
- """Apply stop-loss configuration to base config"""
2133
- merged = base_config.copy()
2134
-
2135
- merged['stop_loss_enabled'] = stop_config.get('enabled', False)
2136
-
2137
- if merged['stop_loss_enabled']:
2138
- sl_config = {
2139
- 'type': stop_config['type'],
2140
- 'value': stop_config['value']
2141
- }
2142
-
2143
- if 'trailing_distance' in stop_config:
2144
- sl_config['trailing_distance'] = stop_config['trailing_distance']
2145
-
2146
- merged['stop_loss_config'] = sl_config
2147
-
2148
- return merged
2149
-
2150
-
2151
- def create_stoploss_comparison_chart(results, filename='stoploss_comparison.png', show_plots=True):
2152
- """Create comparison chart"""
2153
- try:
2154
- fig, axes = plt.subplots(2, 2, figsize=(16, 12))
2155
- fig.suptitle('Stop-Loss Configuration Comparison', fontsize=16, fontweight='bold')
2156
-
2157
- names = [r['config']['name'] for r in results.values()]
2158
- returns = [r['total_return'] for r in results.values()]
2159
- sharpes = [r['sharpe'] for r in results.values()]
2160
- drawdowns = [r['max_drawdown'] for r in results.values()]
2161
- stop_counts = [r['stoploss_count'] for r in results.values()]
2162
-
2163
- ax1 = axes[0, 0]
2164
- colors = ['#4CAF50' if r > 0 else '#f44336' for r in returns]
2165
- ax1.barh(range(len(names)), returns, color=colors, alpha=0.7, edgecolor='black')
2166
- ax1.set_yticks(range(len(names)))
2167
- ax1.set_yticklabels(names, fontsize=9)
2168
- ax1.set_xlabel('Total Return (%)')
2169
- ax1.set_title('Total Return by Stop-Loss Type', fontsize=12, fontweight='bold')
2170
- ax1.axvline(x=0, color='black', linestyle='-', linewidth=1)
2171
- ax1.grid(True, alpha=0.3, axis='x')
2172
-
2173
- ax2 = axes[0, 1]
2174
- colors_sharpe = ['#4CAF50' if s > 1 else '#FF9800' if s > 0 else '#f44336' for s in sharpes]
2175
- ax2.barh(range(len(names)), sharpes, color=colors_sharpe, alpha=0.7, edgecolor='black')
2176
- ax2.set_yticks(range(len(names)))
2177
- ax2.set_yticklabels(names, fontsize=9)
2178
- ax2.set_xlabel('Sharpe Ratio')
2179
- ax2.set_title('Sharpe Ratio by Stop-Loss Type', fontsize=12, fontweight='bold')
2180
- ax2.axvline(x=1, color='green', linestyle='--', linewidth=1, label='Good (>1)')
2181
- ax2.axvline(x=0, color='black', linestyle='-', linewidth=1)
2182
- ax2.legend()
2183
- ax2.grid(True, alpha=0.3, axis='x')
2184
-
2185
- ax3 = axes[1, 0]
2186
- ax3.barh(range(len(names)), drawdowns, color='#f44336', alpha=0.7, edgecolor='black')
2187
- ax3.set_yticks(range(len(names)))
2188
- ax3.set_yticklabels(names, fontsize=9)
2189
- ax3.set_xlabel('Maximum Drawdown (%)')
2190
- ax3.set_title('Maximum Drawdown (Lower is Better)', fontsize=12, fontweight='bold')
2191
- ax3.grid(True, alpha=0.3, axis='x')
2192
-
2193
- ax4 = axes[1, 1]
2194
- ax4.barh(range(len(names)), stop_counts, color='#2196F3', alpha=0.7, edgecolor='black')
2195
- ax4.set_yticks(range(len(names)))
2196
- ax4.set_yticklabels(names, fontsize=9)
2197
- ax4.set_xlabel('Number of Stop-Loss Exits')
2198
- ax4.set_title('Stop-Loss Frequency', fontsize=12, fontweight='bold')
2199
- ax4.grid(True, alpha=0.3, axis='x')
2200
-
2201
- plt.tight_layout()
2202
- plt.savefig(filename, dpi=300, bbox_inches='tight')
2203
-
2204
- if show_plots:
2205
- plt.show()
2206
- else:
2207
- plt.close()
2208
-
2209
- print(f"Comparison chart saved: {filename}")
2210
-
2211
- except Exception as e:
2212
- print(f"Failed to create comparison chart: {e}")
2213
-
2214
-
2215
-
2216
- # ============================================================
2217
- # DATA PRELOADING FUNCTION (FOR OPTIMIZATION)
2218
- # ============================================================
2219
- def preload_options_data(config, progress_widgets=None):
2220
- """
2221
- Предзагрузка опционных данных для оптимизации.
2222
- Загружает данные ОДИН РАЗ и возвращает кеш.
2223
-
2224
- Returns:
2225
- tuple: (lean_df, options_cache)
2226
- - lean_df: DataFrame с историей IV lean
2227
- - options_cache: dict {date: DataFrame} с опционными данными
2228
- """
2229
- if progress_widgets:
2230
- progress_bar, status_label, monitor, start_time = progress_widgets
2231
- status_label.value = "<b style='color:#0066cc'>🔄 Preloading options data (ONCE)...</b>"
2232
- progress_bar.value = 5
2233
-
2234
- # Extract config
2235
- from datetime import datetime, timedelta
2236
- import pandas as pd
2237
- import numpy as np
2238
- import gc
2239
-
2240
- start_date = datetime.strptime(config['start_date'], '%Y-%m-%d').date()
2241
- end_date = datetime.strptime(config['end_date'], '%Y-%m-%d').date()
2242
- symbol = config['symbol']
2243
- dte_target = config.get('dte_target', 30)
2244
- lookback_period = config.get('lookback_period', 60)
2245
- chunk_months = config.get('chunk_months', 3)
2246
-
2247
- # Calculate date chunks
2248
- data_start = start_date - timedelta(days=lookback_period + 60)
2249
-
2250
- date_chunks = []
2251
- current_chunk_start = data_start
2252
- while current_chunk_start <= end_date:
2253
- chunk_end = min(
2254
- current_chunk_start + timedelta(days=chunk_months * 31),
2255
- end_date
2256
- )
2257
- date_chunks.append((current_chunk_start, chunk_end))
2258
- current_chunk_start = chunk_end + timedelta(days=1)
2259
-
2260
- # Store lean calculations
2261
- lean_history = []
2262
- options_cache = {} # {date: DataFrame with bid/ask data}
2263
-
2264
- # Track time for ETA
2265
- preload_start_time = time.time()
2266
-
2267
- try:
2268
- import ivolatility as ivol
2269
- getOptionsData = ivol.setMethod('/equities/eod/options-rawiv')
2270
-
2271
- # Process each chunk
2272
- for chunk_idx, (chunk_start, chunk_end) in enumerate(date_chunks):
2273
- if progress_widgets:
2274
- # Use update_progress for full display with ETA, CPU, RAM
2275
- update_progress(
2276
- progress_bar, status_label, monitor,
2277
- current=chunk_idx + 1,
2278
- total=len(date_chunks),
2279
- start_time=preload_start_time,
2280
- message=f"🔄 Loading chunk {chunk_idx+1}/{len(date_chunks)}"
2281
- )
2282
-
2283
- raw_data = getOptionsData(
2284
- symbol=symbol,
2285
- from_=chunk_start.strftime('%Y-%m-%d'),
2286
- to=chunk_end.strftime('%Y-%m-%d')
2287
- )
2288
-
2289
- if raw_data is None:
2290
- continue
2291
-
2292
- df = pd.DataFrame(raw_data)
2293
-
2294
- if df.empty:
2295
- continue
2296
-
2297
- # Essential columns
2298
- essential_cols = ['date', 'expiration', 'strike', 'Call/Put', 'iv', 'Adjusted close']
2299
- if 'bid' in df.columns:
2300
- essential_cols.append('bid')
2301
- if 'ask' in df.columns:
2302
- essential_cols.append('ask')
2303
-
2304
- df = df[essential_cols].copy()
2305
-
2306
- # Process bid/ask
2307
- if 'bid' in df.columns:
2308
- df['bid'] = pd.to_numeric(df['bid'], errors='coerce').astype('float32')
2309
- else:
2310
- df['bid'] = np.nan
2311
-
2312
- if 'ask' in df.columns:
2313
- df['ask'] = pd.to_numeric(df['ask'], errors='coerce').astype('float32')
2314
- else:
2315
- df['ask'] = np.nan
2316
-
2317
- # Calculate mid price
2318
- df['mid'] = (df['bid'] + df['ask']) / 2
2319
- df['mid'] = df['mid'].fillna(df['iv'])
2320
-
2321
- df['date'] = pd.to_datetime(df['date']).dt.date
2322
- df['expiration'] = pd.to_datetime(df['expiration']).dt.date
2323
- df['strike'] = pd.to_numeric(df['strike'], errors='coerce').astype('float32')
2324
- df['iv'] = pd.to_numeric(df['iv'], errors='coerce').astype('float32')
2325
- df['Adjusted close'] = pd.to_numeric(df['Adjusted close'], errors='coerce').astype('float32')
2326
-
2327
- df['dte'] = (pd.to_datetime(df['expiration']) - pd.to_datetime(df['date'])).dt.days
2328
- df['dte'] = df['dte'].astype('int16')
2329
-
2330
- df = df.dropna(subset=['strike', 'iv', 'Adjusted close'])
2331
-
2332
- if df.empty:
2333
- del df
2334
- gc.collect()
2335
- continue
2336
-
2337
- # Cache options data for position tracking
2338
- for date_val in df['date'].unique():
2339
- if date_val not in options_cache:
2340
- options_cache[date_val] = df[df['date'] == date_val].copy()
2341
-
2342
- # Calculate lean for this chunk
2343
- trading_dates = sorted(df['date'].unique())
2344
-
2345
- for current_date in trading_dates:
2346
- day_data = df[df['date'] == current_date]
2347
-
2348
- if day_data.empty:
2349
- continue
2350
-
2351
- stock_price = float(day_data['Adjusted close'].iloc[0])
2352
-
2353
- dte_filtered = day_data[
2354
- (day_data['dte'] >= dte_target - 7) &
2355
- (day_data['dte'] <= dte_target + 7)
2356
- ]
2357
-
2358
- if dte_filtered.empty:
2359
- continue
2360
-
2361
- dte_filtered = dte_filtered.copy()
2362
- dte_filtered['strike_diff'] = abs(dte_filtered['strike'] - stock_price)
2363
- atm_idx = dte_filtered['strike_diff'].idxmin()
2364
- atm_strike = float(dte_filtered.loc[atm_idx, 'strike'])
2365
-
2366
- atm_options = dte_filtered[dte_filtered['strike'] == atm_strike]
2367
- atm_call = atm_options[atm_options['Call/Put'] == 'C']
2368
- atm_put = atm_options[atm_options['Call/Put'] == 'P']
2369
-
2370
- if not atm_call.empty and not atm_put.empty:
2371
- call_iv = float(atm_call['iv'].iloc[0])
2372
- put_iv = float(atm_put['iv'].iloc[0])
2373
-
2374
- if pd.notna(call_iv) and pd.notna(put_iv) and call_iv > 0 and put_iv > 0:
2375
- iv_lean = call_iv - put_iv
2376
-
2377
- lean_history.append({
2378
- 'date': current_date,
2379
- 'stock_price': stock_price,
2380
- 'iv_lean': iv_lean
2381
- })
2382
-
2383
- del df, raw_data
2384
- gc.collect()
2385
-
2386
- lean_df = pd.DataFrame(lean_history)
2387
- lean_df['stock_price'] = lean_df['stock_price'].astype('float32')
2388
- lean_df['iv_lean'] = lean_df['iv_lean'].astype('float32')
2389
-
2390
- del lean_history
2391
- gc.collect()
2392
-
2393
- if progress_widgets:
2394
- status_label.value = f"<b style='color:#00cc00'>✓ Data preloaded: {len(lean_df)} days, {len(options_cache)} cached dates</b>"
2395
- progress_bar.value = 35
2396
-
2397
- print(f"✓ Data preloaded: {len(lean_df)} days, {len(options_cache)} cached dates")
2398
-
2399
- return lean_df, options_cache
2400
-
2401
- except Exception as e:
2402
- print(f"Error preloading data: {e}")
2403
- return pd.DataFrame(), {}
2404
-
2405
-
2406
- # ============================================================
2407
- # NEW: OPTIMIZATION FRAMEWORK
2408
- # ============================================================
2409
- def optimize_parameters(base_config, param_grid, strategy_function,
2410
- optimization_metric='sharpe', min_trades=5,
2411
- max_drawdown_limit=None, parallel=False,
2412
- export_each_combo=True # ← НОВЫЙ ПАРАМЕТР
2413
- ):
2414
- """
2415
- Optimize strategy parameters across multiple combinations
2416
-
2417
- Args:
2418
- base_config: Base configuration dict
2419
- param_grid: Dict of parameters to optimize
2420
- Example: {'z_score_entry': [1.0, 1.5, 2.0], 'z_score_exit': [0.1, 0.3, 0.5]}
2421
- strategy_function: Strategy function to run
2422
- optimization_metric: Metric to optimize ('sharpe', 'total_return', 'profit_factor', 'calmar')
2423
- min_trades: Minimum number of trades required
2424
- max_drawdown_limit: Maximum acceptable drawdown (e.g., 0.10 for 10%)
2425
- parallel: Use parallel processing (not implemented yet)
2426
- export_each_combo: If True, exports files for each combination # ←
2427
-
2428
- Returns:
2429
- tuple: (results_df, best_params, results_folder)
2430
- """
2431
-
2432
- # ═══ ДОБАВИТЬ В НАЧАЛО ФУНКЦИИ ═══
2433
- # Create results folder
2434
- results_folder = create_optimization_folder()
2435
- print(f"📊 Results will be saved to: {results_folder}\n")
2436
-
2437
- # Record start time
2438
- optimization_start_time = datetime.now()
2439
- start_time_str = optimization_start_time.strftime('%Y-%m-%d %H:%M:%S')
2440
-
2441
- print("\n" + "="*80)
2442
- print(" "*20 + "PARAMETER OPTIMIZATION")
2443
- print("="*80)
2444
- print(f"Strategy: {base_config.get('strategy_name', 'Unknown')}")
2445
- print(f"Period: {base_config.get('start_date')} to {base_config.get('end_date')}")
2446
- print(f"Optimization Metric: {optimization_metric}")
2447
- print(f"Min Trades: {min_trades}")
2448
- print(f"🕐 Started: {start_time_str}")
2449
- if max_drawdown_limit:
2450
- print(f"Max Drawdown Limit: {max_drawdown_limit*100:.0f}%")
2451
- print("="*80 + "\n")
2452
-
2453
- # Generate all combinations
2454
- param_names = list(param_grid.keys())
2455
- param_values = list(param_grid.values())
2456
- all_combinations = list(product(*param_values))
2457
-
2458
- total_combinations = len(all_combinations)
2459
- print(f"Testing {total_combinations} parameter combinations...")
2460
- print(f"Parameters: {param_names}")
2461
- print(f"Grid: {param_grid}\n")
2462
-
2463
- # Create SHARED progress context for all backtests
2464
- try:
2465
- from IPython.display import display
2466
- import ipywidgets as widgets
2467
-
2468
- progress_bar = widgets.FloatProgress(
2469
- value=0, min=0, max=100,
2470
- description='Optimizing:',
2471
- bar_style='info',
2472
- layout=widgets.Layout(width='100%', height='30px')
2473
- )
2474
-
2475
- status_label = widgets.HTML(value="<b>Starting optimization...</b>")
2476
- display(widgets.VBox([progress_bar, status_label]))
2477
-
2478
- monitor = ResourceMonitor()
2479
- opt_start_time = time.time()
2480
-
2481
- # Create shared progress context (will suppress individual backtest progress)
2482
- shared_progress = {
2483
- 'progress_widgets': (progress_bar, status_label, monitor, opt_start_time),
2484
- 'is_optimization': True
2485
- }
2486
- has_widgets = True
2487
- except:
2488
- shared_progress = None
2489
- has_widgets = False
2490
- print("Running optimization (no progress bar)...")
2491
-
2492
- # ═══════════════════════════════════════════════════════════════════════════
2493
- # PRELOAD DATA ONCE (FOR ALL OPTIMIZATION ITERATIONS)
2494
- # ═══════════════════════════════════════════════════════════════════════════
2495
- print("\n" + "="*80)
2496
- print("📥 PRELOADING OPTIONS DATA (loads ONCE, reused for all combinations)")
2497
- print("="*80)
2498
-
2499
- preloaded_lean_df, preloaded_options_cache = preload_options_data(
2500
- base_config,
2501
- progress_widgets=shared_progress['progress_widgets'] if shared_progress else None
2502
- )
2503
-
2504
- if preloaded_lean_df.empty:
2505
- print("\n❌ ERROR: Failed to preload data. Cannot proceed with optimization.")
2506
- return pd.DataFrame(), None
2507
-
2508
- print(f"✓ Preloading complete! Data will be reused for all {total_combinations} combinations")
2509
- print("="*80 + "\n")
2510
-
2511
- # ═══════════════════════════════════════════════════════════════════════════
2512
- # RESET PROGRESS BAR FOR OPTIMIZATION LOOP
2513
- # ═══════════════════════════════════════════════════════════════════════════
2514
- if has_widgets:
2515
- progress_bar.value = 0
2516
- progress_bar.bar_style = 'info'
2517
- status_label.value = "<b style='color:#0066cc'>Starting optimization loop...</b>"
2518
-
2519
- # Run backtests
2520
- results = []
2521
- start_time = time.time()
2522
-
2523
- for idx, param_combo in enumerate(all_combinations, 1):
2524
- # Create test config
2525
- test_config = base_config.copy()
2526
-
2527
- # Update parameters
2528
- for param_name, param_value in zip(param_names, param_combo):
2529
- test_config[param_name] = param_value
2530
-
2531
- # Update name
2532
- param_str = "_".join([f"{k}={v}" for k, v in zip(param_names, param_combo)])
2533
- test_config['strategy_name'] = f"{base_config.get('strategy_name', 'Strategy')} [{param_str}]"
2534
-
2535
- # ═══ ADD PRELOADED DATA TO CONFIG ═══
2536
- test_config['_preloaded_lean_df'] = preloaded_lean_df
2537
- test_config['_preloaded_options_cache'] = preloaded_options_cache
2538
-
2539
- # Update progress
2540
- if has_widgets:
2541
- # Use update_progress for full display with ETA, CPU, RAM
2542
- update_progress(
2543
- progress_bar, status_label, monitor,
2544
- current=idx,
2545
- total=total_combinations,
2546
- start_time=start_time,
2547
- message=f"Testing: {param_str}"
2548
- )
2549
- else:
2550
- if idx % max(1, total_combinations // 10) == 0:
2551
- print(f"[{idx}/{total_combinations}] {param_str}")
2552
-
2553
- # ═══ ИЗМЕНИТЬ ВЫЗОВ run_backtest (строки ~2240-2248) ═══
2554
- try:
2555
- # Create compact parameter string (e.g., Z1.0_E0.1_PT20)
2556
- param_parts = []
2557
- for name, value in zip(param_names, param_combo):
2558
- if 'z_score_entry' in name:
2559
- param_parts.append(f"Z{value}")
2560
- elif 'z_score_exit' in name:
2561
- param_parts.append(f"E{value}")
2562
- elif 'profit_target' in name:
2563
- if value is None:
2564
- param_parts.append("PTNo")
2565
- else:
2566
- param_parts.append(f"PT{int(value*100)}")
2567
- elif 'min_days' in name:
2568
- param_parts.append(f"D{value}")
2569
- else:
2570
- # Generic short name for other params
2571
- short_name = ''.join([c for c in name if c.isupper() or c.isdigit()])[:3]
2572
- param_parts.append(f"{short_name}{value}")
2573
-
2574
- compact_params = "_".join(param_parts)
2575
-
2576
- # Create combo folder: c01_Z1.0_E0.1_PT20
2577
- combo_folder = os.path.join(results_folder, f'c{idx:02d}_{compact_params}')
2578
- os.makedirs(combo_folder, exist_ok=True)
2579
-
2580
- # File prefix: c01_Z1.0_E0.1_PT20
2581
- combo_prefix = f"c{idx:02d}_{compact_params}"
2582
-
2583
- # Run backtest WITH EXPORT AND CHARTS (saved but not displayed)
2584
- analyzer = run_backtest(
2585
- strategy_function,
2586
- test_config,
2587
- print_report=False,
2588
- create_charts=export_each_combo, # ← СОЗДАЕМ ГРАФИКИ (сохраняются, но не показываются)
2589
- export_results=export_each_combo, # ← ИЗМЕНЕНО
2590
- progress_context=shared_progress,
2591
- chart_filename=os.path.join(combo_folder, 'equity_curve.png') if export_each_combo else None, # ← ГРАФИКИ СОХРАНЯЮТСЯ
2592
- export_prefix=os.path.join(combo_folder, combo_prefix) if export_each_combo else None # ← ДОБАВЛЕНО
2593
- )
2594
-
2595
- # Check validity
2596
- is_valid = True
2597
- invalid_reason = ""
2598
-
2599
- if analyzer.metrics['total_trades'] < min_trades:
2600
- is_valid = False
2601
- invalid_reason = f"Too few trades ({analyzer.metrics['total_trades']})"
2602
-
2603
- if max_drawdown_limit and analyzer.metrics['max_drawdown'] > (max_drawdown_limit * 100):
2604
- is_valid = False
2605
- invalid_reason = f"Excessive drawdown ({analyzer.metrics['max_drawdown']:.1f}%)"
2606
-
2607
- # Print compact statistics for this combination
2608
- status_symbol = "✓" if is_valid else "✗"
2609
- status_color = "#00cc00" if is_valid else "#ff6666"
2610
-
2611
- print(f"\n[{idx}/{total_combinations}] {param_str}")
2612
- print("-" * 80)
2613
- if is_valid:
2614
- print(f" {status_symbol} Return: {analyzer.metrics['total_return']:>7.2f}% | "
2615
- f"Sharpe: {analyzer.metrics['sharpe']:>6.2f} | "
2616
- f"Max DD: {analyzer.metrics['max_drawdown']:>6.2f}% | "
2617
- f"Trades: {analyzer.metrics['total_trades']:>3} | "
2618
- f"Win Rate: {analyzer.metrics['win_rate']:>5.1f}% | "
2619
- f"PF: {analyzer.metrics['profit_factor']:>5.2f}")
2620
- else:
2621
- print(f" {status_symbol} INVALID: {invalid_reason}")
2622
-
2623
- # Update widget status with last result
2624
- if has_widgets:
2625
- result_text = f"Return: {analyzer.metrics['total_return']:.1f}% | Sharpe: {analyzer.metrics['sharpe']:.2f}" if is_valid else invalid_reason
2626
-
2627
- # Get resource usage
2628
- cpu_pct = monitor.get_cpu_percent()
2629
- mem_info = monitor.get_memory_info()
2630
- ram_mb = mem_info[0] # process_mb
2631
- resource_text = f"CPU: {cpu_pct:.0f}% | RAM: {ram_mb:.0f}MB"
2632
-
2633
- status_label.value = (
2634
- f"<b style='color:{status_color}'>[{idx}/{total_combinations}] {param_str}</b><br>"
2635
- f"<span style='color:#666'>{result_text}</span><br>"
2636
- f"<span style='color:#999;font-size:10px'>{resource_text}</span>"
2637
- )
2638
-
2639
- # Store results
2640
- result = {
2641
- 'combination_id': idx,
2642
- 'is_valid': is_valid,
2643
- 'invalid_reason': invalid_reason,
2644
- **{name: value for name, value in zip(param_names, param_combo)},
2645
- 'total_return': analyzer.metrics['total_return'],
2646
- 'sharpe': analyzer.metrics['sharpe'],
2647
- 'sortino': analyzer.metrics['sortino'],
2648
- 'calmar': analyzer.metrics['calmar'],
2649
- 'max_drawdown': analyzer.metrics['max_drawdown'],
2650
- 'win_rate': analyzer.metrics['win_rate'],
2651
- 'profit_factor': analyzer.metrics['profit_factor'],
2652
- 'total_trades': analyzer.metrics['total_trades'],
2653
- 'avg_win': analyzer.metrics['avg_win'],
2654
- 'avg_loss': analyzer.metrics['avg_loss'],
2655
- 'volatility': analyzer.metrics['volatility'],
2656
- }
2657
-
2658
- results.append(result)
2659
-
2660
- # Show intermediate summary every 10 combinations (or at end)
2661
- if idx % 10 == 0 or idx == total_combinations:
2662
- valid_so_far = [r for r in results if r['is_valid']]
2663
- if valid_so_far:
2664
- print("\n" + "="*80)
2665
- print(f"INTERMEDIATE SUMMARY ({idx}/{total_combinations} tested)")
2666
- print("="*80)
2667
-
2668
- # Sort by optimization metric
2669
- if optimization_metric == 'sharpe':
2670
- valid_so_far.sort(key=lambda x: x['sharpe'], reverse=True)
2671
- elif optimization_metric == 'total_return':
2672
- valid_so_far.sort(key=lambda x: x['total_return'], reverse=True)
2673
- elif optimization_metric == 'profit_factor':
2674
- valid_so_far.sort(key=lambda x: x['profit_factor'], reverse=True)
2675
- elif optimization_metric == 'calmar':
2676
- valid_so_far.sort(key=lambda x: x['calmar'], reverse=True)
2677
-
2678
- # Show top 3
2679
- print(f"\n🏆 TOP 3 BY {optimization_metric.upper()}:")
2680
- print("-"*80)
2681
- for rank, res in enumerate(valid_so_far[:3], 1):
2682
- params_display = ", ".join([f"{name}={res[name]}" for name in param_names])
2683
- print(f" {rank}. [{params_display}]")
2684
- print(f" Return: {res['total_return']:>7.2f}% | "
2685
- f"Sharpe: {res['sharpe']:>6.2f} | "
2686
- f"Max DD: {res['max_drawdown']:>6.2f}% | "
2687
- f"Trades: {res['total_trades']:>3}")
2688
-
2689
- print(f"\nValid: {len(valid_so_far)}/{idx} | "
2690
- f"Invalid: {idx - len(valid_so_far)}/{idx}")
2691
- print("="*80 + "\n")
2692
-
2693
- except Exception as e:
2694
- print(f"\n[{idx}/{total_combinations}] {param_str}")
2695
- print("-" * 80)
2696
- print(f" ✗ ERROR: {str(e)[:100]}")
2697
-
2698
- result = {
2699
- 'combination_id': idx,
2700
- 'is_valid': False,
2701
- 'invalid_reason': f"Error: {str(e)[:50]}",
2702
- **{name: value for name, value in zip(param_names, param_combo)},
2703
- 'total_return': 0, 'sharpe': 0, 'sortino': 0, 'calmar': 0,
2704
- 'max_drawdown': 0, 'win_rate': 0, 'profit_factor': 0,
2705
- 'total_trades': 0, 'avg_win': 0, 'avg_loss': 0, 'volatility': 0
2706
- }
2707
- results.append(result)
2708
-
2709
- elapsed = time.time() - start_time
2710
-
2711
- if has_widgets:
2712
- progress_bar.value = 100
2713
- progress_bar.bar_style = 'success'
2714
- status_label.value = f"<b style='color:#00cc00'>✓ Optimization complete in {int(elapsed)}s</b>"
2715
-
2716
- # Create results DataFrame
2717
- results_df = pd.DataFrame(results)
2718
-
2719
- # Round numeric columns to 2 decimals
2720
- numeric_columns = results_df.select_dtypes(include=['float64', 'float32', 'float']).columns
2721
- for col in numeric_columns:
2722
- results_df[col] = results_df[col].round(5)
2723
-
2724
- # ═══ ДОБАВИТЬ СОХРАНЕНИЕ SUMMARY В ПАПКУ ═══
2725
- summary_path = os.path.join(results_folder, 'optimization_summary.csv')
2726
- results_df.to_csv(summary_path, index=False)
2727
- print(f"\n✓ Summary saved: {summary_path}")
2728
-
2729
- # Find best parameters
2730
- valid_results = results_df[results_df['is_valid'] == True].copy()
2731
-
2732
- if len(valid_results) == 0:
2733
- print("\n" + "="*80)
2734
- print("WARNING: No valid combinations found!")
2735
- print("Try relaxing constraints or checking parameter ranges")
2736
- print("="*80)
2737
- return results_df, None, results_folder
2738
-
2739
- # Select best based on metric
2740
- if optimization_metric == 'sharpe':
2741
- best_idx = valid_results['sharpe'].idxmax()
2742
- elif optimization_metric == 'total_return':
2743
- best_idx = valid_results['total_return'].idxmax()
2744
- elif optimization_metric == 'profit_factor':
2745
- best_idx = valid_results['profit_factor'].idxmax()
2746
- elif optimization_metric == 'calmar':
2747
- best_idx = valid_results['calmar'].idxmax()
2748
- else:
2749
- best_idx = valid_results['sharpe'].idxmax()
2750
-
2751
- best_result = valid_results.loc[best_idx]
2752
-
2753
- # Extract best parameters
2754
- best_params = {name: best_result[name] for name in param_names}
2755
-
2756
- # Calculate total time
2757
- optimization_end_time = datetime.now()
2758
- total_duration = optimization_end_time - optimization_start_time
2759
- end_time_str = optimization_end_time.strftime('%Y-%m-%d %H:%M:%S')
2760
- duration_str = format_time(total_duration.total_seconds())
2761
-
2762
- # Print summary
2763
- print("="*80)
2764
- print(" "*20 + "OPTIMIZATION COMPLETE")
2765
- print("="*80)
2766
- print(f"\nTotal Combinations Tested: {total_combinations}")
2767
- print(f"Valid Combinations: {len(valid_results)}")
2768
- print(f"Invalid Combinations: {len(results_df) - len(valid_results)}")
2769
- print(f"🕐 Started: {start_time_str}")
2770
- print(f"🕐 Finished: {end_time_str}")
2771
- print(f"⏱️ Total Duration: {duration_str}")
2772
-
2773
- print(f"\n{'='*80}")
2774
- print(" "*20 + "BEST PARAMETERS")
2775
- print("="*80)
2776
- for param_name, param_value in best_params.items():
2777
- print(f"{param_name:25s}: {param_value}")
2778
-
2779
- print(f"\n{'='*80}")
2780
- print(" "*20 + "BEST PERFORMANCE")
2781
- print("="*80)
2782
- print(f"Total Return: {best_result['total_return']:>10.2f}%")
2783
- print(f"Sharpe Ratio: {best_result['sharpe']:>10.2f}")
2784
- print(f"Sortino Ratio: {best_result['sortino']:>10.2f}")
2785
- print(f"Calmar Ratio: {best_result['calmar']:>10.2f}")
2786
- print(f"Max Drawdown: {best_result['max_drawdown']:>10.2f}%")
2787
- print(f"Win Rate: {best_result['win_rate']:>10.1f}%")
2788
- print(f"Profit Factor: {best_result['profit_factor']:>10.2f}")
2789
- print(f"Total Trades: {best_result['total_trades']:>10.0f}")
2790
- print(f"Avg Win: ${best_result['avg_win']:>10.2f}")
2791
- print(f"Avg Loss: ${best_result['avg_loss']:>10.2f}")
2792
- print("="*80)
2793
-
2794
- # ═══════════════════════════════════════════════════════════════════════════
2795
- # НОВОЕ! ПОЛНЫЙ БЭКТЕСТ ЛУЧШЕЙ КОМБИНАЦИИ СО ВСЕМИ ГРАФИКАМИ
2796
- # ═══════════════════════════════════════════════════════════════════════════
2797
- print("\n" + "="*80)
2798
- print(" "*15 + "RUNNING FULL BACKTEST FOR BEST COMBINATION")
2799
- print("="*80)
2800
- print("\n📊 Creating detailed report for best combination...")
2801
- print(f"Parameters: {', '.join([f'{k}={v}' for k, v in best_params.items()])}\n")
2802
-
2803
- # Create config for best combination
2804
- best_config = base_config.copy()
2805
- best_config.update(best_params)
2806
- best_config['_preloaded_lean_df'] = preloaded_lean_df
2807
- best_config['_preloaded_options_cache'] = preloaded_options_cache
2808
-
2809
- # Create folder for best combination
2810
- best_combo_folder = os.path.join(results_folder, 'best_combination')
2811
- os.makedirs(best_combo_folder, exist_ok=True)
2812
-
2813
- # Run FULL backtest with ALL charts and exports
2814
- # Note: progress_context=None, so plt.show() will be called but fail due to renderer
2815
- # We'll display charts explicitly afterwards using IPython.display.Image
2816
- best_analyzer = run_backtest(
2817
- strategy_function,
2818
- best_config,
2819
- print_report=True, # ← ПОКАЗЫВАЕМ ПОЛНЫЙ ОТЧЕТ
2820
- create_charts=True, # ← СОЗДАЕМ ВСЕ ГРАФИКИ
2821
- export_results=True, # ← ЭКСПОРТИРУЕМ ВСЕ ФАЙЛЫ
2822
- progress_context=None, # ← Обычный режим
2823
- chart_filename=os.path.join(best_combo_folder, 'equity_curve.png'),
2824
- export_prefix=os.path.join(best_combo_folder, 'best')
2825
- )
2826
-
2827
- # Save detailed metrics to optimization_metrics.csv
2828
- metrics_data = {
2829
- 'metric': list(best_analyzer.metrics.keys()),
2830
- 'value': list(best_analyzer.metrics.values())
2831
- }
2832
- metrics_df = pd.DataFrame(metrics_data)
2833
- metrics_path = os.path.join(results_folder, 'optimization_metrics.csv')
2834
- metrics_df.to_csv(metrics_path, index=False)
2835
-
2836
- print(f"\n✓ Detailed metrics saved: {metrics_path}")
2837
- print(f"✓ Best combination results saved to: {best_combo_folder}/")
2838
-
2839
- # ═══════════════════════════════════════════════════════════════════════════
2840
- # ОТОБРАЖЕНИЕ ГРАФИКОВ ЛУЧШЕЙ КОМБИНАЦИИ В NOTEBOOK
2841
- # ═══════════════════════════════════════════════════════════════════════════
2842
- try:
2843
- # Charts are displayed in the notebook, not here
2844
- chart_file = os.path.join(best_combo_folder, 'equity_curve.png')
2845
- if os.path.exists(chart_file):
2846
- print(f"\n📈 Best combination charts saved to: {chart_file}")
2847
- except Exception as e:
2848
- print(f"\n⚠ Could not display charts (saved to {best_combo_folder}/): {e}")
2849
-
2850
- print("="*80 + "\n")
2851
-
2852
- return results_df, best_params, results_folder
2853
-
2854
-
2855
- def plot_optimization_results(results_df, param_names, filename='optimization_results.png'):
2856
- """
2857
- Create visualization of optimization results
2858
-
2859
- Args:
2860
- results_df: Results DataFrame from optimize_parameters()
2861
- param_names: List of parameter names
2862
- filename: Output filename
2863
- """
2864
- import matplotlib.pyplot as plt
2865
- import seaborn as sns
2866
-
2867
- valid_results = results_df[results_df['is_valid'] == True].copy()
2868
-
2869
- if valid_results.empty:
2870
- print("No valid results to plot")
2871
- return
2872
-
2873
- sns.set_style("whitegrid")
2874
-
2875
- fig = plt.figure(figsize=(18, 12))
2876
-
2877
- # 1. Sharpe vs Total Return scatter
2878
- ax1 = plt.subplot(2, 3, 1)
2879
- scatter = ax1.scatter(
2880
- valid_results['total_return'],
2881
- valid_results['sharpe'],
2882
- c=valid_results['max_drawdown'],
2883
- s=valid_results['total_trades']*10,
2884
- alpha=0.6,
2885
- cmap='RdYlGn_r'
2886
- )
2887
- ax1.set_xlabel('Total Return (%)', fontsize=10)
2888
- ax1.set_ylabel('Sharpe Ratio', fontsize=10)
2889
- ax1.set_title('Sharpe vs Return (size=trades, color=drawdown)', fontsize=11, fontweight='bold')
2890
- plt.colorbar(scatter, ax=ax1, label='Max Drawdown (%)')
2891
- ax1.grid(True, alpha=0.3)
2892
-
2893
- # 2. Parameter heatmap (if 2 parameters)
2894
- if len(param_names) == 2:
2895
- ax2 = plt.subplot(2, 3, 2)
2896
- pivot_data = valid_results.pivot_table(
2897
- values='sharpe',
2898
- index=param_names[0],
2899
- columns=param_names[1],
2900
- aggfunc='mean'
2901
- )
2902
- sns.heatmap(pivot_data, annot=True, fmt='.2f', cmap='RdYlGn', ax=ax2)
2903
- ax2.set_title(f'Sharpe Ratio Heatmap', fontsize=11, fontweight='bold')
2904
- else:
2905
- ax2 = plt.subplot(2, 3, 2)
2906
- ax2.text(0.5, 0.5, 'Heatmap requires\nexactly 2 parameters',
2907
- ha='center', va='center', fontsize=12)
2908
- ax2.axis('off')
2909
-
2910
- # 3. Win Rate vs Profit Factor
2911
- ax3 = plt.subplot(2, 3, 3)
2912
- scatter3 = ax3.scatter(
2913
- valid_results['win_rate'],
2914
- valid_results['profit_factor'],
2915
- c=valid_results['sharpe'],
2916
- s=100,
2917
- alpha=0.6,
2918
- cmap='viridis'
2919
- )
2920
- ax3.set_xlabel('Win Rate (%)', fontsize=10)
2921
- ax3.set_ylabel('Profit Factor', fontsize=10)
2922
- ax3.set_title('Win Rate vs Profit Factor (color=Sharpe)', fontsize=11, fontweight='bold')
2923
- plt.colorbar(scatter3, ax=ax3, label='Sharpe Ratio')
2924
- ax3.grid(True, alpha=0.3)
2925
-
2926
- # 4. Distribution of Sharpe Ratios
2927
- ax4 = plt.subplot(2, 3, 4)
2928
- ax4.hist(valid_results['sharpe'], bins=20, color='steelblue', alpha=0.7, edgecolor='black')
2929
- ax4.axvline(valid_results['sharpe'].mean(), color='red', linestyle='--', linewidth=2, label='Mean')
2930
- ax4.axvline(valid_results['sharpe'].median(), color='green', linestyle='--', linewidth=2, label='Median')
2931
- ax4.set_xlabel('Sharpe Ratio', fontsize=10)
2932
- ax4.set_ylabel('Frequency', fontsize=10)
2933
- ax4.set_title('Distribution of Sharpe Ratios', fontsize=11, fontweight='bold')
2934
- ax4.legend()
2935
- ax4.grid(True, alpha=0.3, axis='y')
2936
-
2937
- # 5. Total Trades distribution
2938
- ax5 = plt.subplot(2, 3, 5)
2939
- ax5.hist(valid_results['total_trades'], bins=15, color='coral', alpha=0.7, edgecolor='black')
2940
- ax5.set_xlabel('Total Trades', fontsize=10)
2941
- ax5.set_ylabel('Frequency', fontsize=10)
2942
- ax5.set_title('Distribution of Trade Counts', fontsize=11, fontweight='bold')
2943
- ax5.grid(True, alpha=0.3, axis='y')
2944
-
2945
- # 6. Top 10 combinations
2946
- ax6 = plt.subplot(2, 3, 6)
2947
- top_10 = valid_results.nlargest(10, 'sharpe')[['combination_id', 'sharpe']].sort_values('sharpe')
2948
- ax6.barh(range(len(top_10)), top_10['sharpe'], color='green', alpha=0.7)
2949
- ax6.set_yticks(range(len(top_10)))
2950
- ax6.set_yticklabels([f"#{int(x)}" for x in top_10['combination_id']])
2951
- ax6.set_xlabel('Sharpe Ratio', fontsize=10)
2952
- ax6.set_title('Top 10 Combinations by Sharpe', fontsize=11, fontweight='bold')
2953
- ax6.grid(True, alpha=0.3, axis='x')
2954
-
2955
- plt.tight_layout()
2956
- plt.savefig(filename, dpi=150, bbox_inches='tight')
2957
- print(f"\nVisualization saved: {filename}")
2958
- plt.close() # Закрываем без показа, так как отображаем через display(Image)
2959
-
2960
-
2961
- # Export all
2962
- __all__ = [
2963
- 'BacktestResults', 'BacktestAnalyzer', 'ResultsReporter',
2964
- 'ChartGenerator', 'ResultsExporter', 'run_backtest', 'run_backtest_with_stoploss',
2965
- 'init_api', 'api_call', 'APIHelper', 'APIManager',
2966
- 'ResourceMonitor', 'create_progress_bar', 'update_progress', 'format_time',
2967
- 'StopLossManager', 'PositionManager', 'StopLossConfig',
2968
- 'calculate_stoploss_metrics', 'print_stoploss_section', 'create_stoploss_charts',
2969
- 'create_stoploss_comparison_chart',
2970
- 'optimize_parameters', 'plot_optimization_results',
2971
- 'create_optimization_folder',
2972
- 'preload_options_data' # ← ДОБАВЛЕНО
2973
- ]