feat(stock): 매매알람 쿨다운 중복억제 + 종목명 해석
- 쿨다운(TRADE_ALERT_COOLDOWN_HOURS 기본 6h): 같은 종목·조건 해제→재발화 오실레이션 시 재알림 억제(set_alert_firing mark_fired=False로 firing 유지·발동시각 미갱신, suppressed 카운트). - 종목명: 워커 firing에 name 없어도 NAS가 watchlist→portfolio→krx_master로 해석해 알림·이력에 포함.
This commit is contained in:
@@ -465,10 +465,17 @@ def get_alert_state_firing() -> set:
|
|||||||
return {(r["ticker"], r["kind"], r["condition"]) for r in rows}
|
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()
|
now = at_iso or _now_iso()
|
||||||
with _conn() as conn:
|
with _conn() as conn:
|
||||||
if firing:
|
if firing and mark_fired:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""INSERT INTO trade_alert_state(ticker,kind,condition,currently_firing,first_fired_at,last_fired_at,last_seen_at)
|
"""INSERT INTO trade_alert_state(ticker,kind,condition,currently_firing,first_fired_at,last_fired_at,last_seen_at)
|
||||||
VALUES(?,?,?,1,?,?,?)
|
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""",
|
last_seen_at=excluded.last_seen_at""",
|
||||||
(ticker, kind, condition, now, now, now),
|
(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:
|
else:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE trade_alert_state SET currently_firing=0, last_seen_at=? WHERE ticker=? AND kind=? AND condition=?",
|
"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:
|
def touch_alert_seen(keys: list, at_iso: str) -> None:
|
||||||
with _conn() as conn:
|
with _conn() as conn:
|
||||||
for (ticker, kind, condition) in keys:
|
for (ticker, kind, condition) in keys:
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ from .db import (
|
|||||||
add_sell_history, get_sell_history, update_sell_history, delete_sell_history,
|
add_sell_history, get_sell_history, update_sell_history, delete_sell_history,
|
||||||
add_watchlist, remove_watchlist, get_watchlist, get_alert_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_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 .scraper import fetch_market_news, fetch_major_indices
|
||||||
from .price_fetcher import get_current_prices, get_current_prices_detail
|
from .price_fetcher import get_current_prices, get_current_prices_detail
|
||||||
@@ -548,15 +549,30 @@ def post_trade_alert_report(req: TradeAlertReport):
|
|||||||
전송 실패 시 상태를 채택하지 않아 다음 사이클에 동일 alert가 다시
|
전송 실패 시 상태를 채택하지 않아 다음 사이클에 동일 alert가 다시
|
||||||
"신규"로 잡혀 재시도된다(멱등). 해제(cleared)는 전송과 무관하게 firing=False.
|
"신규"로 잡혀 재시도된다(멱등). 해제(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()
|
prev = get_alert_state_firing()
|
||||||
|
last_fired = get_alert_last_fired_map()
|
||||||
d = diff_firing(req.firing, prev)
|
d = diff_firing(req.firing, prev)
|
||||||
|
|
||||||
new_count = 0
|
new_count = 0
|
||||||
|
suppressed = 0
|
||||||
for a in d["new"]:
|
for a in d["new"]:
|
||||||
if trade_alerts.notify_agent_office([a]):
|
key = (a["ticker"], a["kind"], a["condition"])
|
||||||
set_alert_firing(a["ticker"], a["kind"], a["condition"], firing=True, at_iso=req.as_of)
|
# 쿨다운: 같은 종목·조건이 최근 발동됐으면(해제→재발화 오실레이션) 재알림 억제
|
||||||
|
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(
|
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 {},
|
a.get("price"), a.get("detail") or {},
|
||||||
)
|
)
|
||||||
new_count += 1
|
new_count += 1
|
||||||
@@ -566,7 +582,19 @@ def post_trade_alert_report(req: TradeAlertReport):
|
|||||||
|
|
||||||
touch_alert_seen(d["seen"], req.as_of or "")
|
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)
|
@app.post("/api/portfolio", status_code=201)
|
||||||
|
|||||||
@@ -46,11 +46,40 @@ def test_report_send_failure_does_not_persist(client):
|
|||||||
assert r2.json()["new_alerts"] == 1
|
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",
|
firing = [{"ticker": "005930", "name": "삼성", "kind": "buy",
|
||||||
"condition": "buy_breakout", "price": 71500, "detail": {}}]
|
"condition": "buy_breakout", "price": 71500, "detail": {}}]
|
||||||
with patch("app.trade_alerts.notify_agent_office", return_value=True):
|
with patch("app.trade_alerts.notify_agent_office", return_value=True):
|
||||||
_report(client, firing)
|
_report(client, firing)
|
||||||
_report(client, []) # 해제
|
_report(client, [])
|
||||||
r = _report(client, firing) # 재발화
|
r = _report(client, firing)
|
||||||
assert r.json()["new_alerts"] == 1
|
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하이닉스"
|
||||||
|
|||||||
Reference in New Issue
Block a user