From d1b2b6a4ba4c4d8a0fda51d81a1480b16da3f939 Mon Sep 17 00:00:00 2001 From: gahusb Date: Fri, 15 May 2026 08:40:49 +0900 Subject: [PATCH] feat(stock-webai): /api/webai/news-sentiment daily dump JOINs news_sentiment with krx_master for name fallback. Sorted by score DESC. Date param defaults to latest. Empty table returns {date: null, count: 0, items: []}. 4 integration tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- stock/app/main.py | 48 ++++++++++++++++++ stock/app/test_webai_endpoints.py | 84 +++++++++++++++++++++++++++++++ 2 files changed, 132 insertions(+) diff --git a/stock/app/main.py b/stock/app/main.py index c444450..94b6968 100644 --- a/stock/app/main.py +++ b/stock/app/main.py @@ -405,6 +405,54 @@ def get_webai_portfolio(): return _augment_portfolio_with_pnl_pct(get_portfolio()) +def _fetch_news_sentiment_dump(date: str | None) -> dict: + """news_sentiment 일별 dump (krx_master JOIN, score DESC).""" + from .db import _conn + conn = _conn() + try: + # 1) date resolve — None 이면 최신 date + if date is None: + row = conn.execute( + "SELECT MAX(date) FROM news_sentiment" + ).fetchone() + date = row[0] if row and row[0] else None + + if date is None: + return {"date": None, "count": 0, "items": []} + + # 2) JOIN krx_master.name (없으면 ticker 그대로) + rows = conn.execute( + """ + SELECT ns.ticker, + COALESCE(km.name, ns.ticker) AS name, + ns.score_raw, + ns.reason, + ns.news_count, + ns.source + FROM news_sentiment ns + LEFT JOIN krx_master km ON km.ticker = ns.ticker + WHERE ns.date = ? + ORDER BY ns.score_raw DESC + """, + (date,) + ).fetchall() + finally: + conn.close() + + items = [ + {"ticker": r[0], "name": r[1], "score": r[2], + "reason": r[3], "news_count": r[4], "source": r[5]} + for r in rows + ] + return {"date": date, "count": len(items), "items": items} + + +@app.get("/api/webai/news-sentiment", dependencies=[Depends(verify_webai_key)]) +def get_webai_news_sentiment(date: str | None = None): + """web-ai 전용 news sentiment 일별 dump.""" + return _fetch_news_sentiment_dump(date) + + @app.post("/api/portfolio", status_code=201) def create_portfolio_item(req: PortfolioItemRequest): """포트폴리오 종목 추가""" diff --git a/stock/app/test_webai_endpoints.py b/stock/app/test_webai_endpoints.py index 32bb40f..60318fe 100644 --- a/stock/app/test_webai_endpoints.py +++ b/stock/app/test_webai_endpoints.py @@ -87,3 +87,87 @@ def test_webai_portfolio_missing_key_returns_401(client): r = client.get("/api/webai/portfolio") assert r.status_code == 401 assert "X-WebAI-Key" in r.json()["detail"] + + +def _seed_news_sentiment(date_str: str, rows: list[tuple]): + """rows: list of (ticker, score_raw, reason, news_count).""" + db_path = os.environ["STOCK_DB_PATH"] + c = sqlite3.connect(db_path) + for ticker, score, reason, news_count in rows: + c.execute( + "INSERT OR REPLACE INTO news_sentiment " + "(ticker, date, score_raw, reason, news_count, source) " + "VALUES (?, ?, ?, ?, ?, 'articles')", + (ticker, date_str, score, reason, news_count) + ) + c.commit() + c.close() + + +def _seed_krx_master(rows: list[tuple]): + """rows: list of (ticker, name).""" + db_path = os.environ["STOCK_DB_PATH"] + c = sqlite3.connect(db_path) + import datetime as dt + now = dt.datetime.utcnow().isoformat() + for ticker, name in rows: + c.execute( + "INSERT OR REPLACE INTO krx_master " + "(ticker, name, market, market_cap, updated_at) VALUES (?, ?, 'KOSPI', 0, ?)", + (ticker, name, now) + ) + c.commit() + c.close() + + +def test_webai_news_sentiment_returns_latest_date_when_no_param(client): + _seed_krx_master([("005930", "삼성전자"), ("000660", "SK하이닉스")]) + _seed_news_sentiment("2026-05-14", [("005930", 5.0, "old", 5)]) + _seed_news_sentiment("2026-05-15", [ + ("005930", 6.2, "HBM 양산 가시화", 12), + ("000660", 5.5, "PPI 우려에도 강세", 8), + ]) + + r = client.get("/api/webai/news-sentiment", headers=HEADERS_OK) + assert r.status_code == 200 + body = r.json() + assert body["date"] == "2026-05-15" + assert body["count"] == 2 + # sorted by score DESC + assert body["items"][0]["ticker"] == "005930" + assert body["items"][0]["score"] == 6.2 + assert body["items"][0]["name"] == "삼성전자" + assert body["items"][0]["reason"] == "HBM 양산 가시화" + + +def test_webai_news_sentiment_filters_by_date_param(client): + _seed_krx_master([("005930", "삼성전자")]) + _seed_news_sentiment("2026-05-14", [("005930", 5.0, "yesterday", 5)]) + _seed_news_sentiment("2026-05-15", [("005930", 6.2, "today", 12)]) + + r = client.get("/api/webai/news-sentiment?date=2026-05-14", headers=HEADERS_OK) + body = r.json() + assert body["date"] == "2026-05-14" + assert body["count"] == 1 + assert body["items"][0]["reason"] == "yesterday" + + +def test_webai_news_sentiment_empty_table_returns_count_zero(client): + r = client.get("/api/webai/news-sentiment", headers=HEADERS_OK) + body = r.json() + assert body["date"] is None + assert body["count"] == 0 + assert body["items"] == [] + + +def test_webai_news_sentiment_items_sorted_by_score_desc(client): + _seed_krx_master([("A", "A주"), ("B", "B주"), ("C", "C주")]) + _seed_news_sentiment("2026-05-15", [ + ("A", 1.0, "low", 1), + ("B", 9.0, "high", 1), + ("C", 5.0, "mid", 1), + ]) + + r = client.get("/api/webai/news-sentiment", headers=HEADERS_OK) + items = r.json()["items"] + assert [i["score"] for i in items] == [9.0, 5.0, 1.0]