diff --git a/agent-office/app/notifiers/telegram_stock.py b/agent-office/app/notifiers/telegram_stock.py new file mode 100644 index 0000000..00e619b --- /dev/null +++ b/agent-office/app/notifiers/telegram_stock.py @@ -0,0 +1,41 @@ +"""보유종목 인텔리전스 텔레그램 포매터 (advisory).""" +import logging +from typing import Any, Dict + +logger = logging.getLogger("agent-office") + +_ACTION_KR = {"add": "🟢 추가매수", "hold": "⚪ 보유", "trim": "🟡 축소", "sell": "🔴 매도"} +_SEV = {"high": "🔴", "med": "🟠", "low": "🟡"} + + +def format_holdings_brief(payload: Dict[str, Any]) -> str: + date = payload.get("date") or "?" + lines = [f"📊 보유종목 인텔리전스 ({date})", ""] + ph = payload.get("portfolio_health") or {} + if ph: + lines.append(f"포트 손익 {ph.get('total_pnl_rate',0):+.1f}% · " + f"종목 {ph.get('positions',0)} · 최대비중 {ph.get('max_weight',0)*100:.0f}% · " + f"현금 {ph.get('cash_ratio',0)*100:.0f}%") + lines.append("") + for h in payload.get("holdings", []): + act = _ACTION_KR.get(h.get("action"), h.get("action", "?")) + pnl = h.get("pnl_rate") + pnl_txt = f"{pnl:+.1f}%" if pnl is not None else "—" + line = f"{act} {h.get('name') or h.get('ticker')} ({pnl_txt})" + if h.get("reasons"): + line += f" — {h['reasons']}" + lines.append(line) + for iss in (h.get("issues") or [])[:3]: + lines.append(f" {_SEV.get(iss.get('severity'),'•')} {iss.get('summary','')}") + lines.append("") + lines.append("ℹ️ 투자 판단 보조용 제안입니다(자동매매 아님).") + return "\n".join(lines) + + +async def send_holdings_brief(payload: Dict[str, Any]) -> None: + from ..telegram.messaging import send_raw + text = format_holdings_brief(payload) + try: + await send_raw(text) + except Exception as e: + logger.warning(f"[telegram_stock] holdings brief send failed: {e}") diff --git a/agent-office/tests/test_holdings_brief_format.py b/agent-office/tests/test_holdings_brief_format.py new file mode 100644 index 0000000..108518a --- /dev/null +++ b/agent-office/tests/test_holdings_brief_format.py @@ -0,0 +1,82 @@ +import sys, os +sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) + +from app.notifiers import telegram_stock as ts + + +def test_format_holdings_brief(): + payload = { + "date": "2026-05-29", + "holdings": [ + {"ticker": "005930", "name": "삼성전자", "action": "trim", "tech_score": 60.0, + "exit_flags": {"ma50_break": True}, "issues": [{"type":"news","severity":"high","summary":"악재"}], + "pnl_rate": 5.2, "reasons": "MA50 이탈"}, + {"ticker": "000660", "name": "SK하이닉스", "action": "hold", "tech_score": 75.0, + "exit_flags": {}, "issues": [], "pnl_rate": -2.0, "reasons": "특이 신호 없음"}, + ], + "portfolio_health": {"positions": 2, "total_pnl_rate": 3.1, "max_weight": 0.6, "cash_ratio": 0.2}, + } + txt = ts.format_holdings_brief(payload) + assert "삼성전자" in txt + assert "축소" in txt or "trim" in txt + assert "%" in txt + + +def test_format_holdings_brief_empty_holdings(): + """빈 holdings + None portfolio_health에도 크래시 없음.""" + payload = {"date": "2026-05-29", "holdings": [], "portfolio_health": None} + txt = ts.format_holdings_brief(payload) + assert "보유종목 인텔리전스" in txt + assert "자동매매" in txt + + +def test_format_holdings_brief_missing_fields(): + """pnl_rate None·name None·issues None 방어적 처리.""" + payload = { + "date": None, + "holdings": [ + {"ticker": "005930", "name": None, "action": "sell", + "pnl_rate": None, "reasons": None, "issues": None}, + ], + "portfolio_health": {}, + } + txt = ts.format_holdings_brief(payload) + assert "005930" in txt # ticker fallback + assert "🔴 매도" in txt + + +def test_format_holdings_brief_sell_action(): + """sell 액션은 🔴 매도로 표시.""" + payload = { + "date": "2026-05-29", + "holdings": [ + {"ticker": "000660", "name": "SK하이닉스", "action": "sell", + "pnl_rate": -12.5, "reasons": "손절선 이탈", "issues": []}, + ], + "portfolio_health": {"positions": 1, "total_pnl_rate": -12.5, + "max_weight": 1.0, "cash_ratio": 0.0}, + } + txt = ts.format_holdings_brief(payload) + assert "🔴 매도" in txt + assert "-12.5%" in txt + + +def test_format_holdings_brief_issue_severity_icons(): + """이슈 심각도별 이모지 매핑 확인.""" + payload = { + "date": "2026-05-29", + "holdings": [ + {"ticker": "005930", "name": "삼성전자", "action": "hold", "pnl_rate": 2.0, + "reasons": "특이 신호 없음", + "issues": [ + {"type": "news", "severity": "high", "summary": "심각 악재"}, + {"type": "volume_surge", "severity": "med", "summary": "거래량 급증"}, + {"type": "price_move", "severity": "low", "summary": "소폭 변동"}, + ]}, + ], + "portfolio_health": {}, + } + txt = ts.format_holdings_brief(payload) + assert "🔴" in txt # high severity + assert "🟠" in txt # med severity + assert "🟡" in txt # low severity