galaxis-po/backend/app/services/notification.py
머니페니 12d235a1f1 feat: add 9 new modules - notification alerts, trading journal, position sizing, pension allocation, drawdown monitoring, benchmark dashboard, tax simulation, correlation analysis, parameter optimizer
Phase 1:
- Real-time signal alerts (Discord/Telegram webhook)
- Trading journal with entry/exit tracking
- Position sizing calculator (Fixed/Kelly/ATR)

Phase 2:
- Pension asset allocation (DC/IRP 70% risk limit)
- Drawdown monitoring with SVG gauge
- Benchmark dashboard (portfolio vs KOSPI vs deposit)

Phase 3:
- Tax benefit simulation (Korean pension tax rules)
- Correlation matrix heatmap
- Parameter optimizer with grid search + overfit detection
2026-03-29 10:03:08 +09:00

133 lines
4.1 KiB
Python

"""
Notification service for sending signal alerts via Discord/Telegram.
"""
import logging
from datetime import datetime, timedelta
import httpx
from sqlalchemy.orm import Session
from app.models.signal import Signal
from app.models.notification import (
NotificationSetting, NotificationHistory,
ChannelType, NotificationStatus,
)
logger = logging.getLogger(__name__)
async def send_discord(webhook_url: str, message: str) -> None:
"""Send a message to Discord via webhook."""
async with httpx.AsyncClient(timeout=10) as client:
response = await client.post(
webhook_url,
json={"content": message},
)
response.raise_for_status()
async def send_telegram(webhook_url: str, message: str) -> None:
"""Send a message to Telegram via Bot API.
webhook_url format: https://api.telegram.org/bot<TOKEN>/sendMessage?chat_id=<CHAT_ID>
"""
async with httpx.AsyncClient(timeout=10) as client:
response = await client.post(
webhook_url,
json={"text": message, "parse_mode": "Markdown"},
)
response.raise_for_status()
def format_signal_message(signal: Signal) -> str:
"""Format a signal into a human-readable notification message."""
signal_type_kr = {
"buy": "매수",
"sell": "매도",
"partial_sell": "부분매도",
}
type_label = signal_type_kr.get(signal.signal_type.value, signal.signal_type.value)
lines = [
f"[KJB 신호] {signal.name or signal.ticker} ({signal.ticker})",
f"신호: {type_label}",
]
if signal.entry_price:
lines.append(f"진입가: {signal.entry_price:,.0f}")
if signal.target_price:
lines.append(f"목표가: {signal.target_price:,.0f}")
if signal.stop_loss_price:
lines.append(f"손절가: {signal.stop_loss_price:,.0f}")
if signal.reason:
lines.append(f"사유: {signal.reason}")
return "\n".join(lines)
def _is_duplicate(db: Session, signal_id: int, channel_type: ChannelType) -> bool:
"""Check if a notification was already sent for this signal+channel within 24h."""
cutoff = datetime.utcnow() - timedelta(hours=24)
existing = (
db.query(NotificationHistory)
.filter(
NotificationHistory.signal_id == signal_id,
NotificationHistory.channel_type == channel_type,
NotificationHistory.status == NotificationStatus.SENT,
NotificationHistory.sent_at >= cutoff,
)
.first()
)
return existing is not None
async def send_notification(signal: Signal, db: Session) -> None:
"""Send notification for a signal to all enabled channels.
Skips duplicate notifications (same signal_id + channel_type within 24h).
"""
settings = (
db.query(NotificationSetting)
.filter(NotificationSetting.enabled.is_(True))
.all()
)
message = format_signal_message(signal)
for setting in settings:
if _is_duplicate(db, signal.id, setting.channel_type):
logger.info(
f"Skipping duplicate notification for signal {signal.id} "
f"on {setting.channel_type.value}"
)
continue
history = NotificationHistory(
signal_id=signal.id,
channel_type=setting.channel_type,
message=message,
)
try:
if setting.channel_type == ChannelType.DISCORD:
await send_discord(setting.webhook_url, message)
elif setting.channel_type == ChannelType.TELEGRAM:
await send_telegram(setting.webhook_url, message)
history.status = NotificationStatus.SENT
logger.info(
f"Notification sent for signal {signal.id} "
f"via {setting.channel_type.value}"
)
except Exception as e:
history.status = NotificationStatus.FAILED
history.error_message = str(e)[:500]
logger.error(
f"Failed to send notification for signal {signal.id} "
f"via {setting.channel_type.value}: {e}"
)
db.add(history)
db.commit()