From 2906a2ae3e9070e91b5ca625ad3862aa04ba93c4 Mon Sep 17 00:00:00 2001 From: gahusb Date: Thu, 2 Jul 2026 19:56:58 +0900 Subject: [PATCH] =?UTF-8?q?feat(stock):=20webai=20report=20=E2=80=94=20edg?= =?UTF-8?q?e=20diff=E2=86=92agent-office=20push=E2=86=92=EC=83=81=ED=83=9C?= =?UTF-8?q?/=EC=9D=B4=EB=A0=A5(=EC=A0=84=EC=86=A1=EC=84=B1=EA=B3=B5?= =?UTF-8?q?=EC=8B=9C=EB=A7=8C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- stock/app/main.py | 39 +++++++++++++- stock/app/trade_alerts.py | 18 +++++++ stock/tests/test_trade_alerts_report_api.py | 56 +++++++++++++++++++++ 3 files changed, 112 insertions(+), 1 deletion(-) create mode 100644 stock/tests/test_trade_alerts_report_api.py diff --git a/stock/app/main.py b/stock/app/main.py index de5c118..f78cc4c 100644 --- a/stock/app/main.py +++ b/stock/app/main.py @@ -22,6 +22,7 @@ from .db import ( upsert_asset_snapshot, get_asset_snapshots, 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, ) from .scraper import fetch_market_news, fetch_major_indices from .price_fetcher import get_current_prices, get_current_prices_detail @@ -29,8 +30,9 @@ from .ai_summarizer import summarize_news, OllamaError from .auth import verify_webai_key from . import webai_cache from . import holdings_intel +from . import trade_alerts from .trade_alerts import ( - build_monitor_set, current_session, DEFAULT_EXIT_PARAMS, DEFAULT_BUY_PARAMS, + build_monitor_set, current_session, diff_firing, DEFAULT_EXIT_PARAMS, DEFAULT_BUY_PARAMS, ) app = FastAPI() @@ -532,6 +534,41 @@ def get_trade_alert_monitor_set(): conn.close() +class TradeAlertReport(BaseModel): + as_of: str | None = None + firing: list[dict] = [] + + +@app.post("/api/webai/trade-alert/report", dependencies=[Depends(verify_webai_key)]) +def post_trade_alert_report(req: TradeAlertReport): + """web-ai(Windows 워커) 전용 — 발화 보고 수신 (계약 §5.2). + + 직전 발화상태 대비 edge diff(diff_firing) 후, 신규 alert는 + agent-office 전송 성공 시에만 상태(firing=True)+이력 반영한다. + 전송 실패 시 상태를 채택하지 않아 다음 사이클에 동일 alert가 다시 + "신규"로 잡혀 재시도된다(멱등). 해제(cleared)는 전송과 무관하게 firing=False. + """ + prev = get_alert_state_firing() + d = diff_firing(req.firing, prev) + + new_count = 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) + add_alert_history( + a["ticker"], a.get("name"), a["kind"], a["condition"], + a.get("price"), a.get("detail") or {}, + ) + new_count += 1 + + for ticker, kind, condition in d["cleared"]: + set_alert_firing(ticker, kind, condition, firing=False) + + touch_alert_seen(d["seen"], req.as_of or "") + + return {"new_alerts": new_count, "cleared": len(d["cleared"])} + + @app.post("/api/portfolio", status_code=201) def create_portfolio_item(req: PortfolioItemRequest): """포트폴리오 종목 추가""" diff --git a/stock/app/trade_alerts.py b/stock/app/trade_alerts.py index 97fe3f9..2845973 100644 --- a/stock/app/trade_alerts.py +++ b/stock/app/trade_alerts.py @@ -5,6 +5,9 @@ Windows 워커가 GET /api/webai/trade-alert/monitor-set 로 받는 응답을 NAS는 watchlist ∪ screener 최신 성공 run 후보를 buy_targets로, 보유 종목을 sell_targets로 병합해 넘긴다. TA/조건판정은 워커 쪽 책임. """ +import os +import httpx + from datetime import datetime, timedelta, timezone, time as _time from typing import Optional @@ -117,3 +120,18 @@ def diff_firing(reported: list, prev: set) -> dict: "cleared": cleared, "seen": sorted(cur_keys), } + + +def notify_agent_office(alerts: list) -> bool: + """신규 alert들을 agent-office로 push (계약 §5.2). 전송 성공 시 True. + + 실패(네트워크 오류/비-200)는 False — 호출부가 상태/이력 미채택 후 다음 + 사이클에 동일 alert를 재시도하도록 한다(멱등, at-least-once). + """ + url = os.getenv("AGENT_OFFICE_URL", "http://agent-office:8000") + "/api/agent-office/stock/trade-alert" + try: + with httpx.Client(timeout=10) as c: + resp = c.post(url, json={"alerts": alerts}) + return resp.status_code == 200 + except httpx.HTTPError: + return False diff --git a/stock/tests/test_trade_alerts_report_api.py b/stock/tests/test_trade_alerts_report_api.py new file mode 100644 index 0000000..26c093d --- /dev/null +++ b/stock/tests/test_trade_alerts_report_api.py @@ -0,0 +1,56 @@ +import pytest +from unittest.mock import patch +from fastapi.testclient import TestClient + + +@pytest.fixture +def client(monkeypatch, tmp_path): + from app import db as _db + monkeypatch.setattr(_db, "DB_PATH", str(tmp_path / "stock.db")) + _db.init_db() + monkeypatch.setenv("WEBAI_API_KEY", "k") + from app.main import app + return TestClient(app) + + +def _report(client, firing): + return client.post("/api/webai/trade-alert/report", + headers={"X-WebAI-Key": "k"}, + json={"as_of": "2026-07-02T09:01:00+09:00", "firing": firing}) + + +def test_report_new_edge_sends_and_persists(client): + firing = [{"ticker": "005930", "name": "삼성전자", "kind": "buy", + "condition": "buy_breakout", "price": 71500, "detail": {"vol": 2.0}}] + with patch("app.trade_alerts.notify_agent_office", return_value=True) as m: + r1 = _report(client, firing) + assert r1.json()["new_alerts"] == 1 + assert m.called + # 2번째 동일 firing → 유지, 신규 0 + with patch("app.trade_alerts.notify_agent_office", return_value=True): + r2 = _report(client, firing) + assert r2.json()["new_alerts"] == 0 + # 이력 1건 + assert len(client.get("/api/stock/trade-alerts?days=1").json()["alerts"]) == 1 + + +def test_report_send_failure_does_not_persist(client): + firing = [{"ticker": "005930", "name": "삼성전자", "kind": "buy", + "condition": "buy_breakout", "price": 71500, "detail": {}}] + with patch("app.trade_alerts.notify_agent_office", return_value=False): + r = _report(client, firing) + assert r.json()["new_alerts"] == 0 # 전송 실패 → 미채택 + # 다음 사이클(전송 성공) 재시도되어 알림 + with patch("app.trade_alerts.notify_agent_office", return_value=True): + r2 = _report(client, firing) + assert r2.json()["new_alerts"] == 1 + + +def test_report_cleared_rearm(client): + 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) # 재발화 + assert r.json()["new_alerts"] == 1