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:
2026-07-06 01:27:00 +09:00
parent d8ba01afa4
commit 61c72a8a4c
171 changed files with 176914 additions and 7329 deletions

View File

@@ -0,0 +1,761 @@
"""
kis_trader/strategies/updow_strategy.py — Updow 라이브 전략
==========================================================
- **유니버스**: ``updow_stock_config`` 에 등록된 종목만 매수 스캔.
- **파라미터**: 종목별 ``updow_stock_config`` (없으면 ``env_config`` ``UPDOW_*`` 폴백).
- **분봉(tf)**: 종목별 ``updow_tf_min`` 이 양수면 해당 분봉 WS/백테, 아니면 env ``UPDOW_TF_MIN``.
- 신호·청산 로직: ``updow_buy`` 엔진과 동일.
- 진입봉 시각은 ``active_trades.size_class`` 에 ``u|YYYYMMDDHHMM`` 저장 (재기동 복원).
- **시장 레짐(실매)**: ``UPDOW_KOSPI_1MIN_PROXY_CODE`` 가 ``PERMANENT_WS_CODES`` 등으로 WS에
구독돼 있으면 **1분 확정봉을 WS에서 우선** 읽고(REST 절약), 봉 수 부족 시에만
``get_minute_chart`` REST 폴백. env ``UPDOW_REGIME_PREFER_WS_CANDLES``(기본 true),
``UPDOW_REGIME_WS_CANDLE_MIN``(기본 40, 최소 확보 봉 수 하한).
"""
from __future__ import annotations
import random
import time
from typing import Dict, List, Optional, Tuple
from ..engine.limit_entry_common import (
floor_limit_price_krw,
is_limit_atr_entry,
should_cancel_unfilled_limit,
updow_entry_mode,
)
from ..engine.updown_box import (
check_buy_signal_box_live,
check_sell_signal_box_live,
get_box_cfg_from_env,
)
from ..utils.env import get_env_bool, get_env_float, get_env_int, get_env_from_db
from .base import BaseStrategy
from .updow_buy import (
clamp_regime_ma_ease_pct,
kospi_proxy_regime_block_state,
)
from .updow_holding_cfg import (
ensure_updow_stock_config_table,
effective_updow_tf_for_code,
fetch_latest_updow_stock_config_by_code,
load_updow_engine_cfg,
)
from .updown_box_stock_cfg import (
ensure_updown_box_stock_cfg_table,
get_box_overrides,
)
from .updown_scan import run_updown_scan, scan_fetch_n, scan_tf_min
from .updown_watchlist import (
ensure_updown_watchlist_table,
list_active_watchlist,
)
class UpdowStrategy(BaseStrategy):
strategy_id = "UPDOW"
loop_min_sleep = 1.5
loop_max_sleep = 2.5
# DB size_class 에 저장하는 진입봉 키 접두사 (VARCHAR(20) 이하 유지)
_ENTRY_TAG_PREFIX = "u|"
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.candle_tf = 60
self._env_tf_default = 60
self._updow_cfg: Dict = {}
self._holding_row_by_code: Dict[str, Dict] = {}
self.min_price = 1000.0
self._updow_fired_entry_key: Dict[str, str] = {}
self._pending_limit_orders: Dict[str, Dict] = {}
# SCAN(조건검색→박스필터→watchlist) 자체 주기 스로틀 타임스탬프
self._last_scan_ts: float = 0.0
# watchlist 박스 경계 캐시 (code → {box_low, box_high}) — 박스이탈 손절 참조용
self._watch_box_by_code: Dict[str, Dict] = {}
self.reload_config()
def reload_config(self) -> None:
snap = None
try:
snap = self.db.get_latest_env()
except Exception:
pass
self._updow_cfg = get_box_cfg_from_env()
self.candle_tf = scan_tf_min()
self._env_tf_default = int(self.candle_tf)
self.min_price = get_env_float("MIN_STOCK_PRICE", 1000.0)
self.slot_money = int(
float(self._updow_cfg.get("slot_money", get_env_float("UPDOW_SLOT_MONEY", 3_000_000.0)))
)
try:
ensure_updow_stock_config_table(self.db)
from .updow_holding_cfg import migrate_all_updow_from_holding
migrate_all_updow_from_holding(self.db, self._updow_cfg)
self._holding_row_by_code = fetch_latest_updow_stock_config_by_code(self.db)
except Exception as e:
self.logger.warning("updow_stock_config 로드 실패: %s", e)
self._holding_row_by_code = {}
def manage_pending_orders(self) -> None:
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)
continue
tf_code = self._candle_tf_for_code(code)
candles_raw = self.ws.get_candles(code, tf_code, 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(
"🚫 [UPDOW 지정가취소] %s %s — 유효봉 %s 종료 미체결",
disp, code, vu,
)
else:
self.logger.info(
"🚫 [UPDOW 지정가만료] %s — 유효봉 %s (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"),
"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,
),
}
def _reentry_cooldown_sec(self) -> int:
v = get_env_int("UPDOW_REENTRY_COOLDOWN_SEC", 0)
if v > 0:
return v
return super()._reentry_cooldown_sec()
def _max_stocks(self) -> int:
"""동시 보유 종목 수 — ``UPDOW_MAX_STOCKS`` (없으면 ``MAX_STOCKS``)."""
n = get_env_int("UPDOW_MAX_STOCKS", 0)
if n > 0:
return n
return get_env_int("MAX_STOCKS", 3)
@staticmethod
def _updow_slot_default_krw() -> int:
"""1회 매수 시도 금액 기본값 — ``UPDOW_SLOT_MONEY`` (총 한도와 별개)."""
return int(get_env_float("UPDOW_SLOT_MONEY", 3_000_000.0))
@staticmethod
def _updow_total_budget_krw() -> int:
"""
전략 총 운용 한도(원). UPDOW 보유 종목 매입금 합 ≤ 이 값.
0 이면 총 한도 검사 생략.
"""
cap = get_env_int("UPDOW_MAX_BUY_AMOUNT", 0)
if cap <= 0:
cap = get_env_int("MAX_BUY_AMOUNT_PER_STOCK", 0)
return int(cap)
def _updow_exposure_krw(self) -> float:
"""현재 UPDOW 메모리 보유의 매입금 합 (재기동 시 DB 로드분 포함)."""
total = 0.0
for h in self.holdings.values():
q = int(h.get("qty") or 0)
p = float(h.get("buy_price") or 0)
if q > 0 and p > 0:
total += q * p
return total
def _updow_remaining_budget_krw(self) -> float:
"""신규 매수에 쓸 수 있는 잔여 운용 한도."""
cap = self._updow_total_budget_krw()
if cap <= 0:
return float("inf")
return max(0.0, float(cap) - self._updow_exposure_krw())
def _candidate_filter(self, candidate: Dict) -> bool:
return bool(candidate.get("updow_on", True))
# ──────────────────────────────────────────────────────────
# SCAN: 조건검색 → 박스필터 → updown_watchlist 충전 (자체 주기)
# ──────────────────────────────────────────────────────────
def _scan_interval_sec(self) -> int:
"""SCAN 실행 최소 간격(초). 박스는 느리게 변하므로 기본 300초(5분)."""
return get_env_int("UPDOWN_SCAN_INTERVAL_SEC", 300)
def _scan_get_candles(self, code: str, tf_min: int, n: int) -> List[Dict]:
"""SCAN 박스 판별용 분봉 조회 — WS 확정봉 우선, 부족 시 REST 폴백.
WS(키움) 에 이미 구독돼 있으면 REST 절약. 봉 수 부족하면 한투
``get_minute_chart`` REST 로 보충(조회는 항상 실키 market_client).
"""
out: List[Dict] = []
try:
raw = self.ws.get_candles(code, tf_min, n=n) if getattr(self, "ws", None) else []
out = [self._norm_candle(c) for c in (raw or [])]
except Exception:
out = []
if len(out) >= n:
return out
# REST 폴백
try:
df = self.client.get_minute_chart(code, str(tf_min), limit=n)
except Exception:
df = None
if df is not None and not getattr(df, "empty", True):
try:
rest_rows = [
{
"candle_time": "",
"open": float(r.get("open", 0) or 0),
"high": float(r.get("high", 0) or 0),
"low": float(r.get("low", 0) or 0),
"close": float(r.get("close", 0) or 0),
"volume": float(r.get("volume", 0) or 0),
}
for r in df.to_dict("records")
]
if len(rest_rows) > len(out):
out = rest_rows
except Exception:
pass
return out
def _run_scan_if_due(self) -> None:
"""조건검색 후보를 박스필터링해 watchlist 에 충전 (스로틀 적용).
- 조건검색 매니저(``self.condition_mgr``) 가 없으면 SCAN 생략(폴백 운영).
- ``UPDOWN_SCAN_INTERVAL_SEC`` 간격으로만 실행 (5분 스캔 원칙).
"""
if not getattr(self, "condition_mgr", None):
return
now = time.time()
if now - float(self._last_scan_ts or 0) < self._scan_interval_sec():
return
self._last_scan_ts = now
try:
cands = self.condition_mgr.get_candidates_for(self.strategy_id) or []
except Exception as e:
self.logger.debug("[UPDOWN SCAN] 조건검색 후보 조회 실패: %s", e)
return
# 전략별 후보 하드캡 (UPDOW_CAND_LIMIT) + 비본주 필터 — WS/REST 부하 절약
cands = self._post_filter_candidates(cands)
if not cands:
self.logger.debug("[UPDOWN SCAN] 조건후보 0 (CAND_LIMIT/필터 후)")
return
try:
run_updown_scan(
self.db,
cands,
self._scan_get_candles,
source="condition",
sleep_between=True,
)
except Exception as e:
self.logger.warning("[UPDOWN SCAN] 실행 실패: %s", e)
def _load_candidates(self) -> List[Dict]:
"""유니버스 = updown_watchlist(active) 우선, 비면 updow_stock_config 폴백.
매 루프 호출되지만 SCAN 은 ``_run_scan_if_due`` 내부에서 5분 스로틀.
(전략 전용 — BaseStrategy 랭킹 경로 미사용)
"""
# 1) 조건검색 → 박스필터 → watchlist 충전 (스로틀)
self._run_scan_if_due()
# 2) watchlist active 를 1순위 유니버스로 사용 (sticky)
cands: List[Dict] = []
self._watch_box_by_code = {}
try:
ensure_updown_watchlist_table(self.db)
for row in list_active_watchlist(self.db):
code = str(row.get("code") or "").strip()
if not code:
continue
nm = (row.get("name") or code or "").strip() or code
self._watch_box_by_code[code] = {
"box_low": float(row.get("box_low") or 0),
"box_high": float(row.get("box_high") or 0),
}
cands.append({
"code": code, "name": nm,
"updow_on": True, "scalp_on": False, "tail_on": False,
})
except Exception as e:
self.logger.warning("[UPDOWN 유니버스] watchlist 로드 실패: %s", e)
if cands:
return self._post_filter_candidates(cands)
# 3) 폴백: 기존 updow_stock_config 기반 유니버스 (수동 등록 종목 보존)
rows = getattr(self, "_holding_row_by_code", None) or {}
if not rows:
self.logger.info(
"📂 [UPDOW 유니버스] watchlist active 0 + updow_stock_config 0 → 후보 0"
)
return []
fb: List[Dict] = []
for code in sorted(rows.keys()):
row = rows[code]
nm = (row.get("name") or code or "").strip() or code
fb.append({
"code": code,
"name": nm,
"updow_on": True,
"scalp_on": False,
"tail_on": False,
})
return self._post_filter_candidates(fb)
def _norm_candle(self, c: dict) -> dict:
ct = c.get("candle_time") or c.get("candle_time_str", "")
if isinstance(ct, str) and len(ct) == 19 and " " in ct:
ct = ct.replace("-", "").replace(" ", "").replace(":", "")[:12]
return {
"candle_time": ct,
"open": float(c.get("open", 0)),
"high": float(c.get("high", 0)),
"low": float(c.get("low", 0)),
"close": float(c.get("close", 0)),
"volume": float(c.get("volume", 0)),
}
def _parse_entry_bar_key(self, holding: dict) -> str:
sc = (holding.get("size_class") or "").strip()
if sc.startswith(self._ENTRY_TAG_PREFIX):
return sc[len(self._ENTRY_TAG_PREFIX) :]
return ""
def _load_holdings_from_db(self) -> None:
super()._load_holdings_from_db()
for _code, h in self.holdings.items():
ek = self._parse_entry_bar_key(h)
if ek:
h["updow_entry_bar_key"] = ek
def _submit_buy(self, signal: Dict):
code = signal.get("code") or ""
key = signal.get("updow_entry_bar_key") or ""
if key:
self._updow_fired_entry_key[code] = key
result = super()._submit_buy(signal)
if not (result and result.success) and key:
self._updow_fired_entry_key.pop(code, None)
elif result and result.success and code in self.holdings:
sc = (signal.get("size_class") or "").strip()
if sc:
self.holdings[code]["size_class"] = sc
if key:
self.holdings[code]["updow_entry_bar_key"] = key
ef = signal.get("entry_features") or {}
if ef.get("box_low"):
self.holdings[code]["box_low"] = float(ef["box_low"])
if ef.get("box_high"):
self.holdings[code]["box_high"] = float(ef["box_high"])
return result
def _merged_cfg(self, code: str) -> Dict:
"""박스 엔진 글로벌 cfg + (옵션) 종목별 slot/레짐 + 종목별 박스 파라미터 오버라이드.
우선순위(낮음→높음): 글로벌 env UPDOWN_BOX_* → 구 updow_stock_config(slot/레짐만)
→ updown_box_stock_cfg(종목별 박스 파라미터 핀, 설정된 키만).
"""
cfg = dict(get_box_cfg_from_env())
# 1) slot/레짐 — 구 updow_stock_config 폴백 (박스 파라미터는 건드리지 않음)
row = self._holding_row_by_code.get(code)
if row:
for k in ("slot_money", "regime_ma_bars", "regime_ma_ease_pct"):
if row.get(k) is not None:
cfg[k] = row[k]
else:
extra = load_updow_engine_cfg(self.db, code, {})
for k in ("slot_money", "regime_ma_bars", "regime_ma_ease_pct"):
if extra.get(k) is not None:
cfg[k] = extra[k]
# 2) 종목별 박스 파라미터 핀(updown_box_stock_cfg) — 설정된 키만 글로벌 위에 덮음(hybrid)
try:
ov = get_box_overrides(self.db, code)
for k, v in ov.items():
cfg[k] = v
except Exception as e:
self.logger.debug("box override 로드 실패 %s: %s", code, e)
return cfg
def _candle_tf_for_code(self, code: str) -> int:
"""박스 판별·진입 분봉 — SCAN 과 동일 (기본 15분)."""
return scan_tf_min()
def _kospi_proxy_regime_eval(self, merged: Dict[str, Any]) -> Tuple[bool, str]:
"""KOSPI 추적 ETF 1분 종가 < N분 단순 MA 이면 신규 매수 차단.
WS ``CandleAggregator`` 확정 1분봉 우선(``PERMANENT_WS_CODES`` 등과 정합, REST 절약),
``ma``개 미만이면 한투 ``get_minute_chart`` 로 폴백.
차단 조건: ``종가 < SMA×(1regime_ma_ease_pct/100)`` (ease=0 이면 종가<SMA).
Returns:
(True, detail) — 차단 시 ``detail`` 에 프록시·종가·SMA·출처(WS/REST) 요약
(False, "") — 차단 아님 또는 레짐 미사용·데이터 부족
"""
try:
ma = int(float(merged.get("regime_ma_bars", 0)))
except (TypeError, ValueError):
ma = 0
if ma < 1:
return False, ""
proxy = (get_env_from_db("UPDOW_KOSPI_1MIN_PROXY_CODE", "069500") or "069500").strip()
if len(proxy) != 6 or not proxy.isdigit():
return False, ""
ws_floor = get_env_int("UPDOW_REGIME_WS_CANDLE_MIN", 40)
need = max(ma + 10, ws_floor)
closes: List[float] = []
src = "WS"
if get_env_bool("UPDOW_REGIME_PREFER_WS_CANDLES", True) and getattr(self, "ws", None):
try:
raw = self.ws.get_candles(proxy, 1, need)
except Exception:
raw = []
for c in raw or []:
try:
cl = float(c.get("close", 0) or 0)
except (TypeError, ValueError):
continue
if cl > 0:
closes.append(cl)
if len(closes) < ma:
closes = []
src = "REST"
try:
df = self.client.get_minute_chart(proxy, "1", limit=max(need, ma + 10, ws_floor))
except Exception:
df = None
if df is None or getattr(df, "empty", True):
return False, ""
try:
closes = [float(x) for x in df["close"].tolist()]
except Exception:
return False, ""
if len(closes) < ma:
return False, ""
ease = clamp_regime_ma_ease_pct(merged.get("regime_ma_ease_pct", 0))
st = kospi_proxy_regime_block_state(closes, ma, ease)
if st is None:
return False, ""
blocks, last, sma, floor, easeu = st
if not blocks:
return False, ""
detail = (
f"프록시={proxy}({src}) 1분종가={last:.2f} SMA({ma})={sma:.2f} "
f"ease={easeu:.3f}% 차단선={floor:.2f} 갭(종가−차단선)={last - floor:.2f} → 신규매수 정지"
)
return True, detail
def check_buy(self, code: str, name: str) -> Optional[Dict]:
merged = self._merged_cfg(code)
# 대형 주도주 등 하락매수 제외 종목 차단 (DIP_BUY_EXCLUDE_CODES 비면 무효)
if self.is_dip_buy_excluded(code):
self.logger.info("🔍 [탈락-대형주제외] %s %s: DIP_BUY_EXCLUDE_CODES", name, code)
return None
regime_block, regime_detail = self._kospi_proxy_regime_eval(merged)
if regime_block:
self.logger.info(
"🔍 [탈락-시장레짐] %s %s: %s | 참고: 코스피200·지수 일봉 상승과 무관 "
"(레짐은 프록시 ETF 확정 1분봉의 단기 MA 대비)",
name,
code,
regime_detail,
)
return None
if get_env_bool("FORCE_BUY_TEST", False):
px = 0.0
wsd = self.ws.get_price(code)
if wsd:
try:
px = abs(float(str(wsd.get("stck_prpr", 0)).replace(",", "")))
except Exception:
px = 0.0
if px <= 0:
self.logger.info("🔍 [탈락-FORCE] %s %s: 현재가 없음", name, code)
return None
slot_def = self._updow_slot_default_krw()
slot = int(float(merged.get("slot_money", slot_def)))
remain = self._updow_remaining_budget_krw()
invest = min(float(slot), remain) if remain != float("inf") else float(slot)
if invest <= 0:
self.logger.info(
"🔍 [탈락-총한도] %s %s: UPDOW 총운용한도 소진 (노출 %.0f / 한도 %d)",
name, code, self._updow_exposure_krw(), self._updow_total_budget_krw(),
)
return None
qty = max(1, int(invest / px))
sl_pct = float(merged.get("stop_loss_pct", get_env_float("UPDOW_STOP_LOSS_PCT", 2.0))) / 100.0
tp_pct = float(merged.get("tp_pct", get_env_float("UPDOW_TP_PCT", 3.0))) / 100.0
return {
"code": code,
"name": name,
"price": px,
"qty": qty,
"stop_price": px * (1.0 - sl_pct),
"target_price": px * (1.0 + tp_pct),
"atr_entry": 0.0,
"size_class": "",
"entry_features": {},
}
min_len = max(
int(self._updow_cfg.get("min_bars", 20)),
get_env_int("UPDOW_MIN_CANDLE_LEN", get_env_int("MIN_CANDLE_LEN_UPDOW", 20)),
)
n_fetch = max(min_len + 5, scan_fetch_n(), get_env_int("UPDOW_CANDLE_FETCH_N", 50))
tf_code = self._candle_tf_for_code(code)
candles_raw = self.ws.get_candles(code, tf_code, n=n_fetch)
if len(candles_raw) < min_len:
self.logger.info(
"🔍 [탈락-봉부족] %s %s: WS확정봉 %d개 (최소 %d, tf=%d)",
name, code, len(candles_raw), min_len, tf_code,
)
return None
candles = [self._norm_candle(c) for c in candles_raw]
box_cfg = self._merged_cfg(code)
reject, msg, sig = check_buy_signal_box_live(
candles,
box_cfg,
last_fired_entry_key=self._updow_fired_entry_key.get(code),
)
if reject:
self.logger.info("🔍 [%s] %s %s: %s", reject, name, code, msg or "")
return None
if not sig:
self.logger.info("🔍 [탈락-무신호] %s %s", name, code)
return None
entry_open = float(sig["entry_price"])
wsd = self.ws.get_price(code)
curr_price = entry_open
if wsd:
try:
curr_price = abs(float(str(wsd.get("stck_prpr", entry_open)).replace(",", ""))) or entry_open
except Exception:
curr_price = entry_open
if curr_price <= 0 or curr_price < self.min_price:
self.logger.info(
"🔍 [탈락-가격] %s %s: 현재가 %.0f (최소 %.0f)",
name, code, curr_price, self.min_price,
)
return None
slot_def = self._updow_slot_default_krw()
total_cap = self._updow_total_budget_krw()
remain = self._updow_remaining_budget_krw()
exposure = self._updow_exposure_krw()
slot = float(box_cfg.get("slot_money", float(self.slot_money)))
if slot <= 0:
slot = float(slot_def)
from ..utils.position_sizing import resolve_invest_amount_krw
cap_remain = int(remain) if remain != float("inf") else 0
invest_amount = resolve_invest_amount_krw(
int(slot),
extra_cap=cap_remain if cap_remain > 0 else 0,
)
if remain != float("inf"):
invest_amount = min(invest_amount, int(remain))
if remain != float("inf") and remain < entry_open * 0.99:
self.logger.info(
"🔍 [탈락-총한도] %s %s: 잔여 %.0f원 < 1주(%.0f원) | 노출 %.0f / 총한도 %d",
name, code, remain, entry_open, exposure, total_cap,
)
return None
if invest_amount <= 0:
self.logger.info(
"🔍 [탈락-금액] %s %s: invest=0 (slot=%.0f 잔여=%s)",
name, code, slot,
f"{remain:,.0f}" if remain != float("inf") else "무제한",
)
return None
qty = max(1, int(invest_amount / entry_open))
order_krw = qty * entry_open
if remain != float("inf") and order_krw > remain * 1.001:
qty = max(1, int(remain / entry_open))
order_krw = qty * entry_open
ent_key = str(sig.get("updow_entry_bar_key") or "")
size_class = f"{self._ENTRY_TAG_PREFIX}{ent_key}" if ent_key else ""
box_low = float(sig.get("box_low", 0) or self._watch_box_by_code.get(code, {}).get("box_low", 0) or 0)
self.logger.info(
"✅ [통과-박스매수] %s %s: tp=%.2f%% sl=%.2f%% box_low=%.0f "
"주문≈%s원 qty=%d 진입=%.0f score=%.2f",
name, code,
float(sig.get("tp_pct", 0)), float(sig.get("sl_pct", 0)),
box_low, f"{order_krw:,.0f}", qty, entry_open,
float(sig.get("box_score", 0)),
)
return {
"code": code,
"name": name,
"price": entry_open,
"qty": qty,
"stop_price": float(sig.get("stop_price", 0.0)),
"target_price": float(sig.get("target_price", 0.0)),
"atr_entry": 0.0,
"size_class": size_class,
"updow_entry_bar_key": ent_key,
"entry_features": {
"box_low": box_low,
"box_high": float(sig.get("box_high", 0) or 0),
"box_score": float(sig.get("box_score", 0) or 0),
"sl_pct": float(sig.get("sl_pct", 0.0)),
"tp_pct": float(sig.get("tp_pct", 0.0)),
},
}
def check_sell_signals(self) -> List[Dict]:
if not self.holdings:
return []
signals: List[Dict] = []
base_n = get_env_int("UPDOW_CANDLE_FETCH_N", 50)
for code, holding in list(self.holdings.items()):
try:
name = holding.get("name", code)
buy_price = float(holding.get("buy_price", 0))
qty = int(holding.get("qty", 0))
if qty <= 0 or buy_price <= 0:
continue
merged = self._merged_cfg(code)
max_hold = int(float(merged.get("max_hold_bars", 16)))
n_fetch = max(max_hold + 8, base_n)
current_price = 0.0
wsd = self.ws.get_price(code)
if wsd:
try:
current_price = abs(float(str(wsd.get("stck_prpr", 0)).replace(",", "")))
except Exception:
current_price = 0.0
if current_price <= 0:
pd_ = self.client.inquire_price(code)
if pd_:
try:
current_price = abs(float(str(pd_.get("stck_prpr", 0)).replace(",", "")))
except Exception:
current_price = 0.0
if current_price <= 0:
self.logger.debug("[UPDOW 매도] %s %s: 현재가 없음 — 스킵", name, code)
continue
tf_code = self._candle_tf_for_code(code)
candles_raw = self.ws.get_candles(code, tf_code, n=n_fetch)
candles = [self._norm_candle(c) for c in candles_raw]
entry_key = holding.get("updow_entry_bar_key") or self._parse_entry_bar_key(holding)
buy_time = str(holding.get("buy_time", "") or "")
mp = float(holding.get("max_price") or buy_price)
try:
h_now = float(candles[-1].get("high", 0) or 0) if candles else 0.0
except (TypeError, ValueError):
h_now = 0.0
mp = max(mp, current_price, h_now)
holding["max_price"] = mp
# 진입 시 저장한 박스 경계 (entry_features → holding) 우선, 없으면 watchlist 캐시
box_low = float(holding.get("box_low") or 0)
if box_low <= 0:
box_low = float(self._watch_box_by_code.get(code, {}).get("box_low", 0) or 0)
box_high = float(holding.get("box_high") or 0)
if box_high <= 0:
box_high = float(self._watch_box_by_code.get(code, {}).get("box_high", 0) or 0)
res = check_sell_signal_box_live(
buy_price=buy_price,
candles=candles,
cfg=merged,
entry_bar_key=entry_key,
buy_time_str=buy_time,
current_price=current_price,
max_price=mp,
box_low=box_low,
box_high=box_high,
)
if not res:
continue
reason, exit_price = res
profit_pct = (current_price - buy_price) / buy_price if buy_price > 0 else 0.0
self.logger.info(
"✅ [통과-매도신호] %s %s: %s (참고가 %.0f, 손익 %.2f%%) [holding tp/sl/hold=%.1f/%.1f/%d]",
name,
code,
reason,
exit_price,
profit_pct * 100.0,
float(merged.get("tp_pct", 0)),
float(merged.get("stop_loss_pct", 0)),
int(float(merged.get("max_hold_bars", 0))),
)
signals.append({
"code": code,
"name": name,
"current_price": current_price,
"price": exit_price,
"qty": qty,
"buy_price": buy_price,
"profit_pct": profit_pct,
"reason": reason,
})
except Exception as e:
self.logger.error("UPDOW 매도 시그널 체크 오류(%s): %s", code, e)
time.sleep(random.uniform(0.05, 0.15))
return signals