From 04aff34883395979e8fbc438dce4f0e5c9574205 Mon Sep 17 00:00:00 2001 From: gahusb Date: Fri, 3 Jul 2026 01:46:37 +0900 Subject: [PATCH] =?UTF-8?q?feat(trade-monitor):=20NAS=20trade-alert=20?= =?UTF-8?q?=ED=81=B4=EB=9D=BC=EC=9D=B4=EC=96=B8=ED=8A=B8=20(monitor-set/re?= =?UTF-8?q?port)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- services/trade-monitor/nas_client.py | 48 +++++++++++++++++++ .../trade-monitor/tests/test_nas_client.py | 39 +++++++++++++++ 2 files changed, 87 insertions(+) create mode 100644 services/trade-monitor/nas_client.py create mode 100644 services/trade-monitor/tests/test_nas_client.py diff --git a/services/trade-monitor/nas_client.py b/services/trade-monitor/nas_client.py new file mode 100644 index 0000000..040564a --- /dev/null +++ b/services/trade-monitor/nas_client.py @@ -0,0 +1,48 @@ +"""NAS stock 백엔드 trade-alert 계약 — X-WebAI-Key + retry.""" +from __future__ import annotations + +import asyncio +import logging + +import httpx + +logger = logging.getLogger(__name__) + +_MAX_ATTEMPTS = 3 +_RETRY_STATUSES = {429, 500, 502, 503, 504} + + +class NASClient: + def __init__(self, base_url: str, api_key: str, timeout: float = 10.0): + self._base_url = base_url.rstrip("/") + self._api_key = api_key + self._client = httpx.AsyncClient(timeout=timeout) + + async def close(self) -> None: + await self._client.aclose() + + async def get_monitor_set(self) -> dict: + return await self._request("GET", "/api/webai/trade-alert/monitor-set") + + async def post_report(self, as_of: str, firing: list[dict]) -> dict: + return await self._request( + "POST", "/api/webai/trade-alert/report", + json={"as_of": as_of, "firing": firing}) + + async def _request(self, method: str, path: str, **kwargs) -> dict: + url = f"{self._base_url}{path}" + headers = {"X-WebAI-Key": self._api_key} + for attempt in range(_MAX_ATTEMPTS): + try: + resp = await self._client.request(method, url, headers=headers, **kwargs) + if resp.status_code in _RETRY_STATUSES and attempt < _MAX_ATTEMPTS - 1: + await asyncio.sleep(2 ** attempt) + continue + resp.raise_for_status() + return resp.json() + except httpx.TimeoutException: + if attempt < _MAX_ATTEMPTS - 1: + await asyncio.sleep(2 ** attempt) + continue + raise + raise RuntimeError("retry exhausted") diff --git a/services/trade-monitor/tests/test_nas_client.py b/services/trade-monitor/tests/test_nas_client.py new file mode 100644 index 0000000..96d2967 --- /dev/null +++ b/services/trade-monitor/tests/test_nas_client.py @@ -0,0 +1,39 @@ +"""NASClient — monitor-set/report + X-WebAI-Key (respx).""" +import json as _json + +import httpx +import respx + +from nas_client import NASClient + +BASE = "http://nas.test" + + +@respx.mock +async def test_get_monitor_set_sends_key(): + route = respx.get(f"{BASE}/api/webai/trade-alert/monitor-set").mock( + return_value=httpx.Response(200, json={"session": "regular", "buy_targets": []})) + c = NASClient(BASE, "KEY") + ms = await c.get_monitor_set() + assert ms["session"] == "regular" + assert route.calls.last.request.headers["X-WebAI-Key"] == "KEY" + await c.close() + + +@respx.mock +async def test_post_report_payload(): + captured = {} + + def _resp(request): + captured.update(_json.loads(request.content)) + return httpx.Response(200, json={"new_alerts": 1, "cleared": 0}) + + respx.post(f"{BASE}/api/webai/trade-alert/report").mock(side_effect=_resp) + c = NASClient(BASE, "KEY") + firing = [{"ticker": "005930", "kind": "buy", "condition": "buy_breakout", + "price": 71500, "detail": {}}] + out = await c.post_report("2026-07-02T09:01:00+09:00", firing) + assert out["new_alerts"] == 1 + assert captured["as_of"] == "2026-07-02T09:01:00+09:00" + assert captured["firing"] == firing + await c.close()