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"]