feat(stock-lab): position_sizer — ATR Wilder + entry/stop/target
This commit is contained in:
51
stock-lab/app/screener/position_sizer.py
Normal file
51
stock-lab/app/screener/position_sizer.py
Normal file
@@ -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
|
||||||
33
stock-lab/app/test_screener_position_sizer.py
Normal file
33
stock-lab/app/test_screener_position_sizer.py
Normal file
@@ -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
|
||||||
Reference in New Issue
Block a user