sigma-terminal 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1506 @@
1
+ """LEAN Engine backtesting tools for Sigma.
2
+
3
+ Generate QuantConnect LEAN-compatible Python algorithms for backtesting trading strategies.
4
+ """
5
+
6
+ import os
7
+ import json
8
+ from datetime import datetime, timedelta
9
+ from typing import Any, Optional
10
+
11
+
12
+ # LEAN Algorithm Templates
13
+ LEAN_BASE_TEMPLATE = '''# LEAN Algorithm - Generated by Sigma
14
+ # Strategy: {strategy_name}
15
+ # Generated: {timestamp}
16
+ # Symbol: {symbol}
17
+
18
+ from AlgorithmImports import *
19
+
20
+
21
+ class {class_name}(QCAlgorithm):
22
+ """
23
+ {strategy_description}
24
+
25
+ Generated by Sigma Financial Research Agent
26
+ """
27
+
28
+ def Initialize(self):
29
+ """Initialize algorithm parameters and data."""
30
+ # Backtest period
31
+ self.SetStartDate({start_year}, {start_month}, {start_day})
32
+ self.SetEndDate({end_year}, {end_month}, {end_day})
33
+
34
+ # Starting capital
35
+ self.SetCash({initial_capital})
36
+
37
+ # Add securities
38
+ self.symbol = self.AddEquity("{symbol}", Resolution.Daily).Symbol
39
+
40
+ # Strategy parameters
41
+ {strategy_params}
42
+
43
+ # Indicators
44
+ {indicators}
45
+
46
+ # Tracking variables
47
+ self.previous_price = 0
48
+ self.trade_count = 0
49
+
50
+ def OnData(self, data: Slice):
51
+ """Handle incoming data and execute strategy logic."""
52
+ if not data.Bars.ContainsKey(self.symbol):
53
+ return
54
+
55
+ bar = data.Bars[self.symbol]
56
+ price = bar.Close
57
+
58
+ {strategy_logic}
59
+
60
+ self.previous_price = price
61
+
62
+ def OnOrderEvent(self, orderEvent):
63
+ """Handle order events."""
64
+ if orderEvent.Status == OrderStatus.Filled:
65
+ self.trade_count += 1
66
+ self.Debug(f"Order filled: {{orderEvent.Symbol}} {{orderEvent.FillQuantity}} @ {{orderEvent.FillPrice}}")
67
+
68
+ def OnEndOfAlgorithm(self):
69
+ """Called at end of backtest."""
70
+ self.Debug(f"Total trades executed: {{self.trade_count}}")
71
+ '''
72
+
73
+ # Strategy-specific templates
74
+ STRATEGY_TEMPLATES = {
75
+ "sma_crossover": {
76
+ "name": "SMA Crossover",
77
+ "description": "Classic moving average crossover strategy. Goes long when fast SMA crosses above slow SMA, exits when it crosses below.",
78
+ "params": ''' self.fast_period = {fast_period}
79
+ self.slow_period = {slow_period}
80
+ self.position_size = {position_size} # Fraction of portfolio''',
81
+ "indicators": ''' self.fast_sma = self.SMA(self.symbol, self.fast_period, Resolution.Daily)
82
+ self.slow_sma = self.SMA(self.symbol, self.slow_period, Resolution.Daily)
83
+
84
+ # Warm up indicators
85
+ self.SetWarmUp(self.slow_period)''',
86
+ "logic": ''' # Wait for indicators to warm up
87
+ if self.IsWarmingUp:
88
+ return
89
+
90
+ if not self.fast_sma.IsReady or not self.slow_sma.IsReady:
91
+ return
92
+
93
+ holdings = self.Portfolio[self.symbol].Quantity
94
+
95
+ # Buy signal: fast SMA crosses above slow SMA
96
+ if self.fast_sma.Current.Value > self.slow_sma.Current.Value:
97
+ if holdings <= 0:
98
+ self.SetHoldings(self.symbol, self.position_size)
99
+ self.Debug(f"BUY: Fast SMA ({self.fast_sma.Current.Value:.2f}) > Slow SMA ({self.slow_sma.Current.Value:.2f})")
100
+
101
+ # Sell signal: fast SMA crosses below slow SMA
102
+ elif self.fast_sma.Current.Value < self.slow_sma.Current.Value:
103
+ if holdings > 0:
104
+ self.Liquidate(self.symbol)
105
+ self.Debug(f"SELL: Fast SMA ({self.fast_sma.Current.Value:.2f}) < Slow SMA ({self.slow_sma.Current.Value:.2f})")''',
106
+ "default_params": {"fast_period": 10, "slow_period": 30, "position_size": 0.95}
107
+ },
108
+
109
+ "rsi_mean_reversion": {
110
+ "name": "RSI Mean Reversion",
111
+ "description": "Mean reversion strategy using RSI. Buys when RSI is oversold and sells when overbought.",
112
+ "params": ''' self.rsi_period = {rsi_period}
113
+ self.oversold = {oversold}
114
+ self.overbought = {overbought}
115
+ self.position_size = {position_size}''',
116
+ "indicators": ''' self.rsi = self.RSI(self.symbol, self.rsi_period, MovingAverageType.Wilders, Resolution.Daily)
117
+
118
+ # Warm up
119
+ self.SetWarmUp(self.rsi_period * 2)''',
120
+ "logic": ''' if self.IsWarmingUp or not self.rsi.IsReady:
121
+ return
122
+
123
+ holdings = self.Portfolio[self.symbol].Quantity
124
+ rsi_value = self.rsi.Current.Value
125
+
126
+ # Buy when oversold
127
+ if rsi_value < self.oversold:
128
+ if holdings <= 0:
129
+ self.SetHoldings(self.symbol, self.position_size)
130
+ self.Debug(f"BUY: RSI oversold at {rsi_value:.2f}")
131
+
132
+ # Sell when overbought
133
+ elif rsi_value > self.overbought:
134
+ if holdings > 0:
135
+ self.Liquidate(self.symbol)
136
+ self.Debug(f"SELL: RSI overbought at {rsi_value:.2f}")''',
137
+ "default_params": {"rsi_period": 14, "oversold": 30, "overbought": 70, "position_size": 0.95}
138
+ },
139
+
140
+ "macd_momentum": {
141
+ "name": "MACD Momentum",
142
+ "description": "Momentum strategy using MACD histogram. Enters on positive histogram, exits on negative.",
143
+ "params": ''' self.fast_period = {fast_period}
144
+ self.slow_period = {slow_period}
145
+ self.signal_period = {signal_period}
146
+ self.position_size = {position_size}''',
147
+ "indicators": ''' self.macd = self.MACD(self.symbol, self.fast_period, self.slow_period, self.signal_period,
148
+ MovingAverageType.Exponential, Resolution.Daily)
149
+
150
+ # Warm up
151
+ self.SetWarmUp(self.slow_period + self.signal_period)''',
152
+ "logic": ''' if self.IsWarmingUp or not self.macd.IsReady:
153
+ return
154
+
155
+ holdings = self.Portfolio[self.symbol].Quantity
156
+ histogram = self.macd.Histogram.Current.Value
157
+
158
+ # Buy when histogram turns positive
159
+ if histogram > 0:
160
+ if holdings <= 0:
161
+ self.SetHoldings(self.symbol, self.position_size)
162
+ self.Debug(f"BUY: MACD histogram positive at {histogram:.4f}")
163
+
164
+ # Sell when histogram turns negative
165
+ elif histogram < 0:
166
+ if holdings > 0:
167
+ self.Liquidate(self.symbol)
168
+ self.Debug(f"SELL: MACD histogram negative at {histogram:.4f}")''',
169
+ "default_params": {"fast_period": 12, "slow_period": 26, "signal_period": 9, "position_size": 0.95}
170
+ },
171
+
172
+ "bollinger_bands": {
173
+ "name": "Bollinger Bands",
174
+ "description": "Mean reversion strategy using Bollinger Bands. Buys at lower band, sells at upper band.",
175
+ "params": ''' self.bb_period = {bb_period}
176
+ self.std_dev = {std_dev}
177
+ self.position_size = {position_size}''',
178
+ "indicators": ''' self.bb = self.BB(self.symbol, self.bb_period, self.std_dev, Resolution.Daily)
179
+
180
+ # Warm up
181
+ self.SetWarmUp(self.bb_period)''',
182
+ "logic": ''' if self.IsWarmingUp or not self.bb.IsReady:
183
+ return
184
+
185
+ holdings = self.Portfolio[self.symbol].Quantity
186
+ upper = self.bb.UpperBand.Current.Value
187
+ lower = self.bb.LowerBand.Current.Value
188
+ middle = self.bb.MiddleBand.Current.Value
189
+
190
+ # Buy at lower band
191
+ if price <= lower:
192
+ if holdings <= 0:
193
+ self.SetHoldings(self.symbol, self.position_size)
194
+ self.Debug(f"BUY: Price ({price:.2f}) at lower band ({lower:.2f})")
195
+
196
+ # Sell at upper band
197
+ elif price >= upper:
198
+ if holdings > 0:
199
+ self.Liquidate(self.symbol)
200
+ self.Debug(f"SELL: Price ({price:.2f}) at upper band ({upper:.2f})")''',
201
+ "default_params": {"bb_period": 20, "std_dev": 2, "position_size": 0.95}
202
+ },
203
+
204
+ "dual_momentum": {
205
+ "name": "Dual Momentum",
206
+ "description": "Trend-following strategy using both absolute and relative momentum.",
207
+ "params": ''' self.lookback = {lookback}
208
+ self.position_size = {position_size}
209
+ self.benchmark = self.AddEquity("SPY", Resolution.Daily).Symbol''',
210
+ "indicators": ''' self.mom = self.MOM(self.symbol, self.lookback, Resolution.Daily)
211
+ self.benchmark_mom = self.MOM(self.benchmark, self.lookback, Resolution.Daily)
212
+
213
+ # Risk-free rate proxy
214
+ self.bond = self.AddEquity("TLT", Resolution.Daily).Symbol
215
+
216
+ # Warm up
217
+ self.SetWarmUp(self.lookback)''',
218
+ "logic": ''' if self.IsWarmingUp or not self.mom.IsReady:
219
+ return
220
+
221
+ holdings = self.Portfolio[self.symbol].Quantity
222
+ stock_mom = self.mom.Current.Value
223
+ bench_mom = self.benchmark_mom.Current.Value
224
+
225
+ # Absolute momentum: only invest if positive
226
+ # Relative momentum: invest in stock if beats benchmark
227
+ if stock_mom > 0 and stock_mom > bench_mom:
228
+ if holdings <= 0:
229
+ self.SetHoldings(self.symbol, self.position_size)
230
+ self.Debug(f"BUY: Dual momentum positive (Stock: {stock_mom:.2f}, Bench: {bench_mom:.2f})")
231
+ else:
232
+ if holdings > 0:
233
+ self.Liquidate(self.symbol)
234
+ self.Debug(f"SELL: Dual momentum negative")''',
235
+ "default_params": {"lookback": 252, "position_size": 0.95}
236
+ },
237
+
238
+ "breakout": {
239
+ "name": "Breakout Strategy",
240
+ "description": "Donchian channel breakout strategy. Enters on new highs, exits on new lows.",
241
+ "params": ''' self.entry_period = {entry_period}
242
+ self.exit_period = {exit_period}
243
+ self.position_size = {position_size}''',
244
+ "indicators": ''' self.entry_high = self.MAX(self.symbol, self.entry_period, Resolution.Daily)
245
+ self.exit_low = self.MIN(self.symbol, self.exit_period, Resolution.Daily)
246
+
247
+ # Warm up
248
+ self.SetWarmUp(max(self.entry_period, self.exit_period))''',
249
+ "logic": ''' if self.IsWarmingUp or not self.entry_high.IsReady:
250
+ return
251
+
252
+ holdings = self.Portfolio[self.symbol].Quantity
253
+ highest = self.entry_high.Current.Value
254
+ lowest = self.exit_low.Current.Value
255
+
256
+ # Buy on breakout above highest high
257
+ if price >= highest:
258
+ if holdings <= 0:
259
+ self.SetHoldings(self.symbol, self.position_size)
260
+ self.Debug(f"BUY: Breakout above {highest:.2f}")
261
+
262
+ # Sell on breakdown below lowest low
263
+ elif price <= lowest:
264
+ if holdings > 0:
265
+ self.Liquidate(self.symbol)
266
+ self.Debug(f"SELL: Breakdown below {lowest:.2f}")''',
267
+ "default_params": {"entry_period": 20, "exit_period": 10, "position_size": 0.95}
268
+ }
269
+ }
270
+
271
+
272
+ def get_available_strategies() -> dict[str, dict]:
273
+ """Get list of available backtesting strategies."""
274
+ return {
275
+ name: {
276
+ "name": s["name"],
277
+ "description": s["description"],
278
+ "default_params": s["default_params"]
279
+ }
280
+ for name, s in STRATEGY_TEMPLATES.items()
281
+ }
282
+
283
+
284
+ def generate_lean_algorithm(
285
+ symbol: str,
286
+ strategy: str,
287
+ start_date: Optional[str] = None,
288
+ end_date: Optional[str] = None,
289
+ initial_capital: float = 100000,
290
+ params: Optional[dict] = None,
291
+ output_dir: Optional[str] = None,
292
+ ) -> dict[str, Any]:
293
+ """Generate a LEAN-compatible Python algorithm file.
294
+
295
+ Args:
296
+ symbol: Stock ticker symbol
297
+ strategy: Strategy type (sma_crossover, rsi_mean_reversion, etc.)
298
+ start_date: Backtest start date (YYYY-MM-DD)
299
+ end_date: Backtest end date (YYYY-MM-DD)
300
+ initial_capital: Starting capital
301
+ params: Strategy-specific parameters (optional)
302
+ output_dir: Directory to save the file (optional)
303
+
304
+ Returns:
305
+ Dict with file path and algorithm details
306
+ """
307
+ if strategy not in STRATEGY_TEMPLATES:
308
+ available = list(STRATEGY_TEMPLATES.keys())
309
+ return {"error": f"Unknown strategy '{strategy}'. Available: {available}"}
310
+
311
+ template = STRATEGY_TEMPLATES[strategy]
312
+
313
+ # Parse dates
314
+ if start_date:
315
+ start = datetime.strptime(start_date, "%Y-%m-%d")
316
+ else:
317
+ start = datetime.now() - timedelta(days=365 * 2) # 2 years back
318
+
319
+ if end_date:
320
+ end = datetime.strptime(end_date, "%Y-%m-%d")
321
+ else:
322
+ end = datetime.now()
323
+
324
+ # Merge default params with custom params
325
+ strategy_params = template["default_params"].copy()
326
+ if params:
327
+ strategy_params.update(params)
328
+
329
+ # Create class name
330
+ class_name = f"Sigma{strategy.title().replace('_', '')}Algorithm"
331
+
332
+ # Format params
333
+ params_code = template["params"].format(**strategy_params)
334
+ indicators_code = template["indicators"]
335
+ logic_code = template["logic"]
336
+
337
+ # Generate the algorithm
338
+ algorithm_code = LEAN_BASE_TEMPLATE.format(
339
+ strategy_name=template["name"],
340
+ timestamp=datetime.now().isoformat(),
341
+ symbol=symbol.upper(),
342
+ class_name=class_name,
343
+ strategy_description=template["description"],
344
+ start_year=start.year,
345
+ start_month=start.month,
346
+ start_day=start.day,
347
+ end_year=end.year,
348
+ end_month=end.month,
349
+ end_day=end.day,
350
+ initial_capital=int(initial_capital),
351
+ strategy_params=params_code,
352
+ indicators=indicators_code,
353
+ strategy_logic=logic_code,
354
+ )
355
+
356
+ # Create output directory
357
+ if output_dir is None:
358
+ output_dir = os.path.expanduser("~/sigma_backtests")
359
+
360
+ os.makedirs(output_dir, exist_ok=True)
361
+
362
+ # Save algorithm file
363
+ filename = f"{symbol.lower()}_{strategy}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.py"
364
+ filepath = os.path.join(output_dir, filename)
365
+
366
+ with open(filepath, 'w') as f:
367
+ f.write(algorithm_code)
368
+
369
+ # Also save config
370
+ config = {
371
+ "algorithm-type-name": class_name,
372
+ "algorithm-language": "Python",
373
+ "parameters": strategy_params,
374
+ "symbol": symbol.upper(),
375
+ "strategy": strategy,
376
+ "start_date": start.strftime("%Y-%m-%d"),
377
+ "end_date": end.strftime("%Y-%m-%d"),
378
+ "initial_capital": initial_capital,
379
+ }
380
+
381
+ config_path = filepath.replace('.py', '_config.json')
382
+ with open(config_path, 'w') as f:
383
+ json.dump(config, f, indent=2)
384
+
385
+ return {
386
+ "success": True,
387
+ "algorithm_file": filepath,
388
+ "config_file": config_path,
389
+ "class_name": class_name,
390
+ "strategy": template["name"],
391
+ "description": template["description"],
392
+ "symbol": symbol.upper(),
393
+ "period": f"{start.strftime('%Y-%m-%d')} to {end.strftime('%Y-%m-%d')}",
394
+ "initial_capital": f"${initial_capital:,.0f}",
395
+ "parameters": strategy_params,
396
+ "instructions": [
397
+ "1. Install LEAN: pip install lean",
398
+ "2. Initialize workspace: lean init",
399
+ f"3. Copy {filename} to your LEAN project",
400
+ "4. Run backtest: lean backtest <project-name>",
401
+ "Or upload to QuantConnect.com for cloud backtesting"
402
+ ]
403
+ }
404
+
405
+
406
+ def generate_custom_algorithm(
407
+ symbol: str,
408
+ entry_conditions: list[str],
409
+ exit_conditions: list[str],
410
+ indicators: list[str],
411
+ start_date: Optional[str] = None,
412
+ end_date: Optional[str] = None,
413
+ initial_capital: float = 100000,
414
+ output_dir: Optional[str] = None,
415
+ ) -> dict[str, Any]:
416
+ """Generate a custom LEAN algorithm with user-specified conditions.
417
+
418
+ Args:
419
+ symbol: Stock ticker
420
+ entry_conditions: List of entry condition descriptions
421
+ exit_conditions: List of exit condition descriptions
422
+ indicators: List of indicators to use
423
+
424
+ Returns:
425
+ Dict with file path and details
426
+ """
427
+ # Map indicator names to LEAN code
428
+ indicator_map = {
429
+ "sma": ("SMA", "sma_{period}", "self.SMA(self.symbol, {period}, Resolution.Daily)"),
430
+ "ema": ("EMA", "ema_{period}", "self.EMA(self.symbol, {period}, Resolution.Daily)"),
431
+ "rsi": ("RSI", "rsi", "self.RSI(self.symbol, 14, MovingAverageType.Wilders, Resolution.Daily)"),
432
+ "macd": ("MACD", "macd", "self.MACD(self.symbol, 12, 26, 9, MovingAverageType.Exponential, Resolution.Daily)"),
433
+ "bb": ("Bollinger Bands", "bb", "self.BB(self.symbol, 20, 2, Resolution.Daily)"),
434
+ "atr": ("ATR", "atr", "self.ATR(self.symbol, 14, Resolution.Daily)"),
435
+ "adx": ("ADX", "adx", "self.ADX(self.symbol, 14, Resolution.Daily)"),
436
+ }
437
+
438
+ # Build indicator code
439
+ indicator_code_lines = []
440
+ for ind in indicators:
441
+ ind_lower = ind.lower()
442
+ for key, (name, var, code) in indicator_map.items():
443
+ if key in ind_lower:
444
+ indicator_code_lines.append(f" self.{var} = {code}")
445
+ break
446
+
447
+ indicator_code = "\n".join(indicator_code_lines) if indicator_code_lines else " pass # No indicators"
448
+
449
+ # Build entry/exit logic (basic template)
450
+ entry_comment = "\n".join([f" # Entry: {c}" for c in entry_conditions])
451
+ exit_comment = "\n".join([f" # Exit: {c}" for c in exit_conditions])
452
+
453
+ logic_code = f''' {entry_comment}
454
+ # TODO: Implement entry logic based on conditions above
455
+
456
+ {exit_comment}
457
+ # TODO: Implement exit logic based on conditions above
458
+
459
+ holdings = self.Portfolio[self.symbol].Quantity
460
+
461
+ # Example entry (customize based on your conditions)
462
+ if not self.Portfolio[self.symbol].Invested:
463
+ self.SetHoldings(self.symbol, 0.95)
464
+ self.Debug(f"BUY at {{price}}")'''
465
+
466
+ # Parse dates
467
+ if start_date:
468
+ start = datetime.strptime(start_date, "%Y-%m-%d")
469
+ else:
470
+ start = datetime.now() - timedelta(days=365 * 2)
471
+
472
+ if end_date:
473
+ end = datetime.strptime(end_date, "%Y-%m-%d")
474
+ else:
475
+ end = datetime.now()
476
+
477
+ # Generate algorithm
478
+ algorithm_code = LEAN_BASE_TEMPLATE.format(
479
+ strategy_name="Custom Strategy",
480
+ timestamp=datetime.now().isoformat(),
481
+ symbol=symbol.upper(),
482
+ class_name="SigmaCustomAlgorithm",
483
+ strategy_description=f"Custom strategy for {symbol.upper()}\nEntry: {', '.join(entry_conditions)}\nExit: {', '.join(exit_conditions)}",
484
+ start_year=start.year,
485
+ start_month=start.month,
486
+ start_day=start.day,
487
+ end_year=end.year,
488
+ end_month=end.month,
489
+ end_day=end.day,
490
+ initial_capital=int(initial_capital),
491
+ strategy_params=" self.position_size = 0.95",
492
+ indicators=indicator_code,
493
+ strategy_logic=logic_code,
494
+ )
495
+
496
+ # Save
497
+ if output_dir is None:
498
+ output_dir = os.path.expanduser("~/sigma_backtests")
499
+
500
+ os.makedirs(output_dir, exist_ok=True)
501
+ filename = f"{symbol.lower()}_custom_{datetime.now().strftime('%Y%m%d_%H%M%S')}.py"
502
+ filepath = os.path.join(output_dir, filename)
503
+
504
+ with open(filepath, 'w') as f:
505
+ f.write(algorithm_code)
506
+
507
+ return {
508
+ "success": True,
509
+ "algorithm_file": filepath,
510
+ "symbol": symbol.upper(),
511
+ "strategy": "Custom",
512
+ "entry_conditions": entry_conditions,
513
+ "exit_conditions": exit_conditions,
514
+ "indicators": indicators,
515
+ "note": "This is a template. Please review and customize the entry/exit logic before running."
516
+ }
517
+
518
+
519
+ def run_backtest(
520
+ symbol: str,
521
+ strategy: str = "sma_crossover",
522
+ period: str = "2y",
523
+ initial_capital: float = 100000,
524
+ **strategy_params
525
+ ) -> dict[str, Any]:
526
+ """Run a backtest using LEAN CLI.
527
+
528
+ This will:
529
+ 1. Check if LEAN CLI is installed, install if needed
530
+ 2. Initialize LEAN workspace if needed
531
+ 3. Generate the algorithm
532
+ 4. Run the backtest
533
+ 5. Return results
534
+
535
+ Args:
536
+ symbol: Stock ticker symbol
537
+ strategy: Strategy name (sma_crossover, rsi_mean_reversion, macd_momentum, etc.)
538
+ period: Backtest period (1y, 2y, 5y)
539
+ initial_capital: Starting capital
540
+ **strategy_params: Strategy-specific parameters
541
+
542
+ Returns:
543
+ Backtest results including performance metrics
544
+ """
545
+ import subprocess
546
+ import shutil
547
+
548
+ results = {
549
+ "symbol": symbol.upper(),
550
+ "strategy": strategy,
551
+ "status": "pending",
552
+ "steps": []
553
+ }
554
+
555
+ # Step 1: Check if LEAN CLI is installed
556
+ lean_path = shutil.which("lean")
557
+
558
+ if not lean_path:
559
+ results["steps"].append("LEAN CLI not found - installing...")
560
+ try:
561
+ subprocess.run(
562
+ ["pip", "install", "lean"],
563
+ capture_output=True,
564
+ text=True,
565
+ check=True
566
+ )
567
+ results["steps"].append("LEAN CLI installed successfully")
568
+ except subprocess.CalledProcessError as e:
569
+ results["status"] = "error"
570
+ results["error"] = f"Failed to install LEAN CLI: {e.stderr}"
571
+ results["instructions"] = [
572
+ "Please install LEAN CLI manually:",
573
+ " pip install lean",
574
+ "Then run this command again."
575
+ ]
576
+ return results
577
+ else:
578
+ results["steps"].append(f"LEAN CLI found at {lean_path}")
579
+
580
+ # Step 2: Setup LEAN workspace
581
+ lean_workspace = os.path.expanduser("~/sigma_lean")
582
+ config_file = os.path.join(lean_workspace, "lean.json")
583
+
584
+ if not os.path.exists(config_file):
585
+ results["steps"].append("Initializing LEAN workspace...")
586
+ os.makedirs(lean_workspace, exist_ok=True)
587
+
588
+ try:
589
+ subprocess.run(
590
+ ["lean", "init"],
591
+ cwd=lean_workspace,
592
+ capture_output=True,
593
+ text=True,
594
+ check=True
595
+ )
596
+ results["steps"].append(f"LEAN workspace initialized at {lean_workspace}")
597
+ except subprocess.CalledProcessError as e:
598
+ # Try creating minimal config
599
+ minimal_config = {
600
+ "data-folder": os.path.join(lean_workspace, "data"),
601
+ "results-destination-folder": os.path.join(lean_workspace, "results")
602
+ }
603
+ os.makedirs(os.path.join(lean_workspace, "data"), exist_ok=True)
604
+ os.makedirs(os.path.join(lean_workspace, "results"), exist_ok=True)
605
+
606
+ with open(config_file, 'w') as f:
607
+ json.dump(minimal_config, f, indent=2)
608
+ results["steps"].append("Created minimal LEAN config")
609
+ else:
610
+ results["steps"].append(f"Using LEAN workspace at {lean_workspace}")
611
+
612
+ # Step 3: Generate the algorithm
613
+ results["steps"].append(f"Generating {strategy} algorithm for {symbol.upper()}...")
614
+
615
+ algo_result = generate_lean_algorithm(
616
+ symbol=symbol,
617
+ strategy=strategy,
618
+ initial_capital=initial_capital,
619
+ output_dir=lean_workspace,
620
+ **strategy_params
621
+ )
622
+
623
+ if not algo_result.get("success"):
624
+ results["status"] = "error"
625
+ results["error"] = "Failed to generate algorithm"
626
+ return results
627
+
628
+ algo_file = algo_result["algorithm_file"]
629
+ results["algorithm_file"] = algo_file
630
+ results["steps"].append(f"Algorithm saved to {algo_file}")
631
+
632
+ # Step 4: Run the backtest
633
+ results["steps"].append("Running LEAN backtest...")
634
+
635
+ try:
636
+ run_result = subprocess.run(
637
+ ["lean", "backtest", algo_file],
638
+ cwd=lean_workspace,
639
+ capture_output=True,
640
+ text=True,
641
+ timeout=300 # 5 minute timeout
642
+ )
643
+
644
+ if run_result.returncode == 0:
645
+ results["status"] = "success"
646
+ results["steps"].append("Backtest completed successfully")
647
+ results["output"] = run_result.stdout
648
+
649
+ # Try to parse results
650
+ results_dir = os.path.join(lean_workspace, "results")
651
+ if os.path.exists(results_dir):
652
+ result_files = os.listdir(results_dir)
653
+ if result_files:
654
+ latest_result = max(
655
+ [os.path.join(results_dir, f) for f in result_files],
656
+ key=os.path.getctime
657
+ )
658
+ results["results_file"] = latest_result
659
+ else:
660
+ results["status"] = "error"
661
+ results["error"] = run_result.stderr or run_result.stdout
662
+ results["steps"].append("Backtest failed - see error details")
663
+
664
+ # Provide helpful instructions
665
+ if "data" in run_result.stderr.lower():
666
+ results["instructions"] = [
667
+ "LEAN needs market data to run backtests.",
668
+ "Options:",
669
+ "1. Use QuantConnect Cloud (free tier available):",
670
+ " lean cloud backtest " + algo_file,
671
+ "",
672
+ "2. Download free sample data:",
673
+ " lean data download --dataset 'US Equities'",
674
+ "",
675
+ "3. Use the generated algorithm on QuantConnect.com"
676
+ ]
677
+
678
+ except subprocess.TimeoutExpired:
679
+ results["status"] = "timeout"
680
+ results["error"] = "Backtest timed out after 5 minutes"
681
+ except FileNotFoundError:
682
+ results["status"] = "error"
683
+ results["error"] = "LEAN CLI not found in PATH after installation"
684
+ results["instructions"] = [
685
+ "Please restart your terminal and try again, or run:",
686
+ " export PATH=$PATH:~/.local/bin",
687
+ " lean backtest " + algo_file
688
+ ]
689
+
690
+ # Always provide manual run instructions
691
+ results["manual_instructions"] = [
692
+ "To run this backtest manually:",
693
+ f" cd {lean_workspace}",
694
+ f" lean backtest {os.path.basename(algo_file)}",
695
+ "",
696
+ "Or use QuantConnect Cloud (recommended):",
697
+ f" lean cloud backtest {os.path.basename(algo_file)}",
698
+ "",
699
+ "Or upload to QuantConnect.com for free cloud backtesting"
700
+ ]
701
+
702
+ return results
703
+
704
+
705
+ def setup_lean_engine() -> dict[str, Any]:
706
+ """Setup LEAN Engine for backtesting using Docker.
707
+
708
+ This is the proper way to run LEAN - using Docker containers.
709
+
710
+ Returns:
711
+ Setup status and instructions
712
+ """
713
+ import subprocess
714
+ import shutil
715
+
716
+ result = {
717
+ "success": False,
718
+ "steps_completed": [],
719
+ "errors": [],
720
+ "workspace": os.path.expanduser("~/sigma_lean"),
721
+ "next_steps": []
722
+ }
723
+
724
+ # Step 1: Check Docker
725
+ docker_path = shutil.which("docker")
726
+ if not docker_path:
727
+ result["errors"].append("Docker is not installed")
728
+ result["next_steps"] = [
729
+ "LEAN requires Docker to run backtests.",
730
+ "",
731
+ "Please install Docker Desktop:",
732
+ " https://www.docker.com/products/docker-desktop",
733
+ "",
734
+ "After installing, run '/lean setup' again."
735
+ ]
736
+ return result
737
+
738
+ # Check if Docker is running
739
+ try:
740
+ docker_check = subprocess.run(
741
+ ["docker", "info"],
742
+ capture_output=True,
743
+ text=True,
744
+ timeout=10
745
+ )
746
+ if docker_check.returncode != 0:
747
+ result["errors"].append("Docker is installed but not running")
748
+ result["next_steps"] = [
749
+ "Docker is installed but not running.",
750
+ "",
751
+ "Please start Docker Desktop and run '/lean setup' again."
752
+ ]
753
+ return result
754
+ result["steps_completed"].append("Docker is running")
755
+ except Exception as e:
756
+ result["errors"].append(f"Docker check failed: {e}")
757
+ return result
758
+
759
+ # Step 2: Create workspace directory
760
+ workspace = result["workspace"]
761
+ os.makedirs(workspace, exist_ok=True)
762
+ os.makedirs(os.path.join(workspace, "data"), exist_ok=True)
763
+ os.makedirs(os.path.join(workspace, "results"), exist_ok=True)
764
+ os.makedirs(os.path.join(workspace, "algorithms"), exist_ok=True)
765
+ result["steps_completed"].append(f"Created workspace at {workspace}")
766
+
767
+ # Step 3: Pull LEAN Docker image
768
+ result["steps_completed"].append("Pulling LEAN Docker image (this may take a few minutes)...")
769
+ try:
770
+ pull_result = subprocess.run(
771
+ ["docker", "pull", "quantconnect/lean:latest"],
772
+ capture_output=True,
773
+ text=True,
774
+ timeout=600 # 10 minute timeout
775
+ )
776
+ if pull_result.returncode == 0:
777
+ result["steps_completed"].append("LEAN Docker image pulled successfully")
778
+ else:
779
+ result["errors"].append(f"Failed to pull LEAN image: {pull_result.stderr}")
780
+ return result
781
+ except subprocess.TimeoutExpired:
782
+ result["errors"].append("Docker pull timed out - please try again")
783
+ return result
784
+ except Exception as e:
785
+ result["errors"].append(f"Docker pull failed: {e}")
786
+ return result
787
+
788
+ # Step 4: Create lean.json config
789
+ config = {
790
+ "data-folder": os.path.join(workspace, "data"),
791
+ "results-destination-folder": os.path.join(workspace, "results"),
792
+ "algorithm-location": os.path.join(workspace, "algorithms"),
793
+ "debugging-method": "LocalCmdline",
794
+ "log-handler": "ConsoleLogHandler"
795
+ }
796
+
797
+ config_path = os.path.join(workspace, "lean.json")
798
+ with open(config_path, 'w') as f:
799
+ json.dump(config, f, indent=2)
800
+ result["steps_completed"].append("Created lean.json config")
801
+
802
+ # Step 5: Download sample data (optional but helpful)
803
+ result["steps_completed"].append("LEAN Engine setup complete!")
804
+
805
+ result["success"] = True
806
+ result["next_steps"] = [
807
+ "LEAN Engine is ready!",
808
+ "",
809
+ "To run a backtest:",
810
+ " /lean run AAPL sma_crossover",
811
+ "",
812
+ "Available strategies:",
813
+ " sma_crossover, rsi_mean_reversion, macd_momentum,",
814
+ " bollinger_bands, dual_momentum, breakout",
815
+ "",
816
+ f"Algorithms are saved to: {os.path.join(workspace, 'algorithms')}",
817
+ f"Results are saved to: {os.path.join(workspace, 'results')}"
818
+ ]
819
+
820
+ return result
821
+
822
+
823
+ def run_lean_backtest(
824
+ symbol: str,
825
+ strategy: str = "sma_crossover",
826
+ initial_capital: float = 100000,
827
+ start_date: Optional[str] = None,
828
+ end_date: Optional[str] = None,
829
+ **strategy_params
830
+ ) -> dict[str, Any]:
831
+ """Run a comprehensive backtest with detailed metrics and charts.
832
+
833
+ Args:
834
+ symbol: Stock ticker symbol
835
+ strategy: Strategy name
836
+ initial_capital: Starting capital
837
+ start_date: Start date (YYYY-MM-DD)
838
+ end_date: End date (YYYY-MM-DD)
839
+
840
+ Returns:
841
+ Comprehensive backtest results
842
+ """
843
+ result = {
844
+ "symbol": symbol.upper(),
845
+ "strategy": strategy,
846
+ "status": "running",
847
+ "steps": [],
848
+ "metrics": {},
849
+ "charts": {}
850
+ }
851
+
852
+ workspace = os.path.expanduser("~/sigma_lean")
853
+ algorithms_dir = os.path.join(workspace, "algorithms")
854
+ results_dir = os.path.join(workspace, "results")
855
+
856
+ # Ensure directories exist
857
+ os.makedirs(algorithms_dir, exist_ok=True)
858
+ os.makedirs(results_dir, exist_ok=True)
859
+
860
+ # Generate the LEAN algorithm
861
+ result["steps"].append(f"Generating {strategy} algorithm for {symbol.upper()}...")
862
+
863
+ algo_result = generate_lean_algorithm(
864
+ symbol=symbol,
865
+ strategy=strategy,
866
+ initial_capital=initial_capital,
867
+ start_date=start_date,
868
+ end_date=end_date,
869
+ output_dir=algorithms_dir,
870
+ **strategy_params
871
+ )
872
+
873
+ if not algo_result.get("success"):
874
+ result["status"] = "error"
875
+ result["error"] = f"Failed to generate algorithm: {algo_result.get('error', 'Unknown error')}"
876
+ return result
877
+
878
+ algo_file = algo_result["algorithm_file"]
879
+ algo_name = os.path.basename(algo_file)
880
+ result["algorithm_file"] = algo_file
881
+ result["steps"].append(f"Algorithm saved: {algo_name}")
882
+
883
+ # Run comprehensive Python backtest
884
+ result["steps"].append("Running comprehensive backtest analysis...")
885
+
886
+ try:
887
+ backtest_results = run_simple_backtest(
888
+ symbol=symbol,
889
+ strategy=strategy,
890
+ initial_capital=initial_capital,
891
+ start_date=start_date,
892
+ end_date=end_date
893
+ )
894
+
895
+ if backtest_results.get("success"):
896
+ result["status"] = "success"
897
+ result["steps"].append("Backtest completed successfully!")
898
+ result["metrics"] = backtest_results.get("metrics", {})
899
+ result["trades"] = backtest_results.get("trades", [])
900
+ result["monthly_returns"] = backtest_results.get("monthly_returns", [])
901
+ result["charts"] = backtest_results.get("charts", {})
902
+ else:
903
+ result["status"] = "error"
904
+ result["error"] = backtest_results.get("error", "Backtest failed")
905
+
906
+ except Exception as e:
907
+ result["status"] = "error"
908
+ result["error"] = str(e)
909
+
910
+ # Provide QuantConnect instructions for institutional-grade backtest
911
+ result["quantconnect_instructions"] = [
912
+ "For INSTITUTIONAL-GRADE backtesting with full market data:",
913
+ "1. Go to https://www.quantconnect.com (FREE account)",
914
+ "2. Click 'Algorithm Lab' -> 'Create New Algorithm'",
915
+ f"3. Paste the code from: {algo_file}",
916
+ "4. Click 'Backtest' for institutional data!"
917
+ ]
918
+
919
+ result["algorithm_ready"] = True
920
+
921
+ return result
922
+
923
+
924
+ def run_simple_backtest(
925
+ symbol: str,
926
+ strategy: str = "sma_crossover",
927
+ initial_capital: float = 100000,
928
+ start_date: Optional[str] = None,
929
+ end_date: Optional[str] = None
930
+ ) -> dict[str, Any]:
931
+ """Run a comprehensive Python backtest using yfinance data.
932
+
933
+ This provides institutional-quality results with detailed metrics,
934
+ charts, and analysis. For even more accurate results, use QuantConnect.
935
+ """
936
+ import yfinance as yf
937
+ import numpy as np
938
+ import plotext as plt
939
+ from datetime import datetime
940
+
941
+ result = {
942
+ "success": False,
943
+ "symbol": symbol.upper(),
944
+ "strategy": strategy,
945
+ "metrics": {},
946
+ "trades": [],
947
+ "equity_curve": [],
948
+ "monthly_returns": [],
949
+ "charts": {}
950
+ }
951
+
952
+ # Get historical data
953
+ ticker = yf.Ticker(symbol.upper())
954
+
955
+ if start_date and end_date:
956
+ hist = ticker.history(start=start_date, end=end_date)
957
+ else:
958
+ hist = ticker.history(period="2y")
959
+
960
+ if hist.empty or len(hist) < 50:
961
+ result["error"] = f"Insufficient data for {symbol}"
962
+ return result
963
+
964
+ closes = hist["Close"].values.astype(float)
965
+ opens = hist["Open"].values.astype(float)
966
+ highs = hist["High"].values.astype(float)
967
+ lows = hist["Low"].values.astype(float)
968
+ volumes = hist["Volume"].values.astype(float)
969
+ dates = hist.index
970
+
971
+ # Initialize tracking
972
+ cash = initial_capital
973
+ shares = 0
974
+ position = None
975
+ trades = []
976
+ equity_curve = []
977
+ daily_returns = []
978
+
979
+ # Strategy execution
980
+ signals = _generate_signals(closes, strategy)
981
+
982
+ prev_equity = initial_capital
983
+ for i in range(len(closes)):
984
+ price = closes[i]
985
+ date = dates[i]
986
+ signal = signals[i] if i < len(signals) else 0
987
+
988
+ # Calculate current equity
989
+ equity = cash + (shares * price if shares > 0 else 0)
990
+ equity_curve.append({
991
+ "date": str(date.date()),
992
+ "equity": equity,
993
+ "price": price
994
+ })
995
+
996
+ # Track daily returns
997
+ if prev_equity > 0:
998
+ daily_ret = (equity - prev_equity) / prev_equity
999
+ daily_returns.append(daily_ret)
1000
+ prev_equity = equity
1001
+
1002
+ # Execute signals
1003
+ if signal == 1 and position is None and cash > 0: # BUY
1004
+ shares = int(cash * 0.95 / price)
1005
+ if shares > 0:
1006
+ cost = shares * price
1007
+ commission = cost * 0.001 # 0.1% commission
1008
+ cash -= (cost + commission)
1009
+ position = 'long'
1010
+ entry_price = price
1011
+ entry_date = date
1012
+ trades.append({
1013
+ "date": str(date.date()),
1014
+ "action": "BUY",
1015
+ "price": round(price, 2),
1016
+ "shares": shares,
1017
+ "value": round(cost, 2),
1018
+ "commission": round(commission, 2)
1019
+ })
1020
+
1021
+ elif signal == -1 and position == 'long' and shares > 0: # SELL
1022
+ proceeds = shares * price
1023
+ commission = proceeds * 0.001
1024
+ cash += (proceeds - commission)
1025
+
1026
+ # Calculate trade P&L
1027
+ pnl = (price - entry_price) * shares - (commission * 2)
1028
+ pnl_pct = ((price - entry_price) / entry_price) * 100
1029
+ holding_days = (date - entry_date).days
1030
+
1031
+ trades.append({
1032
+ "date": str(date.date()),
1033
+ "action": "SELL",
1034
+ "price": round(price, 2),
1035
+ "shares": shares,
1036
+ "value": round(proceeds, 2),
1037
+ "commission": round(commission, 2),
1038
+ "pnl": round(pnl, 2),
1039
+ "pnl_pct": round(pnl_pct, 2),
1040
+ "holding_days": holding_days
1041
+ })
1042
+ shares = 0
1043
+ position = None
1044
+
1045
+ # Close any open position at end
1046
+ if position == 'long' and shares > 0:
1047
+ final_price = closes[-1]
1048
+ proceeds = shares * final_price
1049
+ cash += proceeds
1050
+ trades.append({
1051
+ "date": str(dates[-1].date()),
1052
+ "action": "SELL (Close)",
1053
+ "price": round(final_price, 2),
1054
+ "shares": shares,
1055
+ "value": round(proceeds, 2)
1056
+ })
1057
+ shares = 0
1058
+
1059
+ # Calculate comprehensive metrics
1060
+ final_equity = cash
1061
+ total_return = ((final_equity - initial_capital) / initial_capital) * 100
1062
+
1063
+ # Buy and hold comparison
1064
+ bh_shares = int(initial_capital * 0.95 / closes[0])
1065
+ bh_final = (initial_capital - bh_shares * closes[0]) + bh_shares * closes[-1]
1066
+ bh_return = ((bh_final - initial_capital) / initial_capital) * 100
1067
+
1068
+ # Trade statistics
1069
+ buy_trades = [t for t in trades if t.get("action") == "BUY"]
1070
+ sell_trades = [t for t in trades if "pnl" in t]
1071
+
1072
+ wins = [t for t in sell_trades if t.get("pnl", 0) > 0]
1073
+ losses = [t for t in sell_trades if t.get("pnl", 0) <= 0]
1074
+
1075
+ win_rate = (len(wins) / len(sell_trades) * 100) if sell_trades else 0
1076
+ avg_win = np.mean([t["pnl"] for t in wins]) if wins else 0
1077
+ avg_loss = np.mean([t["pnl"] for t in losses]) if losses else 0
1078
+
1079
+ # Profit factor
1080
+ gross_profit = sum(t["pnl"] for t in wins) if wins else 0
1081
+ gross_loss = abs(sum(t["pnl"] for t in losses)) if losses else 1
1082
+ profit_factor = gross_profit / gross_loss if gross_loss > 0 else 0
1083
+
1084
+ # Risk metrics
1085
+ daily_returns_arr = np.array(daily_returns)
1086
+
1087
+ # Sharpe Ratio (annualized, assuming 252 trading days)
1088
+ if len(daily_returns_arr) > 1 and np.std(daily_returns_arr) > 0:
1089
+ sharpe = (np.mean(daily_returns_arr) * 252) / (np.std(daily_returns_arr) * np.sqrt(252))
1090
+ else:
1091
+ sharpe = 0
1092
+
1093
+ # Sortino Ratio (downside deviation)
1094
+ downside_returns = daily_returns_arr[daily_returns_arr < 0]
1095
+ if len(downside_returns) > 1 and np.std(downside_returns) > 0:
1096
+ sortino = (np.mean(daily_returns_arr) * 252) / (np.std(downside_returns) * np.sqrt(252))
1097
+ else:
1098
+ sortino = 0
1099
+
1100
+ # Max Drawdown
1101
+ peak = initial_capital
1102
+ max_dd = 0
1103
+ max_dd_duration = 0
1104
+ dd_start = None
1105
+ current_dd_duration = 0
1106
+
1107
+ drawdowns = []
1108
+ for i, point in enumerate(equity_curve):
1109
+ equity = point["equity"]
1110
+ if equity > peak:
1111
+ peak = equity
1112
+ if dd_start is not None:
1113
+ current_dd_duration = i - dd_start
1114
+ if current_dd_duration > max_dd_duration:
1115
+ max_dd_duration = current_dd_duration
1116
+ dd_start = None
1117
+ else:
1118
+ if dd_start is None:
1119
+ dd_start = i
1120
+
1121
+ dd = (peak - equity) / peak * 100
1122
+ drawdowns.append(dd)
1123
+ if dd > max_dd:
1124
+ max_dd = dd
1125
+
1126
+ # Calmar Ratio
1127
+ annual_return = total_return / (len(closes) / 252)
1128
+ calmar = annual_return / max_dd if max_dd > 0 else 0
1129
+
1130
+ # Average holding period
1131
+ holding_periods = [t.get("holding_days", 0) for t in sell_trades if t.get("holding_days")]
1132
+ avg_holding = np.mean(holding_periods) if holding_periods else 0
1133
+
1134
+ # Monthly returns
1135
+ monthly_returns = {}
1136
+ for point in equity_curve:
1137
+ date_str = point["date"]
1138
+ month_key = date_str[:7] # YYYY-MM
1139
+ if month_key not in monthly_returns:
1140
+ monthly_returns[month_key] = {"start": point["equity"], "end": point["equity"]}
1141
+ monthly_returns[month_key]["end"] = point["equity"]
1142
+
1143
+ monthly_pcts = []
1144
+ for month, vals in monthly_returns.items():
1145
+ if vals["start"] > 0:
1146
+ ret = ((vals["end"] - vals["start"]) / vals["start"]) * 100
1147
+ monthly_pcts.append({"month": month, "return": round(ret, 2)})
1148
+
1149
+ # Compile metrics
1150
+ result["success"] = True
1151
+ result["metrics"] = {
1152
+ "performance": {
1153
+ "initial_capital": f"${initial_capital:,.0f}",
1154
+ "final_equity": f"${final_equity:,.2f}",
1155
+ "total_return": f"{total_return:+.2f}%",
1156
+ "annual_return": f"{annual_return:+.2f}%",
1157
+ "buy_hold_return": f"{bh_return:+.2f}%",
1158
+ "alpha": f"{total_return - bh_return:+.2f}%"
1159
+ },
1160
+ "risk": {
1161
+ "max_drawdown": f"{max_dd:.2f}%",
1162
+ "sharpe_ratio": f"{sharpe:.2f}",
1163
+ "sortino_ratio": f"{sortino:.2f}",
1164
+ "calmar_ratio": f"{calmar:.2f}",
1165
+ "volatility": f"{np.std(daily_returns_arr) * np.sqrt(252) * 100:.2f}%"
1166
+ },
1167
+ "trades": {
1168
+ "total_trades": len(sell_trades),
1169
+ "win_rate": f"{win_rate:.1f}%",
1170
+ "profit_factor": f"{profit_factor:.2f}",
1171
+ "avg_win": f"${avg_win:,.2f}",
1172
+ "avg_loss": f"${avg_loss:,.2f}",
1173
+ "avg_holding_days": f"{avg_holding:.1f}"
1174
+ }
1175
+ }
1176
+ result["trades"] = trades[-15:] # Last 15 trades
1177
+ result["monthly_returns"] = monthly_pcts[-12:] # Last 12 months
1178
+
1179
+ # Generate charts
1180
+ try:
1181
+ # 1. Equity Curve Chart
1182
+ plt.clear_figure()
1183
+ plt.plotsize(120, 25)
1184
+ plt.theme("dark")
1185
+ plt.canvas_color("black")
1186
+ plt.axes_color("black")
1187
+
1188
+ equities = [p["equity"] for p in equity_curve]
1189
+ plt.plot(equities, color="cyan", marker="hd")
1190
+
1191
+ # Add buy/hold line
1192
+ bh_equity = []
1193
+ bh_val = initial_capital
1194
+ for i, price in enumerate(closes):
1195
+ if i == 0:
1196
+ bh_val = initial_capital
1197
+ else:
1198
+ bh_val = (initial_capital - bh_shares * closes[0]) + bh_shares * price
1199
+ bh_equity.append(bh_val)
1200
+ plt.plot(bh_equity, color="gray", marker="hd")
1201
+
1202
+ plt.title(f" Equity Curve: {symbol.upper()} {strategy} (cyan) vs Buy&Hold (gray)")
1203
+ plt.ylabel("Equity ($)")
1204
+
1205
+ result["charts"]["equity_curve"] = plt.build()
1206
+
1207
+ # 2. Drawdown Chart
1208
+ plt.clear_figure()
1209
+ plt.plotsize(120, 15)
1210
+ plt.theme("dark")
1211
+ plt.canvas_color("black")
1212
+ plt.axes_color("black")
1213
+
1214
+ plt.plot([-d for d in drawdowns], color="red", marker="hd", fillx=True)
1215
+ plt.title(f" Drawdown Analysis - Max: {max_dd:.2f}%")
1216
+ plt.ylabel("Drawdown (%)")
1217
+
1218
+ result["charts"]["drawdown"] = plt.build()
1219
+
1220
+ # 3. Trade Distribution
1221
+ if sell_trades:
1222
+ plt.clear_figure()
1223
+ plt.plotsize(80, 12)
1224
+ plt.theme("dark")
1225
+ plt.canvas_color("black")
1226
+ plt.axes_color("black")
1227
+
1228
+ pnls = [t.get("pnl", 0) for t in sell_trades]
1229
+ colors = ["green" if p > 0 else "red" for p in pnls]
1230
+ plt.bar(range(len(pnls)), pnls, color=colors)
1231
+ plt.title(f" Trade P&L Distribution ({len(wins)} wins, {len(losses)} losses)")
1232
+ plt.ylabel("P&L ($)")
1233
+
1234
+ result["charts"]["trade_pnl"] = plt.build()
1235
+
1236
+ # 4. Monthly Returns Bar Chart
1237
+ if monthly_pcts:
1238
+ plt.clear_figure()
1239
+ plt.plotsize(100, 12)
1240
+ plt.theme("dark")
1241
+ plt.canvas_color("black")
1242
+ plt.axes_color("black")
1243
+
1244
+ months = [m["month"][-5:] for m in monthly_pcts[-12:]] # MM format
1245
+ returns = [m["return"] for m in monthly_pcts[-12:]]
1246
+ colors = ["green" if r > 0 else "red" for r in returns]
1247
+ plt.bar(months, returns, color=colors)
1248
+ plt.title(" Monthly Returns (%)")
1249
+
1250
+ result["charts"]["monthly_returns"] = plt.build()
1251
+
1252
+ except Exception as e:
1253
+ result["chart_error"] = str(e)
1254
+
1255
+ return result
1256
+
1257
+
1258
+ def _generate_signals(closes, strategy: str) -> list:
1259
+ """Generate trading signals based on strategy.
1260
+
1261
+ Args:
1262
+ closes: numpy array of closing prices
1263
+ strategy: strategy name
1264
+
1265
+ Returns: list of signals (1=buy, -1=sell, 0=hold)
1266
+ """
1267
+ import numpy as np
1268
+
1269
+ signals = [0] * len(closes)
1270
+
1271
+ if strategy == "sma_crossover":
1272
+ fast_period = 10
1273
+ slow_period = 30
1274
+
1275
+ if len(closes) < slow_period:
1276
+ return signals
1277
+
1278
+ fast_sma = np.convolve(closes, np.ones(fast_period)/fast_period, mode='valid')
1279
+ slow_sma = np.convolve(closes, np.ones(slow_period)/slow_period, mode='valid')
1280
+
1281
+ offset = slow_period - fast_period
1282
+ fast_sma = fast_sma[offset:]
1283
+
1284
+ for i in range(1, len(slow_sma)):
1285
+ idx = i + slow_period - 1
1286
+ if idx < len(signals):
1287
+ if fast_sma[i] > slow_sma[i] and fast_sma[i-1] <= slow_sma[i-1]:
1288
+ signals[idx] = 1 # Buy
1289
+ elif fast_sma[i] < slow_sma[i] and fast_sma[i-1] >= slow_sma[i-1]:
1290
+ signals[idx] = -1 # Sell
1291
+
1292
+ elif strategy == "rsi_mean_reversion":
1293
+ rsi_period = 14
1294
+ oversold = 30
1295
+ overbought = 70
1296
+
1297
+ rsi = _calculate_rsi(closes, rsi_period)
1298
+
1299
+ for i in range(1, len(rsi)):
1300
+ if rsi[i] < oversold and rsi[i-1] >= oversold:
1301
+ signals[i] = 1 # Buy on oversold
1302
+ elif rsi[i] > overbought and rsi[i-1] <= overbought:
1303
+ signals[i] = -1 # Sell on overbought
1304
+
1305
+ elif strategy == "macd_momentum":
1306
+ fast, slow, signal = 12, 26, 9
1307
+
1308
+ ema_fast = _ema(closes, fast)
1309
+ ema_slow = _ema(closes, slow)
1310
+ macd_line = ema_fast - ema_slow
1311
+ signal_line = _ema(macd_line, signal)
1312
+ histogram = macd_line - signal_line
1313
+
1314
+ for i in range(slow + signal + 1, len(histogram)):
1315
+ if histogram[i] > 0 and histogram[i-1] <= 0:
1316
+ signals[i] = 1 # Buy
1317
+ elif histogram[i] < 0 and histogram[i-1] >= 0:
1318
+ signals[i] = -1 # Sell
1319
+
1320
+ elif strategy == "bollinger_bands":
1321
+ period = 20
1322
+ std_dev = 2
1323
+
1324
+ if len(closes) < period:
1325
+ return signals
1326
+
1327
+ for i in range(period, len(closes)):
1328
+ window = closes[i-period:i]
1329
+ middle = np.mean(window)
1330
+ std = np.std(window)
1331
+ upper = middle + std_dev * std
1332
+ lower = middle - std_dev * std
1333
+
1334
+ if closes[i] <= lower and closes[i-1] > lower:
1335
+ signals[i] = 1 # Buy at lower band
1336
+ elif closes[i] >= upper and closes[i-1] < upper:
1337
+ signals[i] = -1 # Sell at upper band
1338
+
1339
+ elif strategy == "dual_momentum":
1340
+ lookback = 60 # 3 months
1341
+
1342
+ if len(closes) < lookback:
1343
+ return signals
1344
+
1345
+ for i in range(lookback, len(closes)):
1346
+ momentum = (closes[i] - closes[i-lookback]) / closes[i-lookback]
1347
+ prev_momentum = (closes[i-1] - closes[i-lookback-1]) / closes[i-lookback-1] if i > lookback else 0
1348
+
1349
+ if momentum > 0 and prev_momentum <= 0:
1350
+ signals[i] = 1
1351
+ elif momentum < 0 and prev_momentum >= 0:
1352
+ signals[i] = -1
1353
+
1354
+ elif strategy == "breakout":
1355
+ period = 20 # Donchian channel period
1356
+
1357
+ if len(closes) < period:
1358
+ return signals
1359
+
1360
+ for i in range(period, len(closes)):
1361
+ high_channel = max(closes[i-period:i])
1362
+ low_channel = min(closes[i-period:i])
1363
+
1364
+ if closes[i] > high_channel and closes[i-1] <= high_channel:
1365
+ signals[i] = 1 # Breakout buy
1366
+ elif closes[i] < low_channel and closes[i-1] >= low_channel:
1367
+ signals[i] = -1 # Breakdown sell
1368
+
1369
+ return signals
1370
+
1371
+
1372
+ def _calculate_rsi(closes, period: int = 14):
1373
+ """Calculate RSI indicator.
1374
+
1375
+ Args:
1376
+ closes: numpy array of closing prices
1377
+ period: RSI period (default 14)
1378
+
1379
+ Returns:
1380
+ numpy array of RSI values
1381
+ """
1382
+ import numpy as np
1383
+
1384
+ deltas = np.diff(closes)
1385
+ gains = np.where(deltas > 0, deltas, 0)
1386
+ losses = np.where(deltas < 0, -deltas, 0)
1387
+
1388
+ rsi = np.zeros(len(closes))
1389
+ rsi[:period] = 50 # Neutral
1390
+
1391
+ for i in range(period, len(closes)):
1392
+ avg_gain = np.mean(gains[i-period:i])
1393
+ avg_loss = np.mean(losses[i-period:i])
1394
+ if avg_loss == 0:
1395
+ rsi[i] = 100
1396
+ else:
1397
+ rs = avg_gain / avg_loss
1398
+ rsi[i] = 100 - (100 / (1 + rs))
1399
+
1400
+ return rsi
1401
+
1402
+
1403
+ def _ema(data, period):
1404
+ """Calculate exponential moving average."""
1405
+ import numpy as np
1406
+ ema = np.zeros_like(data)
1407
+ ema[0] = data[0]
1408
+ multiplier = 2 / (period + 1)
1409
+ for i in range(1, len(data)):
1410
+ ema[i] = (data[i] * multiplier) + (ema[i-1] * (1 - multiplier))
1411
+ return ema
1412
+
1413
+
1414
+ def check_lean_status() -> dict[str, Any]:
1415
+ """Check LEAN Engine status and provide setup instructions.
1416
+
1417
+ Returns:
1418
+ Status information and setup instructions
1419
+ """
1420
+ import subprocess
1421
+ import shutil
1422
+
1423
+ status = {
1424
+ "docker_installed": False,
1425
+ "docker_running": False,
1426
+ "lean_image_pulled": False,
1427
+ "workspace_initialized": False,
1428
+ "workspace_path": os.path.expanduser("~/sigma_lean"),
1429
+ "ready": False,
1430
+ "instructions": []
1431
+ }
1432
+
1433
+ # Check Docker
1434
+ docker_path = shutil.which("docker")
1435
+ if docker_path:
1436
+ status["docker_installed"] = True
1437
+
1438
+ # Check if Docker is running
1439
+ try:
1440
+ docker_check = subprocess.run(
1441
+ ["docker", "info"],
1442
+ capture_output=True,
1443
+ text=True,
1444
+ timeout=10
1445
+ )
1446
+ if docker_check.returncode == 0:
1447
+ status["docker_running"] = True
1448
+
1449
+ # Check if LEAN image is pulled
1450
+ images_check = subprocess.run(
1451
+ ["docker", "images", "quantconnect/lean", "--format", "{{.Repository}}"],
1452
+ capture_output=True,
1453
+ text=True
1454
+ )
1455
+ if "quantconnect/lean" in images_check.stdout:
1456
+ status["lean_image_pulled"] = True
1457
+ except:
1458
+ pass
1459
+
1460
+ # Check workspace
1461
+ workspace = status["workspace_path"]
1462
+ if os.path.exists(os.path.join(workspace, "algorithms")):
1463
+ status["workspace_initialized"] = True
1464
+
1465
+ # Determine readiness
1466
+ status["ready"] = status["workspace_initialized"]
1467
+
1468
+ # Provide instructions
1469
+ if not status["docker_installed"]:
1470
+ status["instructions"] = [
1471
+ "Docker is required for full LEAN Engine.",
1472
+ "",
1473
+ "Install Docker Desktop:",
1474
+ " https://www.docker.com/products/docker-desktop",
1475
+ "",
1476
+ "Then run: /lean setup",
1477
+ "",
1478
+ "Note: You can still run quick backtests without Docker!"
1479
+ ]
1480
+ elif not status["docker_running"]:
1481
+ status["instructions"] = [
1482
+ "Docker is installed but not running.",
1483
+ "",
1484
+ "Please start Docker Desktop, then run: /lean setup",
1485
+ "",
1486
+ "Note: You can still run quick backtests without Docker!"
1487
+ ]
1488
+ elif not status["workspace_initialized"]:
1489
+ status["instructions"] = [
1490
+ "LEAN needs to be set up.",
1491
+ "",
1492
+ "Run: /lean setup"
1493
+ ]
1494
+ else:
1495
+ status["instructions"] = [
1496
+ "LEAN Engine is ready!",
1497
+ "",
1498
+ "Run a backtest:",
1499
+ " /lean run AAPL sma_crossover",
1500
+ "",
1501
+ "Available strategies:",
1502
+ " sma_crossover, rsi_mean_reversion, macd_momentum,",
1503
+ " bollinger_bands, dual_momentum, breakout"
1504
+ ]
1505
+
1506
+ return status