From 9baea3a0e202cebf076d796836cf8975fe938c4f Mon Sep 17 00:00:00 2001 From: gahusb Date: Fri, 3 Jul 2026 16:14:51 +0900 Subject: [PATCH] =?UTF-8?q?feat(stock):=20=EB=A7=A4=EB=A7=A4=EC=95=8C?= =?UTF-8?q?=EB=9E=8C=20=EC=BF=A8=EB=8B=A4=EC=9A=B4=20=EC=A4=91=EB=B3=B5?= =?UTF-8?q?=EC=96=B5=EC=A0=9C=20+=20=EC=A2=85=EB=AA=A9=EB=AA=85=20?= =?UTF-8?q?=ED=95=B4=EC=84=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 쿨다운(TRADE_ALERT_COOLDOWN_HOURS 기본 6h): 같은 종목·조건 해제→재발화 오실레이션 시 재알림 억제(set_alert_firing mark_fired=False로 firing 유지·발동시각 미갱신, suppressed 카운트). - 종목명: 워커 firing에 name 없어도 NAS가 watchlist→portfolio→krx_master로 해석해 알림·이력에 포함. --- stock/app/db.py | 45 ++++++++++++++++++++- stock/app/main.py | 36 +++++++++++++++-- stock/tests/test_trade_alerts_report_api.py | 35 ++++++++++++++-- 3 files changed, 107 insertions(+), 9 deletions(-) diff --git a/stock/app/db.py b/stock/app/db.py index 1fca446..80d5fa4 100644 --- a/stock/app/db.py +++ b/stock/app/db.py @@ -465,10 +465,17 @@ def get_alert_state_firing() -> set: return {(r["ticker"], r["kind"], r["condition"]) for r in rows} -def set_alert_firing(ticker: str, kind: str, condition: str, firing: bool, at_iso: str = None) -> None: +def set_alert_firing(ticker: str, kind: str, condition: str, firing: bool, + at_iso: str = None, mark_fired: bool = True) -> None: + """currently_firing 상태 갱신. + + mark_fired=True(기본): 실제 알림 발송 → first/last_fired_at 갱신. + mark_fired=False: 쿨다운으로 발송 억제하되 firing 상태만 유지 → 발동시각 미갱신 + (쿨다운이 계속 연장되지 않도록). + """ now = at_iso or _now_iso() with _conn() as conn: - if firing: + if firing and mark_fired: conn.execute( """INSERT INTO trade_alert_state(ticker,kind,condition,currently_firing,first_fired_at,last_fired_at,last_seen_at) VALUES(?,?,?,1,?,?,?) @@ -479,6 +486,14 @@ def set_alert_firing(ticker: str, kind: str, condition: str, firing: bool, at_is last_seen_at=excluded.last_seen_at""", (ticker, kind, condition, now, now, now), ) + elif firing and not mark_fired: + conn.execute( + """INSERT INTO trade_alert_state(ticker,kind,condition,currently_firing,last_seen_at) + VALUES(?,?,?,1,?) + ON CONFLICT(ticker,kind,condition) DO UPDATE SET + currently_firing=1, last_seen_at=excluded.last_seen_at""", + (ticker, kind, condition, now), + ) else: conn.execute( "UPDATE trade_alert_state SET currently_firing=0, last_seen_at=? WHERE ticker=? AND kind=? AND condition=?", @@ -486,6 +501,32 @@ def set_alert_firing(ticker: str, kind: str, condition: str, firing: bool, at_is ) +def get_alert_last_fired_map() -> dict: + """{(ticker,kind,condition): last_fired_at ISO} — 쿨다운 판정용.""" + with _conn() as conn: + rows = conn.execute( + "SELECT ticker,kind,condition,last_fired_at FROM trade_alert_state" + ).fetchall() + return {(r["ticker"], r["kind"], r["condition"]): r["last_fired_at"] for r in rows} + + +def get_ticker_name(ticker: str) -> Optional[str]: + """종목명 해석 — watchlist → portfolio → krx_master 순. 없으면 None.""" + with _conn() as conn: + for sql in ( + "SELECT name FROM watchlist WHERE ticker=?", + "SELECT name FROM portfolio WHERE ticker=? LIMIT 1", + "SELECT name FROM krx_master WHERE ticker=?", + ): + try: + row = conn.execute(sql, (ticker,)).fetchone() + except sqlite3.OperationalError: + continue # 일부 테스트 DB엔 해당 테이블 부재 + if row and row["name"]: + return row["name"] + return None + + def touch_alert_seen(keys: list, at_iso: str) -> None: with _conn() as conn: for (ticker, kind, condition) in keys: diff --git a/stock/app/main.py b/stock/app/main.py index f78cc4c..593c068 100644 --- a/stock/app/main.py +++ b/stock/app/main.py @@ -23,6 +23,7 @@ from .db import ( add_sell_history, get_sell_history, update_sell_history, delete_sell_history, add_watchlist, remove_watchlist, get_watchlist, get_alert_history, get_alert_state_firing, set_alert_firing, touch_alert_seen, add_alert_history, + get_alert_last_fired_map, get_ticker_name, ) from .scraper import fetch_market_news, fetch_major_indices from .price_fetcher import get_current_prices, get_current_prices_detail @@ -548,15 +549,30 @@ def post_trade_alert_report(req: TradeAlertReport): 전송 실패 시 상태를 채택하지 않아 다음 사이클에 동일 alert가 다시 "신규"로 잡혀 재시도된다(멱등). 해제(cleared)는 전송과 무관하게 firing=False. """ + from datetime import datetime, timedelta + cooldown_h = float(os.getenv("TRADE_ALERT_COOLDOWN_HOURS", "6")) + now = datetime.utcnow() + prev = get_alert_state_firing() + last_fired = get_alert_last_fired_map() d = diff_firing(req.firing, prev) new_count = 0 + suppressed = 0 for a in d["new"]: - if trade_alerts.notify_agent_office([a]): - set_alert_firing(a["ticker"], a["kind"], a["condition"], firing=True, at_iso=req.as_of) + key = (a["ticker"], a["kind"], a["condition"]) + # 쿨다운: 같은 종목·조건이 최근 발동됐으면(해제→재발화 오실레이션) 재알림 억제 + lf = last_fired.get(key) + if cooldown_h > 0 and _within_cooldown(now, lf, timedelta(hours=cooldown_h)): + set_alert_firing(*key, firing=True, mark_fired=False) # firing 유지, 발동시각 미갱신 + suppressed += 1 + continue + name = a.get("name") or get_ticker_name(a["ticker"]) + alert = {**a, "name": name} + if trade_alerts.notify_agent_office([alert]): + set_alert_firing(*key, firing=True) # 발동시각 갱신(UTC) add_alert_history( - a["ticker"], a.get("name"), a["kind"], a["condition"], + a["ticker"], name, a["kind"], a["condition"], a.get("price"), a.get("detail") or {}, ) new_count += 1 @@ -566,7 +582,19 @@ def post_trade_alert_report(req: TradeAlertReport): touch_alert_seen(d["seen"], req.as_of or "") - return {"new_alerts": new_count, "cleared": len(d["cleared"])} + return {"new_alerts": new_count, "cleared": len(d["cleared"]), "suppressed": suppressed} + + +def _within_cooldown(now, last_iso, cooldown) -> bool: + """last_iso(UTC ISO `%Y-%m-%dT%H:%M:%fZ`)가 now 기준 cooldown 이내면 True.""" + if not last_iso: + return False + from datetime import datetime + try: + lf = datetime.strptime(last_iso, "%Y-%m-%dT%H:%M:%fZ") + except (ValueError, TypeError): + return False + return (now - lf) < cooldown @app.post("/api/portfolio", status_code=201) diff --git a/stock/tests/test_trade_alerts_report_api.py b/stock/tests/test_trade_alerts_report_api.py index 26c093d..623e086 100644 --- a/stock/tests/test_trade_alerts_report_api.py +++ b/stock/tests/test_trade_alerts_report_api.py @@ -46,11 +46,40 @@ def test_report_send_failure_does_not_persist(client): assert r2.json()["new_alerts"] == 1 -def test_report_cleared_rearm(client): +def test_report_cooldown_suppresses_immediate_refire(client): + """같은 종목·조건이 해제됐다 곧바로 재발화해도 쿨다운(기본 6h) 내면 재알림 억제.""" + firing = [{"ticker": "005930", "name": "삼성", "kind": "buy", + "condition": "buy_breakout", "price": 71500, "detail": {}}] + with patch("app.trade_alerts.notify_agent_office", return_value=True): + assert _report(client, firing).json()["new_alerts"] == 1 # 최초 알림 + _report(client, []) # 해제 + r = _report(client, firing) # 즉시 재발화 → 쿨다운 억제 + assert r.json()["new_alerts"] == 0 + assert r.json()["suppressed"] == 1 + + +def test_report_refire_after_cooldown_alerts(client, monkeypatch): + """쿨다운=0이면 해제 후 재발화 시 재알림.""" + monkeypatch.setenv("TRADE_ALERT_COOLDOWN_HOURS", "0") firing = [{"ticker": "005930", "name": "삼성", "kind": "buy", "condition": "buy_breakout", "price": 71500, "detail": {}}] with patch("app.trade_alerts.notify_agent_office", return_value=True): _report(client, firing) - _report(client, []) # 해제 - r = _report(client, firing) # 재발화 + _report(client, []) + r = _report(client, firing) assert r.json()["new_alerts"] == 1 + + +def test_report_resolves_stock_name_from_watchlist(client): + """워커 firing에 name이 없어도 NAS가 종목명을 해석해 알림에 포함한다.""" + from app import db + db.add_watchlist("000660", "SK하이닉스") + firing = [{"ticker": "000660", "kind": "buy", "condition": "buy_breakout", + "price": 180000, "detail": {}}] # name 없음 + with patch("app.trade_alerts.notify_agent_office", return_value=True) as m: + _report(client, firing) + sent_alert = m.call_args[0][0][0] + assert sent_alert["name"] == "SK하이닉스" + # 이력에도 종목명 기록 + alerts = client.get("/api/stock/trade-alerts?days=1").json()["alerts"] + assert alerts[0]["name"] == "SK하이닉스"