sigma-terminal 2.0.1__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/__init__.py +182 -6
- sigma/__main__.py +2 -2
- sigma/analytics/__init__.py +636 -0
- sigma/app.py +563 -898
- sigma/backtest.py +372 -0
- sigma/charts.py +407 -0
- sigma/cli.py +434 -0
- sigma/comparison.py +611 -0
- sigma/config.py +195 -0
- sigma/core/__init__.py +4 -17
- sigma/core/engine.py +493 -0
- sigma/core/intent.py +595 -0
- sigma/core/models.py +516 -125
- sigma/data/__init__.py +681 -0
- sigma/data/models.py +130 -0
- sigma/llm.py +401 -0
- sigma/monitoring.py +666 -0
- sigma/portfolio.py +697 -0
- sigma/reporting.py +658 -0
- sigma/robustness.py +675 -0
- sigma/setup.py +305 -402
- sigma/strategy.py +753 -0
- sigma/tools/backtest.py +23 -5
- sigma/tools.py +617 -0
- sigma/visualization.py +766 -0
- sigma_terminal-3.2.0.dist-info/METADATA +298 -0
- sigma_terminal-3.2.0.dist-info/RECORD +30 -0
- sigma_terminal-3.2.0.dist-info/entry_points.txt +6 -0
- sigma_terminal-3.2.0.dist-info/licenses/LICENSE +25 -0
- sigma/core/agent.py +0 -205
- sigma/core/config.py +0 -119
- sigma/core/llm.py +0 -794
- sigma/tools/__init__.py +0 -5
- sigma/tools/charts.py +0 -400
- sigma/tools/financial.py +0 -1457
- sigma/ui/__init__.py +0 -1
- sigma_terminal-2.0.1.dist-info/METADATA +0 -222
- sigma_terminal-2.0.1.dist-info/RECORD +0 -19
- sigma_terminal-2.0.1.dist-info/entry_points.txt +0 -2
- sigma_terminal-2.0.1.dist-info/licenses/LICENSE +0 -42
- {sigma_terminal-2.0.1.dist-info → sigma_terminal-3.2.0.dist-info}/WHEEL +0 -0
sigma/monitoring.py
ADDED
|
@@ -0,0 +1,666 @@
|
|
|
1
|
+
"""Monitoring system - Watchlists, alerts, drift detection."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
from datetime import datetime, date, timedelta
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Callable, Dict, List, Optional, Union
|
|
9
|
+
from enum import Enum
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
import pandas as pd
|
|
13
|
+
from pydantic import BaseModel, Field
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# ============================================================================
|
|
17
|
+
# DATA MODELS
|
|
18
|
+
# ============================================================================
|
|
19
|
+
|
|
20
|
+
class AlertType(str, Enum):
|
|
21
|
+
"""Types of alerts."""
|
|
22
|
+
PRICE_ABOVE = "price_above"
|
|
23
|
+
PRICE_BELOW = "price_below"
|
|
24
|
+
PERCENT_CHANGE = "percent_change"
|
|
25
|
+
VOLUME_SPIKE = "volume_spike"
|
|
26
|
+
VOLATILITY_SPIKE = "volatility_spike"
|
|
27
|
+
MOVING_AVERAGE_CROSS = "ma_cross"
|
|
28
|
+
RSI_OVERSOLD = "rsi_oversold"
|
|
29
|
+
RSI_OVERBOUGHT = "rsi_overbought"
|
|
30
|
+
DRAWDOWN = "drawdown"
|
|
31
|
+
NEW_HIGH = "new_high"
|
|
32
|
+
NEW_LOW = "new_low"
|
|
33
|
+
CORRELATION_BREAK = "correlation_break"
|
|
34
|
+
DRIFT_DETECTED = "drift_detected"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class AlertPriority(str, Enum):
|
|
38
|
+
"""Alert priority levels."""
|
|
39
|
+
LOW = "low"
|
|
40
|
+
MEDIUM = "medium"
|
|
41
|
+
HIGH = "high"
|
|
42
|
+
CRITICAL = "critical"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Alert(BaseModel):
|
|
46
|
+
"""Alert configuration."""
|
|
47
|
+
|
|
48
|
+
id: str = Field(default_factory=lambda: datetime.now().strftime("%Y%m%d%H%M%S%f"))
|
|
49
|
+
symbol: str
|
|
50
|
+
alert_type: AlertType
|
|
51
|
+
threshold: float
|
|
52
|
+
priority: AlertPriority = AlertPriority.MEDIUM
|
|
53
|
+
enabled: bool = True
|
|
54
|
+
created_at: datetime = Field(default_factory=datetime.now)
|
|
55
|
+
last_triggered: Optional[datetime] = None
|
|
56
|
+
trigger_count: int = 0
|
|
57
|
+
cooldown_minutes: int = 60 # Don't re-trigger within this period
|
|
58
|
+
message: Optional[str] = None
|
|
59
|
+
metadata: Dict[str, Any] = Field(default_factory=dict)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class AlertNotification(BaseModel):
|
|
63
|
+
"""Alert notification event."""
|
|
64
|
+
|
|
65
|
+
alert_id: str
|
|
66
|
+
symbol: str
|
|
67
|
+
alert_type: AlertType
|
|
68
|
+
priority: AlertPriority
|
|
69
|
+
message: str
|
|
70
|
+
current_value: float
|
|
71
|
+
threshold: float
|
|
72
|
+
triggered_at: datetime = Field(default_factory=datetime.now)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class WatchlistItem(BaseModel):
|
|
76
|
+
"""Item in a watchlist."""
|
|
77
|
+
|
|
78
|
+
symbol: str
|
|
79
|
+
added_at: datetime = Field(default_factory=datetime.now)
|
|
80
|
+
notes: Optional[str] = None
|
|
81
|
+
target_price: Optional[float] = None
|
|
82
|
+
stop_price: Optional[float] = None
|
|
83
|
+
tags: List[str] = Field(default_factory=list)
|
|
84
|
+
alerts: List[str] = Field(default_factory=list) # Alert IDs
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class Watchlist(BaseModel):
|
|
88
|
+
"""A watchlist of securities."""
|
|
89
|
+
|
|
90
|
+
id: str = Field(default_factory=lambda: datetime.now().strftime("%Y%m%d%H%M%S"))
|
|
91
|
+
name: str
|
|
92
|
+
description: Optional[str] = None
|
|
93
|
+
items: List[WatchlistItem] = Field(default_factory=list)
|
|
94
|
+
created_at: datetime = Field(default_factory=datetime.now)
|
|
95
|
+
updated_at: datetime = Field(default_factory=datetime.now)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# ============================================================================
|
|
99
|
+
# ALERT ENGINE
|
|
100
|
+
# ============================================================================
|
|
101
|
+
|
|
102
|
+
class AlertEngine:
|
|
103
|
+
"""
|
|
104
|
+
Monitor markets and trigger alerts.
|
|
105
|
+
Supports price alerts, technical alerts, and custom conditions.
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
def __init__(self, storage_dir: str = None):
|
|
109
|
+
self.storage_dir = Path(storage_dir or os.path.expanduser("~/.sigma/alerts"))
|
|
110
|
+
self.storage_dir.mkdir(parents=True, exist_ok=True)
|
|
111
|
+
self.alerts: Dict[str, Alert] = {}
|
|
112
|
+
self.notification_handlers: List[Callable[[AlertNotification], None]] = []
|
|
113
|
+
self._load_alerts()
|
|
114
|
+
|
|
115
|
+
def _load_alerts(self):
|
|
116
|
+
"""Load alerts from storage."""
|
|
117
|
+
|
|
118
|
+
alerts_file = self.storage_dir / "alerts.json"
|
|
119
|
+
if alerts_file.exists():
|
|
120
|
+
data = json.loads(alerts_file.read_text())
|
|
121
|
+
for alert_data in data:
|
|
122
|
+
alert = Alert(**alert_data)
|
|
123
|
+
self.alerts[alert.id] = alert
|
|
124
|
+
|
|
125
|
+
def _save_alerts(self):
|
|
126
|
+
"""Save alerts to storage."""
|
|
127
|
+
|
|
128
|
+
alerts_file = self.storage_dir / "alerts.json"
|
|
129
|
+
data = [alert.model_dump() for alert in self.alerts.values()]
|
|
130
|
+
alerts_file.write_text(json.dumps(data, indent=2, default=str))
|
|
131
|
+
|
|
132
|
+
def add_alert(self, alert: Alert) -> str:
|
|
133
|
+
"""Add a new alert."""
|
|
134
|
+
|
|
135
|
+
self.alerts[alert.id] = alert
|
|
136
|
+
self._save_alerts()
|
|
137
|
+
return alert.id
|
|
138
|
+
|
|
139
|
+
def remove_alert(self, alert_id: str) -> bool:
|
|
140
|
+
"""Remove an alert."""
|
|
141
|
+
|
|
142
|
+
if alert_id in self.alerts:
|
|
143
|
+
del self.alerts[alert_id]
|
|
144
|
+
self._save_alerts()
|
|
145
|
+
return True
|
|
146
|
+
return False
|
|
147
|
+
|
|
148
|
+
def enable_alert(self, alert_id: str, enabled: bool = True):
|
|
149
|
+
"""Enable or disable an alert."""
|
|
150
|
+
|
|
151
|
+
if alert_id in self.alerts:
|
|
152
|
+
self.alerts[alert_id].enabled = enabled
|
|
153
|
+
self._save_alerts()
|
|
154
|
+
|
|
155
|
+
def add_notification_handler(self, handler: Callable[[AlertNotification], None]):
|
|
156
|
+
"""Add a notification handler."""
|
|
157
|
+
|
|
158
|
+
self.notification_handlers.append(handler)
|
|
159
|
+
|
|
160
|
+
async def check_alerts(self, market_data: Dict[str, Dict[str, Any]]) -> List[AlertNotification]:
|
|
161
|
+
"""
|
|
162
|
+
Check all alerts against current market data.
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
market_data: Dict of symbol -> current data
|
|
166
|
+
|
|
167
|
+
Returns:
|
|
168
|
+
List of triggered notifications
|
|
169
|
+
"""
|
|
170
|
+
|
|
171
|
+
notifications = []
|
|
172
|
+
now = datetime.now()
|
|
173
|
+
|
|
174
|
+
for alert_id, alert in self.alerts.items():
|
|
175
|
+
if not alert.enabled:
|
|
176
|
+
continue
|
|
177
|
+
|
|
178
|
+
# Check cooldown
|
|
179
|
+
if alert.last_triggered:
|
|
180
|
+
cooldown_end = alert.last_triggered + timedelta(minutes=alert.cooldown_minutes)
|
|
181
|
+
if now < cooldown_end:
|
|
182
|
+
continue
|
|
183
|
+
|
|
184
|
+
# Get market data for symbol
|
|
185
|
+
data = market_data.get(alert.symbol)
|
|
186
|
+
if not data:
|
|
187
|
+
continue
|
|
188
|
+
|
|
189
|
+
# Check alert condition
|
|
190
|
+
triggered, current_value, message = self._check_alert_condition(alert, data)
|
|
191
|
+
|
|
192
|
+
if triggered:
|
|
193
|
+
# Create notification
|
|
194
|
+
notification = AlertNotification(
|
|
195
|
+
alert_id=alert.id,
|
|
196
|
+
symbol=alert.symbol,
|
|
197
|
+
alert_type=alert.alert_type,
|
|
198
|
+
priority=alert.priority,
|
|
199
|
+
message=message or alert.message or f"{alert.alert_type.value} triggered",
|
|
200
|
+
current_value=current_value,
|
|
201
|
+
threshold=alert.threshold,
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
notifications.append(notification)
|
|
205
|
+
|
|
206
|
+
# Update alert
|
|
207
|
+
alert.last_triggered = now
|
|
208
|
+
alert.trigger_count += 1
|
|
209
|
+
|
|
210
|
+
# Notify handlers
|
|
211
|
+
for handler in self.notification_handlers:
|
|
212
|
+
try:
|
|
213
|
+
handler(notification)
|
|
214
|
+
except Exception as e:
|
|
215
|
+
print(f"Notification handler error: {e}")
|
|
216
|
+
|
|
217
|
+
self._save_alerts()
|
|
218
|
+
return notifications
|
|
219
|
+
|
|
220
|
+
def _check_alert_condition(
|
|
221
|
+
self,
|
|
222
|
+
alert: Alert,
|
|
223
|
+
data: Dict[str, Any],
|
|
224
|
+
) -> tuple[bool, float, str]:
|
|
225
|
+
"""Check if alert condition is met."""
|
|
226
|
+
|
|
227
|
+
current_price = data.get("price", data.get("close", 0))
|
|
228
|
+
|
|
229
|
+
if alert.alert_type == AlertType.PRICE_ABOVE:
|
|
230
|
+
if current_price > alert.threshold:
|
|
231
|
+
return True, current_price, f"{alert.symbol} above ${alert.threshold:.2f} (${current_price:.2f})"
|
|
232
|
+
|
|
233
|
+
elif alert.alert_type == AlertType.PRICE_BELOW:
|
|
234
|
+
if current_price < alert.threshold:
|
|
235
|
+
return True, current_price, f"{alert.symbol} below ${alert.threshold:.2f} (${current_price:.2f})"
|
|
236
|
+
|
|
237
|
+
elif alert.alert_type == AlertType.PERCENT_CHANGE:
|
|
238
|
+
change = data.get("change_percent", 0)
|
|
239
|
+
if abs(change) > alert.threshold:
|
|
240
|
+
direction = "up" if change > 0 else "down"
|
|
241
|
+
return True, change, f"{alert.symbol} {direction} {abs(change):.1f}%"
|
|
242
|
+
|
|
243
|
+
elif alert.alert_type == AlertType.VOLUME_SPIKE:
|
|
244
|
+
volume = data.get("volume", 0)
|
|
245
|
+
avg_volume = data.get("avg_volume", volume)
|
|
246
|
+
if avg_volume > 0 and volume / avg_volume > alert.threshold:
|
|
247
|
+
multiple = volume / avg_volume
|
|
248
|
+
return True, multiple, f"{alert.symbol} volume {multiple:.1f}x average"
|
|
249
|
+
|
|
250
|
+
elif alert.alert_type == AlertType.VOLATILITY_SPIKE:
|
|
251
|
+
volatility = data.get("volatility", 0)
|
|
252
|
+
if volatility > alert.threshold:
|
|
253
|
+
return True, volatility, f"{alert.symbol} volatility at {volatility:.1%}"
|
|
254
|
+
|
|
255
|
+
elif alert.alert_type == AlertType.RSI_OVERSOLD:
|
|
256
|
+
rsi = data.get("rsi", 50)
|
|
257
|
+
if rsi < alert.threshold:
|
|
258
|
+
return True, rsi, f"{alert.symbol} RSI oversold at {rsi:.1f}"
|
|
259
|
+
|
|
260
|
+
elif alert.alert_type == AlertType.RSI_OVERBOUGHT:
|
|
261
|
+
rsi = data.get("rsi", 50)
|
|
262
|
+
if rsi > alert.threshold:
|
|
263
|
+
return True, rsi, f"{alert.symbol} RSI overbought at {rsi:.1f}"
|
|
264
|
+
|
|
265
|
+
elif alert.alert_type == AlertType.DRAWDOWN:
|
|
266
|
+
drawdown = data.get("drawdown", 0)
|
|
267
|
+
if abs(drawdown) > alert.threshold:
|
|
268
|
+
return True, drawdown, f"{alert.symbol} drawdown at {abs(drawdown):.1%}"
|
|
269
|
+
|
|
270
|
+
elif alert.alert_type == AlertType.NEW_HIGH:
|
|
271
|
+
high_52w = data.get("52w_high", float('inf'))
|
|
272
|
+
if current_price >= high_52w * (1 - alert.threshold / 100):
|
|
273
|
+
return True, current_price, f"{alert.symbol} near 52-week high"
|
|
274
|
+
|
|
275
|
+
elif alert.alert_type == AlertType.NEW_LOW:
|
|
276
|
+
low_52w = data.get("52w_low", 0)
|
|
277
|
+
if current_price <= low_52w * (1 + alert.threshold / 100):
|
|
278
|
+
return True, current_price, f"{alert.symbol} near 52-week low"
|
|
279
|
+
|
|
280
|
+
return False, 0, ""
|
|
281
|
+
|
|
282
|
+
def get_alerts_for_symbol(self, symbol: str) -> List[Alert]:
|
|
283
|
+
"""Get all alerts for a symbol."""
|
|
284
|
+
|
|
285
|
+
return [a for a in self.alerts.values() if a.symbol == symbol]
|
|
286
|
+
|
|
287
|
+
def get_active_alerts(self) -> List[Alert]:
|
|
288
|
+
"""Get all active alerts."""
|
|
289
|
+
|
|
290
|
+
return [a for a in self.alerts.values() if a.enabled]
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
# ============================================================================
|
|
294
|
+
# WATCHLIST MANAGER
|
|
295
|
+
# ============================================================================
|
|
296
|
+
|
|
297
|
+
class WatchlistManager:
|
|
298
|
+
"""Manage watchlists."""
|
|
299
|
+
|
|
300
|
+
def __init__(self, storage_dir: str = None):
|
|
301
|
+
self.storage_dir = Path(storage_dir or os.path.expanduser("~/.sigma/watchlists"))
|
|
302
|
+
self.storage_dir.mkdir(parents=True, exist_ok=True)
|
|
303
|
+
self.watchlists: Dict[str, Watchlist] = {}
|
|
304
|
+
self._load_watchlists()
|
|
305
|
+
|
|
306
|
+
def _load_watchlists(self):
|
|
307
|
+
"""Load watchlists from storage."""
|
|
308
|
+
|
|
309
|
+
for file in self.storage_dir.glob("*.json"):
|
|
310
|
+
try:
|
|
311
|
+
data = json.loads(file.read_text())
|
|
312
|
+
watchlist = Watchlist(**data)
|
|
313
|
+
self.watchlists[watchlist.id] = watchlist
|
|
314
|
+
except Exception as e:
|
|
315
|
+
print(f"Error loading watchlist {file}: {e}")
|
|
316
|
+
|
|
317
|
+
def _save_watchlist(self, watchlist: Watchlist):
|
|
318
|
+
"""Save a watchlist to storage."""
|
|
319
|
+
|
|
320
|
+
filepath = self.storage_dir / f"{watchlist.id}.json"
|
|
321
|
+
filepath.write_text(watchlist.model_dump_json(indent=2))
|
|
322
|
+
|
|
323
|
+
def create_watchlist(self, name: str, description: str = None) -> Watchlist:
|
|
324
|
+
"""Create a new watchlist."""
|
|
325
|
+
|
|
326
|
+
watchlist = Watchlist(name=name, description=description)
|
|
327
|
+
self.watchlists[watchlist.id] = watchlist
|
|
328
|
+
self._save_watchlist(watchlist)
|
|
329
|
+
return watchlist
|
|
330
|
+
|
|
331
|
+
def delete_watchlist(self, watchlist_id: str) -> bool:
|
|
332
|
+
"""Delete a watchlist."""
|
|
333
|
+
|
|
334
|
+
if watchlist_id in self.watchlists:
|
|
335
|
+
del self.watchlists[watchlist_id]
|
|
336
|
+
filepath = self.storage_dir / f"{watchlist_id}.json"
|
|
337
|
+
if filepath.exists():
|
|
338
|
+
filepath.unlink()
|
|
339
|
+
return True
|
|
340
|
+
return False
|
|
341
|
+
|
|
342
|
+
def add_to_watchlist(
|
|
343
|
+
self,
|
|
344
|
+
watchlist_id: str,
|
|
345
|
+
symbol: str,
|
|
346
|
+
notes: str = None,
|
|
347
|
+
target_price: float = None,
|
|
348
|
+
stop_price: float = None,
|
|
349
|
+
tags: List[str] = None,
|
|
350
|
+
) -> bool:
|
|
351
|
+
"""Add a symbol to a watchlist."""
|
|
352
|
+
|
|
353
|
+
if watchlist_id not in self.watchlists:
|
|
354
|
+
return False
|
|
355
|
+
|
|
356
|
+
watchlist = self.watchlists[watchlist_id]
|
|
357
|
+
|
|
358
|
+
# Check if already in watchlist
|
|
359
|
+
if any(item.symbol == symbol for item in watchlist.items):
|
|
360
|
+
return False
|
|
361
|
+
|
|
362
|
+
item = WatchlistItem(
|
|
363
|
+
symbol=symbol,
|
|
364
|
+
notes=notes,
|
|
365
|
+
target_price=target_price,
|
|
366
|
+
stop_price=stop_price,
|
|
367
|
+
tags=tags or [],
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
watchlist.items.append(item)
|
|
371
|
+
watchlist.updated_at = datetime.now()
|
|
372
|
+
self._save_watchlist(watchlist)
|
|
373
|
+
|
|
374
|
+
return True
|
|
375
|
+
|
|
376
|
+
def remove_from_watchlist(self, watchlist_id: str, symbol: str) -> bool:
|
|
377
|
+
"""Remove a symbol from a watchlist."""
|
|
378
|
+
|
|
379
|
+
if watchlist_id not in self.watchlists:
|
|
380
|
+
return False
|
|
381
|
+
|
|
382
|
+
watchlist = self.watchlists[watchlist_id]
|
|
383
|
+
original_length = len(watchlist.items)
|
|
384
|
+
watchlist.items = [item for item in watchlist.items if item.symbol != symbol]
|
|
385
|
+
|
|
386
|
+
if len(watchlist.items) < original_length:
|
|
387
|
+
watchlist.updated_at = datetime.now()
|
|
388
|
+
self._save_watchlist(watchlist)
|
|
389
|
+
return True
|
|
390
|
+
|
|
391
|
+
return False
|
|
392
|
+
|
|
393
|
+
def get_watchlist(self, watchlist_id: str) -> Optional[Watchlist]:
|
|
394
|
+
"""Get a watchlist by ID."""
|
|
395
|
+
|
|
396
|
+
return self.watchlists.get(watchlist_id)
|
|
397
|
+
|
|
398
|
+
def get_all_watchlists(self) -> List[Watchlist]:
|
|
399
|
+
"""Get all watchlists."""
|
|
400
|
+
|
|
401
|
+
return list(self.watchlists.values())
|
|
402
|
+
|
|
403
|
+
def get_all_symbols(self) -> List[str]:
|
|
404
|
+
"""Get all unique symbols across all watchlists."""
|
|
405
|
+
|
|
406
|
+
symbols = set()
|
|
407
|
+
for watchlist in self.watchlists.values():
|
|
408
|
+
for item in watchlist.items:
|
|
409
|
+
symbols.add(item.symbol)
|
|
410
|
+
return list(symbols)
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
# ============================================================================
|
|
414
|
+
# DRIFT DETECTOR
|
|
415
|
+
# ============================================================================
|
|
416
|
+
|
|
417
|
+
class DriftDetector:
|
|
418
|
+
"""
|
|
419
|
+
Detect drift in portfolios and strategies.
|
|
420
|
+
Monitors for changes in:
|
|
421
|
+
- Portfolio weights
|
|
422
|
+
- Risk characteristics
|
|
423
|
+
- Return patterns
|
|
424
|
+
- Correlations
|
|
425
|
+
"""
|
|
426
|
+
|
|
427
|
+
def __init__(
|
|
428
|
+
self,
|
|
429
|
+
weight_threshold: float = 0.05,
|
|
430
|
+
volatility_threshold: float = 0.5,
|
|
431
|
+
correlation_threshold: float = 0.3,
|
|
432
|
+
):
|
|
433
|
+
self.weight_threshold = weight_threshold
|
|
434
|
+
self.volatility_threshold = volatility_threshold
|
|
435
|
+
self.correlation_threshold = correlation_threshold
|
|
436
|
+
|
|
437
|
+
def check_weight_drift(
|
|
438
|
+
self,
|
|
439
|
+
current_weights: Dict[str, float],
|
|
440
|
+
target_weights: Dict[str, float],
|
|
441
|
+
) -> Dict[str, Any]:
|
|
442
|
+
"""Check for portfolio weight drift."""
|
|
443
|
+
|
|
444
|
+
drifts = {}
|
|
445
|
+
max_drift = 0
|
|
446
|
+
|
|
447
|
+
all_assets = set(current_weights.keys()) | set(target_weights.keys())
|
|
448
|
+
|
|
449
|
+
for asset in all_assets:
|
|
450
|
+
current = current_weights.get(asset, 0)
|
|
451
|
+
target = target_weights.get(asset, 0)
|
|
452
|
+
drift = current - target
|
|
453
|
+
drifts[asset] = drift
|
|
454
|
+
max_drift = max(max_drift, abs(drift))
|
|
455
|
+
|
|
456
|
+
needs_rebalance = max_drift > self.weight_threshold
|
|
457
|
+
|
|
458
|
+
return {
|
|
459
|
+
"drifts": drifts,
|
|
460
|
+
"max_drift": max_drift,
|
|
461
|
+
"needs_rebalance": needs_rebalance,
|
|
462
|
+
"threshold": self.weight_threshold,
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
def check_volatility_drift(
|
|
466
|
+
self,
|
|
467
|
+
current_vol: float,
|
|
468
|
+
historical_vol: float,
|
|
469
|
+
) -> Dict[str, Any]:
|
|
470
|
+
"""Check for volatility regime change."""
|
|
471
|
+
|
|
472
|
+
if historical_vol == 0:
|
|
473
|
+
return {"drift_detected": False, "reason": "No historical data"}
|
|
474
|
+
|
|
475
|
+
vol_change = (current_vol - historical_vol) / historical_vol
|
|
476
|
+
drift_detected = abs(vol_change) > self.volatility_threshold
|
|
477
|
+
|
|
478
|
+
return {
|
|
479
|
+
"current_vol": current_vol,
|
|
480
|
+
"historical_vol": historical_vol,
|
|
481
|
+
"change": vol_change,
|
|
482
|
+
"drift_detected": drift_detected,
|
|
483
|
+
"direction": "up" if vol_change > 0 else "down",
|
|
484
|
+
"threshold": self.volatility_threshold,
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
def check_correlation_drift(
|
|
488
|
+
self,
|
|
489
|
+
current_corr: pd.DataFrame,
|
|
490
|
+
historical_corr: pd.DataFrame,
|
|
491
|
+
) -> Dict[str, Any]:
|
|
492
|
+
"""Check for correlation breakdown."""
|
|
493
|
+
|
|
494
|
+
# Calculate correlation changes
|
|
495
|
+
corr_diff = current_corr - historical_corr
|
|
496
|
+
|
|
497
|
+
# Find significant changes
|
|
498
|
+
significant_changes = []
|
|
499
|
+
|
|
500
|
+
for i in range(len(corr_diff.columns)):
|
|
501
|
+
for j in range(i + 1, len(corr_diff.columns)):
|
|
502
|
+
asset1 = corr_diff.columns[i]
|
|
503
|
+
asset2 = corr_diff.columns[j]
|
|
504
|
+
change = corr_diff.iloc[i, j]
|
|
505
|
+
|
|
506
|
+
if abs(change) > self.correlation_threshold:
|
|
507
|
+
significant_changes.append({
|
|
508
|
+
"asset1": asset1,
|
|
509
|
+
"asset2": asset2,
|
|
510
|
+
"change": change,
|
|
511
|
+
"current": current_corr.iloc[i, j],
|
|
512
|
+
"historical": historical_corr.iloc[i, j],
|
|
513
|
+
})
|
|
514
|
+
|
|
515
|
+
return {
|
|
516
|
+
"drift_detected": len(significant_changes) > 0,
|
|
517
|
+
"significant_changes": significant_changes,
|
|
518
|
+
"threshold": self.correlation_threshold,
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
def check_strategy_drift(
|
|
522
|
+
self,
|
|
523
|
+
recent_returns: pd.Series,
|
|
524
|
+
historical_returns: pd.Series,
|
|
525
|
+
) -> Dict[str, Any]:
|
|
526
|
+
"""Check for strategy performance drift."""
|
|
527
|
+
|
|
528
|
+
# Calculate rolling stats
|
|
529
|
+
recent_mean = recent_returns.mean() * 252
|
|
530
|
+
recent_vol = recent_returns.std() * np.sqrt(252)
|
|
531
|
+
recent_sharpe = recent_mean / recent_vol if recent_vol > 0 else 0
|
|
532
|
+
|
|
533
|
+
historical_mean = historical_returns.mean() * 252
|
|
534
|
+
historical_vol = historical_returns.std() * np.sqrt(252)
|
|
535
|
+
historical_sharpe = historical_mean / historical_vol if historical_vol > 0 else 0
|
|
536
|
+
|
|
537
|
+
# Detect drift
|
|
538
|
+
return_drift = recent_mean - historical_mean
|
|
539
|
+
vol_drift = recent_vol - historical_vol
|
|
540
|
+
sharpe_drift = recent_sharpe - historical_sharpe
|
|
541
|
+
|
|
542
|
+
# Significant if Sharpe dropped by more than 0.5
|
|
543
|
+
drift_detected = sharpe_drift < -0.5
|
|
544
|
+
|
|
545
|
+
return {
|
|
546
|
+
"drift_detected": drift_detected,
|
|
547
|
+
"metrics": {
|
|
548
|
+
"recent_return": recent_mean,
|
|
549
|
+
"historical_return": historical_mean,
|
|
550
|
+
"return_drift": return_drift,
|
|
551
|
+
"recent_vol": recent_vol,
|
|
552
|
+
"historical_vol": historical_vol,
|
|
553
|
+
"vol_drift": vol_drift,
|
|
554
|
+
"recent_sharpe": recent_sharpe,
|
|
555
|
+
"historical_sharpe": historical_sharpe,
|
|
556
|
+
"sharpe_drift": sharpe_drift,
|
|
557
|
+
},
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
# ============================================================================
|
|
562
|
+
# SCHEDULED RUNNER
|
|
563
|
+
# ============================================================================
|
|
564
|
+
|
|
565
|
+
class ScheduledRunner:
|
|
566
|
+
"""Run scheduled analysis tasks."""
|
|
567
|
+
|
|
568
|
+
def __init__(self):
|
|
569
|
+
self.tasks: Dict[str, Dict[str, Any]] = {}
|
|
570
|
+
self.running = False
|
|
571
|
+
|
|
572
|
+
def add_task(
|
|
573
|
+
self,
|
|
574
|
+
task_id: str,
|
|
575
|
+
func: Callable,
|
|
576
|
+
schedule: str, # daily, weekly, monthly
|
|
577
|
+
time: str = "09:00", # HH:MM
|
|
578
|
+
args: tuple = None,
|
|
579
|
+
kwargs: dict = None,
|
|
580
|
+
):
|
|
581
|
+
"""Add a scheduled task."""
|
|
582
|
+
|
|
583
|
+
self.tasks[task_id] = {
|
|
584
|
+
"func": func,
|
|
585
|
+
"schedule": schedule,
|
|
586
|
+
"time": time,
|
|
587
|
+
"args": args or (),
|
|
588
|
+
"kwargs": kwargs or {},
|
|
589
|
+
"last_run": None,
|
|
590
|
+
"enabled": True,
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
def remove_task(self, task_id: str):
|
|
594
|
+
"""Remove a scheduled task."""
|
|
595
|
+
|
|
596
|
+
if task_id in self.tasks:
|
|
597
|
+
del self.tasks[task_id]
|
|
598
|
+
|
|
599
|
+
def enable_task(self, task_id: str, enabled: bool = True):
|
|
600
|
+
"""Enable or disable a task."""
|
|
601
|
+
|
|
602
|
+
if task_id in self.tasks:
|
|
603
|
+
self.tasks[task_id]["enabled"] = enabled
|
|
604
|
+
|
|
605
|
+
async def run_once(self):
|
|
606
|
+
"""Check and run due tasks once."""
|
|
607
|
+
|
|
608
|
+
now = datetime.now()
|
|
609
|
+
current_time = now.strftime("%H:%M")
|
|
610
|
+
|
|
611
|
+
for task_id, task in self.tasks.items():
|
|
612
|
+
if not task["enabled"]:
|
|
613
|
+
continue
|
|
614
|
+
|
|
615
|
+
# Check if task is due
|
|
616
|
+
if self._is_task_due(task, now):
|
|
617
|
+
try:
|
|
618
|
+
result = task["func"](*task["args"], **task["kwargs"])
|
|
619
|
+
if asyncio.iscoroutine(result):
|
|
620
|
+
await result
|
|
621
|
+
task["last_run"] = now
|
|
622
|
+
except Exception as e:
|
|
623
|
+
print(f"Task {task_id} failed: {e}")
|
|
624
|
+
|
|
625
|
+
def _is_task_due(self, task: Dict[str, Any], now: datetime) -> bool:
|
|
626
|
+
"""Check if a task is due to run."""
|
|
627
|
+
|
|
628
|
+
schedule = task["schedule"]
|
|
629
|
+
scheduled_time = task["time"]
|
|
630
|
+
last_run = task["last_run"]
|
|
631
|
+
|
|
632
|
+
current_time = now.strftime("%H:%M")
|
|
633
|
+
|
|
634
|
+
# Check if time matches (within 1 minute)
|
|
635
|
+
if current_time != scheduled_time:
|
|
636
|
+
return False
|
|
637
|
+
|
|
638
|
+
# Check if already run today
|
|
639
|
+
if last_run and last_run.date() == now.date():
|
|
640
|
+
return False
|
|
641
|
+
|
|
642
|
+
# Check schedule
|
|
643
|
+
if schedule == "daily":
|
|
644
|
+
return True
|
|
645
|
+
elif schedule == "weekly":
|
|
646
|
+
# Run on Monday
|
|
647
|
+
return now.weekday() == 0
|
|
648
|
+
elif schedule == "monthly":
|
|
649
|
+
# Run on 1st of month
|
|
650
|
+
return now.day == 1
|
|
651
|
+
|
|
652
|
+
return False
|
|
653
|
+
|
|
654
|
+
async def run_continuous(self, check_interval: int = 60):
|
|
655
|
+
"""Run continuously, checking for due tasks."""
|
|
656
|
+
|
|
657
|
+
self.running = True
|
|
658
|
+
|
|
659
|
+
while self.running:
|
|
660
|
+
await self.run_once()
|
|
661
|
+
await asyncio.sleep(check_interval)
|
|
662
|
+
|
|
663
|
+
def stop(self):
|
|
664
|
+
"""Stop the continuous runner."""
|
|
665
|
+
|
|
666
|
+
self.running = False
|