From 94d6a39ce8e400fcd69e5c0b18c7f7405c7906f7 Mon Sep 17 00:00:00 2001 From: gahusb Date: Tue, 12 May 2026 08:54:47 +0900 Subject: [PATCH] =?UTF-8?q?feat(stock-lab):=20VolumeSurge=20=EB=85=B8?= =?UTF-8?q?=EB=93=9C=20=E2=80=94=20log(=EC=B5=9C=EA=B7=BC/=ED=8F=89?= =?UTF-8?q?=EA=B7=A0)=20=EA=B1=B0=EB=9E=98=EB=9F=89=20=EA=B8=89=EC=A6=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- stock-lab/app/screener/nodes/volume_surge.py | 40 +++++++++++++++++++ .../app/test_screener_nodes_volume_surge.py | 28 +++++++++++++ 2 files changed, 68 insertions(+) create mode 100644 stock-lab/app/screener/nodes/volume_surge.py create mode 100644 stock-lab/app/test_screener_nodes_volume_surge.py diff --git a/stock-lab/app/screener/nodes/volume_surge.py b/stock-lab/app/screener/nodes/volume_surge.py new file mode 100644 index 0000000..4b4393f --- /dev/null +++ b/stock-lab/app/screener/nodes/volume_surge.py @@ -0,0 +1,40 @@ +"""거래량 급증 — log1p(recent/baseline).""" + +import numpy as np +import pandas as pd + +from .base import ScoreNode, percentile_rank + + +class VolumeSurge(ScoreNode): + name = "volume_surge" + label = "거래량 급증" + default_params = {"baseline_days": 20, "eval_days": 3} + param_schema = { + "type": "object", + "properties": { + "baseline_days": {"type": "integer", "minimum": 5, "maximum": 60, "default": 20}, + "eval_days": {"type": "integer", "minimum": 1, "maximum": 10, "default": 3}, + }, + } + + def compute(self, ctx, params: dict) -> pd.Series: + baseline = int(params.get("baseline_days", 20)) + eval_d = int(params.get("eval_days", 3)) + prices = ctx.prices + if prices.empty: + return pd.Series(dtype=float) + + ordered = prices.sort_values("date") + last_recent = ordered.groupby("ticker").tail(eval_d).groupby("ticker")["volume"].mean() + last_baseline = ( + ordered.groupby("ticker") + .tail(baseline + eval_d) + .groupby("ticker") + .head(baseline) + .groupby("ticker")["volume"] + .mean() + ) + ratio = last_recent / last_baseline.replace(0, pd.NA) + raw = np.log1p(ratio.astype(float)) + return percentile_rank(raw).fillna(50.0) diff --git a/stock-lab/app/test_screener_nodes_volume_surge.py b/stock-lab/app/test_screener_nodes_volume_surge.py new file mode 100644 index 0000000..daabcf4 --- /dev/null +++ b/stock-lab/app/test_screener_nodes_volume_surge.py @@ -0,0 +1,28 @@ +import datetime as dt +import pandas as pd + +from app.screener.engine import ScreenContext +from app.screener.nodes.volume_surge import VolumeSurge +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_recent_volume_surge_gets_higher_score(): + asof = dt.date(2026, 5, 12) + master = make_master(["A", "B"]) + prices = make_prices(["A", "B"], days=30, asof=asof) + # A는 최근 3일 거래량 10배로 + mask = (prices["ticker"] == "A") & (prices["date"] >= (asof - dt.timedelta(days=3)).isoformat()) + prices.loc[mask, "volume"] *= 10 + flow = make_flow(["A", "B"], days=30, asof=asof) + + out = VolumeSurge().compute( + _ctx(master, prices, flow), + {"baseline_days": 20, "eval_days": 3}, + ) + assert out["A"] > out["B"]