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()