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) <noreply@anthropic.com>
This commit is contained in:
@@ -405,6 +405,54 @@ def get_webai_portfolio():
|
|||||||
return _augment_portfolio_with_pnl_pct(get_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)
|
@app.post("/api/portfolio", status_code=201)
|
||||||
def create_portfolio_item(req: PortfolioItemRequest):
|
def create_portfolio_item(req: PortfolioItemRequest):
|
||||||
"""포트폴리오 종목 추가"""
|
"""포트폴리오 종목 추가"""
|
||||||
|
|||||||
@@ -87,3 +87,87 @@ def test_webai_portfolio_missing_key_returns_401(client):
|
|||||||
r = client.get("/api/webai/portfolio")
|
r = client.get("/api/webai/portfolio")
|
||||||
assert r.status_code == 401
|
assert r.status_code == 401
|
||||||
assert "X-WebAI-Key" in r.json()["detail"]
|
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]
|
||||||
|
|||||||
Reference in New Issue
Block a user