""" 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 _after_holdings_sync(self) -> None: for _code, h in self.holdings.items(): ek = self._parse_entry_bar_key(h) if ek: h["updow_entry_bar_key"] = ek self._runtime.setdefault(_code, {})["updow_entry_bar_key"] = ek def _load_holdings_from_db(self, *, log_restore: bool = False) -> None: super()._load_holdings_from_db(log_restore=log_restore) 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"]) self._capture_runtime_overlay() 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×(1−regime_ma_ease_pct/100)`` (ease=0 이면 종가 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_last_quote(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: try: self.ws.fill_gap([code], force=True) except Exception: pass 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_last_quote(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, only_code: Optional[str] = None) -> 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 = self._resolve_sell_price( code, is_eod=False, buy_price=buy_price, ) 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