From 16a651f670b753a438275c0938c941ab54a6edf4 Mon Sep 17 00:00:00 2001 From: gahusb Date: Tue, 12 May 2026 07:52:01 +0900 Subject: [PATCH] =?UTF-8?q?feat(stock-lab):=20ScoreNode/GateNode=20?= =?UTF-8?q?=EC=B6=94=EC=83=81=20+=20percentile=5Frank=20=EC=9C=A0=ED=8B=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- stock-lab/app/screener/nodes/__init__.py | 0 stock-lab/app/screener/nodes/base.py | 40 +++++++++++++++++++++++ stock-lab/app/test_screener_nodes_base.py | 24 ++++++++++++++ 3 files changed, 64 insertions(+) create mode 100644 stock-lab/app/screener/nodes/__init__.py create mode 100644 stock-lab/app/screener/nodes/base.py create mode 100644 stock-lab/app/test_screener_nodes_base.py diff --git a/stock-lab/app/screener/nodes/__init__.py b/stock-lab/app/screener/nodes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/stock-lab/app/screener/nodes/base.py b/stock-lab/app/screener/nodes/base.py new file mode 100644 index 0000000..fbe1050 --- /dev/null +++ b/stock-lab/app/screener/nodes/base.py @@ -0,0 +1,40 @@ +"""Node base classes + helpers.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Any, ClassVar + +import pandas as pd + + +class ScoreNode(ABC): + name: ClassVar[str] + label: ClassVar[str] + default_params: ClassVar[dict] + param_schema: ClassVar[dict] + + @abstractmethod + def compute(self, ctx: "Any", params: dict) -> pd.Series: + """returns Series indexed by ticker, 0..100 float.""" + + +class GateNode(ABC): + name: ClassVar[str] + label: ClassVar[str] + default_params: ClassVar[dict] + param_schema: ClassVar[dict] + + @abstractmethod + def filter(self, ctx: "Any", params: dict) -> pd.Index: + """returns surviving tickers.""" + + +def percentile_rank(series: pd.Series) -> pd.Series: + """Percentile rank in [0, 100]. All-equal → 50. NaN preserved.""" + if series.empty: + return series.astype(float) + if series.dropna().nunique() == 1: + return pd.Series(50.0, index=series.index) + ranked = series.rank(pct=True, na_option="keep") * 100.0 + return ranked diff --git a/stock-lab/app/test_screener_nodes_base.py b/stock-lab/app/test_screener_nodes_base.py new file mode 100644 index 0000000..d3ee746 --- /dev/null +++ b/stock-lab/app/test_screener_nodes_base.py @@ -0,0 +1,24 @@ +import pandas as pd +import pytest + +from app.screener.nodes.base import percentile_rank + + +def test_percentile_rank_basic(): + s = pd.Series([10, 20, 30, 40, 50]) + out = percentile_rank(s) + assert (out >= 0).all() and (out <= 100).all() + assert out.iloc[0] < out.iloc[-1] # smallest gets lowest rank + + +def test_percentile_rank_all_equal_returns_50(): + s = pd.Series([42, 42, 42, 42]) + out = percentile_rank(s) + assert (out == 50.0).all() + + +def test_percentile_rank_handles_nan(): + s = pd.Series([1.0, float("nan"), 3.0, 5.0]) + out = percentile_rank(s) + assert pd.isna(out.iloc[1]) + assert (out.dropna() >= 0).all()