fix(stock): AI 뉴스 호재/악재 명확히 구분
(1) 부호 게이트: top_pos는 score>0, top_neg는 score<0만 분류해 양수(호재) 종목이 악재란에 채워지는 문제 제거. 중립(0)은 양쪽 모두 제외. (2) 프롬프트: reason을 score 부호와 같은 방향 근거만 쓰도록 명시 — 호재 평가에 악재 내용, 악재 평가에 호재 내용 혼입 금지. 부호 게이트 회귀 테스트 2건 추가. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -15,9 +15,15 @@ PROMPT_TEMPLATE = """다음은 종목 {name}({ticker})에 대한 최근 뉴스 {
|
|||||||
|
|
||||||
{news_block}
|
{news_block}
|
||||||
|
|
||||||
이 뉴스들이 종목에 호재인지 악재인지 평가하세요.
|
이 뉴스들이 종목 주가에 호재인지 악재인지 종합 평가하세요.
|
||||||
score: -10(매우 강한 악재) ~ +10(매우 강한 호재) 사이의 실수. 0은 중립.
|
|
||||||
reason: 30자 이내 한 줄 근거.
|
규칙:
|
||||||
|
- score: -10(매우 강한 악재) ~ +10(매우 강한 호재) 사이의 실수. 명확한 방향성이 없으면 0(중립).
|
||||||
|
- 뉴스가 호재·악재로 섞여 있으면 주가에 더 우세한 쪽을 기준으로 부호를 정하세요.
|
||||||
|
- reason은 반드시 score 부호와 같은 방향의 근거만 쓰세요.
|
||||||
|
· score가 양수(호재)면 호재 근거만, 음수(악재)면 악재 근거만 적습니다.
|
||||||
|
· 호재 평가에 악재 내용을, 악재 평가에 호재 내용을 섞지 마세요.
|
||||||
|
- reason: 30자 이내 한 줄.
|
||||||
|
|
||||||
JSON으로만 응답하세요. 다른 텍스트 금지:
|
JSON으로만 응답하세요. 다른 텍스트 금지:
|
||||||
{{"score": <float>, "reason": "<string>"}}"""
|
{{"score": <float>, "reason": "<string>"}}"""
|
||||||
|
|||||||
@@ -124,8 +124,10 @@ async def refresh_daily(
|
|||||||
if successes:
|
if successes:
|
||||||
_upsert_news_sentiment(conn, asof, successes, source="articles")
|
_upsert_news_sentiment(conn, asof, successes, source="articles")
|
||||||
|
|
||||||
top_pos = sorted(successes, key=lambda r: -r["score_raw"])[:5]
|
# 부호 게이트: 호재(score>0)·악재(score<0)만 분류. score 미만 종목이 5개 미만이어도
|
||||||
top_neg = sorted(successes, key=lambda r: r["score_raw"])[:5]
|
# 반대 부호 종목으로 채우지 않음 (양수 종목이 악재란에 섞이는 문제 방지). 중립(0)은 제외.
|
||||||
|
top_pos = sorted([r for r in successes if r["score_raw"] > 0], key=lambda r: -r["score_raw"])[:5]
|
||||||
|
top_neg = sorted([r for r in successes if r["score_raw"] < 0], key=lambda r: r["score_raw"])[:5]
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"asof": asof.isoformat(),
|
"asof": asof.isoformat(),
|
||||||
|
|||||||
@@ -140,6 +140,71 @@ async def test_refresh_daily_no_match_ticker_skipped(conn):
|
|||||||
assert {r["ticker"] for r in rows} == {"005930"}
|
assert {r["ticker"] for r in rows} == {"005930"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_refresh_daily_sign_gate_no_positive_in_neg(conn):
|
||||||
|
"""전 종목 양수 점수면 top_neg는 비어야 함 (호재 종목이 악재란에 채워지면 안 됨)."""
|
||||||
|
asof = dt.date(2026, 5, 13)
|
||||||
|
fake_articles_by_ticker = {
|
||||||
|
"005930": [{"title": "h", "summary": "", "press": "", "pub_date": ""}],
|
||||||
|
"000660": [{"title": "h", "summary": "", "press": "", "pub_date": ""}],
|
||||||
|
"373220": [{"title": "h", "summary": "", "press": "", "pub_date": ""}],
|
||||||
|
}
|
||||||
|
fake_stats = {"total_articles": 3, "matched_pairs": 3, "hit_tickers": 3}
|
||||||
|
scores = {"005930": 6.0, "000660": 2.0, "373220": 0.5} # 모두 양수
|
||||||
|
|
||||||
|
async def fake_score(llm, ticker, news, *, name=None, model="m"):
|
||||||
|
return {
|
||||||
|
"ticker": ticker, "score_raw": scores[ticker], "reason": "r",
|
||||||
|
"news_count": 1, "tokens_input": 1, "tokens_output": 1, "model": model,
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch.object(pipeline, "articles_source") as mas, \
|
||||||
|
patch.object(pipeline, "_analyzer") as ma, \
|
||||||
|
patch.object(pipeline, "_make_llm") as ml:
|
||||||
|
mas.gather_articles_for_tickers = MagicMock(return_value=(fake_articles_by_ticker, fake_stats))
|
||||||
|
ma.score_sentiment = fake_score
|
||||||
|
ml.return_value.__aenter__.return_value = AsyncMock()
|
||||||
|
ml.return_value.__aexit__.return_value = None
|
||||||
|
result = await pipeline.refresh_daily(conn, asof, concurrency=3)
|
||||||
|
|
||||||
|
assert len(result["top_pos"]) == 3
|
||||||
|
assert result["top_neg"] == [] # 양수 종목이 악재란에 들어가면 안 됨
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_refresh_daily_sign_gate_excludes_neutral(conn):
|
||||||
|
"""score=0(중립)은 호재·악재 어디에도 포함되지 않음."""
|
||||||
|
asof = dt.date(2026, 5, 13)
|
||||||
|
fake_articles_by_ticker = {
|
||||||
|
"005930": [{"title": "h", "summary": "", "press": "", "pub_date": ""}],
|
||||||
|
"000660": [{"title": "h", "summary": "", "press": "", "pub_date": ""}],
|
||||||
|
"373220": [{"title": "h", "summary": "", "press": "", "pub_date": ""}],
|
||||||
|
}
|
||||||
|
fake_stats = {"total_articles": 3, "matched_pairs": 3, "hit_tickers": 3}
|
||||||
|
scores = {"005930": 3.0, "000660": 0.0, "373220": -3.0}
|
||||||
|
|
||||||
|
async def fake_score(llm, ticker, news, *, name=None, model="m"):
|
||||||
|
return {
|
||||||
|
"ticker": ticker, "score_raw": scores[ticker], "reason": "r",
|
||||||
|
"news_count": 1, "tokens_input": 1, "tokens_output": 1, "model": model,
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch.object(pipeline, "articles_source") as mas, \
|
||||||
|
patch.object(pipeline, "_analyzer") as ma, \
|
||||||
|
patch.object(pipeline, "_make_llm") as ml:
|
||||||
|
mas.gather_articles_for_tickers = MagicMock(return_value=(fake_articles_by_ticker, fake_stats))
|
||||||
|
ma.score_sentiment = fake_score
|
||||||
|
ml.return_value.__aenter__.return_value = AsyncMock()
|
||||||
|
ml.return_value.__aexit__.return_value = None
|
||||||
|
result = await pipeline.refresh_daily(conn, asof, concurrency=3)
|
||||||
|
|
||||||
|
pos_tickers = {r["ticker"] for r in result["top_pos"]}
|
||||||
|
neg_tickers = {r["ticker"] for r in result["top_neg"]}
|
||||||
|
assert pos_tickers == {"005930"}
|
||||||
|
assert neg_tickers == {"373220"}
|
||||||
|
assert "000660" not in pos_tickers and "000660" not in neg_tickers
|
||||||
|
|
||||||
|
|
||||||
def test_top_market_cap_tickers(conn):
|
def test_top_market_cap_tickers(conn):
|
||||||
out = pipeline._top_market_cap_tickers(conn, n=2)
|
out = pipeline._top_market_cap_tickers(conn, n=2)
|
||||||
assert out == ["005930", "000660"]
|
assert out == ["005930", "000660"]
|
||||||
|
|||||||
Reference in New Issue
Block a user