diff --git a/services/trade-monitor/DESIGN.md b/services/trade-monitor/DESIGN.md index cd35a07..44c0753 100644 --- a/services/trade-monitor/DESIGN.md +++ b/services/trade-monitor/DESIGN.md @@ -87,9 +87,9 @@ | `sell_take_profit` | `(price-avg)/avg ≥ take_pct` | `avg_price, pnl_pct, take_pct` | | `sell_trailing_stop` | `price ≤ holding_high × (1-trailing_pct)` (기본 0.10) | `holding_high, trailing_pct, drawdown_pct` | | `sell_ma_break` | `price < ma50` (추가 `price list[dict]: stop = params.get("stop_pct", 0.08) take = params.get("take_pct", 0.25) trail = params.get("trailing_pct", 0.10) - climax_mult = ctx.get("climax_vol_mult", 3.0) firing: list[dict] = [] if avg: @@ -84,12 +83,17 @@ def evaluate_sell(ctx: dict, params: dict) -> list[dict]: "ma200": round(ma200, 1) if ma200 else None, "severity": severity})) - # sell_climax — 휴리스틱(추후 holdings_intel 정합): 거래량 급증 + 반전 캔들 + # sell_climax — holdings_intel 정합(stock/app/holdings_intel.py:109-118): + # 거래량 ≥ 20일평균 × climax_vol_x AND 종가 < 당일고가 × climax_close_pct (윗꼬리) + # 실시간이므로 day_high = 당일 세션 누적 고가(최신 1분봉 고가 아님). + climax_vol_x = params.get("climax_vol_x", ctx.get("climax_vol_mult", 3.0)) + climax_close_pct = params.get("climax_close_pct", 0.97) avg_vol20 = sma(vols, 20) - if avg_vol20 and ctx["today_volume"] >= climax_mult * avg_vol20 \ - and price < ctx["day_open"]: + day_high = ctx.get("day_high") + if avg_vol20 and day_high and ctx["today_volume"] >= climax_vol_x * avg_vol20 \ + and price < day_high * climax_close_pct: firing.append(_fire(ctx, "sell", "sell_climax", price, { "vol_mult": round(ctx["today_volume"] / avg_vol20, 2), - "day_open": ctx["day_open"]})) # TODO: holdings_intel 대조 + "day_high": day_high, "climax_close_pct": climax_close_pct})) return firing diff --git a/services/trade-monitor/kis_client.py b/services/trade-monitor/kis_client.py index ed6a212..22d7505 100644 --- a/services/trade-monitor/kis_client.py +++ b/services/trade-monitor/kis_client.py @@ -93,6 +93,7 @@ class KISClient: return { "price": int(o["stck_prpr"]), "day_open": int(o["stck_oprc"]), + "day_high": int(o["stck_hgpr"]), "today_volume": int(o["acml_vol"]), "as_of": datetime.now(KST).isoformat(), } diff --git a/services/trade-monitor/monitor.py b/services/trade-monitor/monitor.py index d34e9db..60904b8 100644 --- a/services/trade-monitor/monitor.py +++ b/services/trade-monitor/monitor.py @@ -36,6 +36,7 @@ async def _build_ctx(kis, target: dict, settings) -> dict: return { "ticker": ticker, "name": target.get("name", ""), "price": quote["price"], "day_open": quote["day_open"], + "day_high": quote["day_high"], "today_volume": quote["today_volume"], "closes": [b["close"] for b in daily], "highs": [b["high"] for b in daily], diff --git a/services/trade-monitor/tests/test_conditions_sell.py b/services/trade-monitor/tests/test_conditions_sell.py index 5bd5681..fb60277 100644 --- a/services/trade-monitor/tests/test_conditions_sell.py +++ b/services/trade-monitor/tests/test_conditions_sell.py @@ -1,14 +1,15 @@ """evaluate_sell — 5개 매도 조건 경계.""" from conditions import evaluate_sell -EXIT = {"stop_pct": 0.08, "take_pct": 0.25, "trailing_pct": 0.10} +EXIT = {"stop_pct": 0.08, "take_pct": 0.25, "trailing_pct": 0.10, + "climax_vol_x": 3.0, "climax_close_pct": 0.97} def _ctx(**over): base = dict( ticker="000660", name="SK하이닉스", price=100.0, day_open=100.0, - today_volume=100.0, closes=[100.0] * 60, highs=[100.0] * 60, - lows=[100.0] * 60, volumes=[100.0] * 60, + day_high=100.0, today_volume=100.0, closes=[100.0] * 60, + highs=[100.0] * 60, lows=[100.0] * 60, volumes=[100.0] * 60, avg_price=100.0, qty=10, holding_high=100.0, climax_vol_mult=3.0, ) base.update(over) @@ -49,18 +50,37 @@ def test_ma_break_severity_high(): def test_climax_fires(): - # 거래량 3배 이상 + 종가(현재가)<시가 반전 - ctx = _ctx(price=98.0, day_open=100.0, today_volume=400.0, - volumes=[100.0] * 60) # 400 >= 3*100, 98<100 + # holdings_intel 정합: 거래량 3배 이상 + 종가 < 당일고가×0.97 (윗꼬리) + ctx = _ctx(price=96.0, day_high=100.0, today_volume=400.0, + volumes=[100.0] * 60) # 400>=3*100, 96 < 100*0.97=97 assert "sell_climax" in _c(evaluate_sell(ctx, EXIT)) def test_climax_skips_when_not_reversal(): - ctx = _ctx(price=101.0, day_open=100.0, today_volume=400.0, - volumes=[100.0] * 60) # 상승 마감 → 반전 아님 + # 종가가 당일고가의 97% 이상 → 윗꼬리 아님 + ctx = _ctx(price=99.0, day_high=100.0, today_volume=400.0, + volumes=[100.0] * 60) # 99 >= 100*0.97=97 → 반전 아님 assert "sell_climax" not in _c(evaluate_sell(ctx, EXIT)) +def test_climax_uses_exit_params_vol_x(): + # exit_params.climax_vol_x=5.0 → 400 < 5*100=500 → 미발화 + exit5 = {**EXIT, "climax_vol_x": 5.0} + ctx = _ctx(price=96.0, day_high=100.0, today_volume=400.0, + volumes=[100.0] * 60) + assert "sell_climax" not in _c(evaluate_sell(ctx, exit5)) + + +def test_climax_uses_exit_params_close_pct(): + # climax_close_pct=0.90 → 임계 90, price=95 → 95<90? No → 미발화 + exit90 = {**EXIT, "climax_close_pct": 0.90} + ctx = _ctx(price=95.0, day_high=100.0, today_volume=400.0, + volumes=[100.0] * 60) + assert "sell_climax" not in _c(evaluate_sell(ctx, exit90)) + # 기본 0.97이면 95 < 97 → 발화 + assert "sell_climax" in _c(evaluate_sell(ctx, EXIT)) + + def test_no_avg_no_pnl_conditions(): # avg_price None(보유정보 없음) → stop/take 미발화 ctx = _ctx(price=50.0, avg_price=None, holding_high=None, diff --git a/services/trade-monitor/tests/test_kis_client.py b/services/trade-monitor/tests/test_kis_client.py index e91a7dc..b0efde2 100644 --- a/services/trade-monitor/tests/test_kis_client.py +++ b/services/trade-monitor/tests/test_kis_client.py @@ -29,10 +29,12 @@ async def test_get_quote_parses(): return_value=httpx.Response(200, json={"access_token": "TKN", "expires_in": 86400})) respx.get(f"{BASE}/uapi/domestic-stock/v1/quotations/inquire-price").mock( return_value=httpx.Response(200, json={"output": { - "stck_prpr": "71500", "stck_oprc": "71000", "acml_vol": "1234567"}})) + "stck_prpr": "71500", "stck_oprc": "71000", "stck_hgpr": "72000", + "acml_vol": "1234567"}})) c = _client() q = await c.get_quote("005930") assert q["price"] == 71500 and q["day_open"] == 71000 and q["today_volume"] == 1234567 + assert q["day_high"] == 72000 await c.close() diff --git a/services/trade-monitor/tests/test_monitor.py b/services/trade-monitor/tests/test_monitor.py index f4843dd..6e7fcb1 100644 --- a/services/trade-monitor/tests/test_monitor.py +++ b/services/trade-monitor/tests/test_monitor.py @@ -32,8 +32,8 @@ class _FakeKIS: async def get_quote(self, ticker): if ticker in self._fail_on: raise RuntimeError("KIS down") - return {"price": self._price, "day_open": 99, "today_volume": 1000, - "as_of": "x"} + return {"price": self._price, "day_open": 99, "day_high": 100, + "today_volume": 1000, "as_of": "x"} async def get_daily_ohlcv(self, ticker, days=250): # 정배열 + 저가 근접 → ma20_pullback 발화 유도