feat(stock): webai report — edge diff→agent-office push→상태/이력(전송성공시만)
This commit is contained in:
@@ -22,6 +22,7 @@ from .db import (
|
|||||||
upsert_asset_snapshot, get_asset_snapshots,
|
upsert_asset_snapshot, get_asset_snapshots,
|
||||||
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,
|
||||||
)
|
)
|
||||||
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
|
||||||
@@ -29,8 +30,9 @@ from .ai_summarizer import summarize_news, OllamaError
|
|||||||
from .auth import verify_webai_key
|
from .auth import verify_webai_key
|
||||||
from . import webai_cache
|
from . import webai_cache
|
||||||
from . import holdings_intel
|
from . import holdings_intel
|
||||||
|
from . import trade_alerts
|
||||||
from .trade_alerts import (
|
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()
|
app = FastAPI()
|
||||||
@@ -532,6 +534,41 @@ def get_trade_alert_monitor_set():
|
|||||||
conn.close()
|
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)
|
@app.post("/api/portfolio", status_code=201)
|
||||||
def create_portfolio_item(req: PortfolioItemRequest):
|
def create_portfolio_item(req: PortfolioItemRequest):
|
||||||
"""포트폴리오 종목 추가"""
|
"""포트폴리오 종목 추가"""
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ Windows 워커가 GET /api/webai/trade-alert/monitor-set 로 받는 응답을
|
|||||||
NAS는 watchlist ∪ screener 최신 성공 run 후보를 buy_targets로, 보유 종목을
|
NAS는 watchlist ∪ screener 최신 성공 run 후보를 buy_targets로, 보유 종목을
|
||||||
sell_targets로 병합해 넘긴다. TA/조건판정은 워커 쪽 책임.
|
sell_targets로 병합해 넘긴다. TA/조건판정은 워커 쪽 책임.
|
||||||
"""
|
"""
|
||||||
|
import os
|
||||||
|
import httpx
|
||||||
|
|
||||||
from datetime import datetime, timedelta, timezone, time as _time
|
from datetime import datetime, timedelta, timezone, time as _time
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
@@ -117,3 +120,18 @@ def diff_firing(reported: list, prev: set) -> dict:
|
|||||||
"cleared": cleared,
|
"cleared": cleared,
|
||||||
"seen": sorted(cur_keys),
|
"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
|
||||||
|
|||||||
56
stock/tests/test_trade_alerts_report_api.py
Normal file
56
stock/tests/test_trade_alerts_report_api.py
Normal file
@@ -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
|
||||||
Reference in New Issue
Block a user