fix(trade-monitor): sell_climax holdings_intel 정합
BE 회신(holdings_intel.py:109-118)에 맞춰 반전 기준을 price<day_open → price<day_high×climax_close_pct(윗꼬리)로 변경. - kis_client.get_quote에 day_high(stck_hgpr) 추가 - monitor._build_ctx가 day_high를 ctx로 전달 - climax_vol_x·climax_close_pct를 monitor-set exit_params에서 읽기 (fallback: TM_CLIMAX_VOL_MULT/0.97) - 테스트 36/36 (climax exit_params 2건 추가) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N83vbXEA8h83GMXQcg8fxD
This commit is contained in:
@@ -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<ma200`이면 detail.severity="high") | `ma50, ma200, severity` |
|
||||
| `sell_climax` | **휴리스틱(추후 holdings_intel 정합)**: `today_volume ≥ climax_vol_mult × avg_volume(20)` **AND** `price < 당일시가`(반전 캔들) | `vol_mult, day_open` + `TODO: holdings_intel 대조` |
|
||||
| `sell_climax` | **holdings_intel 정합**: `today_volume ≥ climax_vol_x × avg_volume(20)` **AND** `price < day_high × climax_close_pct`(윗꼬리) | `vol_mult, day_high, climax_close_pct` |
|
||||
|
||||
`climax_vol_mult` 는 env `TM_CLIMAX_VOL_MULT`(기본 3.0)로 조정.
|
||||
`climax_vol_x`(기본 3.0)·`climax_close_pct`(기본 0.97)는 monitor-set `exit_params`에서 읽음(BE 중앙화, main ed17193). 없으면 env `TM_CLIMAX_VOL_MULT` fallback. `day_high`는 KIS quote `stck_hgpr`(당일 세션 누적 고가).
|
||||
|
||||
---
|
||||
|
||||
@@ -159,7 +159,7 @@
|
||||
|
||||
## 11. 미해결 플래그 / 후속
|
||||
|
||||
1. **sell_climax** 휴리스틱은 근사 — holdings_intel 원본 확보 후 정합(BE에 원본 요청).
|
||||
1. **sell_climax** — ✅ 2026-07-03 holdings_intel 정합 완료(`price < day_high × climax_close_pct` + `exit_params` 파라미터화). BE 회신 기준.
|
||||
2. **KIS 지표 필드 실검증** — quote의 `acml_vol`/`stck_oprc`, daily TR 응답 필드는 첫 운영 raw 캡처로 대조.
|
||||
3. **`buy_ma20_pullback`·`buy_rsi_bounce` 해석** — "current candle series" 문구를 일봉 시계열로 해석. 첫 운영 4주 IC 검증 시 재조정 가능.
|
||||
4. **KIS rate limit 공존** — ai_trade와 동시 부하. 전용 app_key로 토큰 무효화는 회피, 초당 호출 총량은 운영 모니터링.
|
||||
|
||||
@@ -59,7 +59,6 @@ def evaluate_sell(ctx: dict, params: dict) -> 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
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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 발화 유도
|
||||
|
||||
Reference in New Issue
Block a user