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
133 lines
4.1 KiB
Python
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()
|