feat(screener): AiNewsSentiment ScoreNode (percentile_rank + min_news_count)
This commit is contained in:
36
stock-lab/app/screener/nodes/ai_news.py
Normal file
36
stock-lab/app/screener/nodes/ai_news.py
Normal file
@@ -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"])
|
||||||
57
stock-lab/tests/test_ai_news_node.py
Normal file
57
stock-lab/tests/test_ai_news_node.py
Normal file
@@ -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"]
|
||||||
Reference in New Issue
Block a user