diff --git a/stock/app/holdings_intel.py b/stock/app/holdings_intel.py index 759794b..64500c8 100644 --- a/stock/app/holdings_intel.py +++ b/stock/app/holdings_intel.py @@ -124,6 +124,54 @@ def exit_rules(holding: dict, ticker_prices: "pd.DataFrame", params: dict) -> di ADD_SCORE = 70.0 # 이 이상이면 추가매수 후보 +# ---- Task 3.1: market_events ---- + +_DEFAULT_EVENT_PARAMS = {"move_pct": 7.0, "vol_z": 2.5} + + +def market_events(ticker: str, ticker_prices: "pd.DataFrame", + ticker_flow: "pd.DataFrame | None", params: dict) -> list[dict]: + """일봉/flow 기반 시장 이벤트 (급변·거래량 Z·외인 순매도).""" + p = {**_DEFAULT_EVENT_PARAMS, **(params or {})} + events = [] + if ticker_prices is None or ticker_prices.empty or len(ticker_prices) < 2: + return events + tp = ticker_prices.sort_values("date").reset_index(drop=True) + close = tp["close"].astype(float) + pct = (close.iloc[-1] - close.iloc[-2]) / close.iloc[-2] * 100.0 if close.iloc[-2] else 0.0 + if abs(pct) >= p["move_pct"]: + events.append({ + "type": "price_move", + "severity": "high" if abs(pct) >= p["move_pct"] * 1.5 else "med", + "summary": f"전일 대비 {pct:+.1f}%", + }) + vol = tp["volume"].astype(float) + if len(vol) >= 21: + base = vol.iloc[-21:-1] + mu, sd = base.mean(), base.std(ddof=0) + last_vol = vol.iloc[-1] + if mu > 0 and ( + (sd and (last_vol - mu) / sd >= p["vol_z"]) + or (not sd and last_vol >= mu * p["vol_z"]) # sd=0 fallback: plain ratio + ): + z_txt = f"{(last_vol - mu) / sd:.1f}" if sd else f"ratio={last_vol / mu:.1f}x" + events.append({ + "type": "volume_surge", + "severity": "med", + "summary": f"거래량 평소 대비 급증(Z={z_txt})", + }) + if ticker_flow is not None and not ticker_flow.empty: + tf = ticker_flow.sort_values("date") + recent = tf["foreign_net"].astype(float).iloc[-3:] + if len(recent) >= 3 and (recent < 0).all(): + events.append({ + "type": "foreign_selling", + "severity": "med", + "summary": "외국인 3일 연속 순매도", + }) + return events + + def decide_action(tech_score: float, exit_flags: dict, pnl: float | None, add_score: float = ADD_SCORE) -> tuple[str, str]: """액션 결정 매트릭스: sell > trim > add > hold (우선순위 순). diff --git a/stock/app/test_holdings_intel.py b/stock/app/test_holdings_intel.py index 4c18ec4..c099bff 100644 --- a/stock/app/test_holdings_intel.py +++ b/stock/app/test_holdings_intel.py @@ -188,3 +188,18 @@ def test_technical_posture_empty_kospi_not_penalized(): scores = hi.technical_posture(ctx, ["005930"]) # ma_alignment+momentum만으로 정규화 → 상승추세면 충분히 높은 점수 assert scores["005930"] > 50.0 + + +# ---- Phase 3 tests ---- + +DEFAULT_EVENT = {"move_pct": 7.0, "vol_z": 2.5} + + +def test_market_events_detects_move_and_volume(): + closes = [1000]*30 + [1100] # 마지막날 +10% + vols = [1000]*30 + [10000] # 거래량 급증 + df = _ticker_prices(closes, vols) + evts = hi.market_events("005930", df, None, DEFAULT_EVENT) + types = {e["type"] for e in evts} + assert "price_move" in types + assert "volume_surge" in types