sigma-terminal 2.0.2__py3-none-any.whl → 3.2.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.
sigma/backtest.py ADDED
@@ -0,0 +1,372 @@
1
+ """Backtesting engine for Sigma."""
2
+
3
+ import json
4
+ from datetime import datetime, timedelta
5
+ from typing import Any, Optional
6
+
7
+ import numpy as np
8
+ import pandas as pd
9
+ import yfinance as yf
10
+
11
+
12
+ # ============================================================================
13
+ # BACKTEST STRATEGIES
14
+ # ============================================================================
15
+
16
+ STRATEGIES = {
17
+ "sma_crossover": {
18
+ "name": "SMA Crossover",
19
+ "description": "Buy when short SMA crosses above long SMA, sell when crosses below",
20
+ "params": {"short_window": 20, "long_window": 50},
21
+ },
22
+ "rsi_mean_reversion": {
23
+ "name": "RSI Mean Reversion",
24
+ "description": "Buy when RSI < 30 (oversold), sell when RSI > 70 (overbought)",
25
+ "params": {"rsi_period": 14, "oversold": 30, "overbought": 70},
26
+ },
27
+ "macd_momentum": {
28
+ "name": "MACD Momentum",
29
+ "description": "Buy on MACD bullish crossover, sell on bearish crossover",
30
+ "params": {"fast": 12, "slow": 26, "signal": 9},
31
+ },
32
+ "bollinger_bands": {
33
+ "name": "Bollinger Bands",
34
+ "description": "Buy when price touches lower band, sell at upper band",
35
+ "params": {"window": 20, "num_std": 2},
36
+ },
37
+ "dual_momentum": {
38
+ "name": "Dual Momentum",
39
+ "description": "Combines absolute and relative momentum",
40
+ "params": {"lookback": 12},
41
+ },
42
+ "breakout": {
43
+ "name": "Breakout",
44
+ "description": "Buy on new highs, sell on new lows",
45
+ "params": {"lookback": 20},
46
+ },
47
+ }
48
+
49
+
50
+ def run_backtest(
51
+ symbol: str,
52
+ strategy: str,
53
+ period: str = "2y",
54
+ initial_capital: float = 100000,
55
+ params: Optional[dict] = None,
56
+ ) -> dict:
57
+ """Run a backtest for a given strategy."""
58
+
59
+ if strategy not in STRATEGIES:
60
+ return {"error": f"Unknown strategy: {strategy}", "available": list(STRATEGIES.keys())}
61
+
62
+ try:
63
+ # Get historical data
64
+ ticker = yf.Ticker(symbol.upper())
65
+ hist = ticker.history(period=period)
66
+
67
+ if hist.empty or len(hist) < 50:
68
+ return {"error": "Insufficient data for backtest", "symbol": symbol}
69
+
70
+ # Get strategy params
71
+ strat_info = STRATEGIES[strategy]
72
+ strat_params = params or strat_info["params"]
73
+
74
+ # Generate signals based on strategy
75
+ if strategy == "sma_crossover":
76
+ signals = _sma_crossover_signals(hist, **strat_params)
77
+ elif strategy == "rsi_mean_reversion":
78
+ signals = _rsi_signals(hist, **strat_params)
79
+ elif strategy == "macd_momentum":
80
+ signals = _macd_signals(hist, **strat_params)
81
+ elif strategy == "bollinger_bands":
82
+ signals = _bollinger_signals(hist, **strat_params)
83
+ elif strategy == "dual_momentum":
84
+ signals = _momentum_signals(hist, **strat_params)
85
+ elif strategy == "breakout":
86
+ signals = _breakout_signals(hist, **strat_params)
87
+ else:
88
+ signals = pd.Series(0, index=hist.index)
89
+
90
+ # Run simulation
91
+ results = _simulate_trades(hist, signals, initial_capital)
92
+
93
+ # Calculate metrics
94
+ metrics = _calculate_metrics(results, hist, initial_capital)
95
+
96
+ return {
97
+ "symbol": symbol.upper(),
98
+ "strategy": strat_info["name"],
99
+ "strategy_description": strat_info["description"],
100
+ "period": period,
101
+ "initial_capital": initial_capital,
102
+ "parameters": strat_params,
103
+ "performance": metrics["performance"],
104
+ "risk": metrics["risk"],
105
+ "trades": metrics["trades"],
106
+ "monthly_returns": metrics["monthly_returns"],
107
+ }
108
+
109
+ except Exception as e:
110
+ return {"error": str(e), "symbol": symbol}
111
+
112
+
113
+ def _sma_crossover_signals(hist: pd.DataFrame, short_window: int, long_window: int) -> pd.Series:
114
+ """Generate SMA crossover signals."""
115
+ short_sma = hist["Close"].rolling(short_window).mean()
116
+ long_sma = hist["Close"].rolling(long_window).mean()
117
+
118
+ signals = pd.Series(0, index=hist.index)
119
+ signals[short_sma > long_sma] = 1 # Long
120
+ signals[short_sma < long_sma] = -1 # Exit
121
+
122
+ return signals
123
+
124
+
125
+ def _rsi_signals(hist: pd.DataFrame, rsi_period: int, oversold: int, overbought: int) -> pd.Series:
126
+ """Generate RSI mean reversion signals."""
127
+ delta = hist["Close"].diff()
128
+ gain = (delta.where(delta > 0, 0)).rolling(rsi_period).mean()
129
+ loss = (-delta.where(delta < 0, 0)).rolling(rsi_period).mean()
130
+ rs = gain / loss
131
+ rsi = 100 - (100 / (1 + rs))
132
+
133
+ signals = pd.Series(0, index=hist.index)
134
+ signals[rsi < oversold] = 1 # Buy oversold
135
+ signals[rsi > overbought] = -1 # Sell overbought
136
+
137
+ return signals
138
+
139
+
140
+ def _macd_signals(hist: pd.DataFrame, fast: int, slow: int, signal: int) -> pd.Series:
141
+ """Generate MACD momentum signals."""
142
+ ema_fast = hist["Close"].ewm(span=fast).mean()
143
+ ema_slow = hist["Close"].ewm(span=slow).mean()
144
+ macd = ema_fast - ema_slow
145
+ macd_signal = macd.ewm(span=signal).mean()
146
+
147
+ signals = pd.Series(0, index=hist.index)
148
+ signals[macd > macd_signal] = 1 # Bullish
149
+ signals[macd < macd_signal] = -1 # Bearish
150
+
151
+ return signals
152
+
153
+
154
+ def _bollinger_signals(hist: pd.DataFrame, window: int, num_std: int) -> pd.Series:
155
+ """Generate Bollinger Bands signals."""
156
+ mid = hist["Close"].rolling(window).mean()
157
+ std = hist["Close"].rolling(window).std()
158
+ upper = mid + (std * num_std)
159
+ lower = mid - (std * num_std)
160
+
161
+ signals = pd.Series(0, index=hist.index)
162
+ signals[hist["Close"] < lower] = 1 # Buy at lower band
163
+ signals[hist["Close"] > upper] = -1 # Sell at upper band
164
+
165
+ return signals
166
+
167
+
168
+ def _momentum_signals(hist: pd.DataFrame, lookback: int) -> pd.Series:
169
+ """Generate dual momentum signals."""
170
+ returns = hist["Close"].pct_change(lookback * 21) # Monthly lookback
171
+
172
+ signals = pd.Series(0, index=hist.index)
173
+ signals[returns > 0] = 1 # Positive momentum
174
+ signals[returns < 0] = -1 # Negative momentum
175
+
176
+ return signals
177
+
178
+
179
+ def _breakout_signals(hist: pd.DataFrame, lookback: int) -> pd.Series:
180
+ """Generate breakout signals."""
181
+ high_roll = hist["High"].rolling(lookback).max()
182
+ low_roll = hist["Low"].rolling(lookback).min()
183
+
184
+ signals = pd.Series(0, index=hist.index)
185
+ signals[hist["Close"] >= high_roll] = 1 # New high breakout
186
+ signals[hist["Close"] <= low_roll] = -1 # New low breakdown
187
+
188
+ return signals
189
+
190
+
191
+ def _simulate_trades(hist: pd.DataFrame, signals: pd.Series, initial_capital: float) -> dict:
192
+ """Simulate trades based on signals."""
193
+ capital = initial_capital
194
+ position = 0
195
+ shares = 0
196
+ trades = []
197
+ equity_curve = [initial_capital]
198
+
199
+ for i in range(1, len(hist)):
200
+ date = hist.index[i]
201
+ price = hist["Close"].iloc[i]
202
+ signal = signals.iloc[i]
203
+ prev_signal = signals.iloc[i-1]
204
+
205
+ # Buy signal
206
+ if signal == 1 and prev_signal != 1 and position == 0:
207
+ shares = int(capital * 0.95 / price) # Use 95% of capital
208
+ if shares > 0:
209
+ cost = shares * price
210
+ capital -= cost
211
+ position = 1
212
+ trades.append({
213
+ "date": str(date.date()),
214
+ "action": "BUY",
215
+ "price": round(price, 2),
216
+ "shares": shares,
217
+ "value": round(cost, 2),
218
+ })
219
+
220
+ # Sell signal
221
+ elif signal == -1 and position == 1:
222
+ proceeds = shares * price
223
+ pnl = proceeds - (trades[-1]["value"] if trades else 0)
224
+ capital += proceeds
225
+ trades.append({
226
+ "date": str(date.date()),
227
+ "action": "SELL",
228
+ "price": round(price, 2),
229
+ "shares": shares,
230
+ "value": round(proceeds, 2),
231
+ "pnl": round(pnl, 2),
232
+ })
233
+ shares = 0
234
+ position = 0
235
+
236
+ # Track equity
237
+ equity = capital + (shares * price if position == 1 else 0)
238
+ equity_curve.append(equity)
239
+
240
+ return {
241
+ "trades": trades,
242
+ "equity_curve": equity_curve,
243
+ "final_equity": equity_curve[-1],
244
+ "final_position": position,
245
+ "final_shares": shares,
246
+ }
247
+
248
+
249
+ def _calculate_metrics(results: dict, hist: pd.DataFrame, initial_capital: float) -> dict:
250
+ """Calculate backtest metrics."""
251
+ equity = np.array(results["equity_curve"])
252
+ trades = results["trades"]
253
+
254
+ # Performance metrics
255
+ total_return = (equity[-1] / initial_capital - 1) * 100
256
+
257
+ # Calculate daily returns
258
+ daily_returns = np.diff(equity) / equity[:-1]
259
+
260
+ # Annualized metrics
261
+ trading_days = len(equity) - 1
262
+ years = trading_days / 252
263
+ annual_return = ((equity[-1] / initial_capital) ** (1/years) - 1) * 100 if years > 0 else 0
264
+
265
+ # Risk metrics
266
+ volatility = np.std(daily_returns) * np.sqrt(252) * 100 if len(daily_returns) > 0 else 0
267
+
268
+ # Sharpe ratio (assuming 0% risk-free rate)
269
+ sharpe = (np.mean(daily_returns) * 252) / (np.std(daily_returns) * np.sqrt(252)) if np.std(daily_returns) > 0 else 0
270
+
271
+ # Max drawdown
272
+ peak = np.maximum.accumulate(equity)
273
+ drawdown = (peak - equity) / peak
274
+ max_drawdown = np.max(drawdown) * 100
275
+
276
+ # Sortino ratio (downside deviation)
277
+ negative_returns = daily_returns[daily_returns < 0]
278
+ downside_std = np.std(negative_returns) * np.sqrt(252) if len(negative_returns) > 0 else 0
279
+ sortino = (np.mean(daily_returns) * 252) / downside_std if downside_std > 0 else 0
280
+
281
+ # Calmar ratio
282
+ calmar = annual_return / max_drawdown if max_drawdown > 0 else 0
283
+
284
+ # Trade statistics
285
+ winning_trades = [t for t in trades if t.get("action") == "SELL" and t.get("pnl", 0) > 0]
286
+ losing_trades = [t for t in trades if t.get("action") == "SELL" and t.get("pnl", 0) <= 0]
287
+
288
+ num_trades = len([t for t in trades if t.get("action") == "SELL"])
289
+ win_rate = len(winning_trades) / num_trades * 100 if num_trades > 0 else 0
290
+
291
+ avg_win = np.mean([t["pnl"] for t in winning_trades]) if winning_trades else 0
292
+ avg_loss = np.mean([t["pnl"] for t in losing_trades]) if losing_trades else 0
293
+
294
+ profit_factor = abs(sum(t["pnl"] for t in winning_trades) / sum(t["pnl"] for t in losing_trades)) if losing_trades and sum(t["pnl"] for t in losing_trades) != 0 else 0
295
+
296
+ # Buy and hold comparison
297
+ buy_hold_return = (hist["Close"].iloc[-1] / hist["Close"].iloc[0] - 1) * 100
298
+ alpha = total_return - buy_hold_return
299
+
300
+ # Monthly returns
301
+ monthly_returns = []
302
+ if len(equity) > 21:
303
+ for i in range(21, len(equity), 21):
304
+ month_return = (equity[i] / equity[i-21] - 1) * 100
305
+ month_date = hist.index[min(i, len(hist)-1)]
306
+ monthly_returns.append({
307
+ "month": month_date.strftime("%Y-%m"),
308
+ "return": round(month_return, 2),
309
+ })
310
+
311
+ return {
312
+ "performance": {
313
+ "initial_capital": f"${initial_capital:,.0f}",
314
+ "final_equity": f"${results['final_equity']:,.0f}",
315
+ "total_return": f"{total_return:.2f}%",
316
+ "annual_return": f"{annual_return:.2f}%",
317
+ "buy_hold_return": f"{buy_hold_return:.2f}%",
318
+ "alpha": f"{alpha:.2f}%",
319
+ },
320
+ "risk": {
321
+ "volatility": f"{volatility:.2f}%",
322
+ "max_drawdown": f"{max_drawdown:.2f}%",
323
+ "sharpe_ratio": f"{sharpe:.2f}",
324
+ "sortino_ratio": f"{sortino:.2f}",
325
+ "calmar_ratio": f"{calmar:.2f}",
326
+ },
327
+ "trades": {
328
+ "total_trades": num_trades,
329
+ "win_rate": f"{win_rate:.1f}%",
330
+ "profit_factor": f"{profit_factor:.2f}",
331
+ "avg_win": f"${avg_win:,.0f}",
332
+ "avg_loss": f"${avg_loss:,.0f}",
333
+ "trade_list": trades[-10:], # Last 10 trades
334
+ },
335
+ "monthly_returns": monthly_returns[-12:], # Last 12 months
336
+ }
337
+
338
+
339
+ def get_available_strategies() -> dict:
340
+ """Get list of available strategies."""
341
+ return {
342
+ name: {
343
+ "name": info["name"],
344
+ "description": info["description"],
345
+ "default_params": info["params"],
346
+ }
347
+ for name, info in STRATEGIES.items()
348
+ }
349
+
350
+
351
+ # Tool definition for LLM
352
+ BACKTEST_TOOL = {
353
+ "type": "function",
354
+ "function": {
355
+ "name": "run_backtest",
356
+ "description": "Run a backtest simulation with a trading strategy",
357
+ "parameters": {
358
+ "type": "object",
359
+ "properties": {
360
+ "symbol": {"type": "string", "description": "Stock ticker symbol"},
361
+ "strategy": {
362
+ "type": "string",
363
+ "enum": list(STRATEGIES.keys()),
364
+ "description": "Trading strategy to test"
365
+ },
366
+ "period": {"type": "string", "description": "Backtest period (e.g., 2y)", "default": "2y"},
367
+ "initial_capital": {"type": "number", "description": "Starting capital", "default": 100000},
368
+ },
369
+ "required": ["symbol", "strategy"]
370
+ }
371
+ }
372
+ }