sigma-terminal 2.0.2__py3-none-any.whl → 3.3.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/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