From 9709e5b019546d50c400170acd66dfa3ffe9f70b Mon Sep 17 00:00:00 2001 From: gahusb Date: Tue, 12 May 2026 08:57:23 +0900 Subject: [PATCH] =?UTF-8?q?feat(stock-lab):=20Momentum20=20=EB=85=B8?= =?UTF-8?q?=EB=93=9C=20=E2=80=94=20N=EC=9D=BC=20=EC=88=98=EC=9D=B5?= =?UTF-8?q?=EB=A5=A0=20=EB=B0=B1=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/momentum.py | 34 +++++++++++++++++++ stock-lab/app/test_screener_nodes_momentum.py | 24 +++++++++++++ 2 files changed, 58 insertions(+) create mode 100644 stock-lab/app/screener/nodes/momentum.py create mode 100644 stock-lab/app/test_screener_nodes_momentum.py diff --git a/stock-lab/app/screener/nodes/momentum.py b/stock-lab/app/screener/nodes/momentum.py new file mode 100644 index 0000000..ab09a46 --- /dev/null +++ b/stock-lab/app/screener/nodes/momentum.py @@ -0,0 +1,34 @@ +"""20일 모멘텀.""" + +import pandas as pd + +from .base import ScoreNode, percentile_rank + + +class Momentum20(ScoreNode): + name = "momentum" + label = "20일 모멘텀" + default_params = {"window_days": 20} + param_schema = { + "type": "object", + "properties": { + "window_days": {"type": "integer", "minimum": 5, "maximum": 120, "default": 20} + }, + } + + def compute(self, ctx, params: dict) -> pd.Series: + window = int(params.get("window_days", 20)) + prices = ctx.prices + if prices.empty: + return pd.Series(dtype=float) + + ordered = prices.sort_values("date") + last = ordered.groupby("ticker").tail(window + 1) + + def _ret(s): + if len(s) < window + 1: + return float("nan") + return s.iloc[-1] / s.iloc[0] - 1 + + raw = last.groupby("ticker")["close"].apply(_ret) + return percentile_rank(raw).fillna(50.0) diff --git a/stock-lab/app/test_screener_nodes_momentum.py b/stock-lab/app/test_screener_nodes_momentum.py new file mode 100644 index 0000000..2e358d5 --- /dev/null +++ b/stock-lab/app/test_screener_nodes_momentum.py @@ -0,0 +1,24 @@ +import datetime as dt +import pandas as pd + +from app.screener.engine import ScreenContext +from app.screener.nodes.momentum import Momentum20 +from app.screener._test_fixtures import make_master, make_prices, make_flow + + +def _ctx(master, prices, flow): + return ScreenContext(master=master, prices=prices, flow=flow, + kospi=pd.Series(dtype=float, name="kospi"), + asof=dt.date(2026, 5, 12)) + + +def test_higher_momentum_gets_higher_score(): + asof = dt.date(2026, 5, 12) + master = make_master(["UP", "DN"]) + up = make_prices(["UP"], days=30, asof=asof, trend_pct=0.5) + dn = make_prices(["DN"], days=30, asof=asof, trend_pct=-0.3) + prices = pd.concat([up, dn], ignore_index=True) + flow = make_flow(["UP", "DN"], days=30, asof=asof) + + out = Momentum20().compute(_ctx(master, prices, flow), {"window_days": 20}) + assert out["UP"] > out["DN"]