From b8229c0ffa5a56b83fe8cf54f6cc8e63e3722be8 Mon Sep 17 00:00:00 2001 From: gahusb Date: Thu, 9 Jul 2026 12:47:34 +0900 Subject: [PATCH] =?UTF-8?q?feat(stock):=20/api/stock/trade-alerts=20?= =?UTF-8?q?=EC=9D=91=EB=8B=B5=EC=97=90=20=EC=A1=B0=EA=B1=B4=EB=B3=84=20?= =?UTF-8?q?=EA=B7=BC=EA=B1=B0(reason)=20=EB=AC=B8=EC=9E=90=EC=97=B4=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FE가 detail(객체)을 그대로 렌더해 React #31 크래시 → 안전한 텍스트 필드 제공. condition_reason(조건→한줄근거), agent-office telegram_trade._COND_REASON과 동일 유지. --- stock/app/main.py | 7 +++++-- stock/app/trade_alerts.py | 18 ++++++++++++++++++ stock/tests/test_trade_alerts_report_api.py | 11 +++++++++++ 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/stock/app/main.py b/stock/app/main.py index 593c068..b479a14 100644 --- a/stock/app/main.py +++ b/stock/app/main.py @@ -775,8 +775,11 @@ def delete_watchlist_item(ticker: str): @app.get("/api/stock/trade-alerts") def list_trade_alerts(days: int = 7): - """매매 알람 이력 조회 (최근 N일)""" - return {"alerts": get_alert_history(days)} + """매매 알람 이력 조회 (최근 N일). 조건별 근거(reason) 문자열 포함 — FE가 detail 객체 대신 렌더.""" + alerts = get_alert_history(days) + for a in alerts: + a["reason"] = trade_alerts.condition_reason(a.get("condition", "")) + return {"alerts": alerts} # --- Holdings Intelligence API --- diff --git a/stock/app/trade_alerts.py b/stock/app/trade_alerts.py index 8d46d10..afca0f4 100644 --- a/stock/app/trade_alerts.py +++ b/stock/app/trade_alerts.py @@ -36,6 +36,24 @@ DEFAULT_EXIT_PARAMS = {"stop_pct": 0.08, "take_pct": 0.25, "trailing_pct": 0.10, "climax_vol_x": 3.0, "climax_close_pct": 0.97} DEFAULT_BUY_PARAMS = {"rsi_oversold": 30, "breakout_vol_mult": 1.5, "pullback_pct": 0.02} +# 조건별 "왜 매수/매도인가" 한 줄 근거 — agent-office `telegram_trade._COND_REASON`과 동일 유지(sync). +# API(/api/stock/trade-alerts)가 이 문자열을 반환해 FE가 detail(객체) 대신 안전하게 렌더한다. +_COND_REASON = { + "buy_ma20_pullback": "상승추세 중 MA20 지지선 눌림목 반등 — 저가 진입 기회", + "buy_breakout": "전고점·저항 돌파 + 거래량 증가 — 추세 상승 진입 신호", + "buy_rsi_bounce": "RSI 과매도(30↓)에서 반등 — 단기 낙폭과대 되돌림", + "sell_stop_loss": "평단 대비 손절선 도달 — 추가 하락 리스크 차단", + "sell_ma_break": "주요 이평선(MA50/200) 이탈 — 추세 훼손, 보유 재검토", + "sell_take_profit": "목표 수익 도달 — 이익 실현 구간", + "sell_climax": "거래량 급증 + 윗꼬리(고점 대비 하락 마감) — 분산·소진 의심", + "sell_trailing_stop":"보유기간 고점 대비 하락 — 수익 반납 방어(트레일링 스톱)", +} + + +def condition_reason(condition: str) -> str: + """조건 키 → 사람이 읽는 근거 한 줄. 미지의 조건이면 조건 키 그대로.""" + return _COND_REASON.get(condition, condition) + def latest_screener_candidates(conn) -> list: """최신 성공(status='success') screener run의 후보 {ticker,name} 목록.""" diff --git a/stock/tests/test_trade_alerts_report_api.py b/stock/tests/test_trade_alerts_report_api.py index 623e086..438531d 100644 --- a/stock/tests/test_trade_alerts_report_api.py +++ b/stock/tests/test_trade_alerts_report_api.py @@ -70,6 +70,17 @@ def test_report_refire_after_cooldown_alerts(client, monkeypatch): assert r.json()["new_alerts"] == 1 +def test_trade_alerts_history_includes_reason_string(client): + """이력 API가 조건별 사람이 읽는 근거(reason) 문자열을 함께 반환한다(FE가 detail 객체 대신 렌더).""" + firing = [{"ticker": "005930", "name": "삼성", "kind": "sell", + "condition": "sell_ma_break", "price": 60000, + "detail": {"ma50": 1, "ma200": 2, "severity": "high"}}] + with patch("app.trade_alerts.notify_agent_office", return_value=True): + _report(client, firing) + alerts = client.get("/api/stock/trade-alerts?days=1").json()["alerts"] + assert isinstance(alerts[0]["reason"], str) and len(alerts[0]["reason"]) > 3 + + def test_report_resolves_stock_name_from_watchlist(client): """워커 firing에 name이 없어도 NAS가 종목명을 해석해 알림에 포함한다.""" from app import db