From 804fdcba2619c0605aafc08960f90e9db49c09b5 Mon Sep 17 00:00:00 2001 From: gahusb Date: Tue, 12 May 2026 08:19:44 +0900 Subject: [PATCH] =?UTF-8?q?feat(stock-lab):=20ForeignBuy=20=EB=85=B8?= =?UTF-8?q?=EB=93=9C=20=E2=80=94=20=EC=99=B8=EA=B5=AD=EC=9D=B8=20N?= =?UTF-8?q?=EC=9D=BC=20=EB=88=84=EC=A0=81=20=EC=88=9C=EB=A7=A4=EC=88=98=20?= =?UTF-8?q?=EA=B0=95=EB=8F=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- stock-lab/app/screener/nodes/foreign_buy.py | 33 +++++++++++++++++++ .../app/test_screener_nodes_foreign_buy.py | 32 ++++++++++++++++++ 2 files changed, 65 insertions(+) create mode 100644 stock-lab/app/screener/nodes/foreign_buy.py create mode 100644 stock-lab/app/test_screener_nodes_foreign_buy.py diff --git a/stock-lab/app/screener/nodes/foreign_buy.py b/stock-lab/app/screener/nodes/foreign_buy.py new file mode 100644 index 0000000..f035a96 --- /dev/null +++ b/stock-lab/app/screener/nodes/foreign_buy.py @@ -0,0 +1,33 @@ +"""외국인 N일 누적 순매수 강도 (시총 대비).""" + +import pandas as pd + +from .base import ScoreNode, percentile_rank + + +class ForeignBuy(ScoreNode): + name = "foreign_buy" + label = "외국인 누적 순매수" + default_params = {"window_days": 5} + param_schema = { + "type": "object", + "properties": { + "window_days": {"type": "integer", "minimum": 1, "maximum": 60, "default": 5} + }, + } + + def compute(self, ctx, params: dict) -> pd.Series: + window = int(params.get("window_days", 5)) + flow = ctx.flow + if flow.empty: + return pd.Series(dtype=float) + + last_dates = ( + flow.sort_values("date").groupby("ticker").tail(window) + ) + net_sum = last_dates.groupby("ticker")["foreign_net"].sum() + + market_cap = ctx.master["market_cap"].fillna(0).reindex(net_sum.index) + raw = (net_sum / market_cap.replace(0, pd.NA)).astype(float) + + return percentile_rank(raw).fillna(50.0) diff --git a/stock-lab/app/test_screener_nodes_foreign_buy.py b/stock-lab/app/test_screener_nodes_foreign_buy.py new file mode 100644 index 0000000..f795f2e --- /dev/null +++ b/stock-lab/app/test_screener_nodes_foreign_buy.py @@ -0,0 +1,32 @@ +import datetime as dt +import pandas as pd + +from app.screener.engine import ScreenContext +from app.screener.nodes.foreign_buy import ForeignBuy +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_foreign_buy_gets_higher_score(): + asof = dt.date(2026, 5, 12) + master = make_master(["A", "B"]) + prices = make_prices(["A", "B"], days=30, asof=asof) + flow = make_flow(["A", "B"], days=30, asof=asof, + foreign_per_day={"A": 100_000_000, "B": 0}) + out = ForeignBuy().compute(_ctx(master, prices, flow), {"window_days": 5}) + assert out["A"] > out["B"] + assert 0 <= out.min() <= out.max() <= 100 + + +def test_all_zero_returns_50(): + asof = dt.date(2026, 5, 12) + master = make_master(["A", "B"]) + prices = make_prices(["A", "B"], days=30, asof=asof) + flow = make_flow(["A", "B"], days=30, asof=asof, foreign_per_day={"A": 0, "B": 0}) + out = ForeignBuy().compute(_ctx(master, prices, flow), {"window_days": 5}) + assert (out == 50.0).all()