diff --git a/stock-lab/app/screener/nodes/ai_news.py b/stock-lab/app/screener/nodes/ai_news.py new file mode 100644 index 0000000..08f2d4b --- /dev/null +++ b/stock-lab/app/screener/nodes/ai_news.py @@ -0,0 +1,36 @@ +"""AI 뉴스 호재/악재 점수 노드. + +ScreenContext.news_sentiment (DataFrame: ticker, score_raw, news_count) 를 +min_news_count 로 필터한 뒤 percentile_rank 로 0~100 변환. +""" + +from __future__ import annotations + +import pandas as pd + +from .base import ScoreNode, percentile_rank + + +class AiNewsSentiment(ScoreNode): + name = "ai_news" + label = "AI 뉴스 호재/악재" + default_params = {"min_news_count": 1} + param_schema = { + "type": "object", + "properties": { + "min_news_count": { + "type": "integer", "minimum": 0, "default": 1, + "description": "최소 분석 뉴스 수. 미만이면 점수 미산출.", + }, + }, + } + + def compute(self, ctx, params: dict) -> pd.Series: + df = getattr(ctx, "news_sentiment", None) + if df is None or df.empty: + return pd.Series(dtype=float) + min_news = int(params.get("min_news_count", 1)) + df = df[df["news_count"] >= min_news] + if df.empty: + return pd.Series(dtype=float) + return percentile_rank(df.set_index("ticker")["score_raw"]) diff --git a/stock-lab/tests/test_ai_news_node.py b/stock-lab/tests/test_ai_news_node.py new file mode 100644 index 0000000..92aca7e --- /dev/null +++ b/stock-lab/tests/test_ai_news_node.py @@ -0,0 +1,57 @@ +import datetime as dt +import pandas as pd +import pytest +from app.screener.nodes.ai_news import AiNewsSentiment + + +class FakeCtx: + def __init__(self, df=None): + self.news_sentiment = df + self.asof = dt.date(2026, 5, 13) + + +def test_compute_empty_context(): + out = AiNewsSentiment().compute(FakeCtx(None), {"min_news_count": 1}) + assert out.empty + + +def test_compute_with_data_percentile_ranks(): + df = pd.DataFrame([ + {"ticker": "A", "score_raw": -5.0, "news_count": 3}, + {"ticker": "B", "score_raw": 0.0, "news_count": 3}, + {"ticker": "C", "score_raw": 8.0, "news_count": 3}, + ]) + out = AiNewsSentiment().compute(FakeCtx(df), {"min_news_count": 1}) + assert len(out) == 3 + # percentile rank: A (lowest) < B < C (highest) + assert out.loc["A"] < out.loc["B"] < out.loc["C"] + # all within [0, 100] + assert (out >= 0).all() and (out <= 100).all() + + +def test_compute_filters_by_min_news_count(): + df = pd.DataFrame([ + {"ticker": "A", "score_raw": -5.0, "news_count": 0}, # 필터됨 + {"ticker": "B", "score_raw": 0.0, "news_count": 2}, + {"ticker": "C", "score_raw": 8.0, "news_count": 5}, + ]) + out = AiNewsSentiment().compute(FakeCtx(df), {"min_news_count": 1}) + assert "A" not in out.index + assert "B" in out.index + assert "C" in out.index + + +def test_compute_all_filtered_returns_empty(): + df = pd.DataFrame([ + {"ticker": "A", "score_raw": 5.0, "news_count": 0}, + ]) + out = AiNewsSentiment().compute(FakeCtx(df), {"min_news_count": 1}) + assert out.empty + + +def test_metadata(): + n = AiNewsSentiment() + assert n.name == "ai_news" + assert "AI" in n.label or "뉴스" in n.label + assert n.default_params == {"min_news_count": 1} + assert "min_news_count" in n.param_schema["properties"]