Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions core/notifier.py
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,28 @@ def _send_email_tracked(
# ------------------------------------------------------------------
# 디스코드 결과 추적
# ------------------------------------------------------------------
def _discord_deliverable(self) -> bool:
"""디스코드가 실제 채널로 전달 가능한 상태인지.

DiscordBot은 비활성(웹훅 미설정) 시 콘솔에 출력하고 True를 반환한다 —
그 True를 '발송 성공'으로 믿으면 이메일 폴백이 영영 트리거되지 않아,
웹훅 미설정 환경에서 일반 알림이 콘솔에만 남는다(무인 운영에서는 아무도
못 본다). 콘솔 폴백은 채널 전달이 아니므로 구분한다.
"""
return bool(
getattr(self.discord, "enabled", False)
and getattr(self.discord, "webhook_url", "")
)

def _discord_send_message(self, text: str) -> bool:
if not self._discord_deliverable():
# 콘솔 기록은 유지하되 '전달 안 됨'으로 취급해 이메일 폴백을 트리거한다.
# 설정상 비활성은 장애가 아니므로 실패 카운트는 올리지 않는다(경보 오탐 방지).
try:
self.discord.send_message(text)
except Exception:
pass
return False
try:
ok = self.discord.send_message(text)
if ok:
Expand All @@ -208,6 +229,12 @@ def _discord_send_message(self, text: str) -> bool:
def _discord_send_embed(
self, title: str, description: str, color: int = 0x4F9EF8, fields: list = None,
) -> bool:
if not self._discord_deliverable():
try:
self.discord.send_embed(title, description, color, fields)
except Exception:
pass
return False
try:
ok = self.discord.send_embed(title, description, color, fields)
if ok:
Expand Down
4 changes: 2 additions & 2 deletions docs/PROJECT_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ quant_trader/
│ ├── ensemble_correlation.py # 앙상블 전략 신호 상관계수 + BUY 동시 발생률 + 대안 전략 권고 + auto_downgrade
│ ├── strategy_ensemble.py # 앙상블: ensemble.components (technical·momentum_factor·volatility_condition·fundamental_factor 선택), auto_downgrade
│ ├── data_validator.py # OHLCV 정합성 검사 (Null, NaN, 음수 주가, 타임스탬프 역전, lookahead 방지 정제)
│ ├── notifier.py # 통합 알림 이중화 (1차 디스코드 → 2차 텔레그램 → 3차 이메일, critical 전채널 동시)
│ ├── notifier.py # 통합 알림 이중화 (1차 디스코드 → 2차 이메일 SMTP, critical 동시 발송; 웹훅 미설정 시에도 이메일 폴백. 텔레그램은 미구현)
│ ├── strategy_diagnostics.py # 전략 진단 보조: DiagnosticLine — 전략별 신호·점수 진단 라인 생성
│ ├── paper_evidence.py # Paper Evidence 수집 (일별 22개 지표, benchmark excess, anomaly detection)
│ ├── paper_runtime.py # Paper Runtime State Machine (5개 상태, schema quarantine, allowed_actions)
Expand Down Expand Up @@ -290,7 +290,7 @@ quant_trader/
| **order_guard.py** | 동일 종목에 대해 최근 주문 접수 후 TTL(기본 600초) 동안 추가 주문 차단. |
| **strategy_ensemble.py** | `strategies.yaml` → `ensemble.components`에 정의된 구성(기본: technical·momentum_factor·volatility_condition·**fundamental_factor** 등) 신호 통합. majority_vote / weighted_sum / conservative. **auto_downgrade**. 설계서 §4.4. |
| **data_validator.py** | OHLCV Null·NaN·음수 주가·거래량·타임스탬프 역전 등 검사. 결측 정제는 시간순 정렬 뒤 과거값 `ffill`만 사용하고, 선행 OHLC 결측은 제거해 미래 가격이 과거 신호·백테스트에 섞이지 않게 한다. |
| **notifier.py** | 통합 알림 이중화. 1차 디스코드 → 2차 텔레그램 Bot API → 3차 이메일(SMTP). `critical=True` 시 모든 채널 동시 발송. `Scheduler`, `CircuitBreaker`, `main.py` 등 주요 모듈이 `DiscordBot` 대신 `Notifier` 사용. 알림 실패 5회 누적 시 점검 경고. |
| **notifier.py** | 통합 알림 이중화. 1차 디스코드 → 2차 이메일(SMTP) 폴백, `critical=True` 시 동시 발송 (텔레그램은 미구현 — 문서만 앞서 있던 것 정정). 디스코드 웹훅 미설정(콘솔 폴백) 상태도 '전달 안 됨'으로 취급해 이메일 폴백을 트리거한다. `Scheduler`, `CircuitBreaker`, `main.py` 등 주요 모듈이 `DiscordBot` 대신 `Notifier` 사용. 알림 실패 5회 누적 시 점검 경고. |
| **strategy_diagnostics.py** | `DiagnosticLine` — 전략별 신호·점수 진단 라인 생성. 스케줄러·대시보드에서 전략 실행 현황 요약 시 사용. |
| **paper_evidence.py** | Paper Evidence 런타임 수집. `DailyEvidence` 데이터클래스, `collect_daily_evidence()`, `append_shadow_plan_evidence()`, `finalize_daily_evidence()`, `generate_promotion_package()`, 3종 benchmark excess (same_universe/exposure_matched/cash_adjusted), 6 anomaly rule (repeated_reject, phantom_position, stale_pending, duplicate_flood, reconcile, deep_drawdown), cash-only carry-forward (zero-return semantics). PortfolioSnapshot의 양수 MDD는 evidence 표준인 음수 drawdown(%)으로 정규화해 anomaly/promotion 차단에 반영한다. Canonical view는 같은 날짜의 뒤쪽 최신 record를 유지하되, 검증된 `real_paper`/`pilot_paper` 증거를 나중에 추가된 backfill/shadow/비승격 repair record가 덮지 못하게 보호한 뒤 날짜순으로 반환하고, promotion package에 `earliest_evidence_date`/`latest_evidence_date`와 `trade_quality`를 남긴다. Shadow plan evidence는 `execution_backed=False`라 promotion에는 반영되지 않는다. package 생성 시 canonical `strategy_specs`가 target-weight 후보로 식별한 전략은 prefix가 없어도 verified pilot proof와 canonical params hash 일치를 요구한다. |
| **paper_runtime.py** | Paper Runtime State Machine. 5개 상태 (research_disabled/normal/degraded/frozen/blocked_insufficient_evidence), schema quarantine (legacy record 제외), allowed_actions (모든 상태에서 exit/cancel/reconcile/finalize/evidence/reporting 허용). legacy `approved_strategies.json`가 깨지면 fail-closed로 entry/shadow를 닫고 exit/finalize만 유지한다. `get_paper_runtime_state()`, `filter_runtime_eligible()`. |
Expand Down
5 changes: 5 additions & 0 deletions tests/test_notifier.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ class _MockConfig:


class _FailingDiscord:
# 실제 DiscordBot 인터페이스 반영: 활성+웹훅 설정 상태(전달 가능)에서의 '발송 실패'.
# (비활성 콘솔 폴백과 구분 — 비활성은 실패 카운트가 오르지 않는다)
enabled = True
webhook_url = "https://example.invalid/webhook"

def __init__(self):
self.embeds = []

Expand Down
71 changes: 71 additions & 0 deletions tests/test_notifier_fallback.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
"""Notifier 폴백 체인 회귀 테스트.

핵심: DiscordBot은 비활성(웹훅 미설정) 시 콘솔 폴백하며 True를 반환한다 —
Notifier가 그 True를 '발송 성공'으로 믿으면 이메일 폴백이 영영 트리거되지 않아,
웹훅 미설정 + SMTP 설정 환경에서 일반 알림이 콘솔에만 남는다(무인 운영 깜깜이).
"""
import sys, os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))

from types import SimpleNamespace
from unittest.mock import MagicMock

from core.notifier import Notifier


def _notifier(discord_enabled, webhook="https://discord/webhook", send_ok=True):
n = Notifier.__new__(Notifier) # __init__ 우회(설정 로드 없이 구성)
n.config = SimpleNamespace()
n.discord = SimpleNamespace(
enabled=discord_enabled,
webhook_url=webhook if discord_enabled else "",
send_message=MagicMock(return_value=True if not discord_enabled else send_ok),
send_embed=MagicMock(return_value=True if not discord_enabled else send_ok),
)
n._email_enabled = True
n._email_calls = []
n._send_email_tracked = lambda *a, **kw: n._email_calls.append((a, kw)) or True
return n


def test_disabled_discord_triggers_email_fallback():
"""웹훅 미설정(콘솔 폴백 True)이어도 일반 알림은 이메일 폴백으로 가야 한다."""
n = _notifier(discord_enabled=False)
n.send_message("일반 알림")
assert len(n._email_calls) == 1
# 콘솔 기록은 유지(디스코드 객체 호출은 함)
assert n.discord.send_message.called


def test_disabled_discord_does_not_count_as_failure():
"""설정상 비활성은 장애가 아니다 — 실패 카운트 비증가(양채널 사망 경보 오탐 방지)."""
Notifier._discord_fail_count = 0
n = _notifier(discord_enabled=False)
n.send_message("일반 알림")
assert Notifier._discord_fail_count == 0


def test_enabled_discord_success_skips_email():
"""디스코드 실제 발송 성공이면 일반 알림은 이메일을 보내지 않는다(기존 동작)."""
n = _notifier(discord_enabled=True, send_ok=True)
n.send_message("일반 알림")
assert n._email_calls == []


def test_enabled_discord_failure_falls_back_to_email():
n = _notifier(discord_enabled=True, send_ok=False)
n.send_message("일반 알림")
assert len(n._email_calls) == 1


def test_critical_always_attempts_email():
"""critical은 디스코드 성공 여부와 무관하게 이메일도 발송한다."""
n = _notifier(discord_enabled=True, send_ok=True)
n.send_message("긴급", critical=True)
assert len(n._email_calls) == 1


def test_embed_disabled_discord_triggers_email_fallback():
n = _notifier(discord_enabled=False)
n.send_embed("제목", "설명", fields=[{"name": "종목", "value": "005930"}])
assert len(n._email_calls) == 1
Loading