From 55f2fa9cfff290b114481712c2c54a8284095e59 Mon Sep 17 00:00:00 2001 From: gahusb Date: Tue, 12 May 2026 09:05:02 +0900 Subject: [PATCH] =?UTF-8?q?feat(stock-lab):=20MaAlignment=20=EB=85=B8?= =?UTF-8?q?=EB=93=9C=20=E2=80=94=20=EC=9D=B4=ED=8F=89=EC=84=A0=20=EC=A0=95?= =?UTF-8?q?=EB=B0=B0=EC=97=B4=205=EC=A1=B0=EA=B1=B4=20=EB=A3=B0=20?= =?UTF-8?q?=EC=A0=90=EC=88=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- stock-lab/app/screener/nodes/ma_alignment.py | 51 +++++++++++++++++++ .../app/test_screener_nodes_ma_alignment.py | 30 +++++++++++ 2 files changed, 81 insertions(+) create mode 100644 stock-lab/app/screener/nodes/ma_alignment.py create mode 100644 stock-lab/app/test_screener_nodes_ma_alignment.py diff --git a/stock-lab/app/screener/nodes/ma_alignment.py b/stock-lab/app/screener/nodes/ma_alignment.py new file mode 100644 index 0000000..768da7b --- /dev/null +++ b/stock-lab/app/screener/nodes/ma_alignment.py @@ -0,0 +1,51 @@ +"""이평선 정배열 점수 — 5개 조건 충족 개수 / 5 × 100.""" + +import pandas as pd + +from .base import ScoreNode + + +class MaAlignment(ScoreNode): + name = "ma_alignment" + label = "이평선 정배열" + default_params = {"ma_periods": [50, 150, 200]} + param_schema = { + "type": "object", + "properties": { + "ma_periods": {"type": "array", "items": {"type": "integer"}} + }, + } + + def compute(self, ctx, params: dict) -> pd.Series: + ma_periods = params.get("ma_periods", self.default_params["ma_periods"]) + if len(ma_periods) != 3: + raise ValueError("ma_periods must have 3 entries (short, medium, long)") + ma_s, ma_m, ma_l = (int(x) for x in ma_periods) + + prices = ctx.prices + if prices.empty: + return pd.Series(dtype=float) + + ordered = prices.sort_values("date") + min_history = max(252, ma_l) + + def _score(s: pd.Series) -> float: + closes = s.astype(float).reset_index(drop=True) + if len(closes) < min_history: + return float("nan") + close = closes.iloc[-1] + ma_short = closes.rolling(ma_s).mean().iloc[-1] + ma_medium = closes.rolling(ma_m).mean().iloc[-1] + ma_long = closes.rolling(ma_l).mean().iloc[-1] + low52 = closes.iloc[-252:].min() + conds = [ + close > ma_short, + ma_short > ma_medium, + ma_medium > ma_long, + close > ma_long, + close >= low52 * 1.25, + ] + return sum(conds) / 5 * 100.0 + + raw = ordered.groupby("ticker", group_keys=False)["close"].apply(_score) + return raw.fillna(0.0) diff --git a/stock-lab/app/test_screener_nodes_ma_alignment.py b/stock-lab/app/test_screener_nodes_ma_alignment.py new file mode 100644 index 0000000..431ad3e --- /dev/null +++ b/stock-lab/app/test_screener_nodes_ma_alignment.py @@ -0,0 +1,30 @@ +import datetime as dt +import pandas as pd + +from app.screener.engine import ScreenContext +from app.screener.nodes.ma_alignment import MaAlignment +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_strong_uptrend_returns_100(): + asof = dt.date(2026, 5, 12) + master = make_master(["UP"]) + prices = make_prices(["UP"], days=260, asof=asof, start_close=50000, trend_pct=0.2) + flow = make_flow(["UP"], days=260, asof=asof) + out = MaAlignment().compute(_ctx(master, prices, flow), MaAlignment.default_params) + assert out["UP"] == 100.0 + + +def test_downtrend_returns_low(): + asof = dt.date(2026, 5, 12) + master = make_master(["DN"]) + prices = make_prices(["DN"], days=260, asof=asof, start_close=100000, trend_pct=-0.1) + flow = make_flow(["DN"], days=260, asof=asof) + out = MaAlignment().compute(_ctx(master, prices, flow), MaAlignment.default_params) + assert out["DN"] <= 20.0