feat(tests): 신규 키움 웹소켓 조건검색 및 실시간 조건검색 테스트 추가
변경 사항 ---- - _test_kiwoom_condition_list.py: 키움 웹소켓 조건검색 '목록조회' 기능을 단독으로 테스트하는 스크립트 추가 - _test_kiwoom_condition_realtime.py: 'momentum' 조건식을 실시간으로 등록하고 초기 매칭 종목 리스트 및 실시간 편입/이탈을 수신하는 테스트 스크립트 추가 - _verify_columnar_bitid.py, _verify_shared_e2e_breakout.py, _verify_shared_e2e.py: 공유 메모리 및 dict 간의 데이터 일관성을 검증하는 테스트 추가 영향 ---- - 신규 테스트 스크립트 추가로 키움 웹소켓 API의 기능 검증 및 안정성을 높임 - 기존 기능에 대한 영향 없음 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -11,10 +11,20 @@ from datetime import datetime as dt
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
try:
|
||||
import tail_engine as te
|
||||
from ..engine import tail_engine as te
|
||||
except ImportError:
|
||||
te = None
|
||||
|
||||
from ..engine.limit_entry_common import (
|
||||
compute_atr_limit_price,
|
||||
floor_limit_price_krw,
|
||||
is_limit_atr_entry,
|
||||
limit_valid_until_bar_key,
|
||||
resolve_limit_anchor_price,
|
||||
short_entry_mode,
|
||||
should_cancel_unfilled_limit,
|
||||
tail_limit_params,
|
||||
)
|
||||
from ..utils.env import get_env_bool, get_env_float, get_env_int
|
||||
from .base import BaseStrategy
|
||||
|
||||
@@ -28,6 +38,7 @@ class TailCatchStrategy(BaseStrategy):
|
||||
super().__init__(**kwargs)
|
||||
self.candle_tf = 3 # 3분봉
|
||||
self._engine_params: Optional[Dict] = None
|
||||
self._pending_limit_orders: Dict[str, Dict] = {}
|
||||
self.reload_config()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@@ -35,10 +46,21 @@ class TailCatchStrategy(BaseStrategy):
|
||||
self.min_price = get_env_float("MIN_STOCK_PRICE", 1000.0)
|
||||
self.stop_loss_pct = get_env_float("STOP_LOSS_PCT", -0.04)
|
||||
self.take_profit_pct = get_env_float("TAKE_PROFIT_PCT", 0.05)
|
||||
self.slot_money = get_env_int("SLOT_MONEY_DEFAULT", 3000000)
|
||||
self.slot_money = (
|
||||
get_env_int("TAIL_SLOT_MONEY", 0)
|
||||
or get_env_int("SLOT_MONEY_DEFAULT", 3_000_000)
|
||||
)
|
||||
if te is not None:
|
||||
try:
|
||||
self._engine_params = te.get_tail_defaults_from_db(self.db)
|
||||
p = te.get_tail_defaults_from_db(self.db)
|
||||
p["live_backtest_align"] = get_env_bool(
|
||||
"SHORT_LIVE_BACKTEST_ALIGN", True,
|
||||
)
|
||||
p["live_signal_lookback_bars"] = get_env_int(
|
||||
"SHORT_LIVE_SIGNAL_LOOKBACK_BARS", 1,
|
||||
)
|
||||
p["entry_mode"] = short_entry_mode()
|
||||
self._engine_params = p
|
||||
except Exception as e:
|
||||
self.logger.debug("tail_engine defaults 조회 실패: %s", e)
|
||||
|
||||
@@ -46,6 +68,76 @@ class TailCatchStrategy(BaseStrategy):
|
||||
"""tail_on 이 True 인 후보만 대상 (SCALP 과 분리)."""
|
||||
return bool(candidate.get("tail_on", True))
|
||||
|
||||
def manage_pending_orders(self) -> None:
|
||||
"""ATR 지정가 미체결 — 유효 봉 지나면 취소, 체결 시 DB 반영."""
|
||||
if not self._pending_limit_orders:
|
||||
return
|
||||
from ..execution.order_manager import OrderRequest
|
||||
|
||||
for code in list(self._pending_limit_orders.keys()):
|
||||
pend = self._pending_limit_orders.get(code)
|
||||
if not pend:
|
||||
continue
|
||||
if code in self.holdings:
|
||||
self._pending_limit_orders.pop(code, None)
|
||||
continue
|
||||
req = pend.get("request")
|
||||
ord_no = pend.get("ord_no")
|
||||
if req and ord_no:
|
||||
fin = self.order_mgr.try_finalize_limit_buy(req, ord_no)
|
||||
if fin.success and fin.filled_qty > 0:
|
||||
self._load_holdings_from_db()
|
||||
self._pending_limit_orders.pop(code, None)
|
||||
self.logger.info(
|
||||
"✅ [지정가체결-반영] %s ODNO=%s", code, ord_no,
|
||||
)
|
||||
continue
|
||||
candles_raw = self.ws.get_candles(code, self.candle_tf, n=30)
|
||||
if not candles_raw:
|
||||
continue
|
||||
candles = [self._norm_candle(c) for c in candles_raw]
|
||||
latest_key = str(candles[-1].get("candle_time") or "")[:12]
|
||||
vu = str(pend.get("valid_until_bar_key") or "")[:12]
|
||||
if not should_cancel_unfilled_limit(latest_key, vu):
|
||||
continue
|
||||
disp = pend.get("name") or code
|
||||
if ord_no and self.order_mgr.client.cancel_order(str(ord_no)):
|
||||
self.logger.info(
|
||||
"🚫 [지정가취소] %s %s — 유효봉 종료(%s→%s) 미체결",
|
||||
disp, code, vu, latest_key,
|
||||
)
|
||||
else:
|
||||
self.logger.info(
|
||||
"🚫 [지정가만료] %s — 유효봉 %s 지남 (취소 API 실패 시 HTS 확인)",
|
||||
code, vu,
|
||||
)
|
||||
self._pending_limit_orders.pop(code, None)
|
||||
|
||||
def on_limit_buy_submitted(self, signal: Dict, result) -> None:
|
||||
from ..execution.order_manager import OrderRequest
|
||||
|
||||
code = signal["code"]
|
||||
self._pending_limit_orders[code] = {
|
||||
"ord_no": result.ord_no,
|
||||
"valid_until_bar_key": signal.get("valid_until_bar_key"),
|
||||
"signal_bar_key": signal.get("signal_bar_key"),
|
||||
"name": signal.get("name", code),
|
||||
"request": OrderRequest(
|
||||
strategy_id=self.strategy_id,
|
||||
code=code,
|
||||
name=signal.get("name", code),
|
||||
side="BUY",
|
||||
qty=int(signal.get("qty", 0)),
|
||||
price_ref=float(signal.get("price", 0)),
|
||||
stop_price=float(signal.get("stop_price", 0)),
|
||||
target_price=float(signal.get("target_price", 0)),
|
||||
atr_entry=float(signal.get("atr_entry", 0)),
|
||||
size_class=signal.get("size_class"),
|
||||
entry_features=signal.get("entry_features"),
|
||||
use_limit_buy=True,
|
||||
),
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 매수
|
||||
# ------------------------------------------------------------------
|
||||
@@ -53,6 +145,10 @@ class TailCatchStrategy(BaseStrategy):
|
||||
if te is None:
|
||||
self.logger.warning("tail_engine 미탑재 → 매수 체크 스킵")
|
||||
return None
|
||||
# 대형 주도주 등 하락매수 제외 종목 차단 (DIP_BUY_EXCLUDE_CODES 비면 무효)
|
||||
if self.is_dip_buy_excluded(code):
|
||||
self.logger.info("🔍 [탈락-대형주제외] %s %s: DIP_BUY_EXCLUDE_CODES", name, code)
|
||||
return None
|
||||
try:
|
||||
if get_env_bool("FORCE_BUY_TEST", False):
|
||||
return self._force_buy_test(code, name)
|
||||
@@ -84,7 +180,14 @@ class TailCatchStrategy(BaseStrategy):
|
||||
daily_cnt = 0
|
||||
state = {"last_exit_dt": last_exit_dt, "daily_cnt": daily_cnt}
|
||||
|
||||
params = self._engine_params or {}
|
||||
params = dict(self._engine_params or {})
|
||||
params["_whipsaw_ws"] = self.ws
|
||||
params["_whipsaw_code"] = code
|
||||
params["_orderbook_ws"] = self.ws
|
||||
params["_orderbook_code"] = code
|
||||
params["_program_ws"] = self.ws
|
||||
params["_program_code"] = code
|
||||
params["slot_money"] = self.slot_money
|
||||
reject, msg, sig = te.check_buy_signal_live(candles, params, state)
|
||||
if reject:
|
||||
self.logger.info("🔍 [%s] %s %s: %s", reject, name, code, msg or "")
|
||||
@@ -92,46 +195,127 @@ class TailCatchStrategy(BaseStrategy):
|
||||
if not sig:
|
||||
return None
|
||||
|
||||
curr_price = float(candles[-1]["close"])
|
||||
wsd = self.ws.get_price(code)
|
||||
if wsd:
|
||||
try:
|
||||
curr_price = abs(float(str(wsd.get("stck_prpr", curr_price)).replace(",", ""))) or curr_price
|
||||
except Exception:
|
||||
pass
|
||||
if code in self._pending_limit_orders:
|
||||
return None
|
||||
|
||||
params = self._engine_params or {}
|
||||
eng = params if params else te.get_tail_defaults_from_db(self.db)
|
||||
atr_period = int(eng.get("atr_period", 14))
|
||||
atr_series = te.compute_atr_series(candles, atr_period)
|
||||
|
||||
if is_limit_atr_entry(short_entry_mode(eng)):
|
||||
if len(candles) < 2:
|
||||
return None
|
||||
sig_i = len(candles) - 2
|
||||
sig_bar = candles[sig_i]
|
||||
lp_cfg = tail_limit_params(eng)
|
||||
anchor_px = resolve_limit_anchor_price(
|
||||
lp_cfg["anchor"], sig_bar, candles, sig_i,
|
||||
)
|
||||
atr_val = atr_series[sig_i] if sig_i < len(atr_series) else None
|
||||
limit_px = compute_atr_limit_price(
|
||||
anchor_px, atr_val, lp_cfg["mult"], min_price=self.min_price,
|
||||
)
|
||||
limit_int = floor_limit_price_krw(limit_px)
|
||||
if limit_int <= 0:
|
||||
return None
|
||||
stop_price, target_price = te.compute_tail_atr_prices(
|
||||
float(limit_int), float(atr_val or limit_int * 0.01), eng,
|
||||
)
|
||||
valid_until = limit_valid_until_bar_key(
|
||||
candles, sig_i, lp_cfg["valid_bars"],
|
||||
)
|
||||
hard_cap = get_env_int("SHORT_MAX_BUY_AMOUNT", 0) \
|
||||
or get_env_int("TAIL_MAX_BUY_AMOUNT", 0) \
|
||||
or get_env_int("MAX_BUY_AMOUNT_PER_STOCK", 0)
|
||||
qty, rej = self._resolve_buy_qty_live(
|
||||
float(limit_int), hard_cap=hard_cap,
|
||||
)
|
||||
if rej:
|
||||
self.logger.info(
|
||||
"🔍 [탈락-%s] %s(%s) limit=%s",
|
||||
rej, name, code, f"{limit_int:,}",
|
||||
)
|
||||
return None
|
||||
self.logger.info(
|
||||
"🎯 [SHORT 지정가] %s(%s) limit=%s원 유효~%s qty=%d",
|
||||
name, code, f"{limit_int:,}", valid_until, qty,
|
||||
)
|
||||
return {
|
||||
"code": code,
|
||||
"name": name,
|
||||
"price": float(limit_int),
|
||||
"qty": qty,
|
||||
"use_limit_buy": True,
|
||||
"valid_until_bar_key": valid_until,
|
||||
"signal_bar_key": str(sig_bar.get("candle_time") or "")[:12],
|
||||
"stop_price": stop_price,
|
||||
"target_price": target_price,
|
||||
"atr_entry": float(atr_val or 0),
|
||||
"session_low": float(limit_int),
|
||||
"max_price": float(limit_int),
|
||||
"size_class": "",
|
||||
"entry_features": {
|
||||
"rsi": sig.get("rsi_val", 50),
|
||||
"tail_length_pct": sig.get("tail_pct", 0) * 100,
|
||||
"entry_mode": "limit_atr",
|
||||
},
|
||||
}
|
||||
|
||||
# align — 다음 3분봉 시가 시장가
|
||||
align_on = get_env_bool("SHORT_LIVE_BACKTEST_ALIGN", True)
|
||||
entry_open = float(sig.get("entry_price", 0) or 0)
|
||||
if align_on and entry_open > 0:
|
||||
curr_price = entry_open
|
||||
else:
|
||||
curr_price = float(candles[-1]["close"])
|
||||
wsd = self.ws.get_price(code)
|
||||
if wsd:
|
||||
try:
|
||||
curr_price = abs(
|
||||
float(str(wsd.get("stck_prpr", curr_price)).replace(",", ""))
|
||||
) or curr_price
|
||||
except Exception:
|
||||
pass
|
||||
if curr_price <= 0 or curr_price < self.min_price:
|
||||
return None
|
||||
|
||||
# 포지션 크기: 손실허용액 / 손절비율 (꼬리잡기는 손절폭이 스캘핑보다 큼)
|
||||
max_loss_krw = get_env_int("TAIL_MAX_LOSS_PER_TRADE_KRW", 0) \
|
||||
or get_env_int("MAX_LOSS_PER_TRADE_KRW", 200000)
|
||||
sl_pct = abs(self.stop_loss_pct)
|
||||
if max_loss_krw > 0 and sl_pct > 0:
|
||||
invest_limit = max_loss_krw / sl_pct
|
||||
invest_amount = min(invest_limit, self.slot_money)
|
||||
else:
|
||||
invest_amount = self.slot_money
|
||||
|
||||
# ── [하드캡] 종목당 최대 매수금액 상한 ────────────────────────
|
||||
# 우선순위: SHORT 전용(=TAIL) > 공용 > 미설정(=무시)
|
||||
hard_cap = get_env_int("SHORT_MAX_BUY_AMOUNT", 0) \
|
||||
or get_env_int("TAIL_MAX_BUY_AMOUNT", 0) \
|
||||
or get_env_int("MAX_BUY_AMOUNT_PER_STOCK", 0)
|
||||
if hard_cap > 0 and invest_amount > hard_cap:
|
||||
qty, rej = self._resolve_buy_qty_live(
|
||||
curr_price, hard_cap=hard_cap,
|
||||
)
|
||||
if rej:
|
||||
self.logger.info(
|
||||
"💰 [투자금 상한 적용] %s: %s원 → %s원 (cap=%s)",
|
||||
code, f"{int(invest_amount):,}", f"{hard_cap:,}", f"{hard_cap:,}",
|
||||
"🔍 [탈락-%s] %s(%s) price=%.0f",
|
||||
rej, name, code, curr_price,
|
||||
)
|
||||
invest_amount = hard_cap
|
||||
|
||||
qty = max(1, int(invest_amount / curr_price))
|
||||
return None
|
||||
|
||||
stop_price = curr_price * (1 + self.stop_loss_pct)
|
||||
target_price = curr_price * (1 + self.take_profit_pct)
|
||||
atr_entry = 0.0
|
||||
try:
|
||||
atr_val = atr_series[-1] if atr_series else None
|
||||
if atr_val is not None and float(atr_val) > 0:
|
||||
atr_entry = float(atr_val)
|
||||
stop_price, target_price = te.compute_tail_atr_prices(
|
||||
curr_price, atr_entry, eng,
|
||||
)
|
||||
self.logger.info(
|
||||
"📊 [SHORT ATR] %s(%s) ATR=%.0f 손절=%.0f 목표=%.0f",
|
||||
name, code, atr_entry, stop_price, target_price,
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.debug("SHORT ATR 손절/목표 계산 스킵(%s): %s", code, e)
|
||||
if atr_entry <= 0:
|
||||
atr_entry = curr_price * 0.01
|
||||
|
||||
self.logger.info(
|
||||
"🎯 [SHORT 시그널] %s(%s) price=%.0f qty=%d tail=%.2f rec=%.0f%% RSI=%.1f",
|
||||
"🎯 [SHORT 시그널] %s(%s) price=%.0f qty=%d pat=%s tail=%.2f rec=%.0f%% RSI=%.1f",
|
||||
name, code, curr_price, qty,
|
||||
sig.get("pattern", "hammer"),
|
||||
sig.get("tail_ratio", 0), sig.get("recovery_pos", 0) * 100, sig.get("rsi_val", 0),
|
||||
)
|
||||
return {
|
||||
@@ -141,11 +325,14 @@ class TailCatchStrategy(BaseStrategy):
|
||||
"qty": qty,
|
||||
"stop_price": stop_price,
|
||||
"target_price": target_price,
|
||||
"atr_entry": float(sig.get("atr_calc_val") or 0.0),
|
||||
"atr_entry": atr_entry if atr_entry > 0 else float(sig.get("atr_calc_val") or 0.0),
|
||||
"session_low": curr_price,
|
||||
"max_price": curr_price,
|
||||
"size_class": "",
|
||||
"entry_features": {
|
||||
"rsi": sig.get("rsi_val", 50),
|
||||
"tail_length_pct": sig.get("tail_pct", 0) * 100,
|
||||
"pattern": sig.get("pattern", "hammer"),
|
||||
},
|
||||
}
|
||||
except Exception as e:
|
||||
@@ -222,11 +409,16 @@ class TailCatchStrategy(BaseStrategy):
|
||||
if current_price <= 0:
|
||||
continue
|
||||
|
||||
max_price = float(holding.get("max_price", buy_price))
|
||||
max_price = float(holding.get("max_price") or buy_price)
|
||||
if current_price > max_price:
|
||||
max_price = current_price
|
||||
holding["max_price"] = max_price
|
||||
|
||||
session_low = float(holding.get("session_low") or buy_price)
|
||||
if current_price < session_low:
|
||||
session_low = current_price
|
||||
holding["session_low"] = session_low
|
||||
|
||||
position = {
|
||||
"entry_price": buy_price,
|
||||
"entry_time": holding.get("buy_time", ""),
|
||||
@@ -237,7 +429,7 @@ class TailCatchStrategy(BaseStrategy):
|
||||
}
|
||||
candle = {
|
||||
"high": max_price,
|
||||
"low": current_price,
|
||||
"low": session_low,
|
||||
"close": current_price,
|
||||
"candle_time": now.strftime("%Y%m%d%H%M"),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user