feat(screener): ai_news pipeline (top-100 parallel, fail-soft, upsert)
This commit is contained in:
110
stock-lab/tests/test_ai_news_pipeline.py
Normal file
110
stock-lab/tests/test_ai_news_pipeline.py
Normal file
@@ -0,0 +1,110 @@
|
||||
import datetime as dt
|
||||
import sqlite3
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from app.screener.ai_news import pipeline
|
||||
from app.screener.schema import ensure_screener_schema
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def conn():
|
||||
c = sqlite3.connect(":memory:")
|
||||
c.row_factory = sqlite3.Row
|
||||
ensure_screener_schema(c)
|
||||
# 시총 상위 3종목 시드
|
||||
c.execute("INSERT INTO krx_master (ticker, name, market, market_cap, updated_at) "
|
||||
"VALUES (?, ?, 'KOSPI', ?, datetime('now'))", ("005930", "삼성전자", 9_000_000))
|
||||
c.execute("INSERT INTO krx_master (ticker, name, market, market_cap, updated_at) "
|
||||
"VALUES (?, ?, 'KOSPI', ?, datetime('now'))", ("000660", "SK하이닉스", 8_000_000))
|
||||
c.execute("INSERT INTO krx_master (ticker, name, market, market_cap, updated_at) "
|
||||
"VALUES (?, ?, 'KOSPI', ?, datetime('now'))", ("373220", "LG에너지솔루션", 7_000_000))
|
||||
c.commit()
|
||||
yield c
|
||||
c.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refresh_daily_happy_path(conn):
|
||||
"""3종목 mini integration — 각 종목별로 scraper/analyzer mock."""
|
||||
asof = dt.date(2026, 5, 13)
|
||||
fake_news = [{"title": "헤드라인"}]
|
||||
|
||||
async def fake_fetch(client, ticker, n):
|
||||
return fake_news
|
||||
|
||||
scores_by_ticker = {
|
||||
"005930": 7.5, "000660": 4.0, "373220": -6.0,
|
||||
}
|
||||
async def fake_score(llm, ticker, news, *, name=None, model="m"):
|
||||
return {
|
||||
"ticker": ticker, "score_raw": scores_by_ticker[ticker],
|
||||
"reason": f"r{ticker}", "news_count": 1,
|
||||
"tokens_input": 100, "tokens_output": 20, "model": model,
|
||||
}
|
||||
|
||||
with patch.object(pipeline, "_scraper") as ms, \
|
||||
patch.object(pipeline, "_analyzer") as ma, \
|
||||
patch.object(pipeline, "_make_llm") as ml, \
|
||||
patch.object(pipeline, "_make_http") as mh:
|
||||
ms.fetch_news = fake_fetch
|
||||
ma.score_sentiment = fake_score
|
||||
ml.return_value.__aenter__.return_value = AsyncMock()
|
||||
ml.return_value.__aexit__.return_value = None
|
||||
mh.return_value.__aenter__.return_value = AsyncMock()
|
||||
mh.return_value.__aexit__.return_value = None
|
||||
result = await pipeline.refresh_daily(conn, asof, concurrency=3, rate_limit_sec=0)
|
||||
|
||||
assert result["asof"] == "2026-05-13"
|
||||
assert result["updated"] == 3
|
||||
assert result["failures"] == []
|
||||
assert len(result["top_pos"]) == 3
|
||||
assert result["top_pos"][0]["ticker"] == "005930" # 가장 큰 점수
|
||||
assert result["top_neg"][0]["ticker"] == "373220" # 가장 작은 점수
|
||||
assert result["tokens_input"] == 300
|
||||
assert result["tokens_output"] == 60
|
||||
|
||||
# DB upsert 확인
|
||||
rows = conn.execute("SELECT ticker, score_raw FROM news_sentiment WHERE date=?",
|
||||
("2026-05-13",)).fetchall()
|
||||
assert len(rows) == 3
|
||||
by_ticker = {r["ticker"]: r["score_raw"] for r in rows}
|
||||
assert by_ticker["005930"] == 7.5
|
||||
assert by_ticker["373220"] == -6.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refresh_daily_failures_isolated(conn):
|
||||
"""한 종목이 예외 던져도 나머지 종목은 정상 처리."""
|
||||
asof = dt.date(2026, 5, 13)
|
||||
|
||||
async def fake_fetch(client, ticker, n):
|
||||
return [{"title": "h"}]
|
||||
|
||||
async def fake_score(llm, ticker, news, *, name=None, model="m"):
|
||||
if ticker == "000660":
|
||||
raise RuntimeError("llm exploded")
|
||||
return {
|
||||
"ticker": ticker, "score_raw": 5.0, "reason": "r", "news_count": 1,
|
||||
"tokens_input": 100, "tokens_output": 20, "model": model,
|
||||
}
|
||||
|
||||
with patch.object(pipeline, "_scraper") as ms, \
|
||||
patch.object(pipeline, "_analyzer") as ma, \
|
||||
patch.object(pipeline, "_make_llm") as ml, \
|
||||
patch.object(pipeline, "_make_http") as mh:
|
||||
ms.fetch_news = fake_fetch
|
||||
ma.score_sentiment = fake_score
|
||||
ml.return_value.__aenter__.return_value = AsyncMock()
|
||||
ml.return_value.__aexit__.return_value = None
|
||||
mh.return_value.__aenter__.return_value = AsyncMock()
|
||||
mh.return_value.__aexit__.return_value = None
|
||||
result = await pipeline.refresh_daily(conn, asof, concurrency=3, rate_limit_sec=0)
|
||||
|
||||
assert result["updated"] == 2
|
||||
assert len(result["failures"]) == 1
|
||||
|
||||
|
||||
def test_top_market_cap_tickers(conn):
|
||||
out = pipeline._top_market_cap_tickers(conn, n=2)
|
||||
assert out == ["005930", "000660"]
|
||||
Reference in New Issue
Block a user