StrReplace

    "LOOP_PROFILE_ENABLED",
    "LOOP_PROFILE_EVERY_N",
    "LOOP_PROFILE_LOG_PATH",
    # 틱매도 — WS 현재가 갱신 시 매도 검사 (기본 false · 루프 매도 유지 + 틱 보강)

    "LOOP_PROFILE_ENABLED",
    "LOOP_PROFILE_EVERY_N",
    "LOOP_PROFILE_LOG_PATH",
    "CHECK_BUY_PROF_MIN_MS",
    # 틱매도 — WS 현재가 갱신 시 매도 검사 (기본 false · 루프 매도 유지 + 틱 보강)

            _f("LOOP_PROFILE_LOG_PATH", "계측 로그 파일", "str", default="logs/loop_profile.log",
               hint="상대경로=레포루트 기준 · 비우면 전략 logger INFO 만"),
            _f("TICK_SELL_ENABLED", "틱매도(공통)", "bool", default=False,

            _f("LOOP_PROFILE_LOG_PATH", "계측 로그 파일", "str", default="logs/loop_profile.log",
               hint="상대경로=레포루트 기준 · 비우면 전략 logger INFO 만"),
            _f("CHECK_BUY_PROF_MIN_MS", "check_buy 상세 로그 하한(ms)", "int", default=500,
               hint="이 ms 이상인 check_buy 만 [CHECK_BUY_PROF] 단계별 기록 · LOOP_PROFILE_ENABLED 필요"),
            _f("TICK_SELL_ENABLED", "틱매도(공통)", "bool", default=False,

    def check_buy(self, code: str, name: str) -> Optional[Dict]:
        if se is None:
            self.logger.warning("scalping_engine 미탑재 → 매수 체크 스킵")
            return None
        try:
            if get_env_bool("FORCE_BUY_TEST", False):
                return self._force_buy_test(code, name)

            candles_raw = list(self.ws.get_candles(code, self.candle_tf, n=50) or [])
            # ALIGN: 형성 중 봉(T)을 진입봉으로 붙여 BT(portfolio next open/첫 틱)와 동일 시점
            params = self._scan_engine_params or {}
            use_forming = bool(params.get("live_align_use_forming_bar", True))
            if params.get("live_backtest_align", True) and use_forming:
                try:
                    cur = self.ws.get_current_candle(code, self.candle_tf)
                except Exception:
                    cur = None
                if cur and float(cur.get("open", 0) or 0) > 0:
                    ct = str(cur.get("candle_time") or "")[:12]
                    last_ct = ""
                    if candles_raw:
                        last_ct = str(candles_raw[-1].get("candle_time") or "")[:12]
                    if ct and ct != last_ct:
                        cur_d = dict(cur)
                        cur_d["is_confirmed"] = 0
                        candles_raw.append(cur_d)
            if len(candles_raw) < 5:
                force_sec = max(30, get_env_int("SCALP_CANDLE_GAP_FORCE_SEC", 120))
                now_g = time.time()
                if not hasattr(self, "_candle_gap_force_ts"):
                    self._candle_gap_force_ts = {}
                last_g = float(self._candle_gap_force_ts.get(code, 0) or 0)
                do_force = (now_g - last_g) >= float(force_sec)
                if do_force:
                    self._candle_gap_force_ts[code] = now_g
                try:
                    self.ws.fill_gap([code], force=do_force)
                except Exception:
                    pass
                log_sec = max(15, get_env_int("SCALP_CANDLE_SHORT_LOG_SEC", 60))
                if not hasattr(self, "_candle_short_log_ts"):
                    self._candle_short_log_ts = {}
                last_l = float(self._candle_short_log_ts.get(code, 0) or 0)
                if now_g - last_l >= float(log_sec):
                    self._candle_short_log_ts[code] = now_g
                    self.logger.info(
                        "🔍 [캔들부족] %s(%s) need>=5 have=%d",
                        name, code, len(candles_raw),
                    )
                return None
            candles = [self._norm_candle(c) for c in candles_raw]

            # 엔진 state
            today = dt.now().strftime("%Y%m%d")
            last_exit_dt = None
            if code in self.recently_sold:
                try:
                    last_exit_dt = dt.fromtimestamp(self.recently_sold[code])
                    if last_exit_dt.strftime("%Y%m%d") != today:
                        last_exit_dt = None
                except Exception:
                    pass
            try:
                today_trades = self.db.get_trades_by_date(today)
                daily_cnt = len([
                    t for t in today_trades
                    if t.get("code") == code and str(t.get("strategy", "")).startswith("SCALP")
                ])
            except Exception:
                daily_cnt = 0
            state = {"last_exit_dt": last_exit_dt, "daily_cnt": daily_cnt}

            # SCALP 는 reversal 단일 모드 (momentum 은 MomentumStrategy 로 분리됨)
            # 호가/프로그램/휩쏘 — 모멘텀·꼬리·돌파와 동일하게 WS 주입
            # (미주입 시 orderbook_reject 가 스냅 None → 수집·필터 둘 다 스킵)
            params = dict(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 = se.check_buy_signal_live(candles, params, state)
            if reject:
                self.logger.info("🔍 [%s] %s %s: %s", reject, name, code, msg or "")
                return None
            if not sig:
                return None

            # 중분 편입 → 같은 진입봉 시가 매수 보류 (다음 분부터)
            _defer = self._defer_mid_enroll_entry(
                code, sig.get("entry_bar_key"), int(self.candle_tf or 1), params,
            )
            if _defer:
                self.logger.info("🔍 [%s] %s(%s)", _defer, name, code)
                return None

            # 진입 계산가: align 시 T봉 첫 틱(RAM) → 없으면 시가. 폴백만 WS 현재가
            align_on = bool(params.get("live_backtest_align", True))
            entry_open = float(sig.get("entry_price", 0) or 0)
            entry_src = "ohlc_open"
            if align_on and entry_open > 0:
                from kis_trader.engine.tail_tick_replay import live_align_entry_price
                curr_price, entry_src = live_align_entry_price(
                    self.ws,
                    code,
                    entry_open,
                    entry_bar_key=str(sig.get("entry_bar_key") or "")[:12],
                    tf_min=int(self.candle_tf or 1),
                )
            else:
                latest = candles[-1]
                curr_price = float(latest["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

            hard_cap = get_env_int("SCALP_MAX_BUY_AMOUNT", 0) \
                or get_env_int("MAX_BUY_AMOUNT_PER_STOCK", 0)
            qty, rej = self._resolve_buy_qty_live(
                curr_price, hard_cap=hard_cap,
            )
            if rej:
                self.logger.info(
                    "🔍 [탈락-%s] %s(%s) price=%.0f",
                    rej, name, code, curr_price,
                )
                return None

            stop_price = curr_price * (1 + self.scalp_stop_loss_pct)
            eff_tp = se.resolve_effective_tp_pct(
                self.scalp_take_profit_pct, self.scalp_tp_max_pct,
            )
            target_price = curr_price * (1 + eff_tp)

            self.logger.info(
                "🎯 [SCALP-REVERSAL 시그널] %s(%s) price=%.0f qty=%d RSI=%.1f entry_src=%s",
                name, code, curr_price, qty, sig.get("rsi", 0), entry_src,
            )
            return {
                "code": code,
                "name": name,
                "price": curr_price,
                "qty": qty,
                "stop_price": stop_price,
                "target_price": target_price,
                "atr_entry": 0.0,
                "size_class": "",
                "entry_features": {"rsi": sig.get("rsi", 0)},
            }
        except Exception as e:
            self.logger.info("🔍 [탈락-예외] %s %s: %s", name, code, e)
            return None

    def check_buy(self, code: str, name: str) -> Optional[Dict]:
        if se is None:
            self.logger.warning("scalping_engine 미탑재 → 매수 체크 스킵")
            return None
        _cb = self._cb_prof_start(code)
        try:
            if get_env_bool("FORCE_BUY_TEST", False):
                return self._force_buy_test(code, name)

            candles_raw = list(self.ws.get_candles(code, self.candle_tf, n=50) or [])
            # ALIGN: 형성 중 봉(T)을 진입봉으로 붙여 BT(portfolio next open/첫 틱)와 동일 시점
            params = self._scan_engine_params or {}
            use_forming = bool(params.get("live_align_use_forming_bar", True))
            if params.get("live_backtest_align", True) and use_forming:
                try:
                    cur = self.ws.get_current_candle(code, self.candle_tf)
                except Exception:
                    cur = None
                if cur and float(cur.get("open", 0) or 0) > 0:
                    ct = str(cur.get("candle_time") or "")[:12]
                    last_ct = ""
                    if candles_raw:
                        last_ct = str(candles_raw[-1].get("candle_time") or "")[:12]
                    if ct and ct != last_ct:
                        cur_d = dict(cur)
                        cur_d["is_confirmed"] = 0
                        candles_raw.append(cur_d)
            self._cb_prof_mark(_cb, "candles")
            if len(candles_raw) < 5:
                force_sec = max(30, get_env_int("SCALP_CANDLE_GAP_FORCE_SEC", 120))
                now_g = time.time()
                if not hasattr(self, "_candle_gap_force_ts"):
                    self._candle_gap_force_ts = {}
                last_g = float(self._candle_gap_force_ts.get(code, 0) or 0)
                do_force = (now_g - last_g) >= float(force_sec)
                if do_force:
                    self._candle_gap_force_ts[code] = now_g
                try:
                    self.ws.fill_gap([code], force=do_force)
                except Exception:
                    pass
                self._cb_prof_mark(_cb, "fill_gap")
                log_sec = max(15, get_env_int("SCALP_CANDLE_SHORT_LOG_SEC", 60))
                if not hasattr(self, "_candle_short_log_ts"):
                    self._candle_short_log_ts = {}
                last_l = float(self._candle_short_log_ts.get(code, 0) or 0)
                if now_g - last_l >= float(log_sec):
                    self._candle_short_log_ts[code] = now_g
                    self.logger.info(
                        "🔍 [캔들부족] %s(%s) need>=5 have=%d",
                        name, code, len(candles_raw),
                    )
                return None
            candles = [self._norm_candle(c) for c in candles_raw]
            self._cb_prof_mark(_cb, "norm")

            # 엔진 state
            today = dt.now().strftime("%Y%m%d")
            last_exit_dt = None
            if code in self.recently_sold:
                try:
                    last_exit_dt = dt.fromtimestamp(self.recently_sold[code])
                    if last_exit_dt.strftime("%Y%m%d") != today:
                        last_exit_dt = None
                except Exception:
                    pass
            try:
                today_trades = self.db.get_trades_by_date(today)
                daily_cnt = len([
                    t for t in today_trades
                    if t.get("code") == code and str(t.get("strategy", "")).startswith("SCALP")
                ])
            except Exception:
                daily_cnt = 0
            self._cb_prof_mark(_cb, "trades_db")
            state = {"last_exit_dt": last_exit_dt, "daily_cnt": daily_cnt}

            # SCALP 는 reversal 단일 모드 (momentum 은 MomentumStrategy 로 분리됨)
            # 호가/프로그램/휩쏘 — 모멘텀·꼬리·돌파와 동일하게 WS 주입
            # (미주입 시 orderbook_reject 가 스냅 None → 수집·필터 둘 다 스킵)
            params = dict(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 = se.check_buy_signal_live(candles, params, state)
            self._cb_prof_mark(_cb, "engine")
            if reject:
                self.logger.info("🔍 [%s] %s %s: %s", reject, name, code, msg or "")
                return None
            if not sig:
                return None

            # 중분 편입 → 같은 진입봉 시가 매수 보류 (다음 분부터)
            _defer = self._defer_mid_enroll_entry(
                code, sig.get("entry_bar_key"), int(self.candle_tf or 1), params,
            )
            self._cb_prof_mark(_cb, "mid_enroll")
            if _defer:
                self.logger.info("🔍 [%s] %s(%s)", _defer, name, code)
                return None

            # 진입 계산가: align 시 T봉 첫 틱(RAM) → 없으면 시가. 폴백만 WS 현재가
            align_on = bool(params.get("live_backtest_align", True))
            entry_open = float(sig.get("entry_price", 0) or 0)
            entry_src = "ohlc_open"
            if align_on and entry_open > 0:
                from kis_trader.engine.tail_tick_replay import live_align_entry_price
                curr_price, entry_src = live_align_entry_price(
                    self.ws,
                    code,
                    entry_open,
                    entry_bar_key=str(sig.get("entry_bar_key") or "")[:12],
                    tf_min=int(self.candle_tf or 1),
                )
            else:
                latest = candles[-1]
                curr_price = float(latest["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
            self._cb_prof_mark(_cb, "align")
            if curr_price <= 0 or curr_price < self.min_price:
                return None

            hard_cap = get_env_int("SCALP_MAX_BUY_AMOUNT", 0) \
                or get_env_int("MAX_BUY_AMOUNT_PER_STOCK", 0)
            qty, rej = self._resolve_buy_qty_live(
                curr_price, hard_cap=hard_cap,
            )
            self._cb_prof_mark(_cb, "qty")
            if rej:
                self.logger.info(
                    "🔍 [탈락-%s] %s(%s) price=%.0f",
                    rej, name, code, curr_price,
                )
                return None

            stop_price = curr_price * (1 + self.scalp_stop_loss_pct)
            eff_tp = se.resolve_effective_tp_pct(
                self.scalp_take_profit_pct, self.scalp_tp_max_pct,
            )
            target_price = curr_price * (1 + eff_tp)

            self.logger.info(
                "🎯 [SCALP-REVERSAL 시그널] %s(%s) price=%.0f qty=%d RSI=%.1f entry_src=%s",
                name, code, curr_price, qty, sig.get("rsi", 0), entry_src,
            )
            return {
                "code": code,
                "name": name,
                "price": curr_price,
                "qty": qty,
                "stop_price": stop_price,
                "target_price": target_price,
                "atr_entry": 0.0,
                "size_class": "",
                "entry_features": {"rsi": sig.get("rsi", 0)},
            }
        except Exception as e:
            self.logger.info("🔍 [탈락-예외] %s %s: %s", name, code, e)
            return None
        finally:
            self._cb_prof_finish(_cb)


    def check_buy(self, code: str, name: str) -> Optional[Dict]:
        _cb = self._cb_prof_start(code)
        try:
            if get_env_bool("FORCE_BUY_TEST", False):
                return self._force_buy_test(code, name)

            # E조건(전일시가) — 당일 50봉만으로는 불가 → 키움 REST 갭보정 RAM (DB 구데이터 미사용)
            min_need = get_env_int("MOMENTUM_LIVE_MIN_CANDLES", 500)
            candles_raw = list(self.ws.get_candles(code, self.candle_tf, n=min_need) or [])
            # ALIGN: 형성 중 봉(T)을 진입봉으로 붙여 BT(portfolio)와 동일 시점
            use_forming = bool((self._engine_params or {}).get("live_align_use_forming_bar", True))
            if (self._engine_params or {}).get("live_backtest_align", True) and use_forming:
                try:
                    cur = self.ws.get_current_candle(code, self.candle_tf)
                except Exception:
                    cur = None
                if cur and float(cur.get("open", 0) or 0) > 0:
                    ct = str(cur.get("candle_time") or "")[:12]
                    last_ct = ""
                    if candles_raw:
                        last_ct = str(candles_raw[-1].get("candle_time") or "")[:12]
                    if ct and ct != last_ct:
                        cur_d = dict(cur)
                        cur_d["is_confirmed"] = 0
                        candles_raw.append(cur_d)
            self._cb_prof_mark(_cb, "candles")
            if len(candles_raw) < 6:
                try:
                    # force: EXIT 후 _gap_filled 잔존 시에도 재채움 (봉부족 복구)
                    self.ws.fill_gap([code], force=True)
                except Exception:
                    pass
                self._cb_prof_mark(_cb, "fill_gap")
                return None
            candles = [self._norm_candle(c) for c in candles_raw]
            self._cb_prof_mark(_cb, "norm")

            today = dt.now().strftime("%Y%m%d")
            last_exit_dt = None
            if code in self.recently_sold:
                try:
                    last_exit_dt = dt.fromtimestamp(self.recently_sold[code])
                    if last_exit_dt.strftime("%Y%m%d") != today:
                        last_exit_dt = None
                    # 분 단위 floor — 엔진 쿨다운이 신호봉 candle_time 과 같은 시계를 쓰도록
                    elif bool((self._engine_params or {}).get("cooldown_use_candle_floor", True)):
                        last_exit_dt = last_exit_dt.replace(second=0, microsecond=0)
                except Exception:
                    pass
            try:
                today_trades = self.db.get_trades_by_date(today)
                daily_cnt = len([
                    t for t in today_trades
                    if t.get("code") == code and str(t.get("strategy", "")).upper() == "MOMENTUM"
                ])
            except Exception:
                daily_cnt = 0
            self._cb_prof_mark(_cb, "trades_db")
            state = {"last_exit_dt": last_exit_dt, "daily_cnt": daily_cnt}

            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 = me.check_buy_signal_momentum_live(candles, params, state)
            self._cb_prof_mark(_cb, "engine")
            if reject:
                # 갭보정 워밍업 중 — 전일시가 없음·봉부족 시 force 재큐 (로그 스팸 전에 복구)
                if reject in ("탈락-전일시가없음", "탈락-봉부족"):
                    try:
                        self.ws.fill_gap([code], force=True)
                    except Exception:
                        pass
                    self._cb_prof_mark(_cb, "fill_gap")
                    if reject == "탈락-전일시가없음" and len(candles_raw) < min_need:
                        return None
                    if reject == "탈락-봉부족":
                        return None
                self.logger.info("🔍 [%s] %s %s: %s", reject, name, code, msg or "")
                return None
            if not sig:
                return None

            _defer = self._defer_mid_enroll_entry(
                code,
                sig.get("entry_bar_key"),
                int(self.candle_tf or 1),
                self._engine_params,
            )
            self._cb_prof_mark(_cb, "mid_enroll")
            if _defer:
                self.logger.info("🔍 [%s] %s(%s)", _defer, name, code)
                return None

            align_on = get_env_bool("MOMENTUM_LIVE_BACKTEST_ALIGN", True)
            entry_open = float(sig.get("entry_price", 0) or 0)
            entry_src = "ohlc_open"
            if align_on and entry_open > 0:
                from kis_trader.engine.tail_tick_replay import live_align_entry_price
                curr_price, entry_src = live_align_entry_price(
                    self.ws,
                    code,
                    entry_open,
                    entry_bar_key=str(sig.get("entry_bar_key") or "")[:12],
                    tf_min=int(self.candle_tf or 1),
                )
            else:
                latest = candles[-1]
                curr_price = float(latest["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
            self._cb_prof_mark(_cb, "align")
            if curr_price <= 0 or curr_price < self.min_price:
                return None
            # entry_src: ws_ticks | ohlc_open — 수량/손절 계산가 출처 (시장가 체결가와 별개)
            if entry_src:
                sig = dict(sig)
                sig["entry_src"] = entry_src

            hard_cap = get_env_int("MOMENTUM_MAX_BUY_AMOUNT", 0) or get_env_int(
                "MAX_BUY_AMOUNT_PER_STOCK", 0,
            )
            qty, rej = self._resolve_buy_qty_live(
                curr_price, hard_cap=hard_cap,
            )
            self._cb_prof_mark(_cb, "qty")
            if rej:
                self.logger.info(
                    "🔍 [탈락-%s] %s(%s) price=%.0f",
                    rej, name, code, curr_price,
                )
                return None

            eff_tp = me.resolve_effective_tp_pct(self.take_profit_pct, self.tp_max_pct)
            stop_price = curr_price * (1 + self.stop_loss_pct)
            target_price = curr_price * (1 + eff_tp)

            self.logger.info(
                "🎯 [MOMENTUM 시그널] %s(%s) price=%.0f qty=%d RSI=%.1f entry_src=%s",
                name, code, curr_price, qty, sig.get("rsi", 0),
                str(sig.get("entry_src") or entry_src),
            )
            return {
                "code": code,
                "name": name,
                "price": curr_price,
                "qty": qty,
                "stop_price": stop_price,
                "target_price": target_price,
                "atr_entry": 0.0,
                "size_class": "",
                "entry_features": {"rsi": sig.get("rsi", 0)},
            }
        except Exception as e:
            self.logger.info("🔍 [탈락-예외] %s %s: %s", name, code, e)
            return None
        finally:
            self._cb_prof_finish(_cb)

    def check_buy(self, code: str, name: str) -> Optional[Dict]:
        try:
            if get_env_bool("FORCE_BUY_TEST", False):
                return self._force_buy_test(code, name)

            # E조건(전일시가) — 당일 50봉만으로는 불가 → 키움 REST 갭보정 RAM (DB 구데이터 미사용)
            min_need = get_env_int("MOMENTUM_LIVE_MIN_CANDLES", 500)
            candles_raw = list(self.ws.get_candles(code, self.candle_tf, n=min_need) or [])
            # ALIGN: 형성 중 봉(T)을 진입봉으로 붙여 BT(portfolio)와 동일 시점
            use_forming = bool((self._engine_params or {}).get("live_align_use_forming_bar", True))
            if (self._engine_params or {}).get("live_backtest_align", True) and use_forming:
                try:
                    cur = self.ws.get_current_candle(code, self.candle_tf)
                except Exception:
                    cur = None
                if cur and float(cur.get("open", 0) or 0) > 0:
                    ct = str(cur.get("candle_time") or "")[:12]
                    last_ct = ""
                    if candles_raw:
                        last_ct = str(candles_raw[-1].get("candle_time") or "")[:12]
                    if ct and ct != last_ct:
                        cur_d = dict(cur)
                        cur_d["is_confirmed"] = 0
                        candles_raw.append(cur_d)
            if len(candles_raw) < 6:
                try:
                    # force: EXIT 후 _gap_filled 잔존 시에도 재채움 (봉부족 복구)
                    self.ws.fill_gap([code], force=True)
                except Exception:
                    pass
                return None
            candles = [self._norm_candle(c) for c in candles_raw]

            today = dt.now().strftime("%Y%m%d")
            last_exit_dt = None
            if code in self.recently_sold:
                try:
                    last_exit_dt = dt.fromtimestamp(self.recently_sold[code])
                    if last_exit_dt.strftime("%Y%m%d") != today:
                        last_exit_dt = None
                    # 분 단위 floor — 엔진 쿨다운이 신호봉 candle_time 과 같은 시계를 쓰도록
                    elif bool((self._engine_params or {}).get("cooldown_use_candle_floor", True)):
                        last_exit_dt = last_exit_dt.replace(second=0, microsecond=0)
                except Exception:
                    pass
            try:
                today_trades = self.db.get_trades_by_date(today)
                daily_cnt = len([
                    t for t in today_trades
                    if t.get("code") == code and str(t.get("strategy", "")).upper() == "MOMENTUM"
                ])
            except Exception:
                daily_cnt = 0
            state = {"last_exit_dt": last_exit_dt, "daily_cnt": daily_cnt}

            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 = me.check_buy_signal_momentum_live(candles, params, state)
            if reject:
                # 갭보정 워밍업 중 — 전일시가 없음·봉부족 시 force 재큐 (로그 스팸 전에 복구)
                if reject in ("탈락-전일시가없음", "탈락-봉부족"):
                    try:
                        self.ws.fill_gap([code], force=True)
                    except Exception:
                        pass
                    if reject == "탈락-전일시가없음" and len(candles_raw) < min_need:
                        return None
                    if reject == "탈락-봉부족":
                        return None
                self.logger.info("🔍 [%s] %s %s: %s", reject, name, code, msg or "")
                return None
            if not sig:
                return None

            _defer = self._defer_mid_enroll_entry(
                code,
                sig.get("entry_bar_key"),
                int(self.candle_tf or 1),
                self._engine_params,
            )
            if _defer:
                self.logger.info("🔍 [%s] %s(%s)", _defer, name, code)
                return None

            align_on = get_env_bool("MOMENTUM_LIVE_BACKTEST_ALIGN", True)
            entry_open = float(sig.get("entry_price", 0) or 0)
            entry_src = "ohlc_open"
            if align_on and entry_open > 0:
                from kis_trader.engine.tail_tick_replay import live_align_entry_price
                curr_price, entry_src = live_align_entry_price(
                    self.ws,
                    code,
                    entry_open,
                    entry_bar_key=str(sig.get("entry_bar_key") or "")[:12],
                    tf_min=int(self.candle_tf or 1),
                )
            else:
                latest = candles[-1]
                curr_price = float(latest["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
            # entry_src: ws_ticks | ohlc_open — 수량/손절 계산가 출처 (시장가 체결가와 별개)
            if entry_src:
                sig = dict(sig)
                sig["entry_src"] = entry_src

            hard_cap = get_env_int("MOMENTUM_MAX_BUY_AMOUNT", 0) or get_env_int(
                "MAX_BUY_AMOUNT_PER_STOCK", 0,
            )
            qty, rej = self._resolve_buy_qty_live(
                curr_price, hard_cap=hard_cap,
            )
            if rej:
                self.logger.info(
                    "🔍 [탈락-%s] %s(%s) price=%.0f",
                    rej, name, code, curr_price,
                )
                return None

            eff_tp = me.resolve_effective_tp_pct(self.take_profit_pct, self.tp_max_pct)
            stop_price = curr_price * (1 + self.stop_loss_pct)
            target_price = curr_price * (1 + eff_tp)

            self.logger.info(
                "🎯 [MOMENTUM 시그널] %s(%s) price=%.0f qty=%d RSI=%.1f entry_src=%s",
                name, code, curr_price, qty, sig.get("rsi", 0),
                str(sig.get("entry_src") or entry_src),
            )
            return {
                "code": code,
                "name": name,
                "price": curr_price,
                "qty": qty,
                "stop_price": stop_price,
                "target_price": target_price,
                "atr_entry": 0.0,
                "size_class": "",
                "entry_features": {"rsi": sig.get("rsi", 0)},
            }
        except Exception as e:
            self.logger.info("🔍 [탈락-예외] %s %s: %s", name, code, e)
            return None

    def check_buy(self, code: str, name: str) -> Optional[Dict]:
        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
        _cb = self._cb_prof_start(code)
        try:
            if get_env_bool("FORCE_BUY_TEST", False):
                return self._force_buy_test(code, name)

            min_len = get_env_int("MIN_CANDLE_LEN_TAIL", 14)
            candles_raw = self.ws.get_candles(code, self.candle_tf, n=50)
            self._cb_prof_mark(_cb, "candles")
            if len(candles_raw) < min_len:
                try:
                    self.ws.fill_gap([code], force=True)
                except Exception:
                    pass
                self._cb_prof_mark(_cb, "fill_gap")
                return None
            candles = [self._norm_candle(c) for c in candles_raw]
            self._cb_prof_mark(_cb, "norm")
            if len(candles) < 10:
                return None

            today = dt.now().strftime("%Y%m%d")
            last_exit_dt = None
            if code in self.recently_sold:
                try:
                    last_exit_dt = dt.fromtimestamp(self.recently_sold[code])
                    if last_exit_dt.strftime("%Y%m%d") != today:
                        last_exit_dt = None
                except Exception:
                    pass
            try:
                today_trades = self.db.get_trades_by_date(today)
                code_trades = [
                    t for t in today_trades
                    if t.get("code") == code and str(t.get("strategy", "")).startswith("SHORT")
                ]
                daily_cnt = len(code_trades)
                daily_pnl_krw = sum(
                    float(t.get("realized_pnl") or 0) for t in code_trades
                )
            except Exception:
                daily_cnt = 0
                daily_pnl_krw = 0.0
            self._cb_prof_mark(_cb, "trades_db")
            state = {
                "last_exit_dt": last_exit_dt,
                "daily_cnt": daily_cnt,
                "daily_pnl_krw": daily_pnl_krw,
            }

            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)
            self._cb_prof_mark(_cb, "engine")
            if reject:
                self.logger.info("🔍 [%s] %s %s: %s", reject, name, code, msg or "")
                return None
            if not sig:
                return None

            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)
            self._cb_prof_mark(_cb, "atr")

            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,
                )
                self._cb_prof_mark(_cb, "qty")
                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분봉: RAM 첫 틱 → 없으면 시가 (시장가 계산용)
            align_on = get_env_bool("SHORT_LIVE_BACKTEST_ALIGN", True)
            entry_open = float(sig.get("entry_price", 0) or 0)
            entry_src = "ohlc_open"
            _ebk = str(sig.get("entry_bar_key") or "")[:12]
            _defer = self._defer_mid_enroll_entry(
                code, _ebk, int(self.candle_tf or 3),
            )
            self._cb_prof_mark(_cb, "mid_enroll")
            if _defer:
                self.logger.info("🔍 [%s] %s(%s)", _defer, name, code)
                return None
            if align_on and entry_open > 0:
                from kis_trader.engine.tail_tick_replay import live_align_entry_price
                curr_price, entry_src = live_align_entry_price(
                    self.ws,
                    code,
                    entry_open,
                    entry_bar_key=_ebk,
                    tf_min=int(self.candle_tf or 3),
                )
            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
            self._cb_prof_mark(_cb, "align")
            if curr_price <= 0 or curr_price < self.min_price:
                return None

            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(
                curr_price, hard_cap=hard_cap,
            )
            self._cb_prof_mark(_cb, "qty")
            if rej:
                self.logger.info(
                    "🔍 [탈락-%s] %s(%s) price=%.0f",
                    rej, name, code, 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 pat=%s tail=%.2f rec=%.0f%% RSI=%.1f entry_src=%s",
                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),
                entry_src,
            )
            return {
                "code": code,
                "name": name,
                "price": curr_price,
                "qty": qty,
                "stop_price": stop_price,
                "target_price": target_price,
                "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:
            self.logger.info("🔍 [탈락-예외] %s %s: %s", name, code, e)
            return None
        finally:
            self._cb_prof_finish(_cb)

    def check_buy(self, code: str, name: str) -> Optional[Dict]:
        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)

            min_len = get_env_int("MIN_CANDLE_LEN_TAIL", 14)
            candles_raw = self.ws.get_candles(code, self.candle_tf, n=50)
            if len(candles_raw) < min_len:
                try:
                    self.ws.fill_gap([code], force=True)
                except Exception:
                    pass
                return None
            candles = [self._norm_candle(c) for c in candles_raw]
            if len(candles) < 10:
                return None

            today = dt.now().strftime("%Y%m%d")
            last_exit_dt = None
            if code in self.recently_sold:
                try:
                    last_exit_dt = dt.fromtimestamp(self.recently_sold[code])
                    if last_exit_dt.strftime("%Y%m%d") != today:
                        last_exit_dt = None
                except Exception:
                    pass
            try:
                today_trades = self.db.get_trades_by_date(today)
                code_trades = [
                    t for t in today_trades
                    if t.get("code") == code and str(t.get("strategy", "")).startswith("SHORT")
                ]
                daily_cnt = len(code_trades)
                daily_pnl_krw = sum(
                    float(t.get("realized_pnl") or 0) for t in code_trades
                )
            except Exception:
                daily_cnt = 0
                daily_pnl_krw = 0.0
            state = {
                "last_exit_dt": last_exit_dt,
                "daily_cnt": daily_cnt,
                "daily_pnl_krw": daily_pnl_krw,
            }

            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 "")
                return None
            if not sig:
                return None

            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분봉: RAM 첫 틱 → 없으면 시가 (시장가 계산용)
            align_on = get_env_bool("SHORT_LIVE_BACKTEST_ALIGN", True)
            entry_open = float(sig.get("entry_price", 0) or 0)
            entry_src = "ohlc_open"
            _ebk = str(sig.get("entry_bar_key") or "")[:12]
            _defer = self._defer_mid_enroll_entry(
                code, _ebk, int(self.candle_tf or 3),
            )
            if _defer:
                self.logger.info("🔍 [%s] %s(%s)", _defer, name, code)
                return None
            if align_on and entry_open > 0:
                from kis_trader.engine.tail_tick_replay import live_align_entry_price
                curr_price, entry_src = live_align_entry_price(
                    self.ws,
                    code,
                    entry_open,
                    entry_bar_key=_ebk,
                    tf_min=int(self.candle_tf or 3),
                )
            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

            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(
                curr_price, hard_cap=hard_cap,
            )
            if rej:
                self.logger.info(
                    "🔍 [탈락-%s] %s(%s) price=%.0f",
                    rej, name, code, 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 pat=%s tail=%.2f rec=%.0f%% RSI=%.1f entry_src=%s",
                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),
                entry_src,
            )
            return {
                "code": code,
                "name": name,
                "price": curr_price,
                "qty": qty,
                "stop_price": stop_price,
                "target_price": target_price,
                "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:
            self.logger.info("🔍 [탈락-예외] %s %s: %s", name, code, e)
            return None


    def check_buy(self, code: str, name: str) -> Optional[Dict]:
        _cb = self._cb_prof_start(code)
        try:
            return self._check_buy_impl(code, name, _cb)
        finally:
            self._cb_prof_finish(_cb)

    def _check_buy_impl(self, code: str, name: str, _cb) -> Optional[Dict]:
        # 골든타임 가드 (_candidate_filter 가 미리 거른 뒤에도 방어용 재검사)
        if not self._is_golden_time():
            return None

        need_n = 1 if self.skip_hts_scan_dupes else max(self.lookback_min, self.vol_window) + 2
        if self.use_ema_filter and not self.skip_hts_scan_dupes:
            need_n = max(need_n, self.ema_slow_period + 5)
        confirmed = self.ws.get_candles(code, self.candle_tf, n=need_n + 5)
        self._cb_prof_mark(_cb, "candles")
        if len(confirmed) < need_n - 1:
            # force 연타 금지 — 시세불가·특수코드는 fail_max 후에도 REST 폭주
            force_sec = max(30, get_env_int("BREAKOUT_CANDLE_GAP_FORCE_SEC", 120))
            now_g = time.time()
            if not hasattr(self, "_candle_gap_force_ts"):
                self._candle_gap_force_ts = {}
            last_g = float(self._candle_gap_force_ts.get(code, 0) or 0)
            do_force = (now_g - last_g) >= float(force_sec)
            if do_force:
                self._candle_gap_force_ts[code] = now_g
            try:
                self.ws.fill_gap([code], force=do_force)
            except Exception:
                pass
            self._cb_prof_mark(_cb, "fill_gap")
            log_sec = max(15, get_env_int("BREAKOUT_CANDLE_SHORT_LOG_SEC", 60))
            if not hasattr(self, "_candle_short_log_ts"):
                self._candle_short_log_ts = {}
            last_l = float(self._candle_short_log_ts.get(code, 0) or 0)
            if now_g - last_l >= float(log_sec):
                self._candle_short_log_ts[code] = now_g
                self.logger.info(
                    "🔍 [캔들부족] %s(%s) need>=%d have=%d",
                    name, code, need_n - 1, len(confirmed),
                )
            return None

        params = {
            "lookback_min":  self.lookback_min,
            "vol_window":    self.vol_window,
            "vol_mult":      self.vol_mult,
            "prev_chg_min":  self.prev_chg_min,
            "prev_chg_max":  self.prev_chg_max,
            "max_daily_chg": self.max_daily_chg,
            "min_price":     self.min_price,
            "min_bar_trade_value_krw": self.min_bar_trade_value_krw,
            "min_turnover_1m_pct": self.min_turnover_1m_pct,
            "share_denom": (
                float(self.ws.get_share_denom(code))
                if getattr(self.ws, "get_share_denom", None)
                else 0.0
            ),
            "confirm_margin_pct": self.confirm_margin_pct,  # 가짜돌파 필터 (0=OFF)
            "body_min_pct":  self.body_min_pct,             # 가짜돌파 필터 (0=OFF)
            "use_ema_filter": self.use_ema_filter,
            "ema_fast_period": self.ema_fast_period,
            "ema_slow_period": self.ema_slow_period,
            "time_start_hm": 0,
            "time_end_hm":   2400,
            "entry_mode":    breakout_entry_mode(),
            "live_backtest_align": get_env_bool(
                "BREAKOUT_LIVE_BACKTEST_ALIGN", True,
            ),
            "live_signal_lookback_bars": get_env_int(
                "BREAKOUT_LIVE_SIGNAL_LOOKBACK_BARS", 1,
            ),
            "intrabar_slippage_pct": get_env_float(
                "BREAKOUT_INTRABAR_SLIPPAGE_PCT", 0.0,
            ),
            "_whipsaw_ws": self.ws,
            "_whipsaw_code": code,
            "_orderbook_ws": self.ws,
            "_orderbook_code": code,
            "_program_ws": self.ws,
            "_program_code": code,
            "slot_money": self.slot_money,
            "skip_hts_scan_dupes": self.skip_hts_scan_dupes,
        }

        mode = breakout_entry_mode(params)
        forming = None
        if mode in _INTRABAR_ENTRY_MODES:
            curr_price = self._live_current_price(code)
            if curr_price <= 0:
                # 저유동 B안: 체결 틱 없음 — 60초 1회 INFO (debug 만이면 탈락 이유 안 보임)
                if not hasattr(self, "_no_price_log"):
                    self._no_price_log = {}
                last = self._no_price_log.get(code, 0)
                gap = 15.0 if get_env_bool("SCAN_REJECT_LOG_VERBOSE", False) else 60.0
                if time.time() - last >= gap:
                    self._no_price_log[code] = time.time()
                    self.logger.info("🔍 [탈락-가격없음] %s(%s) B안 WS시세 없음", name, code)
                return None

            forming = None
            if getattr(self.ws, "get_current_candle", None):
                forming = self.ws.get_current_candle(code, self.candle_tf)
            if not forming:
                now_key = dt.now().strftime("%Y%m%d%H%M")
                forming = {
                    "candle_time": now_key,
                    "open": curr_price,
                    "high": curr_price,
                    "low": curr_price,
                    "close": curr_price,
                    "volume": 0,
                }
            bar_key = str(forming.get("candle_time") or "")
            if bar_key and self._intrabar_fired.get(code) == bar_key:
                return None

            reason, msg, signal = check_buy_signal_breakout_intrabar_live(
                confirmed, curr_price, forming,
                _breakout_params_with_ema_closes(
                    params, confirmed + ([forming] if forming else []),
                    len(confirmed),
                    forming_close=curr_price,
                ),
            )
            self._cb_prof_mark(_cb, "engine")
            if reason:
                self.logger.info("🔍 [%s] %s(%s) %s", reason, name, code, msg or "")
                return None
            if not signal:
                return None
            if bar_key:
                self._intrabar_fired[code] = bar_key
            curr_price = float(signal.get("entry_price") or curr_price)
        else:
            reason, msg, signal = check_buy_signal_breakout_live(confirmed, params)
            self._cb_prof_mark(_cb, "engine")
            if reason:
                self.logger.info("🔍 [%s] %s(%s) %s", reason, name, code, msg or "")
                return None
            if not signal:
                return None
            align_on = get_env_bool("BREAKOUT_LIVE_BACKTEST_ALIGN", True)
            entry_open = float(signal.get("entry_price", 0) or 0)
            entry_src = "ohlc_open"
            _ebk = str(signal.get("entry_bar_key") or "")[:12]
            _defer = self._defer_mid_enroll_entry(
                code, _ebk, int(self.candle_tf or 1), params,
            )
            self._cb_prof_mark(_cb, "mid_enroll")
            if _defer:
                self.logger.info("🔍 [%s] %s(%s)", _defer, name, code)
                return None
            if align_on and entry_open > 0:
                from kis_trader.engine.tail_tick_replay import live_align_entry_price
                curr_price, entry_src = live_align_entry_price(
                    self.ws,
                    code,
                    entry_open,
                    entry_bar_key=_ebk,
                    tf_min=int(self.candle_tf or 1),
                )
            else:
                curr_price = float(signal.get("close") or 0.0)
                p = self._live_current_price(code)
                if p > 0:
                    curr_price = p
            self._cb_prof_mark(_cb, "align")
            if curr_price <= 0:
                if get_env_bool("SCAN_REJECT_LOG_VERBOSE", False):
                    if not hasattr(self, "_no_price_log"):
                        self._no_price_log = {}
                    last = self._no_price_log.get(code, 0)
                    if time.time() - last >= 60:
                        self._no_price_log[code] = time.time()
                        self.logger.info("🔍 [가격없음] %s(%s)", name, code)
                else:
                    self.logger.debug("🔍 [가격없음] %s(%s)", name, code)
                return None

        invest_cap = self._breakout_invest_cap_krw()
        if self._live_portfolio_budget_enabled():
            qty, _, rej = self._resolve_live_buy_qty(curr_price, invest_cap=invest_cap)
            if rej:
                self.logger.info(
                    "🔍 [탈락-%s] %s(%s) price=%.0f cap=%.0f",
                    rej, name, code, curr_price, invest_cap,
                )
                return None
        else:
            qty = invest_qty_for_price(curr_price, float(invest_cap))
            if qty < 1:
                self.logger.info(
                    "🔍 [탈락-수량0] %s(%s) price=%.0f cap=%.0f",
                    name, code, curr_price, invest_cap,
                )
                return None
        self._cb_prof_mark(_cb, "qty")

        stop_price = curr_price * (1 + self.stop_loss_pct)
        target_price = curr_price * (1 + self.take_profit_pct)

        mode_tag = "B" if mode in _INTRABAR_ENTRY_MODES else "A"
        # A=align: entry_src 는 계산가 출처. B=intrabar: 현재가 경로라 ohlc_open 표기 안 함
        if mode in _INTRABAR_ENTRY_MODES:
            self.logger.info(
                "🚀 [BREAKOUT-%s] %s(%s) price=%.0f qty=%d 저항=%.0f volX=%.1f prevChg=%.2f%%",
                mode_tag, name, code, curr_price, qty,
                signal["resistance"], signal["vol_ratio"], signal["prev_chg"],
            )
        else:
            self.logger.info(
                "🚀 [BREAKOUT-%s] %s(%s) price=%.0f qty=%d 저항=%.0f volX=%.1f prevChg=%.2f%% entry_src=%s",
                mode_tag, name, code, curr_price, qty,
                signal["resistance"], signal["vol_ratio"], signal["prev_chg"],
                entry_src,
            )
        return {
            "code": code,
            "name": name,
            "price": curr_price,
            "qty": qty,
            "stop_price": stop_price,
            "target_price": target_price,
            # 진입 시점 ATR(변동성) — ATR 동적 손절(sl_mode='atr') 용. holding 에 보존되면
            # check_sell_signals 가 매수 시점 변동성으로 손절선을 잡는다(백테와 정합).
            "atr_entry": _breakout_entry_atr_from_candles(confirmed, self.atr_period),
            "size_class": "",
            "entry_features": {
                "resistance": signal["resistance"],
                "vol_ratio":  signal["vol_ratio"],
                "prev_chg":   signal["prev_chg"],
            },
        }

    # ------------------------------------------------------------------
    # 매도
    # ------------------------------------------------------------------
    def check_sell_signals(self) -> List[Dict]:

    def check_buy(self, code: str, name: str) -> Optional[Dict]:
        # 골든타임 가드 (_candidate_filter 가 미리 거른 뒤에도 방어용 재검사)
        if not self._is_golden_time():
            return None

        need_n = 1 if self.skip_hts_scan_dupes else max(self.lookback_min, self.vol_window) + 2
        if self.use_ema_filter and not self.skip_hts_scan_dupes:
            need_n = max(need_n, self.ema_slow_period + 5)
        confirmed = self.ws.get_candles(code, self.candle_tf, n=need_n + 5)
        if len(confirmed) < need_n - 1:
            # force 연타 금지 — 시세불가·특수코드는 fail_max 후에도 REST 폭주
            force_sec = max(30, get_env_int("BREAKOUT_CANDLE_GAP_FORCE_SEC", 120))
            now_g = time.time()
            if not hasattr(self, "_candle_gap_force_ts"):
                self._candle_gap_force_ts = {}
            last_g = float(self._candle_gap_force_ts.get(code, 0) or 0)
            do_force = (now_g - last_g) >= float(force_sec)
            if do_force:
                self._candle_gap_force_ts[code] = now_g
            try:
                self.ws.fill_gap([code], force=do_force)
            except Exception:
                pass
            log_sec = max(15, get_env_int("BREAKOUT_CANDLE_SHORT_LOG_SEC", 60))
            if not hasattr(self, "_candle_short_log_ts"):
                self._candle_short_log_ts = {}
            last_l = float(self._candle_short_log_ts.get(code, 0) or 0)
            if now_g - last_l >= float(log_sec):
                self._candle_short_log_ts[code] = now_g
                self.logger.info(
                    "🔍 [캔들부족] %s(%s) need>=%d have=%d",
                    name, code, need_n - 1, len(confirmed),
                )
            return None

        params = {
            "lookback_min":  self.lookback_min,
            "vol_window":    self.vol_window,
            "vol_mult":      self.vol_mult,
            "prev_chg_min":  self.prev_chg_min,
            "prev_chg_max":  self.prev_chg_max,
            "max_daily_chg": self.max_daily_chg,
            "min_price":     self.min_price,
            "min_bar_trade_value_krw": self.min_bar_trade_value_krw,
            "min_turnover_1m_pct": self.min_turnover_1m_pct,
            "share_denom": (
                float(self.ws.get_share_denom(code))
                if getattr(self.ws, "get_share_denom", None)
                else 0.0
            ),
            "confirm_margin_pct": self.confirm_margin_pct,  # 가짜돌파 필터 (0=OFF)
            "body_min_pct":  self.body_min_pct,             # 가짜돌파 필터 (0=OFF)
            "use_ema_filter": self.use_ema_filter,
            "ema_fast_period": self.ema_fast_period,
            "ema_slow_period": self.ema_slow_period,
            "time_start_hm": 0,
            "time_end_hm":   2400,
            "entry_mode":    breakout_entry_mode(),
            "live_backtest_align": get_env_bool(
                "BREAKOUT_LIVE_BACKTEST_ALIGN", True,
            ),
            "live_signal_lookback_bars": get_env_int(
                "BREAKOUT_LIVE_SIGNAL_LOOKBACK_BARS", 1,
            ),
            "intrabar_slippage_pct": get_env_float(
                "BREAKOUT_INTRABAR_SLIPPAGE_PCT", 0.0,
            ),
            "_whipsaw_ws": self.ws,
            "_whipsaw_code": code,
            "_orderbook_ws": self.ws,
            "_orderbook_code": code,
            "_program_ws": self.ws,
            "_program_code": code,
            "slot_money": self.slot_money,
            "skip_hts_scan_dupes": self.skip_hts_scan_dupes,
        }

        mode = breakout_entry_mode(params)
        forming = None
        if mode in _INTRABAR_ENTRY_MODES:
            curr_price = self._live_current_price(code)
            if curr_price <= 0:
                # 저유동 B안: 체결 틱 없음 — 60초 1회 INFO (debug 만이면 탈락 이유 안 보임)
                if not hasattr(self, "_no_price_log"):
                    self._no_price_log = {}
                last = self._no_price_log.get(code, 0)
                gap = 15.0 if get_env_bool("SCAN_REJECT_LOG_VERBOSE", False) else 60.0
                if time.time() - last >= gap:
                    self._no_price_log[code] = time.time()
                    self.logger.info("🔍 [탈락-가격없음] %s(%s) B안 WS시세 없음", name, code)
                return None

            forming = None
            if getattr(self.ws, "get_current_candle", None):
                forming = self.ws.get_current_candle(code, self.candle_tf)
            if not forming:
                now_key = dt.now().strftime("%Y%m%d%H%M")
                forming = {
                    "candle_time": now_key,
                    "open": curr_price,
                    "high": curr_price,
                    "low": curr_price,
                    "close": curr_price,
                    "volume": 0,
                }
            bar_key = str(forming.get("candle_time") or "")
            if bar_key and self._intrabar_fired.get(code) == bar_key:
                return None

            reason, msg, signal = check_buy_signal_breakout_intrabar_live(
                confirmed, curr_price, forming,
                _breakout_params_with_ema_closes(
                    params, confirmed + ([forming] if forming else []),
                    len(confirmed),
                    forming_close=curr_price,
                ),
            )
            if reason:
                self.logger.info("🔍 [%s] %s(%s) %s", reason, name, code, msg or "")
                return None
            if not signal:
                return None
            if bar_key:
                self._intrabar_fired[code] = bar_key
            curr_price = float(signal.get("entry_price") or curr_price)
        else:
            reason, msg, signal = check_buy_signal_breakout_live(confirmed, params)
            if reason:
                self.logger.info("🔍 [%s] %s(%s) %s", reason, name, code, msg or "")
                return None
            if not signal:
                return None
            align_on = get_env_bool("BREAKOUT_LIVE_BACKTEST_ALIGN", True)
            entry_open = float(signal.get("entry_price", 0) or 0)
            entry_src = "ohlc_open"
            _ebk = str(signal.get("entry_bar_key") or "")[:12]
            _defer = self._defer_mid_enroll_entry(
                code, _ebk, int(self.candle_tf or 1), params,
            )
            if _defer:
                self.logger.info("🔍 [%s] %s(%s)", _defer, name, code)
                return None
            if align_on and entry_open > 0:
                from kis_trader.engine.tail_tick_replay import live_align_entry_price
                curr_price, entry_src = live_align_entry_price(
                    self.ws,
                    code,
                    entry_open,
                    entry_bar_key=_ebk,
                    tf_min=int(self.candle_tf or 1),
                )
            else:
                curr_price = float(signal.get("close") or 0.0)
                p = self._live_current_price(code)
                if p > 0:
                    curr_price = p
            if curr_price <= 0:
                if get_env_bool("SCAN_REJECT_LOG_VERBOSE", False):
                    if not hasattr(self, "_no_price_log"):
                        self._no_price_log = {}
                    last = self._no_price_log.get(code, 0)
                    if time.time() - last >= 60:
                        self._no_price_log[code] = time.time()
                        self.logger.info("🔍 [가격없음] %s(%s)", name, code)
                else:
                    self.logger.debug("🔍 [가격없음] %s(%s)", name, code)
                return None

        invest_cap = self._breakout_invest_cap_krw()
        if self._live_portfolio_budget_enabled():
            qty, _, rej = self._resolve_live_buy_qty(curr_price, invest_cap=invest_cap)
            if rej:
                self.logger.info(
                    "🔍 [탈락-%s] %s(%s) price=%.0f cap=%.0f",
                    rej, name, code, curr_price, invest_cap,
                )
                return None
        else:
            qty = invest_qty_for_price(curr_price, float(invest_cap))
            if qty < 1:
                self.logger.info(
                    "🔍 [탈락-수량0] %s(%s) price=%.0f cap=%.0f",
                    name, code, curr_price, invest_cap,
                )
                return None

        stop_price = curr_price * (1 + self.stop_loss_pct)
        target_price = curr_price * (1 + self.take_profit_pct)

        mode_tag = "B" if mode in _INTRABAR_ENTRY_MODES else "A"
        # A=align: entry_src 는 계산가 출처. B=intrabar: 현재가 경로라 ohlc_open 표기 안 함
        if mode in _INTRABAR_ENTRY_MODES:
            self.logger.info(
                "🚀 [BREAKOUT-%s] %s(%s) price=%.0f qty=%d 저항=%.0f volX=%.1f prevChg=%.2f%%",
                mode_tag, name, code, curr_price, qty,
                signal["resistance"], signal["vol_ratio"], signal["prev_chg"],
            )
        else:
            self.logger.info(
                "🚀 [BREAKOUT-%s] %s(%s) price=%.0f qty=%d 저항=%.0f volX=%.1f prevChg=%.2f%% entry_src=%s",
                mode_tag, name, code, curr_price, qty,
                signal["resistance"], signal["vol_ratio"], signal["prev_chg"],
                entry_src,
            )
        return {
            "code": code,
            "name": name,
            "price": curr_price,
            "qty": qty,
            "stop_price": stop_price,
            "target_price": target_price,
            # 진입 시점 ATR(변동성) — ATR 동적 손절(sl_mode='atr') 용. holding 에 보존되면
            # check_sell_signals 가 매수 시점 변동성으로 손절선을 잡는다(백테와 정합).
            "atr_entry": _breakout_entry_atr_from_candles(confirmed, self.atr_period),
            "size_class": "",
            "entry_features": {
                "resistance": signal["resistance"],
                "vol_ratio":  signal["vol_ratio"],
                "prev_chg":   signal["prev_chg"],
            },
        }

    # ------------------------------------------------------------------
    # 매도
    # ------------------------------------------------------------------
    def check_sell_signals(self) -> List[Dict]:


Shell

cd /home/hoon/kis_bot && python3 -m py_compile kis_trader/strategies/base.py kis_trader/strategies/scalping.py kis_trader/strategies/momentum.py kis_trader/strategies/tail_catch.py kis_trader/strategies/breakout.py && python3 <<'PY'
from database import TradeDB, apply_env_patch, invalidate_merged_env_cache
# apply_env_patch 심볼 확인
import database as dbmod
fn = getattr(dbmod, 'apply_env_patch', None) or getattr(TradeDB, 'apply_env_patch', None)
print('apply', callable(fn), getattr(dbmod, 'invalidate_merged_env_cache', None))
PY

Compile strategies; find apply_env_patch


cd /home/hoon/kis_bot && python3 -m py_compile kis_trader/strategies/base.py kis_trader/strategies/scalping.py kis_trader/strategies/momentum.py kis_trader/strategies/tail_catch.py kis_trader/strategies/breakout.py; echo compile_ok=$?

Verify Python syntax of edited files


cd /home/hoon/kis_bot && python3 <<'PY'
from kis_trader.backtest.param_search_apply_snapshot import apply_env_patch
from kis_trader.utils.env import invalidate_merged_env_cache, get_env_from_db, get_env_bool, get_env_int

eid = apply_env_patch({
    "CHECK_BUY_PROF_MIN_MS": "500",
    "LOOP_PROFILE_ENABLED": "true",
    "LOOP_PROFILE_EVERY_N": "1",
    "LOOP_PROFILE_LOG_PATH": "logs/loop_profile.log",
})
print("env_id", eid)
invalidate_merged_env_cache()
print("CHECK_BUY_PROF_MIN_MS", get_env_from_db("CHECK_BUY_PROF_MIN_MS", ""))
print("LOOP_PROFILE_ENABLED", get_env_bool("LOOP_PROFILE_ENABLED", False))
print("LOOP_PROFILE_EVERY_N", get_env_int("LOOP_PROFILE_EVERY_N", 0))
PY
sudo systemctl restart kis_trader_main.service
sleep 3
systemctl is-active kis_trader_main.service
# truncate marker for new samples
echo "=== CHECK_BUY_PROF start $(date -Iseconds) ===" >> /home/hoon/kis_bot/logs/loop_profile.log

Apply env keys and restart live bot


sleep 90; rg -n "CHECK_BUY_PROF" /home/hoon/kis_bot/logs/loop_profile.log | tail -n 40; echo '---'; journalctl -u kis_trader_main.service --since '2 min ago' --no-pager 2>/dev/null | rg 'CHECK_BUY_PROF|틱매도' | tail -n 30; systemctl is-active kis_trader_main.service

Wait and collect CHECK_BUY_PROF samples


단계 합이 total보다 훨씬 작습니다. 미계측 구간(탈락 로그·락 대기 등)을 잡도록 프로파일러를 보강한 뒤 다시 샘플을 받겠습니다.


cd /home/hoon/kis_bot && python3 <<'PY'
import time
from database import TradeDB
db = TradeDB()
# 워밍업
for _ in range(3):
    db.get_merged_env_snapshot()
times = []
for _ in range(20):
    t0 = time.perf_counter()
    s = db.get_merged_env_snapshot()
    times.append((time.perf_counter() - t0) * 1000)
print("nkeys", len(s), "ms min/avg/max", round(min(times),2), round(sum(times)/len(times),2), round(max(times),2))
# 연속 2회 (어댑터처럼)
times2 = []
for _ in range(20):
    t0 = time.perf_counter()
    db.get_merged_env_snapshot()
    db.get_merged_env_snapshot()
    times2.append((time.perf_counter() - t0) * 1000)
print("x2 ms min/avg/max", round(min(times2),2), round(sum(times2)/len(times2),2), round(max(times2),2))
PY

Measure get_merged_env_snapshot latency


cd /home/hoon/kis_bot && python3 <<'PY'
import time
from database import TradeDB
db = TradeDB()
today = time.strftime('%Y%m%d')
# 워밍업
db.get_trades_by_date(today)
times=[]
for _ in range(15):
    t0=time.perf_counter()
    rows=db.get_trades_by_date(today)
    times.append((time.perf_counter()-t0)*1000)
print('trades', len(rows), 'ms min/avg/max', round(min(times),1), round(sum(times)/len(times),1), round(max(times),1))
PY
# 미계측 비율 요약
python3 <<'PY'
import re
from collections import defaultdict
path='/home/hoon/kis_bot/logs/loop_profile.log'
rows=[]
with open(path) as f:
    for line in f:
        if '[CHECK_BUY_PROF]' not in line or '2026-08-13 12:3' not in line:
            continue
        m=re.search(r'\[CHECK_BUY_PROF\] (\w+) code=(\S+) total=([\d.]+)(.*)$', line)
        if not m: continue
        sid, code, total, rest = m.group(1), m.group(2), float(m.group(3)), m.group(4)
        stages={k:float(v) for k,v in re.findall(r'(\w+)=([\d.]+)', rest)}
        stages.pop('total', None)
        ssum=sum(stages.values())
        rows.append((sid, total, ssum, total-ssum, stages))
print('samples', len(rows))
by=defaultdict(list)
for sid,total,ssum,gap,st in rows:
    by[sid].append((total,ssum,gap,st))
for sid, items in sorted(by.items()):
    gaps=[g for _,_,g,_ in items]
    totals=[t for t,_,_ ,_ in items]
    tdb=[st.get('trades_db',0) for *_,st in items]
    eng=[st.get('engine',0) for *_,st in items]
    print(f'{sid}: n={len(items)} total_avg={sum(totals)/len(totals):.0f} gap_avg={sum(gaps)/len(gaps):.0f} trades_db_max={max(tdb):.0f} engine_max={max(eng):.1f}')
    # top gap example
    items2=sorted(items, key=lambda x:-x[2])[:2]
    for t,s,g,st in items2:
        print(f'  gap={g:.0f} total={t:.0f} stages={ {k:round(v,1) for k,v in sorted(st.items(), key=lambda x:-x[1])[:5]} }')
PY

Measure trades_db; summarize unaccounted gap


미계측 구간이 total의 대부분입니다. `finish`에 `after`(마지막 mark~종료)를 넣고, 탈락 로그 어댑터의 스냅샷 비용을 확인한 뒤 다시 샘플을 받겠습니다.

StrReplace

    def _cb_prof_finish(self, ctx: Optional[Dict[str, Any]], *, note: str = "") -> None:
        """check_buy 끝 — 느린 건 CHECK_BUY_PROF 로그 + 최근 결과 보관."""
        if not ctx:
            return
        now = time.perf_counter()
        total = (now - float(ctx.get("t0") or now)) * 1000.0
        stages = ctx.setdefault("stages", {})
        # 마지막 mark ~ finish (탈락 logger / 어댑터 등)
        try:
            last = float(ctx.get("_last") or ctx.get("t0") or now)
            after_ms = (now - last) * 1000.0
            if after_ms >= 0.05:
                stages["after"] = float(stages.get("after", 0) or 0) + after_ms
        except Exception:
            pass
        ctx["total_ms"] = total
        self._last_cb_prof = ctx
        try:
            min_ms = float(get_env_int("CHECK_BUY_PROF_MIN_MS", 500) or 500)
        except Exception:
            min_ms = 500.0
        if total < min_ms:
            return
        # 큰 단계 순
        ordered = sorted(
            ((k, float(v or 0)) for k, v in stages.items()),
            key=lambda x: -x[1],
        )
        parts = [
            f"[CHECK_BUY_PROF] {self.strategy_id}",
            f"code={ctx.get('code')}",
            f"total={total:.1f}",
        ]
        if note:
            parts.append(f"note={note}")
        for k, v in ordered[:12]:
            parts.append(f"{k}={v:.1f}")
        line = " ".join(parts)
        try:
            self.logger.info("%s", line)
        except Exception:
            pass
        path = str(get_env_from_db("LOOP_PROFILE_LOG_PATH", "logs/loop_profile.log") or "").strip()
        if not path:
            return
        try:
            import os
            if not os.path.isabs(path):
                root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
                path = os.path.join(root, path)
            os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
            with open(path, "a", encoding="utf-8") as f:
                f.write(dt.now().strftime("%Y-%m-%d %H:%M:%S.%f")[:-3] + " " + line + "\n")
        except Exception:
            pass

    def _cb_prof_finish(self, ctx: Optional[Dict[str, Any]], *, note: str = "") -> None:
        """check_buy 끝 — 느린 건 CHECK_BUY_PROF 로그 + 최근 결과 보관."""
        if not ctx:
            return
        now = time.perf_counter()
        total = (now - float(ctx.get("t0") or now)) * 1000.0
        ctx["total_ms"] = total
        self._last_cb_prof = ctx
        try:
            min_ms = float(get_env_int("CHECK_BUY_PROF_MIN_MS", 500) or 500)
        except Exception:
            min_ms = 500.0
        if total < min_ms:
            return
        stages = ctx.get("stages") or {}
        # 큰 단계 순
        ordered = sorted(
            ((k, float(v or 0)) for k, v in stages.items()),
            key=lambda x: -x[1],
        )
        parts = [
            f"[CHECK_BUY_PROF] {self.strategy_id}",
            f"code={ctx.get('code')}",
            f"total={total:.1f}",
        ]
        if note:
            parts.append(f"note={note}")
        for k, v in ordered[:12]:
            parts.append(f"{k}={v:.1f}")
        line = " ".join(parts)
        try:
            self.logger.info("%s", line)
        except Exception:
            pass
        path = str(get_env_from_db("LOOP_PROFILE_LOG_PATH", "logs/loop_profile.log") or "").strip()
        if not path:
            return
        try:
            import os
            if not os.path.isabs(path):
                root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
                path = os.path.join(root, path)
            os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
            with open(path, "a", encoding="utf-8") as f:
                f.write(dt.now().strftime("%Y-%m-%d %H:%M:%S.%f")[:-3] + " " + line + "\n")
        except Exception:
            pass


Shell

cd /home/hoon/kis_bot && python3 -m py_compile kis_trader/strategies/base.py && sudo systemctl restart kis_trader_main.service && sleep 4 && systemctl is-active kis_trader_main.service && echo "=== after-mark $(date -Iseconds) ===" >> logs/loop_profile.log && sleep 75 && rg "CHECK_BUY_PROF" logs/loop_profile.log | tail -n 25

Restart bot; sample after= attribution


TodoWrite

check_buy 단계 계측 배포·샘플 수집

after/엔진/trades_db 병목 보고·수정안 승인 대기


`check_buy` 안을 쪼개서 재측정했습니다. 결론부터 말하면 **엔진·봉·정렬이 느린 게 아닙니다.**

### 측정 결과 (재시작 후 샘플)
로그: `/home/hoon/kis_bot/logs/loop_profile.log` · `[CHECK_BUY_PROF]`

| 단계 | 전형적 ms | 의미 |
|------|-----------|------|
| `candles` / `norm` / `engine` / `align` / `mid_enroll` | **0.1~1ms** | 신호 계산·봉 조회는 사실상 공짜 |
| `trades_db` | 보통 수 ms, 가끔 **300~1100ms** | `get_trades_by_date` — DB 경합 시 스파이크 |
| **`after`** (마지막 mark ~ 함수 종료) | **total의 95~99%** (0.5~12초) | 탈락 직후 `logger.info("🔍 …")` 구간 |

예:
- `BREAKOUT … total=10111 after=10111 candles=0.2 engine=0.1`
- `MOMENTUM … total=11336 after=11331 trades_db=4.6 engine=0.1`
- `SCALP … total=12540 after=12152 trades_db=387`

### 근본 원인 (측정으로 좁힌 것)
탈락 로그마다 `FeedPrefixLoggerAdapter`가 **전체** `get_merged_env_snapshot()`을 **2번** 호출합니다 (`LIVE_TICK_PROVIDER` / `LIVE_OB_PROVIDER` 한 줄 붙이려고).

4전략이 후보마다 `🔍` 로그를 남기면 → 스냅샷·DB 락이 직렬화 → **한 종목 check_buy가 수 초**로 불어납니다.
(`_sell_lock`·엔진 CPU는 이전/이번 측정 모두 무죄.)

### 영향 분류
- **실매 루프 속도만** (신호식·T−1/T·freeze 불변)
- 웹백테·Optuna **무관**

### 수정안 (승인 후)
어댑터에서 full snapshot 제거 → `get_env_from_db("LIVE_TICK_PROVIDER")` / `LIVE_OB_PROVIDER` (또는 수 초 TTL 캐시). 신호/매매 로직은 손대지 않음.

이 수정 진행할까요? (진행하면 같은 계측으로 `after`가 내려가는지만 확인하면 됩니다.)


<timestamp>Thursday, Aug 13, 2026, 12:43 PM (UTC+9)</timestamp>
<user_query>
alter가 뭐하는애인데 그럼 근본원인이 db 때문이라는건가
</user_query>


`after`는 매매 단계 이름이 아닙니다. **계측용으로만** 붙인 “나머지 시간”입니다.

### `after`가 재는 것
`check_buy` 안에서 마지막에 찍은 단계(`engine` 등) **직후 ~ 함수가 끝날 때까지**입니다.

그 구간에 실제로 있는 일은 거의 이것뿐입니다.

1. 탈락 시 `logger.info("🔍 […]")`
2. 그 로그를 처리하는 `FeedPrefixLoggerAdapter`
3. `finally`에서 계측 마무리

봉·엔진·정렬·중분편입 같은 **신호 로직은 이미 끝난 뒤**입니다.

### 그럼 DB 때문이냐?
**직접 원인은 “탈락 로그 한 줄마다 DB 전체 env 스냅샷을 2번 읽는 것”**이고, 그게 DB를 두드려서 느려진 겁니다.

- 느린 주범: `get_merged_env_snapshot()` × 2 (config 테이블들 전부 조회)
- 4전략이 후보마다 `🔍` 로그 → 같은 DB 락/커넥션에 줄 서기 → **`after`가 0.5~12초**
- 부수 효과: 같은 락 때문에 `trades_db`도 가끔 수백 ms~1초로 튀는 것

반대로 **엔진/`candles` 자체가 DB 때문에 느린 건 아닙니다** (그쪽은 계속 1ms 미만).

한 줄로: **매매 계산 DB 병목이 아니라, 탈락 로그가 매 호출마다 ‘env 전체 재조회’를 해서 DB가 막히는 것**입니다.


<timestamp>Thursday, Aug 13, 2026, 12:45 PM (UTC+9)</timestamp>
<user_query>
탈락 로그가 매 호출마다 ‘env 전체 재조회’를 해서 DB가 막히는 것입니다.

로그하나 찍는데 왜 디비조회를 왜 두번이나 하는데 뭘 얼마나 join 한다고 이렇게 느려질거야? select 문일거아냐? 탈락 로그에 왜 디비를 읽어 ram에 다 잇는거 아냐 ?
</user_query>


맞는 질문입니다. ‘로그인데 왜 DB?’가 정상 감각이고, 코드가 그걸 어기고 있습니다. 어댑터와 env 캐시 경로를 바로 대조해 확인하겠습니다.


Shell

cd /home/hoon/kis_bot && python3 <<'PY'
from database import CONFIG_TABLE_KEYS
print('tables', len(CONFIG_TABLE_KEYS), list(CONFIG_TABLE_KEYS.keys()))
PY

Count config tables in snapshot


맞습니다. **원래는 RAM에서 읽어야 하고, 탈락 로그 때문에 DB를 두드릴 이유가 없습니다.**

### 왜 두 번이냐
로그 한 줄에 provider 두 개만 붙이려고, 같은 무거운 함수를 **두 번** 호출해 둔 겁니다.

```37:38:kis_trader/strategies/base.py
                    tick_p = str(db_obj.get_merged_env_snapshot().get("LIVE_TICK_PROVIDER") or "kiwoom").strip().lower()
                    ob_p = str(db_obj.get_merged_env_snapshot().get("LIVE_OB_PROVIDER") or "kiwoom").strip().lower()
```

JOIN이 아닙니다. `get_merged_env_snapshot()`이 할 일은 대략:

- config 테이블 **10개** 각각 `SELECT * … ORDER BY id DESC LIMIT 30`
- + `env_config` 최신 1행
- + `env_config_ext` 전체

키 2개만 필요한데 **설정판 전체를 매번 다시 조립**합니다.

### RAM에 이미 있지 않냐?
있습니다. 정상 경로(`get_env_from_db` → `get_merged_env_dict`)는 **메모리 TTL 캐시(기본 60초)** 를 씁니다.

문제는 어댑터가 그 경로를 **안 타고**, `TradeDB.get_merged_env_snapshot()`을 **직접** 호출해서 캐시를 우회한다는 점입니다.

| 경로 | 캐시 | 탈락 로그에서 쓰나 |
|------|------|-------------------|
| `get_env_from_db("LIVE_TICK_PROVIDER")` | RAM TTL | ❌ 안 씀 |
| `db.get_merged_env_snapshot()` | **없음** | ✅ 씀 (×2) |

한가할 때 한 번이면 ~20~30ms라 “SELECT라서 괜찮다”처럼 보이지만, 장중엔 전략 4개가 후보마다 `🔍` 탈락 로그를 찍으면 **같은 DB 연결/락에 줄이 쌓여** 초 단위로 늘어납니다. (`after`가 그 대기 시간)

### 한 줄 요약
느린 이유는 DB가 원래 느려서가 아니라, **로그용 접두어 때문에 ‘이미 RAM에 있는 env’를 안 쓰고, 매 로그마다 전체 스냅샷을 DB에서 다시 긁기 때문**입니다.
고치면 `get_env_from_db` 두 키만(또는 어댑터용 짧은 캐시) 쓰면 됩니다.


<timestamp>Thursday, Aug 13, 2026, 12:47 PM (UTC+9)</timestamp>
<user_query>
get_env_from_db("LIVE_TICK_PROVIDER")
RAM TTL
❌ 안 씀
db.get_merged_env_snapshot()
없음
✅ 씀 (×2)

이건 뭐하는 애들인데 여기밖에 안쓰는거야?
</user_query>


두 함수의 역할과 실제 호출 위치를 코드에서 바로 찾겠습니다.


여기만 쓰는 건 아닙니다. **역할이 다릅니다.**

### 한 줄씩
| 함수 | 하는 일 |
|------|---------|
| `TradeDB.get_merged_env_snapshot()` | DB에서 config 테이블들을 읽어 **설정 dict를 새로 조립** (캐시 없음) |
| `get_env_from_db("키")` | 위 스냅샷을 **RAM TTL(기본 60초)로 캐시한 뒤** 키 하나만 꺼냄 |

관계: `get_env_from_db` → (캐시 miss 때만) → `get_merged_env_snapshot()`.

### 어디에 쓰이나
- **`get_env_from_db`**: 실매·WS·주문·엔진 전반. “파라미터 하나 읽기” 표준 경로. 호출이 매우 많음.
- **`get_merged_env_snapshot` 직접 호출**: “설정판 통째로”가 필요할 때 — 웹 백테 폼, Optuna 스냅샷, 엔진 defaults 로드, 마이그레이션/스크립트 등. **가끔·배치성**이면 괜찮음.

### 지금 문제 구간
`base.py` 탈락 로그 어댑터(+ 매수체크 로그 785~794)만 **핫패스에서 직접 ×2** 호출합니다.
시세/호가 provider 문자열 붙이려고 전체 스냅샷을 다시 긁는 용도라, **여기 쓰임이 잘못된 것**이지 `get_merged_env_snapshot` 자체가 쓸모없는 함수는 아닙니다.

정리: 함수는 공용이고, **장중 매 탈락 로그마다 직접 부르는 게 이상**한 겁니다. 그 자리만 `get_env_from_db`로 바꾸면 됩니다.


<timestamp>Thursday, Aug 13, 2026, 12:51 PM (UTC+9)</timestamp>
<user_query>
@kis_trader/strategies/base.py:32-43 얘가 하는일은 뭔데? 이 함수만 고치면 되는거네? tick_p ob_p 이건 뭐 내가 봐도 모르는 수치인데 굳이 찍을 필요가 없고 찍더라도캐시나 ram에 있는거 가지고 계산하는 함수 만들어서 찍던가 하면될 것 같은데 너가 로그를 보려면 찍는게 나을지도
</user_query>


역할만 짧게 정리한 뒤, 핫패스의 동일 패턴(어댑터 + 매수체크 로그)을 RAM 캐시(`get_env_from_db`)로 바꾸겠습니다. 접두어는 디버깅용으로 유지합니다.


StrReplace

class FeedPrefixLoggerAdapter(logging.LoggerAdapter):
    def process(self, msg, kwargs):
        if isinstance(msg, str) and "🔍 [" in msg and "시세:" not in msg and "LIVE_TICK_PROVIDER" in self.extra:
            try:
                db_obj = self.extra.get("db")
                if db_obj:
                    tick_p = str(db_obj.get_merged_env_snapshot().get("LIVE_TICK_PROVIDER") or "kiwoom").strip().lower()
                    ob_p = str(db_obj.get_merged_env_snapshot().get("LIVE_OB_PROVIDER") or "kiwoom").strip().lower()
                    if tick_p or ob_p:
                        msg = msg.replace("🔍 [", f"🔍 [T:{tick_p}|O:{ob_p}|", 1)
            except Exception:
                pass
        return msg, kwargs

def _live_feed_providers() -> Tuple[str, str]:
    """시세(T)·호가(O) provider — RAM TTL 캐시(get_env_from_db). 로그 접두용.

    TradeDB.get_merged_env_snapshot() 직접 호출 금지(핫패스에서 config 전체 재조회).
    """
    tick_p = str(get_env_from_db("LIVE_TICK_PROVIDER", "kiwoom") or "kiwoom").strip().lower()
    ob_p = str(get_env_from_db("LIVE_OB_PROVIDER", "kiwoom") or "kiwoom").strip().lower()
    return tick_p, ob_p

class FeedPrefixLoggerAdapter(logging.LoggerAdapter):
    """탈락/스캔 로그(🔍 [) 앞에 T:시세|O:호가 provider 접두어를 붙인다.

    예: 🔍 [탈락-RSI] → 🔍 [T:kiwoom|O:kiwoom|탈락-RSI]
    (피드 출처 디버깅용 · 매매 수치 아님)
    """

    def process(self, msg, kwargs):
        if isinstance(msg, str) and "🔍 [" in msg and "시세:" not in msg and "LIVE_TICK_PROVIDER" in self.extra:
            try:
                # 이미 T:|O: 접두가 있으면 중복 삽입 금지 (매수체크 로그 등)
                if "🔍 [T:" not in msg[:24]:
                    tick_p, ob_p = _live_feed_providers()
                    if tick_p or ob_p:
                        msg = msg.replace("🔍 [", f"🔍 [T:{tick_p}|O:{ob_p}|", 1)
            except Exception:
                pass
        return msg, kwargs

        if _codes and str(getattr(self, "strategy_id", "")).upper().startswith("US_"):
            self.logger.info(
                "🔍 [매수체크/T:%s|O:%s] 후보 %d (보유 %d/%d) codes=%s",
                self.db.get_merged_env_snapshot().get("LIVE_TICK_PROVIDER", "kiwoom"),
                self.db.get_merged_env_snapshot().get("LIVE_OB_PROVIDER", "kiwoom"),
                len(candidates), active_cnt, max_stocks,
                ",".join(_codes[:12]),
            )
        else:
            self.logger.info(
                "🔍 [매수체크/T:%s|O:%s] 후보 %d (보유 %d/%d)",
                self.db.get_merged_env_snapshot().get("LIVE_TICK_PROVIDER", "kiwoom"),
                self.db.get_merged_env_snapshot().get("LIVE_OB_PROVIDER", "kiwoom"),
                len(candidates), active_cnt, max_stocks,
            )

        _tick_p, _ob_p = _live_feed_providers()
        if _codes and str(getattr(self, "strategy_id", "")).upper().startswith("US_"):
            self.logger.info(
                "🔍 [매수체크/T:%s|O:%s] 후보 %d (보유 %d/%d) codes=%s",
                _tick_p, _ob_p,
                len(candidates), active_cnt, max_stocks,
                ",".join(_codes[:12]),
            )
        else:
            self.logger.info(
                "🔍 [매수체크/T:%s|O:%s] 후보 %d (보유 %d/%d)",
                _tick_p, _ob_p,
                len(candidates), active_cnt, max_stocks,
            )


Shell

cd /home/hoon/kis_bot && python3 -m py_compile kis_trader/strategies/base.py && sudo systemctl restart kis_trader_main.service && sleep 4 && systemctl is-active kis_trader_main.service && echo "=== feed-fix $(date -Iseconds) ===" >> logs/loop_profile.log && sleep 70 && rg "CHECK_BUY_PROF" logs/loop_profile.log | awk '/feed-fix/{p=1;next} p' | tail -n 20

Restart and verify after= dropped


rg -n "feed-fix|CHECK_BUY_PROF" /home/hoon/kis_bot/logs/loop_profile.log | tail -n 35

Tail CHECK_BUY_PROF after feed fix


### 그 함수가 하는 일
탈락/스캔 로그(`🔍 […`) 앞에 **시세·호가 출처**만 붙입니다.

- `T:` = `LIVE_TICK_PROVIDER` (예: kiwoom / kis) — 체결·현재가 피드
- `O:` = `LIVE_OB_PROVIDER` — 호가 피드

매매 수치가 아니라 “지금 어느 브로커 시세를 보나” 디버그용입니다. 그래서 없어도 매매는 되고, 피드 꼬임 추적할 때만 유용합니다.

### 고친 것
말씀하신 대로 **그 핫패스만** 수정했습니다.

- 어댑터 + 매수체크 로그 → `get_env_from_db` (RAM TTL)
- 접두어는 디버깅용으로 **유지**

### 재측정 (수정 후)
| | 수정 전 | 수정 후 |
|--|---------|---------|
| `after` | 수 초~12초 | **~0.1ms** |
| `engine`/`candles` | 그대로 빠름 | 동일 |

남은 스파이크는 `trades_db`(종목당 일일거래 조회, 가끔 0.8~1.9초)뿐입니다. 로그 스냅샷 병목은 해소된 상태입니다.


<timestamp>Thursday, Aug 13, 2026, 12:55 PM (UTC+9)</timestamp>
<user_query>
@base.py (30-59) 엥? 함수만 따로뱄지 똑같잖아
</user_query>


겉모습은 비슷해 보이지만, **호출하는 함수가 다릅니다.**

### 이전 (느림)
```python
db_obj.get_merged_env_snapshot()  # TradeDB 직접 · 캐시 없음 · 매 호출 DB
```
→ config 테이블 10개 + ext **매번 SELECT** × 2

### 지금 (빠름)
```python
get_env_from_db("LIVE_TICK_PROVIDER")  # env.py · RAM TTL(기본 60초)
```
→ 캐시 hit면 **DB 안 감**. miss일 때만 한 번 스냅샷 후 메모리 재사용.

그래서 `_live_feed_providers()`로 뺀 건 “모양만 정리”가 아니라, **비싼 DB 경로 → 캐시된 get_env 경로로 갈아탄 것**입니다.

측정으로도 이미 갈렸습니다: `after` 수 초 → **0.1ms**.
(키가 두 개라 `get_env_from_db`를 두 번 호출해도, 둘 다 같은 RAM 캐시를 읽습니다.)


<timestamp>Thursday, Aug 13, 2026, 12:55 PM (UTC+9)</timestamp>
<user_query>
아니 그럼 왜 무겁게 몇만건 되는걸 select 해온거야
</user_query>


의도적으로 “몇만 건을 긁자”고 짠 게 아닙니다.

로그에 `T:kiwoom|O:…` 네 글자만 붙이려고, **이미 있던 함수** `get_merged_env_snapshot()`을 그대로 호출한 겁니다.
그 함수는 “설정 키 하나”용이 아니라 **웹/저장/마이그레이션용으로 config 전체를 조립하는 API**라서, 탈락 로그 핫패스에 쓰기엔 과합니다.

정리하면:

- 몇만 건 체결/거래를 SELECT한 게 아니라, **설정 테이블들을 매번 통째로 다시 읽는 경로**를 탄 것
- JOIN으로 무겁게 설계한 게 아니라, **편한 함수를 잘못 갖다 쓴 실수**에 가깝습니다
- `get_env_from_db` / RAM 캐시가 이미 있는데도, 어댑터만 그걸 우회한 상태였고요

그래서 “왜 그렇게 무겁게?”에 대한 답은: **그럴 만한 매매/정합 이유가 있는 게 아니라, 로그 접두 한 줄 때문에 잘못된 API를 쓴 것**입니다.


<timestamp>Thursday, Aug 13, 2026, 12:57 PM (UTC+9)</timestamp>
<user_query>
하 시바꺼... 지금 이건 몇초짜리 쿼리인데 몇건중에 select  하는건데
</user_query>


정확한 건수·ms로 바로 재겠습니다. 감이 아니라 숫자로 답할게요.

cd /home/hoon/kis_bot && python3 <<'PY'
import time
from database import TradeDB, CONFIG_TABLE_KEYS
db = TradeDB()
print("=== 테이블별 행수 / LIMIT30 조회 ===")
total_rows = 0
for tbl in CONFIG_TABLE_KEYS:
    try:
        n = db.conn.execute(f"SELECT COUNT(*) AS c FROM {tbl}").fetchone()["c"]
    except Exception as e:
        print(tbl, "ERR", e)
        continue
    t0 = time.perf_counter()
    rows = db.conn.execute(f"SELECT * FROM {tbl} ORDER BY id DESC LIMIT 30").fetchall()
    ms = (time.perf_counter()-t0)*1000
    cols = len(rows[0].keys()) if rows else 0
    total_rows += int(n)
    print(f"{tbl:28} rows={n:6}  LIMIT30 fetch={len(rows):2} cols~{cols:4}  {ms:.1f}ms")
try:
    ext = db.conn.execute("SELECT COUNT(*) AS c FROM env_config_ext").fetchone()["c"]
    t0=time.perf_counter()
    db.conn.execute("SELECT env_key, env_value FROM env_config_ext").fetchall()
    print(f"{'env_config_ext':28} rows={ext:6}  FULL fetch  {(time.perf_counter()-t0)*1000:.1f}ms")
    total_rows += int(ext)
except Exception as e:
    print("ext", e)
print("config 계열 총 행(히스토리 포함):", total_rows)
print()
# 스냅샷 1회 / 2회 (어댑터와 동일)
for _ in range(3):
    db.get_merged_env_snapshot()
times=[]
for _ in range(10):
    t0=time.perf_counter(); db.get_merged_env_snapshot(); times.append((time.perf_counter()-t0)*1000)
print(f"snapshot 1회: avg={sum(times)/len(times):.1f}ms max={max(times):.1f}ms (키수={len(db.get_merged_env_snapshot())})")
times2=[]
for _ in range(10):
    t0=time.perf_counter(); db.get_merged_env_snapshot(); db.get_merged_env_snapshot(); times2.append((time.perf_counter()-t0)*1000)
print(f"snapshot ×2(어댑터): avg={sum(times2)/len(times2):.1f}ms max={max(times2):.1f}ms")
print()
print("※ 장중 after 5~12초 = 쿼리 1건이 그 길이인 게 아니라,")
print("  전략4×탈락로그마다 위 스냅샷이 줄 서서 쌓인 wall-clock")
PY

Measure snapshot query rows and ms


숫자로만 말하면 이렇습니다.

### 한 번 호출이 긁는 양
몇만 건 체결/틱이 **아닙니다.** config 쪽입니다.

| 내용 | 규모 |
|------|------|
| 테이블 | 10개 + `env_config_ext` |
| 테이블당 | `SELECT * … LIMIT 30` (예: `env_config` 컬럼 ~380개) |
| ext | 186행 전체 |
| 합쳐진 키 | ~1363개 |

한가할 때: **스냅샷 1회 ~29ms**, 어댑터처럼 **×2 ~58ms**.

### 그런데 왜 로그에 5~12초?
쿼리 한 방이 몇 초짜리가 아닙니다.
탈락 로그 **매 줄마다** 저 ~60ms짜리 ×2가 전략 4개에서 동시에 줄 서면, **한 종목 기준으로 wall-clock이 초 단위로 불어난 것**입니다.

한 줄로: **몇만 건 big SELECT가 아니라, 싼데 쓸데없는 config 재조립을 로그마다 수백 번 쌓아서 막힌 거**입니다.


<timestamp>Thursday, Aug 13, 2026, 12:58 PM (UTC+9)</timestamp>
<user_query>
지금도 4종목 다 따로 2번 조회하는거 아냐? 지금은 몇건 기준 조회야?
</user_query>


아니요. **지금은 전략 4개가 각자 DB를 2번 두드리는 구조가 아닙니다.**

### 지금 동작
`_live_feed_providers()` → `get_env_from_db` × 2
→ 둘 다 같은 **프로세스 공용 RAM 캐시** (`_merged_env_cache`, TTL 기본 60초)를 읽습니다.

| | 수정 전 | 지금 |
|--|---------|------|
| 탈락 로그 1줄 | DB 스냅샷 **×2** | RAM dict에서 키 2개 |
| 전략 4개 | 각자 DB 연타 | **캐시 1개 공유** (같은 파이썬 프로세스) |
| 종목 N개 | 종목마다 DB | 종목마다도 **RAM만** (TTL 안이면) |

### “몇 건 조회?”
- **TTL 안 (거의 항상)**: **0건** — DB SELECT 없음
- **TTL 만료 후 첫 1회만**: 예전과 같은 스냅샷 1회
  - 테이블당 `LIMIT 30` × 10개 → fetch **최대 300행**
  - + `env_config_ext` ~186행
  - 그다음 60초는 또 0건

함수를 두 번 호출하는 건 맞지만, 두 번째도 **같은 RAM 캐시 hit**이라 DB는 안 갑니다.
(`after ≈ 0.1ms`가 그 증거입니다.)


<timestamp>Thursday, Aug 13, 2026, 12:59 PM (UTC+9)</timestamp>
<user_query>
아직 재시작은 안한거지?
</user_query>


아니요. **이미 재시작했습니다.**

피드 로그 고친 직후 `kis_trader_main.service` 재시작했고, 그다음 샘플에서 `after`가 0.1ms로 떨어진 게 그 재시작 이후 측정입니다.


<timestamp>Thursday, Aug 13, 2026, 1:00 PM (UTC+9)</timestamp>
<user_query>
지금도 느린거같은데 6월달 속도는 아닌데 아직도 또 뭐때문이라고? 원인이
</user_query>


피드 수정 이후 최신 계측만 다시 집계해서, 지금 남은 병목을 숫자로 짚겠습니다.

cd /home/hoon/kis_bot && python3 <<'PY'
import re
from collections import defaultdict
from pathlib import Path
p = Path('logs/loop_profile.log')
lines = p.read_text(errors='ignore').splitlines()
# feed-fix 이후만
idx = 0
for i,l in enumerate(lines):
    if 'feed-fix' in l:
        idx = i
post = lines[idx+1:]
cb = [l for l in post if 'CHECK_BUY_PROF' in l]
lp = [l for l in post if 'LOOP_PROF' in l or '[LOOP_PROF]' in l]
# also journal-style without tag sometimes
print('post-fix CHECK_BUY_PROF', len(cb), 'LOOP lines', len(lp))

def parse_kv(s):
    return {k: float(v) for k,v in re.findall(r'(\w+)=([\d.]+)', s)}

by = defaultdict(list)
for l in cb:
    m = re.search(r'\[CHECK_BUY_PROF\] (\w+) .* total=([\d.]+)(.*)$', l)
    if not m: continue
    sid, total, rest = m.group(1), float(m.group(2)), m.group(3)
    st = parse_kv(rest)
    by[sid].append((total, st))

print('\n=== CHECK_BUY (feed-fix 이후) ===')
for sid, items in sorted(by.items()):
    if not items: continue
    totals = [t for t,_ in items]
    def avgmax(key):
        vals = [st.get(key,0) for _,st in items]
        return sum(vals)/len(vals), max(vals)
    print(f'{sid}: n={len(items)} total avg/max={sum(totals)/len(totals):.0f}/{max(totals):.0f}')
    for k in ['after','trades_db','engine','candles','fill_gap','norm']:
        a,m = avgmax(k)
        if m > 0.05:
            print(f'  {k:10} avg={a:.1f} max={m:.1f}')

# LOOP_PROF from file if any
print('\n=== LOOP_PROF (파일 내, feed 이후) ===')
# may be in same file with LOOP_PROF
loop_lines = [l for l in post if 'LOOP_PROF' in l or 'lock_wait' in l]
print('count', len(loop_lines))
# parse from journal instead
PY
# journal LOOP_PROF recent
journalctl -u kis_trader_main.service --since '10 min ago' --no-pager 2>/dev/null | rg 'LOOP_PROF|CHECK_BUY_PROF' | tail -n 40
echo '---'
# summarize LOOP_PROF from journal since feed-fix ~12:52
journalctl -u kis_trader_main.service --since '2026-08-13 12:52:00' --no-pager 2>/dev/null | rg 'LOOP_PROF' | tail -n 30 | head -n 30
python3 <<'PY'
import re, subprocess
from collections import defaultdict
out = subprocess.check_output(
    ['journalctl','-u','kis_trader_main.service','--since','2026-08-13 12:52:00','--no-pager'],
    text=True, errors='ignore')
rows=[]
for line in out.splitlines():
    if 'LOOP_PROF' not in line: continue
    # [LOOP_PROF] SCALP total=... scan=... buy_max=...
    m=re.search(r'LOOP_PROF\]?\s*(\w+)?.*', line)
    kv={k:float(v) for k,v in re.findall(r'(\w+)=([\d.]+)', line)}
    # strategy often before
    sm=re.search(r'strategy\.(\w+)|LOOP_PROF\] (\w+) |LOOP_PROF (\w+) ', line)
    sid = None
    if 'kis_trader.strategy.' in line:
        sid=line.split('kis_trader.strategy.')[1].split(']')[0].split()[0]
    elif sm:
        sid=sm.group(1) or sm.group(2) or sm.group(3)
    if not kv.get('total'): continue
    rows.append((sid or '?', kv))
print('LOOP_PROF samples', len(rows))
by=defaultdict(list)
for sid,kv in rows:
    by[sid].append(kv)
for sid, items in sorted(by.items()):
    def am(k):
        vs=[x.get(k,0) for x in items]
        return sum(vs)/len(vs), max(vs)
    t_a,t_m=am('total'); s_a,s_m=am('scan'); b_a,b_m=am('buy_max'); r_a,r_m=am('reload')
    lw_a,lw_m=am('lock_wait'); td=am('buy_n')
    print(f'{sid}: n={len(items)} total avg/max={t_a:.0f}/{t_m:.0f}  scan={s_a:.0f}/{s_m:.0f}  buy_max={b_a:.0f}/{b_m:.0f}  reload={r_a:.0f}/{r_m:.0f}  lock_wait={lw_a:.1f}/{lw_m:.1f}')
PY

Aggregate post-fix loop and check_buy costs


cd /home/hoon/kis_bot && python3 <<'PY'
import time
from database import TradeDB
db=TradeDB()
# trade_history 규모 + get_trades_by_date 비용
for q in [
    "SELECT COUNT(*) c FROM trade_history",
    "SELECT COUNT(*) c FROM trade_history WHERE DATE(sell_date)=CURDATE()",
]:
    t0=time.perf_counter()
    r=db.conn.execute(q).fetchone()
    print(q, '->', r['c'], f'{(time.perf_counter()-t0)*1000:.1f}ms')
today=time.strftime('%Y%m%d')
times=[]
for _ in range(8):
    t0=time.perf_counter()
    rows=db.get_trades_by_date(today)
    times.append((time.perf_counter()-t0)*1000)
print(f'get_trades_by_date today: n={len(rows)} avg={sum(times)/len(times):.1f} max={max(times):.1f}ms')
PY

Measure get_trades_by_date cost and row counts


피드 로그 병목은 이미 죽었고, **지금 느린 건 다른 원인**입니다. (수정 후 계측 기준)

### 지금 남은 원인 (우선순위)

1. **`trades_db` — 종목마다 `get_trades_by_date()`**
   SCALP/MOM/SHORT `check_buy`마다 당일 `trade_history`를 다시 조회.
   - 단독 실행: ~0.6ms / 오늘 4건
   - 장중 계측: **평균 ~1초, 최대 ~3~4초**
   → 쿼리 자체가 무거운 게 아니라, **전략 4개가 같은 DB 락에 줄 서서 대기**하는 시간.

2. **SHORT `reload` — 루프마다 설정 리로드**
   SHORT 한 바퀴 `reload` **평균 ~5~8초, 최대 ~14초**.
   루프 total의 상당수가 여기. (6월엔 이런 무게가 없었음)

3. **그 밖의 루프 숙제** (상대적으로 작지만 누적)
   - `cand` (후보/봉 쪽) 수백 ms~2초
   - `sync_hold` 가끔 ~1초
   - `_sell_lock` / `after`(피드 스냅샷) → **이미 무죄**

### 체감이 6월이 아닌 이유
엔진 계산(`engine` 0.1ms) 문제가 아니라, **7월 이후 루프마다 DB를 여러 번 치는 숙제**(일일거래 재조회 + SHORT 리로드 등)가 쌓인 상태입니다.
한 바퀴 예: SCALP `total≈9~17초` 중 `scan`이 대부분이고, 그 안의 `buy_max`가 곧 `trades_db` 대기입니다.

다음으로 손대려면 (승인 후):
- `get_trades_by_date` → **루프/일 단위 RAM 캐시** (종목마다 전체 재조회 금지)
- SHORT `reload`가 왜 수 초인지 **한 단계만 더 계측**

어느 쪽부터 팔까요?


<timestamp>Thursday, Aug 13, 2026, 1:03 PM (UTC+9)</timestamp>
<user_query>
get_trades_by_date → 루프/일 단위 RAM 캐시 (종목마다 전체 재조회 금지)
SHORT reload가 왜 수 초인지 한 단계만 더 계측

이게 왜 이렇게 되어잇는거야? get_trades_by_date  이건 뭐하는애고
SHORT reload가 왜 수 초인지 한 단계만 더 계측
이것도 계측을 해봐 14초면 어마무시하네
</user_query>