From 3ded781059b351bf31a84b458b1d0ee82edf9cd8 Mon Sep 17 00:00:00 2001 From: gahusb Date: Tue, 12 May 2026 09:02:28 +0900 Subject: [PATCH] =?UTF-8?q?feat(stock-lab):=20RsRating=20=EB=85=B8?= =?UTF-8?q?=EB=93=9C=20=E2=80=94=20IBD=20=EA=B0=80=EC=A4=91=20=EC=8B=9C?= =?UTF-8?q?=EC=9E=A5=EC=B4=88=EA=B3=BC=EC=88=98=EC=9D=B5=20=EB=B0=B1?= =?UTF-8?q?=EB=B6=84=EC=9C=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- stock-lab/app/screener/nodes/rs_rating.py | 48 +++++++++++++++++++ .../app/test_screener_nodes_rs_rating.py | 25 ++++++++++ 2 files changed, 73 insertions(+) create mode 100644 stock-lab/app/screener/nodes/rs_rating.py create mode 100644 stock-lab/app/test_screener_nodes_rs_rating.py diff --git a/stock-lab/app/screener/nodes/rs_rating.py b/stock-lab/app/screener/nodes/rs_rating.py new file mode 100644 index 0000000..895002d --- /dev/null +++ b/stock-lab/app/screener/nodes/rs_rating.py @@ -0,0 +1,48 @@ +"""RS Rating — IBD 가중 (3m=2,6m=1,9m=1,12m=1).""" + +import pandas as pd + +from .base import ScoreNode, percentile_rank + + +_PERIOD_TO_DAYS = {"3m": 63, "6m": 126, "9m": 189, "12m": 252} + + +class RsRating(ScoreNode): + name = "rs_rating" + label = "RS Rating (시장 대비 상대강도)" + default_params = {"weights": {"3m": 2, "6m": 1, "9m": 1, "12m": 1}} + param_schema = { + "type": "object", + "properties": { + "weights": {"type": "object"} + }, + } + + def compute(self, ctx, params: dict) -> pd.Series: + weights: dict = params.get("weights", self.default_params["weights"]) + prices = ctx.prices + kospi = ctx.kospi + if prices.empty or kospi.empty: + return pd.Series(dtype=float) + + ordered = prices.sort_values("date") + + def _excess_for_ticker(g: pd.DataFrame) -> float: + closes = g.set_index("date")["close"] + total = 0.0 + wsum = 0.0 + for period, w in weights.items(): + k = _PERIOD_TO_DAYS.get(period, 0) + if len(closes) <= k or len(kospi) <= k: + continue + r_stock = closes.iloc[-1] / closes.iloc[-(k + 1)] - 1 + r_market = kospi.iloc[-1] / kospi.iloc[-(k + 1)] - 1 + total += w * (r_stock - r_market) + wsum += w + return total / wsum if wsum else float("nan") + + raw = ordered.groupby("ticker", group_keys=False).apply( + _excess_for_ticker, include_groups=False + ) + return percentile_rank(raw).fillna(50.0) diff --git a/stock-lab/app/test_screener_nodes_rs_rating.py b/stock-lab/app/test_screener_nodes_rs_rating.py new file mode 100644 index 0000000..ddbcaf0 --- /dev/null +++ b/stock-lab/app/test_screener_nodes_rs_rating.py @@ -0,0 +1,25 @@ +import datetime as dt +import pandas as pd + +from app.screener.engine import ScreenContext +from app.screener.nodes.rs_rating import RsRating +from app.screener._test_fixtures import make_master, make_prices, make_flow, make_kospi + + +def _ctx(master, prices, flow, kospi): + return ScreenContext(master=master, prices=prices, flow=flow, + kospi=kospi, asof=dt.date(2026, 5, 12)) + + +def test_outperformer_gets_higher_score(): + asof = dt.date(2026, 5, 12) + master = make_master(["UP", "DN"]) + up = make_prices(["UP"], days=260, asof=asof, trend_pct=0.3) + dn = make_prices(["DN"], days=260, asof=asof, trend_pct=-0.1) + prices = pd.concat([up, dn], ignore_index=True) + flow = make_flow(["UP", "DN"], days=260, asof=asof) + kospi = make_kospi(days=260, asof=asof, trend_pct=0.0) + + out = RsRating().compute(_ctx(master, prices, flow, kospi), + RsRating.default_params) + assert out["UP"] > out["DN"]