alphaforged 0.3.2__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.
Files changed (123) hide show
  1. alphaforge/__init__.py +5 -0
  2. alphaforge/alerting/__init__.py +1 -0
  3. alphaforge/alerting/channels.py +245 -0
  4. alphaforge/alerting/dispatcher.py +152 -0
  5. alphaforge/alerts/__init__.py +1 -0
  6. alphaforge/alerts/engine.py +185 -0
  7. alphaforge/alerts/notifier.py +69 -0
  8. alphaforge/analysis/__init__.py +1 -0
  9. alphaforge/analysis/consensus.py +195 -0
  10. alphaforge/analysis/scanner.py +178 -0
  11. alphaforge/analysis/stock_analyzer.py +440 -0
  12. alphaforge/api/__init__.py +3 -0
  13. alphaforge/api/contracts.py +51 -0
  14. alphaforge/backtesting/__init__.py +13 -0
  15. alphaforge/backtesting/engine.py +102 -0
  16. alphaforge/backtesting/metrics.py +225 -0
  17. alphaforge/backtesting/monte_carlo.py +132 -0
  18. alphaforge/backtesting/slippage.py +144 -0
  19. alphaforge/backtesting/walk_forward.py +238 -0
  20. alphaforge/cli.py +61 -0
  21. alphaforge/config.py +83 -0
  22. alphaforge/dashboard/__init__.py +1 -0
  23. alphaforge/dashboard/app.py +354 -0
  24. alphaforge/data/__init__.py +3 -0
  25. alphaforge/data/batch_fetcher.py +144 -0
  26. alphaforge/data/cache_store.py +217 -0
  27. alphaforge/data/event_bus.py +36 -0
  28. alphaforge/data/free_market_data.py +1380 -0
  29. alphaforge/data/universe_filter.py +130 -0
  30. alphaforge/domain/__init__.py +33 -0
  31. alphaforge/domain/models.py +168 -0
  32. alphaforge/execution/__init__.py +4 -0
  33. alphaforge/execution/alerts.py +66 -0
  34. alphaforge/execution/order_simulator.py +544 -0
  35. alphaforge/execution/risk_engine.py +91 -0
  36. alphaforge/explainability/__init__.py +1 -0
  37. alphaforge/explainability/shap_explainer.py +295 -0
  38. alphaforge/explanation/__init__.py +5 -0
  39. alphaforge/explanation/engine.py +549 -0
  40. alphaforge/explanation/llm_synthesizer.py +423 -0
  41. alphaforge/explanation/models.py +70 -0
  42. alphaforge/features/__init__.py +3 -0
  43. alphaforge/features/expanded.py +609 -0
  44. alphaforge/features/store.py +39 -0
  45. alphaforge/features/technical.py +302 -0
  46. alphaforge/fundamentals/__init__.py +5 -0
  47. alphaforge/fundamentals/dimensions.py +435 -0
  48. alphaforge/fundamentals/engine.py +337 -0
  49. alphaforge/fundamentals/models.py +60 -0
  50. alphaforge/models/__init__.py +10 -0
  51. alphaforge/models/algorithms/__init__.py +83 -0
  52. alphaforge/models/algorithms/adversarial.py +333 -0
  53. alphaforge/models/algorithms/base.py +34 -0
  54. alphaforge/models/algorithms/lstm_advanced.py +910 -0
  55. alphaforge/models/algorithms/mamba_predictor.py +325 -0
  56. alphaforge/models/algorithms/ml_models.py +379 -0
  57. alphaforge/models/algorithms/momentum.py +326 -0
  58. alphaforge/models/algorithms/pattern.py +198 -0
  59. alphaforge/models/algorithms/regime_model.py +245 -0
  60. alphaforge/models/algorithms/timesfm_predictor.py +215 -0
  61. alphaforge/models/algorithms/transparency.py +1731 -0
  62. alphaforge/models/algorithms/trend.py +187 -0
  63. alphaforge/models/algorithms/volatility.py +281 -0
  64. alphaforge/models/algorithms/volume.py +283 -0
  65. alphaforge/models/base.py +62 -0
  66. alphaforge/models/ensemble.py +78 -0
  67. alphaforge/models/regime.py +51 -0
  68. alphaforge/operator_console/__init__.py +1 -0
  69. alphaforge/operator_console/_util.py +12 -0
  70. alphaforge/operator_console/app.py +1765 -0
  71. alphaforge/operator_console/router_alerts.py +121 -0
  72. alphaforge/operator_console/router_portfolio.py +210 -0
  73. alphaforge/operator_console/router_screener.py +150 -0
  74. alphaforge/operator_console/router_stock_detail.py +143 -0
  75. alphaforge/operator_console/server.py +16 -0
  76. alphaforge/operator_console/static/app.js +5057 -0
  77. alphaforge/operator_console/static/index.html +620 -0
  78. alphaforge/operator_console/static/styles.css +4802 -0
  79. alphaforge/options/__init__.py +1 -0
  80. alphaforge/options/analytics.py +302 -0
  81. alphaforge/orchestration/__init__.py +3 -0
  82. alphaforge/orchestration/control_plane.py +149 -0
  83. alphaforge/portfolio/__init__.py +1 -0
  84. alphaforge/portfolio/correlation.py +258 -0
  85. alphaforge/portfolio/optimizer.py +259 -0
  86. alphaforge/regime/__init__.py +1 -0
  87. alphaforge/regime/detector.py +330 -0
  88. alphaforge/research/__init__.py +3 -0
  89. alphaforge/research/engine.py +71 -0
  90. alphaforge/risk/__init__.py +7 -0
  91. alphaforge/risk/circuit_breaker.py +217 -0
  92. alphaforge/risk/portfolio_risk.py +256 -0
  93. alphaforge/risk/position_sizer.py +206 -0
  94. alphaforge/risk/risk_manager.py +196 -0
  95. alphaforge/runtime/__init__.py +1 -0
  96. alphaforge/runtime/demo.py +109 -0
  97. alphaforge/runtime/forecast_history.py +376 -0
  98. alphaforge/runtime/forecast_monitor.py +881 -0
  99. alphaforge/runtime/free_tier_service.py +1690 -0
  100. alphaforge/runtime/universe_monitor.py +451 -0
  101. alphaforge/screening/__init__.py +5 -0
  102. alphaforge/screening/data_quality.py +184 -0
  103. alphaforge/screening/decimal_safety.py +173 -0
  104. alphaforge/screening/engine.py +1344 -0
  105. alphaforge/screening/morningstar.py +172 -0
  106. alphaforge/screening/quality.py +187 -0
  107. alphaforge/sentiment/__init__.py +34 -0
  108. alphaforge/sentiment/company.py +560 -0
  109. alphaforge/sentiment/finbert_scorer.py +127 -0
  110. alphaforge/sentiment/models.py +151 -0
  111. alphaforge/sentiment/pipeline.py +242 -0
  112. alphaforge/sentiment/scoring.py +136 -0
  113. alphaforge/sentiment/sector.py +144 -0
  114. alphaforge/sentiment/sector_map.py +141 -0
  115. alphaforge/sentiment/statistical.py +216 -0
  116. alphaforge/setup_wizard.py +292 -0
  117. alphaforge/visualization/__init__.py +1 -0
  118. alphaforge/visualization/chart_engine.py +297 -0
  119. alphaforged-0.3.2.dist-info/METADATA +496 -0
  120. alphaforged-0.3.2.dist-info/RECORD +123 -0
  121. alphaforged-0.3.2.dist-info/WHEEL +5 -0
  122. alphaforged-0.3.2.dist-info/entry_points.txt +2 -0
  123. alphaforged-0.3.2.dist-info/top_level.txt +1 -0
alphaforge/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """AlphaForge platform scaffold."""
2
+
3
+ __all__ = ["__version__"]
4
+
5
+ __version__ = "0.1.0"
@@ -0,0 +1 @@
1
+ """Multi-channel alerting system for AlphaForge signals."""
@@ -0,0 +1,245 @@
1
+ """Alert channel implementations — Telegram, Email, Webhook.
2
+
3
+ Each channel implements the AlertChannel ABC with a single `send()` method.
4
+ Channels are configured via environment variables or constructor args.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import logging
10
+ import os
11
+ import smtplib
12
+ import ssl
13
+ import urllib.request
14
+ import urllib.error
15
+ from abc import ABC, abstractmethod
16
+ from dataclasses import dataclass, field
17
+ from typing import Any, Dict, List, Optional
18
+ from email.mime.text import MIMEText
19
+ from email.mime.multipart import MIMEMultipart
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ @dataclass
25
+ class AlertMessage:
26
+ """A formatted alert to send to users."""
27
+ title: str
28
+ body: str
29
+ severity: str # "info", "warning", "critical"
30
+ symbol: str
31
+ signal: str # "BUY", "SELL", "HOLD"
32
+ confidence: float
33
+ data: Dict[str, Any] = field(default_factory=dict)
34
+
35
+ def to_markdown(self) -> str:
36
+ """Format as Markdown for Telegram."""
37
+ severity_emoji = {"info": "ℹ️", "warning": "⚠️", "critical": "🚨"}
38
+ signal_emoji = {"BUY": "🟢", "SELL": "🔴", "HOLD": "🟡"}
39
+ emoji = severity_emoji.get(self.severity, "📊")
40
+ sig = signal_emoji.get(self.signal, "⚪")
41
+
42
+ lines = [
43
+ f"{emoji} **{self.title}**",
44
+ f"",
45
+ f"**{self.symbol}** {sig} {self.signal} ({self.confidence:.0%})",
46
+ f"",
47
+ self.body,
48
+ ]
49
+ return "\n".join(lines)
50
+
51
+ def to_text(self) -> str:
52
+ """Format as plain text for email."""
53
+ return f"[{self.severity.upper()}] {self.title}\n{self.symbol} → {self.signal} ({self.confidence:.0%})\n\n{self.body}"
54
+
55
+
56
+ class AlertChannel(ABC):
57
+ """Base class for all alert channels."""
58
+
59
+ @abstractmethod
60
+ def send(self, message: AlertMessage) -> bool:
61
+ """Send an alert. Returns True if successful."""
62
+ ...
63
+
64
+ @abstractmethod
65
+ def is_configured(self) -> bool:
66
+ """Check if this channel has required credentials."""
67
+ ...
68
+
69
+
70
+ class TelegramChannel(AlertChannel):
71
+ """Telegram Bot alert channel.
72
+
73
+ Configure via:
74
+ TELEGRAM_BOT_TOKEN — Bot token from @BotFather
75
+ TELEGRAM_CHAT_ID — Chat ID (user or group)
76
+ """
77
+
78
+ def __init__(
79
+ self,
80
+ bot_token: Optional[str] = None,
81
+ chat_id: Optional[str] = None,
82
+ ):
83
+ self.bot_token = bot_token or os.environ.get("TELEGRAM_BOT_TOKEN", "")
84
+ self.chat_id = chat_id or os.environ.get("TELEGRAM_CHAT_ID", "")
85
+
86
+ def is_configured(self) -> bool:
87
+ return bool(self.bot_token and self.chat_id)
88
+
89
+ def send(self, message: AlertMessage) -> bool:
90
+ if not self.is_configured():
91
+ logger.debug("Telegram not configured — skipping alert")
92
+ return False
93
+
94
+ text = message.to_markdown()
95
+ url = f"https://api.telegram.org/bot{self.bot_token}/sendMessage"
96
+ payload = json.dumps({
97
+ "chat_id": self.chat_id,
98
+ "text": text,
99
+ "parse_mode": "Markdown",
100
+ "disable_web_page_preview": True,
101
+ }).encode("utf-8")
102
+
103
+ try:
104
+ req = urllib.request.Request(
105
+ url, data=payload,
106
+ headers={"Content-Type": "application/json"},
107
+ )
108
+ with urllib.request.urlopen(req, timeout=10) as resp:
109
+ result = json.loads(resp.read().decode())
110
+ if result.get("ok"):
111
+ logger.info("Telegram alert sent: %s", message.title)
112
+ return True
113
+ else:
114
+ logger.warning("Telegram API error: %s", result.get("description"))
115
+ return False
116
+ except Exception as exc:
117
+ logger.warning("Telegram send failed: %s", exc)
118
+ return False
119
+
120
+
121
+ class EmailChannel(AlertChannel):
122
+ """Email (SMTP) alert channel.
123
+
124
+ Configure via:
125
+ SMTP_HOST — SMTP server (e.g., smtp.gmail.com)
126
+ SMTP_PORT — SMTP port (default 587)
127
+ SMTP_USER — Email address
128
+ SMTP_PASSWORD — App password (not regular password)
129
+ EMAIL_TO — Recipient email(s), comma-separated
130
+ """
131
+
132
+ def __init__(
133
+ self,
134
+ smtp_host: Optional[str] = None,
135
+ smtp_port: Optional[int] = None,
136
+ smtp_user: Optional[str] = None,
137
+ smtp_password: Optional[str] = None,
138
+ email_to: Optional[str] = None,
139
+ ):
140
+ self.smtp_host = smtp_host or os.environ.get("SMTP_HOST", "smtp.gmail.com")
141
+ self.smtp_port = smtp_port or int(os.environ.get("SMTP_PORT", "587"))
142
+ self.smtp_user = smtp_user or os.environ.get("SMTP_USER", "")
143
+ self.smtp_password = smtp_password or os.environ.get("SMTP_PASSWORD", "")
144
+ self.email_to = email_to or os.environ.get("EMAIL_TO", "")
145
+
146
+ def is_configured(self) -> bool:
147
+ return bool(self.smtp_user and self.smtp_password and self.email_to)
148
+
149
+ def send(self, message: AlertMessage) -> bool:
150
+ if not self.is_configured():
151
+ logger.debug("Email not configured — skipping alert")
152
+ return False
153
+
154
+ recipients = [r.strip() for r in self.email_to.split(",") if r.strip()]
155
+
156
+ msg = MIMEMultipart("alternative")
157
+ msg["Subject"] = f"[AlphaForge] {message.signal} — {message.symbol}: {message.title}"
158
+ msg["From"] = self.smtp_user
159
+ msg["To"] = ", ".join(recipients)
160
+
161
+ # Plain text
162
+ msg.attach(MIMEText(message.to_text(), "plain"))
163
+
164
+ # HTML version
165
+ html = f"""
166
+ <html><body>
167
+ <h2>{message.title}</h2>
168
+ <p><strong>{message.symbol}</strong> → <strong>{message.signal}</strong> ({message.confidence:.0%})</p>
169
+ <p>{message.body.replace(chr(10), '<br>')}</p>
170
+ <hr><p><small>Sent by AlphaForge Alerting System</small></p>
171
+ </body></html>
172
+ """
173
+ msg.attach(MIMEText(html, "html"))
174
+
175
+ try:
176
+ context = ssl.create_default_context()
177
+ with smtplib.SMTP(self.smtp_host, self.smtp_port) as server:
178
+ server.starttls(context=context)
179
+ server.login(self.smtp_user, self.smtp_password)
180
+ server.sendmail(self.smtp_user, recipients, msg.as_string())
181
+ logger.info("Email alert sent to %s", recipients)
182
+ return True
183
+ except Exception as exc:
184
+ logger.warning("Email send failed: %s", exc)
185
+ return False
186
+
187
+
188
+ class WebhookChannel(AlertChannel):
189
+ """Generic webhook (HTTP POST JSON) alert channel.
190
+
191
+ Configure via:
192
+ WEBHOOK_URL — Target URL
193
+ WEBHOOK_SECRET — Optional HMAC secret for signature verification
194
+ """
195
+
196
+ def __init__(
197
+ self,
198
+ webhook_url: Optional[str] = None,
199
+ secret: Optional[str] = None,
200
+ ):
201
+ self.webhook_url = webhook_url or os.environ.get("WEBHOOK_URL", "")
202
+ self.secret = secret or os.environ.get("WEBHOOK_SECRET", "")
203
+
204
+ def is_configured(self) -> bool:
205
+ return bool(self.webhook_url)
206
+
207
+ def send(self, message: AlertMessage) -> bool:
208
+ if not self.is_configured():
209
+ logger.debug("Webhook not configured — skipping alert")
210
+ return False
211
+
212
+ import hmac
213
+ import hashlib
214
+ import time
215
+
216
+ payload = {
217
+ "timestamp": int(time.time()),
218
+ "symbol": message.symbol,
219
+ "signal": message.signal,
220
+ "confidence": message.confidence,
221
+ "severity": message.severity,
222
+ "title": message.title,
223
+ "body": message.body,
224
+ "data": message.data,
225
+ }
226
+
227
+ body = json.dumps(payload).encode("utf-8")
228
+ headers = {"Content-Type": "application/json"}
229
+
230
+ # HMAC signature
231
+ if self.secret:
232
+ sig = hmac.new(self.secret.encode(), body, hashlib.sha256).hexdigest()
233
+ headers["X-AlphaForge-Signature"] = f"sha256={sig}"
234
+
235
+ try:
236
+ req = urllib.request.Request(
237
+ self.webhook_url, data=body, headers=headers,
238
+ )
239
+ with urllib.request.urlopen(req, timeout=10) as resp:
240
+ status = resp.status
241
+ logger.info("Webhook sent (HTTP %d)", status)
242
+ return 200 <= status < 300
243
+ except Exception as exc:
244
+ logger.warning("Webhook send failed: %s", exc)
245
+ return False
@@ -0,0 +1,152 @@
1
+ """Alert dispatcher — routes signals to configured channels.
2
+
3
+ Monitors signal changes and triggers alerts when:
4
+ - Consensus signal changes (BUY→SELL, HOLD→BUY, etc.)
5
+ - Confidence crosses threshold (>0.7 strong signal)
6
+ - Risk limits breached
7
+ - Watchlist triggers
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ import time
13
+ from typing import Dict, List, Optional
14
+
15
+ from alphaforge.alerting.channels import (
16
+ AlertChannel,
17
+ AlertMessage,
18
+ EmailChannel,
19
+ TelegramChannel,
20
+ WebhookChannel,
21
+ )
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ class AlertDispatcher:
27
+ """Route alert messages to all configured channels.
28
+
29
+ Usage:
30
+ dispatcher = AlertDispatcher()
31
+ dispatcher.add_channel(TelegramChannel())
32
+ dispatcher.add_channel(EmailChannel())
33
+
34
+ # On signal change
35
+ dispatcher.dispatch(AlertMessage(
36
+ title="Signal Change: INFY.NS",
37
+ body="Consensus shifted from HOLD to BUY (confidence: 72%)",
38
+ severity="info",
39
+ symbol="INFY.NS",
40
+ signal="BUY",
41
+ confidence=0.72,
42
+ ))
43
+ """
44
+
45
+ def __init__(self, channels: Optional[List[AlertChannel]] = None):
46
+ self.channels: List[AlertChannel] = channels or []
47
+ self._signal_history: Dict[str, str] = {} # ticker → last signal
48
+ self._alert_history: List[float] = [] # timestamps of sent alerts
49
+ self._min_interval = 300 # 5 min between alerts for same ticker
50
+
51
+ def add_channel(self, channel: AlertChannel):
52
+ """Add an alert channel (Telegram, Email, Webhook)."""
53
+ if channel.is_configured():
54
+ self.channels.append(channel)
55
+ logger.info("Added alert channel: %s", type(channel).__name__)
56
+ else:
57
+ logger.debug("Skipped unconfigured channel: %s", type(channel).__name__)
58
+
59
+ @classmethod
60
+ def from_env(cls) -> "AlertDispatcher":
61
+ """Create dispatcher with all configured channels from environment."""
62
+ dispatcher = cls()
63
+ dispatcher.add_channel(TelegramChannel())
64
+ dispatcher.add_channel(EmailChannel())
65
+ dispatcher.add_channel(WebhookChannel())
66
+ return dispatcher
67
+
68
+ def dispatch(self, message: AlertMessage) -> Dict[str, bool]:
69
+ """Send message to all configured channels. Returns {channel: success}."""
70
+ results = {}
71
+ for channel in self.channels:
72
+ try:
73
+ ok = channel.send(message)
74
+ results[type(channel).__name__] = ok
75
+ except Exception as exc:
76
+ logger.warning("Channel %s failed: %s", type(channel).__name__, exc)
77
+ results[type(channel).__name__] = False
78
+ return results
79
+
80
+ def check_signal_change(
81
+ self,
82
+ ticker: str,
83
+ new_signal: str,
84
+ confidence: float,
85
+ consensus_summary: str = "",
86
+ ) -> Optional[AlertMessage]:
87
+ """Check if signal changed and generate alert if so.
88
+
89
+ Returns AlertMessage if signal changed, None otherwise.
90
+ """
91
+ old_signal = self._signal_history.get(ticker)
92
+ self._signal_history[ticker] = new_signal
93
+
94
+ # Rate limit
95
+ now = time.time()
96
+ recent_alerts = [t for t in self._alert_history if now - t < self._min_interval]
97
+ if len(recent_alerts) >= 5: # Max 5 alerts per 5 minutes
98
+ return None
99
+
100
+ # Determine if we should alert
101
+ should_alert = False
102
+ severity = "info"
103
+
104
+ if old_signal is None:
105
+ # First analysis — only alert for strong signals
106
+ if confidence > 0.7 and new_signal != "HOLD":
107
+ should_alert = True
108
+ severity = "info"
109
+ elif old_signal != new_signal:
110
+ # Signal change
111
+ should_alert = True
112
+ severity = "warning" if new_signal in ("BUY", "SELL") else "info"
113
+ if confidence > 0.8:
114
+ severity = "critical"
115
+ elif confidence > 0.8 and new_signal != "HOLD":
116
+ # High confidence — periodic reminder
117
+ if now - self._alert_history[-1] if self._alert_history else 0 > 3600:
118
+ should_alert = True
119
+ severity = "info"
120
+
121
+ if not should_alert:
122
+ return None
123
+
124
+ self._alert_history.append(now)
125
+
126
+ body = f"Signal changed: {old_signal or 'N/A'} → {new_signal}"
127
+ if consensus_summary:
128
+ body += f"\n{consensus_summary}"
129
+ body += f"\nConfidence: {confidence:.0%}"
130
+
131
+ return AlertMessage(
132
+ title=f"Signal {'Change' if old_signal else 'Alert'}: {ticker}",
133
+ body=body,
134
+ severity=severity,
135
+ symbol=ticker,
136
+ signal=new_signal,
137
+ confidence=confidence,
138
+ data={"old_signal": old_signal, "ticker": ticker},
139
+ )
140
+
141
+ def dispatch_signal_change(
142
+ self,
143
+ ticker: str,
144
+ new_signal: str,
145
+ confidence: float,
146
+ consensus_summary: str = "",
147
+ ) -> Dict[str, bool]:
148
+ """Check and dispatch signal change alert in one call."""
149
+ msg = self.check_signal_change(ticker, new_signal, confidence, consensus_summary)
150
+ if msg:
151
+ return self.dispatch(msg)
152
+ return {}
@@ -0,0 +1 @@
1
+ """Alert engine and notifier for stock monitoring."""
@@ -0,0 +1,185 @@
1
+ """Enhanced alert engine — volume, consensus, pattern, gap, and technical alerts."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ from dataclasses import dataclass, field, asdict
6
+ from datetime import datetime
7
+ from pathlib import Path
8
+ from typing import List, Optional
9
+
10
+ import pandas as pd
11
+
12
+ from alphaforge.analysis.consensus import ConsensusResult
13
+ from alphaforge.models.algorithms.base import AlgorithmResult
14
+
15
+
16
+ @dataclass
17
+ class StockAlert:
18
+ """A single alert event."""
19
+ ticker: str
20
+ alert_type: str # VOLUME_SPIKE, CONSENSUS, PATTERN, GAP, RSI_EXTREME, SQUEEZE
21
+ severity: str # CRITICAL, HIGH, MEDIUM, LOW
22
+ message: str
23
+ timestamp: str = field(default_factory=lambda: datetime.utcnow().isoformat())
24
+ signal: Optional[str] = None
25
+ confidence: Optional[float] = None
26
+
27
+
28
+ class AlertEngine:
29
+ """Generate alerts from technical analysis, consensus, and price action."""
30
+
31
+ def evaluate(
32
+ self,
33
+ ticker: str,
34
+ df: pd.DataFrame,
35
+ algo_results: List[AlgorithmResult],
36
+ consensus: ConsensusResult,
37
+ ) -> List[StockAlert]:
38
+ """Evaluate all alert conditions and return triggered alerts."""
39
+ alerts = []
40
+ alerts.extend(self._volume_spike(ticker, df))
41
+ alerts.extend(self._gap_detection(ticker, df))
42
+ alerts.extend(self._rsi_extremes(ticker, df))
43
+ alerts.extend(self._bollinger_squeeze(ticker, df))
44
+ alerts.extend(self._consensus_alerts(ticker, consensus))
45
+ alerts.extend(self._divergence_alerts(ticker, algo_results))
46
+ alerts.extend(self._pattern_alerts(ticker, df))
47
+ return alerts
48
+
49
+ def _volume_spike(self, ticker: str, df: pd.DataFrame) -> List[StockAlert]:
50
+ alerts = []
51
+ if "Volume" not in df.columns or len(df) < 20:
52
+ return alerts
53
+ vol = df["Volume"]
54
+ avg_vol = vol.rolling(20).mean().iloc[-1]
55
+ if pd.isna(avg_vol) or avg_vol == 0:
56
+ return alerts
57
+ current_vol = vol.iloc[-1]
58
+ ratio = current_vol / avg_vol
59
+ if ratio > 3.0:
60
+ alerts.append(StockAlert(
61
+ ticker=ticker,
62
+ alert_type="VOLUME_SPIKE",
63
+ severity="HIGH",
64
+ message=f"Volume {ratio:.1f}x average — {current_vol:,.0f} vs avg {avg_vol:,.0f}",
65
+ signal="BUY" if df["Close"].iloc[-1] > df["Close"].iloc[-2] else "SELL",
66
+ ))
67
+ elif ratio > 2.0:
68
+ alerts.append(StockAlert(
69
+ ticker=ticker,
70
+ alert_type="VOLUME_SPIKE",
71
+ severity="MEDIUM",
72
+ message=f"Volume {ratio:.1f}x average",
73
+ ))
74
+ return alerts
75
+
76
+ def _gap_detection(self, ticker: str, df: pd.DataFrame) -> List[StockAlert]:
77
+ alerts = []
78
+ if len(df) < 2:
79
+ return alerts
80
+ prev_close = df["Close"].iloc[-2]
81
+ if pd.isna(prev_close) or prev_close <= 0:
82
+ return alerts
83
+ gap_pct = (df["Open"].iloc[-1] - prev_close) / prev_close
84
+ if not pd.isna(gap_pct) and abs(gap_pct) > 0.03:
85
+ direction = "up" if gap_pct > 0 else "down"
86
+ severity = "CRITICAL" if abs(gap_pct) > 0.05 else "HIGH"
87
+ alerts.append(StockAlert(
88
+ ticker=ticker,
89
+ alert_type="GAP",
90
+ severity=severity,
91
+ message=f"Gap {direction} {abs(gap_pct)*100:.1f}%",
92
+ signal="BUY" if gap_pct > 0 else "SELL",
93
+ ))
94
+ return alerts
95
+
96
+ def _rsi_extremes(self, ticker: str, df: pd.DataFrame) -> List[StockAlert]:
97
+ alerts = []
98
+ if "RSI_14" not in df.columns:
99
+ return alerts
100
+ rsi = df["RSI_14"].iloc[-1]
101
+ if pd.isna(rsi):
102
+ return alerts
103
+ if rsi < 20:
104
+ alerts.append(StockAlert(
105
+ ticker=ticker, alert_type="RSI_EXTREME", severity="CRITICAL",
106
+ message=f"RSI extremely oversold ({rsi:.1f})", signal="BUY",
107
+ ))
108
+ elif rsi < 30:
109
+ alerts.append(StockAlert(
110
+ ticker=ticker, alert_type="RSI_EXTREME", severity="HIGH",
111
+ message=f"RSI oversold ({rsi:.1f})", signal="BUY",
112
+ ))
113
+ elif rsi > 80:
114
+ alerts.append(StockAlert(
115
+ ticker=ticker, alert_type="RSI_EXTREME", severity="CRITICAL",
116
+ message=f"RSI extremely overbought ({rsi:.1f})", signal="SELL",
117
+ ))
118
+ elif rsi > 70:
119
+ alerts.append(StockAlert(
120
+ ticker=ticker, alert_type="RSI_EXTREME", severity="HIGH",
121
+ message=f"RSI overbought ({rsi:.1f})", signal="SELL",
122
+ ))
123
+ return alerts
124
+
125
+ def _bollinger_squeeze(self, ticker: str, df: pd.DataFrame) -> List[StockAlert]:
126
+ alerts = []
127
+ if "SQUEEZE" not in df.columns:
128
+ return alerts
129
+ if df["SQUEEZE"].iloc[-1]:
130
+ alerts.append(StockAlert(
131
+ ticker=ticker, alert_type="SQUEEZE", severity="MEDIUM",
132
+ message="Bollinger Squeeze detected — expect volatility expansion",
133
+ ))
134
+ return alerts
135
+
136
+ def _consensus_alerts(self, ticker: str, consensus: ConsensusResult) -> List[StockAlert]:
137
+ alerts = []
138
+ if consensus.buy_pct >= 0.75:
139
+ alerts.append(StockAlert(
140
+ ticker=ticker, alert_type="CONSENSUS", severity="CRITICAL",
141
+ message=f"{consensus.buy_count}/{consensus.total_algorithms} algorithms agree on BUY ({consensus.buy_pct*100:.0f}%)",
142
+ signal="BUY", confidence=consensus.confidence,
143
+ ))
144
+ elif consensus.sell_pct >= 0.75:
145
+ alerts.append(StockAlert(
146
+ ticker=ticker, alert_type="CONSENSUS", severity="CRITICAL",
147
+ message=f"{consensus.sell_count}/{consensus.total_algorithms} algorithms agree on SELL ({consensus.sell_pct*100:.0f}%)",
148
+ signal="SELL", confidence=consensus.confidence,
149
+ ))
150
+ return alerts
151
+
152
+ def _divergence_alerts(self, ticker: str, results: List[AlgorithmResult]) -> List[StockAlert]:
153
+ alerts = []
154
+ for r in results:
155
+ if "divergence" in r.reason.lower() and r.confidence > 0.5:
156
+ alerts.append(StockAlert(
157
+ ticker=ticker, alert_type="DIVERGENCE", severity="HIGH",
158
+ message=f"{r.algorithm_name}: {r.reason}",
159
+ signal=r.signal, confidence=r.confidence,
160
+ ))
161
+ return alerts
162
+
163
+ def _pattern_alerts(self, ticker: str, df: pd.DataFrame) -> List[StockAlert]:
164
+ alerts = []
165
+ if len(df) < 3:
166
+ return alerts
167
+ # Check for strong candlestick patterns
168
+ recent = df.iloc[-3:]
169
+ o, h, l, c = recent["Open"], recent["High"], recent["Low"], recent["Close"]
170
+
171
+ # Three White Soldiers
172
+ if all(c.iloc[-i] > o.iloc[-i] for i in range(1, 4)):
173
+ if all(c.iloc[-i] > c.iloc[-i-1] for i in range(1, 3)):
174
+ alerts.append(StockAlert(
175
+ ticker=ticker, alert_type="PATTERN", severity="HIGH",
176
+ message="Three White Soldiers pattern detected", signal="BUY",
177
+ ))
178
+ # Three Black Crows
179
+ if all(c.iloc[-i] < o.iloc[-i] for i in range(1, 4)):
180
+ if all(c.iloc[-i] < c.iloc[-i-1] for i in range(1, 3)):
181
+ alerts.append(StockAlert(
182
+ ticker=ticker, alert_type="PATTERN", severity="HIGH",
183
+ message="Three Black Crows pattern detected", signal="SELL",
184
+ ))
185
+ return alerts
@@ -0,0 +1,69 @@
1
+ """Alert persistence and notification."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import sys
6
+ from datetime import datetime
7
+ from pathlib import Path
8
+ from typing import List, Optional
9
+
10
+ from alphaforge.alerts.engine import StockAlert
11
+
12
+ # Windows console can't render emoji, use text fallback
13
+ _WINDOWS = sys.platform == "win32"
14
+ _SEVERITY_ICON = {
15
+ "CRITICAL": "[!!!]",
16
+ "HIGH": "[!!]",
17
+ "MEDIUM": "[!]",
18
+ "LOW": "[+]",
19
+ } if _WINDOWS else {
20
+ "CRITICAL": "\U0001f534",
21
+ "HIGH": "\U0001f7e0",
22
+ "MEDIUM": "\U0001f7e1",
23
+ "LOW": "\U0001f7e2",
24
+ }
25
+ _SIGNAL_ICON = {
26
+ "BUY": "UP" if _WINDOWS else "\U0001f4c8",
27
+ "SELL": "DOWN" if _WINDOWS else "\U0001f4c9",
28
+ } if _WINDOWS else {
29
+ "BUY": "\U0001f4c8",
30
+ "SELL": "\U0001f4c9",
31
+ }
32
+
33
+
34
+ class AlertNotifier:
35
+ """Persist and display alerts."""
36
+
37
+ def __init__(self, output_dir: Optional[Path] = None):
38
+ self.output_dir = output_dir or Path("data/alerts")
39
+ self.output_dir.mkdir(parents=True, exist_ok=True)
40
+
41
+ def save_alerts(self, alerts: List[StockAlert]) -> Path:
42
+ """Save alerts to JSON file."""
43
+ if not alerts:
44
+ return self.output_dir / "no_alerts.json"
45
+
46
+ timestamp = datetime.utcnow().strftime("%Y%m%d_%H%M%S")
47
+ filepath = self.output_dir / f"alerts_{timestamp}.json"
48
+
49
+ data = [vars(a) for a in alerts]
50
+ with open(filepath, "w") as f:
51
+ json.dump(data, f, indent=2)
52
+
53
+ return filepath
54
+
55
+ def format_summary(self, alerts: List[StockAlert]) -> str:
56
+ """Format alerts into a readable summary."""
57
+ if not alerts:
58
+ return "No alerts triggered."
59
+
60
+ lines = ["=== ALERT SUMMARY ===\n"]
61
+ severity_order = {"CRITICAL": 0, "HIGH": 1, "MEDIUM": 2, "LOW": 3}
62
+ sorted_alerts = sorted(alerts, key=lambda a: severity_order.get(a.severity, 4))
63
+
64
+ for alert in sorted_alerts:
65
+ icon = _SEVERITY_ICON.get(alert.severity, "[-]")
66
+ sig = _SIGNAL_ICON.get(alert.signal, "")
67
+ lines.append(f"{icon} [{alert.ticker}] {alert.alert_type}: {alert.message} {sig}")
68
+
69
+ return "\n".join(lines)
@@ -0,0 +1 @@
1
+ """Analysis engine — stock analysis, consensus, and universe scanning."""