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
327 lines
10 KiB
Python
327 lines
10 KiB
Python
"""
|
|
Tests for notification service, models, and API endpoints.
|
|
"""
|
|
import pytest
|
|
from datetime import datetime, timedelta
|
|
from decimal import Decimal
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
from app.models.signal import Signal, SignalType, SignalStatus
|
|
from app.models.notification import (
|
|
NotificationSetting, NotificationHistory,
|
|
ChannelType, NotificationStatus,
|
|
)
|
|
from app.services.notification import (
|
|
format_signal_message,
|
|
_is_duplicate,
|
|
send_notification,
|
|
)
|
|
|
|
|
|
# --- format_signal_message tests ---
|
|
|
|
class TestFormatSignalMessage:
|
|
def test_buy_signal_full(self):
|
|
signal = Signal(
|
|
ticker="005930",
|
|
name="삼성전자",
|
|
signal_type=SignalType.BUY,
|
|
entry_price=Decimal("72000"),
|
|
target_price=Decimal("75600"),
|
|
stop_loss_price=Decimal("69840"),
|
|
reason="breakout, large_candle",
|
|
)
|
|
msg = format_signal_message(signal)
|
|
assert "삼성전자" in msg
|
|
assert "005930" in msg
|
|
assert "매수" in msg
|
|
assert "72,000" in msg
|
|
assert "75,600" in msg
|
|
assert "69,840" in msg
|
|
assert "breakout" in msg
|
|
|
|
def test_sell_signal_minimal(self):
|
|
signal = Signal(
|
|
ticker="035420",
|
|
name=None,
|
|
signal_type=SignalType.SELL,
|
|
entry_price=None,
|
|
target_price=None,
|
|
stop_loss_price=None,
|
|
reason=None,
|
|
)
|
|
msg = format_signal_message(signal)
|
|
assert "035420" in msg
|
|
assert "매도" in msg
|
|
assert "진입가" not in msg
|
|
assert "목표가" not in msg
|
|
|
|
def test_partial_sell_signal(self):
|
|
signal = Signal(
|
|
ticker="000660",
|
|
name="SK하이닉스",
|
|
signal_type=SignalType.PARTIAL_SELL,
|
|
entry_price=Decimal("150000"),
|
|
)
|
|
msg = format_signal_message(signal)
|
|
assert "부분매도" in msg
|
|
assert "150,000" in msg
|
|
|
|
|
|
# --- _is_duplicate tests ---
|
|
|
|
class TestIsDuplicate:
|
|
def test_no_duplicate_when_empty(self, db):
|
|
assert _is_duplicate(db, signal_id=999, channel_type=ChannelType.DISCORD) is False
|
|
|
|
def test_duplicate_within_24h(self, db):
|
|
history = NotificationHistory(
|
|
signal_id=1,
|
|
channel_type=ChannelType.DISCORD,
|
|
status=NotificationStatus.SENT,
|
|
sent_at=datetime.utcnow() - timedelta(hours=1),
|
|
message="test",
|
|
)
|
|
db.add(history)
|
|
db.commit()
|
|
|
|
assert _is_duplicate(db, signal_id=1, channel_type=ChannelType.DISCORD) is True
|
|
|
|
def test_no_duplicate_after_24h(self, db):
|
|
history = NotificationHistory(
|
|
signal_id=1,
|
|
channel_type=ChannelType.DISCORD,
|
|
status=NotificationStatus.SENT,
|
|
sent_at=datetime.utcnow() - timedelta(hours=25),
|
|
message="test",
|
|
)
|
|
db.add(history)
|
|
db.commit()
|
|
|
|
assert _is_duplicate(db, signal_id=1, channel_type=ChannelType.DISCORD) is False
|
|
|
|
def test_failed_notification_not_duplicate(self, db):
|
|
history = NotificationHistory(
|
|
signal_id=1,
|
|
channel_type=ChannelType.DISCORD,
|
|
status=NotificationStatus.FAILED,
|
|
sent_at=datetime.utcnow(),
|
|
message="test",
|
|
)
|
|
db.add(history)
|
|
db.commit()
|
|
|
|
assert _is_duplicate(db, signal_id=1, channel_type=ChannelType.DISCORD) is False
|
|
|
|
def test_different_channel_not_duplicate(self, db):
|
|
history = NotificationHistory(
|
|
signal_id=1,
|
|
channel_type=ChannelType.DISCORD,
|
|
status=NotificationStatus.SENT,
|
|
sent_at=datetime.utcnow(),
|
|
message="test",
|
|
)
|
|
db.add(history)
|
|
db.commit()
|
|
|
|
assert _is_duplicate(db, signal_id=1, channel_type=ChannelType.TELEGRAM) is False
|
|
|
|
|
|
# --- send_notification tests ---
|
|
|
|
class TestSendNotification:
|
|
@pytest.mark.asyncio
|
|
async def test_sends_to_enabled_channels(self, db, test_user):
|
|
setting = NotificationSetting(
|
|
user_id=test_user.id,
|
|
channel_type=ChannelType.DISCORD,
|
|
webhook_url="https://discord.com/api/webhooks/test",
|
|
enabled=True,
|
|
)
|
|
db.add(setting)
|
|
|
|
signal = Signal(
|
|
id=1,
|
|
date=datetime.utcnow().date(),
|
|
ticker="005930",
|
|
name="삼성전자",
|
|
signal_type=SignalType.BUY,
|
|
entry_price=Decimal("72000"),
|
|
status=SignalStatus.ACTIVE,
|
|
)
|
|
db.add(signal)
|
|
db.commit()
|
|
|
|
with patch("app.services.notification.send_discord", new_callable=AsyncMock) as mock_discord:
|
|
await send_notification(signal, db)
|
|
mock_discord.assert_called_once()
|
|
|
|
history = db.query(NotificationHistory).first()
|
|
assert history is not None
|
|
assert history.status == NotificationStatus.SENT
|
|
assert history.signal_id == 1
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_skips_disabled_channels(self, db, test_user):
|
|
setting = NotificationSetting(
|
|
user_id=test_user.id,
|
|
channel_type=ChannelType.DISCORD,
|
|
webhook_url="https://discord.com/api/webhooks/test",
|
|
enabled=False,
|
|
)
|
|
db.add(setting)
|
|
|
|
signal = Signal(
|
|
id=2,
|
|
date=datetime.utcnow().date(),
|
|
ticker="005930",
|
|
name="삼성전자",
|
|
signal_type=SignalType.BUY,
|
|
status=SignalStatus.ACTIVE,
|
|
)
|
|
db.add(signal)
|
|
db.commit()
|
|
|
|
with patch("app.services.notification.send_discord", new_callable=AsyncMock) as mock_discord:
|
|
await send_notification(signal, db)
|
|
mock_discord.assert_not_called()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_skips_duplicate(self, db, test_user):
|
|
setting = NotificationSetting(
|
|
user_id=test_user.id,
|
|
channel_type=ChannelType.DISCORD,
|
|
webhook_url="https://discord.com/api/webhooks/test",
|
|
enabled=True,
|
|
)
|
|
db.add(setting)
|
|
|
|
signal = Signal(
|
|
id=3,
|
|
date=datetime.utcnow().date(),
|
|
ticker="005930",
|
|
name="삼성전자",
|
|
signal_type=SignalType.BUY,
|
|
status=SignalStatus.ACTIVE,
|
|
)
|
|
db.add(signal)
|
|
|
|
# Pre-existing sent notification
|
|
history = NotificationHistory(
|
|
signal_id=3,
|
|
channel_type=ChannelType.DISCORD,
|
|
status=NotificationStatus.SENT,
|
|
sent_at=datetime.utcnow(),
|
|
message="already sent",
|
|
)
|
|
db.add(history)
|
|
db.commit()
|
|
|
|
with patch("app.services.notification.send_discord", new_callable=AsyncMock) as mock_discord:
|
|
await send_notification(signal, db)
|
|
mock_discord.assert_not_called()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_records_failure(self, db, test_user):
|
|
setting = NotificationSetting(
|
|
user_id=test_user.id,
|
|
channel_type=ChannelType.DISCORD,
|
|
webhook_url="https://discord.com/api/webhooks/test",
|
|
enabled=True,
|
|
)
|
|
db.add(setting)
|
|
|
|
signal = Signal(
|
|
id=4,
|
|
date=datetime.utcnow().date(),
|
|
ticker="005930",
|
|
name="삼성전자",
|
|
signal_type=SignalType.BUY,
|
|
status=SignalStatus.ACTIVE,
|
|
)
|
|
db.add(signal)
|
|
db.commit()
|
|
|
|
with patch(
|
|
"app.services.notification.send_discord",
|
|
new_callable=AsyncMock,
|
|
side_effect=Exception("Connection failed"),
|
|
):
|
|
await send_notification(signal, db)
|
|
|
|
history = db.query(NotificationHistory).first()
|
|
assert history.status == NotificationStatus.FAILED
|
|
assert "Connection failed" in history.error_message
|
|
|
|
|
|
# --- API endpoint tests ---
|
|
|
|
class TestNotificationAPI:
|
|
def test_get_settings_empty(self, client, auth_headers):
|
|
response = client.get("/api/notifications/settings", headers=auth_headers)
|
|
assert response.status_code == 200
|
|
assert response.json() == []
|
|
|
|
def test_create_setting(self, client, auth_headers):
|
|
response = client.post(
|
|
"/api/notifications/settings",
|
|
headers=auth_headers,
|
|
json={
|
|
"channel_type": "discord",
|
|
"webhook_url": "https://discord.com/api/webhooks/test/token",
|
|
"enabled": True,
|
|
},
|
|
)
|
|
assert response.status_code == 201
|
|
data = response.json()
|
|
assert data["channel_type"] == "discord"
|
|
assert data["webhook_url"] == "https://discord.com/api/webhooks/test/token"
|
|
assert data["enabled"] is True
|
|
|
|
def test_create_duplicate_channel_fails(self, client, auth_headers):
|
|
payload = {
|
|
"channel_type": "discord",
|
|
"webhook_url": "https://discord.com/api/webhooks/test/token",
|
|
}
|
|
client.post("/api/notifications/settings", headers=auth_headers, json=payload)
|
|
response = client.post("/api/notifications/settings", headers=auth_headers, json=payload)
|
|
assert response.status_code == 400
|
|
|
|
def test_update_setting(self, client, auth_headers):
|
|
create_resp = client.post(
|
|
"/api/notifications/settings",
|
|
headers=auth_headers,
|
|
json={
|
|
"channel_type": "discord",
|
|
"webhook_url": "https://discord.com/api/webhooks/old",
|
|
},
|
|
)
|
|
setting_id = create_resp.json()["id"]
|
|
|
|
update_resp = client.put(
|
|
f"/api/notifications/settings/{setting_id}",
|
|
headers=auth_headers,
|
|
json={"webhook_url": "https://discord.com/api/webhooks/new", "enabled": False},
|
|
)
|
|
assert update_resp.status_code == 200
|
|
data = update_resp.json()
|
|
assert data["webhook_url"] == "https://discord.com/api/webhooks/new"
|
|
assert data["enabled"] is False
|
|
|
|
def test_update_nonexistent_setting(self, client, auth_headers):
|
|
response = client.put(
|
|
"/api/notifications/settings/9999",
|
|
headers=auth_headers,
|
|
json={"enabled": False},
|
|
)
|
|
assert response.status_code == 404
|
|
|
|
def test_get_history(self, client, auth_headers):
|
|
response = client.get("/api/notifications/history", headers=auth_headers)
|
|
assert response.status_code == 200
|
|
assert isinstance(response.json(), list)
|
|
|
|
def test_unauthenticated_access(self, client):
|
|
response = client.get("/api/notifications/settings")
|
|
assert response.status_code == 401
|