diff --git a/stock-lab/app/screener/position_sizer.py b/stock-lab/app/screener/position_sizer.py new file mode 100644 index 0000000..f14d845 --- /dev/null +++ b/stock-lab/app/screener/position_sizer.py @@ -0,0 +1,51 @@ +"""ATR Wilder smoothing + entry/stop/target 계산.""" + +import pandas as pd + + +def compute_atr_wilder(df_one_ticker: pd.DataFrame, window: int = 14) -> float: + """단일 종목 DataFrame(date·open·high·low·close)에 대해 Wilder ATR 마지막 값.""" + g = df_one_ticker.sort_values("date").copy() + high = g["high"].astype(float) + low = g["low"].astype(float) + close = g["close"].astype(float) + prev_close = close.shift(1) + tr = pd.concat([ + (high - low), + (high - prev_close).abs(), + (low - prev_close).abs(), + ], axis=1).max(axis=1) + atr = tr.ewm(alpha=1 / window, adjust=False).mean() + return float(atr.iloc[-1]) + + +def round_won(x: float) -> int: + return int(round(x)) + + +def plan_positions(ctx, tickers: list, params: dict) -> dict: + """각 ticker 에 대해 entry/stop/target/atr14 반환.""" + atr_window = int(params.get("atr_window", 14)) + stop_mult = float(params.get("atr_stop_mult", 2.0)) + rr = float(params.get("rr_ratio", 2.0)) + + prices = ctx.prices.sort_values("date") + out: dict = {} + for t in tickers: + sub = prices[prices["ticker"] == t] + if sub.empty: + continue + close = float(sub["close"].iloc[-1]) + atr14 = compute_atr_wilder(sub, window=atr_window) + entry = round_won(close * 1.005) + stop = round_won(close - stop_mult * atr14) + target = round_won(entry + rr * (entry - stop)) + r_pct = (entry - stop) / entry * 100 if entry else 0.0 + out[t] = { + "entry_price": entry, + "stop_price": stop, + "target_price": target, + "atr14": atr14, + "r_pct": r_pct, + } + return out diff --git a/stock-lab/app/test_screener_position_sizer.py b/stock-lab/app/test_screener_position_sizer.py new file mode 100644 index 0000000..1d0680d --- /dev/null +++ b/stock-lab/app/test_screener_position_sizer.py @@ -0,0 +1,33 @@ +import datetime as dt +import pandas as pd + +from app.screener.engine import ScreenContext +from app.screener.position_sizer import compute_atr_wilder, plan_positions +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_atr_wilder_positive_and_smooth(): + df = make_prices(["A"], days=30) + atr = compute_atr_wilder(df[df["ticker"] == "A"], window=14) + assert atr > 0 + + +def test_plan_positions_returns_entry_stop_target(): + asof = dt.date(2026, 5, 12) + master = make_master(["A"]) + prices = make_prices(["A"], days=30, asof=asof, start_close=50000) + flow = make_flow(["A"], days=30, asof=asof) + ctx = _ctx(master, prices, flow) + sizing = plan_positions(ctx, ["A"], {"atr_window": 14, "atr_stop_mult": 2.0, "rr_ratio": 2.0}) + + row = sizing["A"] + assert row["entry_price"] > 0 + assert row["stop_price"] < row["entry_price"] + assert row["target_price"] > row["entry_price"] + assert row["atr14"] > 0