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.
- alphaforge/__init__.py +5 -0
- alphaforge/alerting/__init__.py +1 -0
- alphaforge/alerting/channels.py +245 -0
- alphaforge/alerting/dispatcher.py +152 -0
- alphaforge/alerts/__init__.py +1 -0
- alphaforge/alerts/engine.py +185 -0
- alphaforge/alerts/notifier.py +69 -0
- alphaforge/analysis/__init__.py +1 -0
- alphaforge/analysis/consensus.py +195 -0
- alphaforge/analysis/scanner.py +178 -0
- alphaforge/analysis/stock_analyzer.py +440 -0
- alphaforge/api/__init__.py +3 -0
- alphaforge/api/contracts.py +51 -0
- alphaforge/backtesting/__init__.py +13 -0
- alphaforge/backtesting/engine.py +102 -0
- alphaforge/backtesting/metrics.py +225 -0
- alphaforge/backtesting/monte_carlo.py +132 -0
- alphaforge/backtesting/slippage.py +144 -0
- alphaforge/backtesting/walk_forward.py +238 -0
- alphaforge/cli.py +61 -0
- alphaforge/config.py +83 -0
- alphaforge/dashboard/__init__.py +1 -0
- alphaforge/dashboard/app.py +354 -0
- alphaforge/data/__init__.py +3 -0
- alphaforge/data/batch_fetcher.py +144 -0
- alphaforge/data/cache_store.py +217 -0
- alphaforge/data/event_bus.py +36 -0
- alphaforge/data/free_market_data.py +1380 -0
- alphaforge/data/universe_filter.py +130 -0
- alphaforge/domain/__init__.py +33 -0
- alphaforge/domain/models.py +168 -0
- alphaforge/execution/__init__.py +4 -0
- alphaforge/execution/alerts.py +66 -0
- alphaforge/execution/order_simulator.py +544 -0
- alphaforge/execution/risk_engine.py +91 -0
- alphaforge/explainability/__init__.py +1 -0
- alphaforge/explainability/shap_explainer.py +295 -0
- alphaforge/explanation/__init__.py +5 -0
- alphaforge/explanation/engine.py +549 -0
- alphaforge/explanation/llm_synthesizer.py +423 -0
- alphaforge/explanation/models.py +70 -0
- alphaforge/features/__init__.py +3 -0
- alphaforge/features/expanded.py +609 -0
- alphaforge/features/store.py +39 -0
- alphaforge/features/technical.py +302 -0
- alphaforge/fundamentals/__init__.py +5 -0
- alphaforge/fundamentals/dimensions.py +435 -0
- alphaforge/fundamentals/engine.py +337 -0
- alphaforge/fundamentals/models.py +60 -0
- alphaforge/models/__init__.py +10 -0
- alphaforge/models/algorithms/__init__.py +83 -0
- alphaforge/models/algorithms/adversarial.py +333 -0
- alphaforge/models/algorithms/base.py +34 -0
- alphaforge/models/algorithms/lstm_advanced.py +910 -0
- alphaforge/models/algorithms/mamba_predictor.py +325 -0
- alphaforge/models/algorithms/ml_models.py +379 -0
- alphaforge/models/algorithms/momentum.py +326 -0
- alphaforge/models/algorithms/pattern.py +198 -0
- alphaforge/models/algorithms/regime_model.py +245 -0
- alphaforge/models/algorithms/timesfm_predictor.py +215 -0
- alphaforge/models/algorithms/transparency.py +1731 -0
- alphaforge/models/algorithms/trend.py +187 -0
- alphaforge/models/algorithms/volatility.py +281 -0
- alphaforge/models/algorithms/volume.py +283 -0
- alphaforge/models/base.py +62 -0
- alphaforge/models/ensemble.py +78 -0
- alphaforge/models/regime.py +51 -0
- alphaforge/operator_console/__init__.py +1 -0
- alphaforge/operator_console/_util.py +12 -0
- alphaforge/operator_console/app.py +1765 -0
- alphaforge/operator_console/router_alerts.py +121 -0
- alphaforge/operator_console/router_portfolio.py +210 -0
- alphaforge/operator_console/router_screener.py +150 -0
- alphaforge/operator_console/router_stock_detail.py +143 -0
- alphaforge/operator_console/server.py +16 -0
- alphaforge/operator_console/static/app.js +5057 -0
- alphaforge/operator_console/static/index.html +620 -0
- alphaforge/operator_console/static/styles.css +4802 -0
- alphaforge/options/__init__.py +1 -0
- alphaforge/options/analytics.py +302 -0
- alphaforge/orchestration/__init__.py +3 -0
- alphaforge/orchestration/control_plane.py +149 -0
- alphaforge/portfolio/__init__.py +1 -0
- alphaforge/portfolio/correlation.py +258 -0
- alphaforge/portfolio/optimizer.py +259 -0
- alphaforge/regime/__init__.py +1 -0
- alphaforge/regime/detector.py +330 -0
- alphaforge/research/__init__.py +3 -0
- alphaforge/research/engine.py +71 -0
- alphaforge/risk/__init__.py +7 -0
- alphaforge/risk/circuit_breaker.py +217 -0
- alphaforge/risk/portfolio_risk.py +256 -0
- alphaforge/risk/position_sizer.py +206 -0
- alphaforge/risk/risk_manager.py +196 -0
- alphaforge/runtime/__init__.py +1 -0
- alphaforge/runtime/demo.py +109 -0
- alphaforge/runtime/forecast_history.py +376 -0
- alphaforge/runtime/forecast_monitor.py +881 -0
- alphaforge/runtime/free_tier_service.py +1690 -0
- alphaforge/runtime/universe_monitor.py +451 -0
- alphaforge/screening/__init__.py +5 -0
- alphaforge/screening/data_quality.py +184 -0
- alphaforge/screening/decimal_safety.py +173 -0
- alphaforge/screening/engine.py +1344 -0
- alphaforge/screening/morningstar.py +172 -0
- alphaforge/screening/quality.py +187 -0
- alphaforge/sentiment/__init__.py +34 -0
- alphaforge/sentiment/company.py +560 -0
- alphaforge/sentiment/finbert_scorer.py +127 -0
- alphaforge/sentiment/models.py +151 -0
- alphaforge/sentiment/pipeline.py +242 -0
- alphaforge/sentiment/scoring.py +136 -0
- alphaforge/sentiment/sector.py +144 -0
- alphaforge/sentiment/sector_map.py +141 -0
- alphaforge/sentiment/statistical.py +216 -0
- alphaforge/setup_wizard.py +292 -0
- alphaforge/visualization/__init__.py +1 -0
- alphaforge/visualization/chart_engine.py +297 -0
- alphaforged-0.3.2.dist-info/METADATA +496 -0
- alphaforged-0.3.2.dist-info/RECORD +123 -0
- alphaforged-0.3.2.dist-info/WHEEL +5 -0
- alphaforged-0.3.2.dist-info/entry_points.txt +2 -0
- alphaforged-0.3.2.dist-info/top_level.txt +1 -0
alphaforge/__init__.py
ADDED
|
@@ -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."""
|