StrReplace

            f"cand={row.get('cand_ms', 0):.1f}",
            f"cand_load={row.get('cand_load_ms', 0):.1f}",
            f"cand_note={row.get('cand_note_ms', 0):.1f}",
            f"ws_sync={row.get('ws_sync_ms', 0):.1f}",
            f"pending={row.get('pending_ms', 0):.1f}",
            f"scan={row.get('scan_ms', 0):.1f}",
            f"scan_other={row.get('scan_other_ms', 0):.1f}",
            f"scan_name={row.get('scan_name_ms', 0):.1f}",
            f"scan_pre={row.get('scan_pre_ms', 0):.1f}",
            f"buy_n={row.get('buy_n', 0)}",
            f"buy_sum={row.get('buy_sum_ms', 0):.1f}",
            f"buy_max={row.get('buy_max_ms', 0):.1f}",
            f"trd_hit={row.get('trd_hit', 0)}",
            f"trd_miss={row.get('trd_miss', 0)}",
            f"trd_db={row.get('trd_db_ms', 0):.1f}",
            f"gap_skip={row.get('gap_skip', 0)}",

            f"cand={row.get('cand_ms', 0):.1f}",
            f"cand_load={row.get('cand_load_ms', 0):.1f}",
            f"cand_mgr={row.get('cand_mgr_ms', 0):.1f}",
            f"cand_post={row.get('cand_post_ms', 0):.1f}",
            f"cand_slot={row.get('cand_slot_ms', 0):.1f}",
            f"cand_note={row.get('cand_note_ms', 0):.1f}",
            f"cand_src={row.get('cand_src', '-')}",
            f"cand_n={row.get('cand_n', 0)}",
            f"ws_sync={row.get('ws_sync_ms', 0):.1f}",
            f"pending={row.get('pending_ms', 0):.1f}",
            f"scan={row.get('scan_ms', 0):.1f}",
            f"scan_other={row.get('scan_other_ms', 0):.1f}",
            f"scan_name={row.get('scan_name_ms', 0):.1f}",
            f"scan_pre={row.get('scan_pre_ms', 0):.1f}",
            f"pre_filt={row.get('pre_filt_ms', 0):.1f}",
            f"pre_guard={row.get('pre_guard_ms', 0):.1f}",
            f"pre_cd={row.get('pre_cd_ms', 0):.1f}",
            f"pre_gap={row.get('pre_gap_ms', 0):.1f}",
            f"sleep_rej={row.get('sleep_rej_ms', 0):.1f}",
            f"sleep_ok={row.get('sleep_ok_ms', 0):.1f}",
            f"sleep_fail={row.get('sleep_fail_ms', 0):.1f}",
            f"buy_n={row.get('buy_n', 0)}",
            f"buy_sum={row.get('buy_sum_ms', 0):.1f}",
            f"buy_max={row.get('buy_max_ms', 0):.1f}",
            f"trd_hit={row.get('trd_hit', 0)}",
            f"trd_miss={row.get('trd_miss', 0)}",
            f"trd_db={row.get('trd_db_ms', 0):.1f}",
            f"gap_skip={row.get('gap_skip', 0)}",

                self._prof_cand_load_ms = 0.0
                self._prof_cand_note_ms = 0.0
                self.reload_config()
                if do_prof:
                    row["reload_ms"] = (time.perf_counter() - t0) * 1000.0

                # 보유 = 이벤트 RAM + 안전망 DB sync(기본 60초). 매수/매도 체결은 RAM 즉시 갱신.
                t0 = time.perf_counter() if do_prof else 0.0
                self._sync_holdings_from_db()
                if do_prof:
                    row["sync_hold_ms"] = (time.perf_counter() - t0) * 1000.0
                    row["sync_db_ms"] = float(self._prof_sync_db_ms)
                    row["sync_merge_ms"] = float(self._prof_sync_merge_ms)
                    row["sync_skip"] = int(getattr(self, "_prof_sync_skipped", 0) or 0)

                self._prof_cand_load_ms = 0.0
                self._prof_cand_note_ms = 0.0
                self._prof_cand_mgr_ms = 0.0
                self._prof_cand_post_ms = 0.0
                self._prof_cand_slot_ms = 0.0
                self._prof_cand_src = "-"
                self._prof_cand_n = 0
                self.reload_config()
                if do_prof:
                    row["reload_ms"] = (time.perf_counter() - t0) * 1000.0

                # 보유 = 이벤트 RAM + 안전망 DB sync(기본 60초). 매수/매도 체결은 RAM 즉시 갱신.
                t0 = time.perf_counter() if do_prof else 0.0
                self._sync_holdings_from_db()
                if do_prof:
                    row["sync_hold_ms"] = (time.perf_counter() - t0) * 1000.0
                    row["sync_db_ms"] = float(self._prof_sync_db_ms)
                    row["sync_merge_ms"] = float(self._prof_sync_merge_ms)
                    row["sync_skip"] = int(getattr(self, "_prof_sync_skipped", 0) or 0)

                if do_prof:
                    row["cand_ms"] = (time.perf_counter() - t0) * 1000.0
                    row["cand_load_ms"] = float(self._prof_cand_load_ms)
                    row["cand_note_ms"] = float(self._prof_cand_note_ms)
                cand_codes = [c.get("code") for c in candidates if c.get("code")]

                if do_prof:
                    row["cand_ms"] = (time.perf_counter() - t0) * 1000.0
                    row["cand_load_ms"] = float(self._prof_cand_load_ms)
                    row["cand_mgr_ms"] = float(getattr(self, "_prof_cand_mgr_ms", 0) or 0)
                    row["cand_post_ms"] = float(getattr(self, "_prof_cand_post_ms", 0) or 0)
                    row["cand_slot_ms"] = float(getattr(self, "_prof_cand_slot_ms", 0) or 0)
                    row["cand_note_ms"] = float(self._prof_cand_note_ms)
                    row["cand_src"] = str(getattr(self, "_prof_cand_src", "-") or "-")
                    row["cand_n"] = int(getattr(self, "_prof_cand_n", 0) or 0)
                cand_codes = [c.get("code") for c in candidates if c.get("code")]

                    row["gap_skip"] = int(sc.get("gap_skip", 0) or 0)
                    # scan 중 check_buy 밖(이름해석·필터·sleep 등)
                    row["scan_other_ms"] = max(
                        0.0,
                        float(row["scan_ms"]) - float(row["buy_sum_ms"]),
                    )
                    row["scan_name_ms"] = float(sc.get("scan_name_ms", 0) or 0)
                    row["scan_pre_ms"] = float(sc.get("scan_pre_ms", 0) or 0)
                    row["trd_hit"] = int(self._prof_trd_hit)
                    row["trd_miss"] = int(self._prof_trd_miss)
                    row["trd_db_ms"] = float(self._prof_trd_db_ms)

                    row["gap_skip"] = int(sc.get("gap_skip", 0) or 0)
                    # scan 중 check_buy 밖(이름해석·필터·sleep 등)
                    row["scan_other_ms"] = max(
                        0.0,
                        float(row["scan_ms"]) - float(row["buy_sum_ms"]),
                    )
                    row["scan_name_ms"] = float(sc.get("scan_name_ms", 0) or 0)
                    row["scan_pre_ms"] = float(sc.get("scan_pre_ms", 0) or 0)
                    row["pre_filt_ms"] = float(sc.get("pre_filt_ms", 0) or 0)
                    row["pre_guard_ms"] = float(sc.get("pre_guard_ms", 0) or 0)
                    row["pre_cd_ms"] = float(sc.get("pre_cd_ms", 0) or 0)
                    row["pre_gap_ms"] = float(sc.get("pre_gap_ms", 0) or 0)
                    row["sleep_rej_ms"] = float(sc.get("sleep_rej_ms", 0) or 0)
                    row["sleep_ok_ms"] = float(sc.get("sleep_ok_ms", 0) or 0)
                    row["sleep_fail_ms"] = float(sc.get("sleep_fail_ms", 0) or 0)
                    row["trd_hit"] = int(self._prof_trd_hit)
                    row["trd_miss"] = int(self._prof_trd_miss)
                    row["trd_db_ms"] = float(self._prof_trd_db_ms)


        prof_scan = self._loop_profile_on()
        buy_n = 0
        buy_sum = 0.0
        buy_max = 0.0
        buy_max_code = ""
        gap_skip = 0
        scan_name_ms = 0.0
        scan_pre_ms = 0.0
        pre_filt_ms = 0.0
        pre_guard_ms = 0.0
        pre_cd_ms = 0.0
        pre_gap_ms = 0.0
        sleep_rej_ms = 0.0
        sleep_ok_ms = 0.0
        sleep_fail_ms = 0.0

        def _snap_scan():
            return {
                "buy_n": buy_n,
                "buy_sum_ms": buy_sum,
                "buy_max_ms": buy_max,
                "buy_max_code": buy_max_code,
                "gap_skip": gap_skip,
                "scan_name_ms": scan_name_ms,
                "scan_pre_ms": scan_pre_ms,
                "pre_filt_ms": pre_filt_ms,
                "pre_guard_ms": pre_guard_ms,
                "pre_cd_ms": pre_cd_ms,
                "pre_gap_ms": pre_gap_ms,
                "sleep_rej_ms": sleep_rej_ms,
                "sleep_ok_ms": sleep_ok_ms,
                "sleep_fail_ms": sleep_fail_ms,
            }

        for c in candidates:
            if not self._running:
                if prof_scan:
                    self._loop_prof_scan = _snap_scan()
                return
            t_pre0 = time.perf_counter() if prof_scan else 0.0
            code = c.get("code") or c.get("stk_cd", "")
            # 매수체크 핫패스: DB 이름조회 금지(느림·불필요).
            # 한글명은 주문/알림 시 OrderManager._resolve_order_display_name 만.
            name = c.get("name") or c.get("stk_nm") or code
            if not str(name or "").strip():
                name = code
            if prof_scan:
                scan_name_ms += (time.perf_counter() - t_pre0) * 1000.0
                t_pre0 = time.perf_counter()
            if not code or code in self.holdings:
                if prof_scan:
                    dt = (time.perf_counter() - t_pre0) * 1000.0
                    scan_pre_ms += dt
                    pre_filt_ms += dt
                continue
            if code in self.untradable_skip:
                if prof_scan:
                    dt = (time.perf_counter() - t_pre0) * 1000.0
                    scan_pre_ms += dt
                    pre_filt_ms += dt
                continue
            if not self._candidate_filter(c):
                if prof_scan:
                    dt = (time.perf_counter() - t_pre0) * 1000.0
                    scan_pre_ms += dt
                    pre_filt_ms += dt
                continue
            if prof_scan:
                dt = (time.perf_counter() - t_pre0) * 1000.0
                scan_pre_ms += dt
                pre_filt_ms += dt
                t_pre0 = time.perf_counter()
            guard = self._live_portfolio_entry_guard(code, max_stocks)
            if guard:
                self.logger.info("🔍 [%s] %s(%s)", guard, name, code)
                if prof_scan:
                    dt = (time.perf_counter() - t_pre0) * 1000.0
                    scan_pre_ms += dt
                    pre_guard_ms += dt
                continue
            if prof_scan:
                dt = (time.perf_counter() - t_pre0) * 1000.0
                scan_pre_ms += dt
                pre_guard_ms += dt
                t_pre0 = time.perf_counter()
            # 재진입 쿨다운
            cooldown_sec = self._reentry_cooldown_sec()
            elapsed = time.time() - self.recently_sold.get(code, 0)
            if elapsed < cooldown_sec:
                if prof_scan:
                    dt = (time.perf_counter() - t_pre0) * 1000.0
                    scan_pre_ms += dt
                    pre_cd_ms += dt
                continue
            if prof_scan:
                dt = (time.perf_counter() - t_pre0) * 1000.0
                scan_pre_ms += dt
                pre_cd_ms += dt
                t_pre0 = time.perf_counter()

            # 갭보정 미완료 → 불완전한 봉으로 매수 판단 금지
            # 갭보정 완료 후 자동으로 다음 루프에서 check_buy 진입
            if hasattr(self.ws, "is_gap_ready") and not self.ws.is_gap_ready(code):
                gap_skip += 1
                if prof_scan:
                    dt = (time.perf_counter() - t_pre0) * 1000.0
                    scan_pre_ms += dt
                    pre_gap_ms += dt
                continue
            if prof_scan:
                dt = (time.perf_counter() - t_pre0) * 1000.0
                scan_pre_ms += dt
                pre_gap_ms += dt

            t_buy0 = time.perf_counter() if prof_scan else 0.0
            signal = self.check_buy(code, name)
            if prof_scan:
                dt_ms = (time.perf_counter() - t_buy0) * 1000.0
                buy_n += 1
                buy_sum += dt_ms
                if dt_ms >= buy_max:
                    buy_max = dt_ms
                    buy_max_code = str(code)
            if not signal:
                if prof_scan:
                    t_sl = time.perf_counter()
                time.sleep(self._scan_sleep("reject"))
                if prof_scan:
                    sleep_rej_ms += (time.perf_counter() - t_sl) * 1000.0
                continue

            result = self._submit_buy(signal)
            if result and result.success:
                if prof_scan:
                    t_sl = time.perf_counter()
                time.sleep(self._scan_sleep("buy_ok"))
                if prof_scan:
                    sleep_ok_ms += (time.perf_counter() - t_sl) * 1000.0
                    self._loop_prof_scan = _snap_scan()
                return  # 1루프당 1매수 (포지션 과집중 방지)
            if prof_scan:
                t_sl = time.perf_counter()
            time.sleep(self._scan_sleep("buy_fail"))
            if prof_scan:
                sleep_fail_ms += (time.perf_counter() - t_sl) * 1000.0
        if prof_scan:
            self._loop_prof_scan = _snap_scan()

    def _note_candidate_enters(self, candidates: List[Dict]) -> None:

        prof_scan = self._loop_profile_on()
        buy_n = 0
        buy_sum = 0.0
        buy_max = 0.0
        buy_max_code = ""
        gap_skip = 0
        scan_name_ms = 0.0
        scan_pre_ms = 0.0
        for c in candidates:
            if not self._running:
                if prof_scan:
                    self._loop_prof_scan = {
                        "buy_n": buy_n,
                        "buy_sum_ms": buy_sum,
                        "buy_max_ms": buy_max,
                        "buy_max_code": buy_max_code,
                        "gap_skip": gap_skip,
                        "scan_name_ms": scan_name_ms,
                        "scan_pre_ms": scan_pre_ms,
                    }
                return
            t_pre0 = time.perf_counter() if prof_scan else 0.0
            code = c.get("code") or c.get("stk_cd", "")
            # 매수체크 핫패스: DB 이름조회 금지(느림·불필요).
            # 한글명은 주문/알림 시 OrderManager._resolve_order_display_name 만.
            name = c.get("name") or c.get("stk_nm") or code
            if not str(name or "").strip():
                name = code
            if prof_scan:
                scan_name_ms += (time.perf_counter() - t_pre0) * 1000.0
                t_pre0 = time.perf_counter()
            if not code or code in self.holdings:
                if prof_scan:
                    scan_pre_ms += (time.perf_counter() - t_pre0) * 1000.0
                continue
            if code in self.untradable_skip:
                if prof_scan:
                    scan_pre_ms += (time.perf_counter() - t_pre0) * 1000.0
                continue
            if not self._candidate_filter(c):
                if prof_scan:
                    scan_pre_ms += (time.perf_counter() - t_pre0) * 1000.0
                continue
            guard = self._live_portfolio_entry_guard(code, max_stocks)
            if guard:
                self.logger.info("🔍 [%s] %s(%s)", guard, name, code)
                if prof_scan:
                    scan_pre_ms += (time.perf_counter() - t_pre0) * 1000.0
                continue
            # 재진입 쿨다운
            cooldown_sec = self._reentry_cooldown_sec()
            elapsed = time.time() - self.recently_sold.get(code, 0)
            if elapsed < cooldown_sec:
                if prof_scan:
                    scan_pre_ms += (time.perf_counter() - t_pre0) * 1000.0
                continue

            # 갭보정 미완료 → 불완전한 봉으로 매수 판단 금지
            # 갭보정 완료 후 자동으로 다음 루프에서 check_buy 진입
            if hasattr(self.ws, "is_gap_ready") and not self.ws.is_gap_ready(code):
                gap_skip += 1
                if prof_scan:
                    scan_pre_ms += (time.perf_counter() - t_pre0) * 1000.0
                continue

            if prof_scan:
                scan_pre_ms += (time.perf_counter() - t_pre0) * 1000.0
            t_buy0 = time.perf_counter() if prof_scan else 0.0
            signal = self.check_buy(code, name)
            if prof_scan:
                dt_ms = (time.perf_counter() - t_buy0) * 1000.0
                buy_n += 1
                buy_sum += dt_ms
                if dt_ms >= buy_max:
                    buy_max = dt_ms
                    buy_max_code = str(code)
            if not signal:
                time.sleep(self._scan_sleep("reject"))
                continue

            result = self._submit_buy(signal)
            if result and result.success:
                time.sleep(self._scan_sleep("buy_ok"))
                if prof_scan:
                    self._loop_prof_scan = {
                        "buy_n": buy_n,
                        "buy_sum_ms": buy_sum,
                        "buy_max_ms": buy_max,
                        "buy_max_code": buy_max_code,
                        "gap_skip": gap_skip,
                        "scan_name_ms": scan_name_ms,
                        "scan_pre_ms": scan_pre_ms,
                    }
                return  # 1루프당 1매수 (포지션 과집중 방지)
            time.sleep(self._scan_sleep("buy_fail"))
        if prof_scan:
            self._loop_prof_scan = {
                "buy_n": buy_n,
                "buy_sum_ms": buy_sum,
                "buy_max_ms": buy_max,
                "buy_max_code": buy_max_code,
                "gap_skip": gap_skip,
                "scan_name_ms": scan_name_ms,
                "scan_pre_ms": scan_pre_ms,
            }

    def _note_candidate_enters(self, candidates: List[Dict]) -> None:


        candidates: List[Dict] = []
        do_prof = self._loop_profile_on()
        self._prof_cand_src = current_src
        self._prof_cand_mgr_ms = 0.0
        self._prof_cand_post_ms = 0.0
        self._prof_cand_slot_ms = 0.0
        for tag in order:
            mgr = mgr_by_src.get(tag)
            if mgr is None:
                continue
            if not self._is_strategy_registered(mgr):
                continue
            try:
                t_m0 = time.perf_counter() if do_prof else 0.0
                universe = mgr.get_universe_for(self.strategy_id)
                # 매니저에 등록은 돼 있으나 아직 비어있음 → [] 이 정답 (폴백 금지).
                # (설정된 유니버스가 비어있을 수 있는 상태: 장 마감 후 등)
                if universe:
                    candidates = mgr.get_candidates_for(self.strategy_id) or []
                else:
                    candidates = []
                if do_prof:
                    self._prof_cand_mgr_ms = (time.perf_counter() - t_m0) * 1000.0
                    self._prof_cand_src = tag
                out = self._post_filter_candidates(candidates)
                if do_prof:
                    self._prof_cand_n = len(out)
                return out
            except Exception as e:
                self.logger.debug("%s 유니버스 로드 실패: %s", tag, e)

        # 매니저 모두 본 전략 설정 없음 → 레거시 DB 경로
        # (ls_condition 은 DB 폴백도 하지 않음 — 빈 후보)
        if self.universe_source == "ls_condition":
            out = self._post_filter_candidates([])
            if do_prof:
                self._prof_cand_n = len(out)
            return out
        try:
            t_m0 = time.perf_counter() if do_prof else 0.0
            candidates = self.db.get_target_candidates() or []
            if do_prof:
                self._prof_cand_mgr_ms = (time.perf_counter() - t_m0) * 1000.0
                self._prof_cand_src = "db_legacy"
        except Exception as e:
            self.logger.debug("target_candidates 조회 실패: %s", e)
            candidates = []
        out = self._post_filter_candidates(candidates)
        if do_prof:
            self._prof_cand_n = len(out)
        return out

    def _post_filter_candidates(self, candidates: List[Dict]) -> List[Dict]:
        """매니저 결과 → ETN/비본주 자동 제외 + 전략별 후보 하드캡.

        - ``EXCLUDE_NON_STOCK=true`` (기본 true): ETN/ETF/스팩/우선주 등 비본주 자동 제외.
          ``kis_trader.utils.non_stock.is_non_stock`` (코드 끝 5/7/9·알파벳 + 이름).
        - ``{STRATEGY_ID}_CAND_LIMIT`` (기본 0=무제한): 매니저 원본 순서 보존하며 상위 N개만 사용.
          WS 구독 41 한도 안전 + cond/ranking 폭주 시 매수 체크 분당 회전율 보장.
          (조건검색 매니저는 신규 ENTER·t1859 스냅을 앞으로 두어 캡에 신규가 안 잘리게 함)
        """
        do_prof = self._loop_profile_on()
        t_post0 = time.perf_counter() if do_prof else 0.0
        if not candidates:
            if do_prof:
                self._prof_cand_post_ms = (time.perf_counter() - t_post0) * 1000.0
                self._prof_cand_slot_ms = 0.0
            return []

        # 1) 비본주 필터
        if get_env_bool("EXCLUDE_NON_STOCK", True):
            filtered: List[Dict] = []
            dropped = 0
            for c in candidates:
                code = (c.get("code") or c.get("stk_cd") or "").strip()
                name = (c.get("name") or c.get("stk_nm") or "").strip()
                if _is_non_stock(name, code):
                    dropped += 1
                    continue
                filtered.append(c)
            if dropped:
                self.logger.debug("🛡 비본주 자동 제외 %d종목 (ETN/ETF/스팩/우선주)", dropped)
            candidates = filtered

        # 2) 전략별 후보 하드캡
        sid = self.strategy_id.upper()
        cap = get_env_int(f"{sid}_CAND_LIMIT", 0)
        if cap > 0 and len(candidates) > cap:
            self.logger.debug(
                "✂ 후보 하드캡 적용: %d → %d (%s_CAND_LIMIT=%d)",
                len(candidates), cap, sid, cap,
            )
            candidates = candidates[:cap]

        # 3) 백테 유니버스 슬롯 정합 — history 스냅샷 ∩ 실시간 후보
        #    ls_condition → ls_candidates_history / 그 외 → target_candidates_history
        from ..utils.live_portfolio_common import (
            filter_candidates_by_history_universe,
            live_universe_slot_align_enabled,
            resolve_live_universe_history_source,
        )
        slot_ms = 0.0
        if live_universe_slot_align_enabled(sid):
            before = len(candidates)
            univ_src = str(getattr(self, "universe_source", "") or "")
            hist_src = resolve_live_universe_history_source(
                sid, universe_source=univ_src,
            )
            t_slot0 = time.perf_counter() if do_prof else 0.0
            candidates, dropped = filter_candidates_by_history_universe(
                candidates,
                self.db,
                sid,
                universe_source=univ_src,
                history_source=hist_src,
            )
            if do_prof:
                slot_ms = (time.perf_counter() - t_slot0) * 1000.0
            if dropped > 0:
                now_ts = time.time()
                last = getattr(self, "_universe_slot_log_ts", 0.0)
                if now_ts - last >= 120:
                    self._universe_slot_log_ts = now_ts
                    self.logger.info(
                        "🔍 [유니버스슬롯] %s 후보 %d→%d "
                        "(history=%s 교집합, 제외 %d)",
                        sid, before, len(candidates), hist_src, dropped,
                    )
                # 전량 탈락 = 운영 치명 (장중만 ops_alert 세션게이트)
                if before > 0 and len(candidates) == 0:
                    try:
                        from ..utils.ops_alert import ops_alert
                        ops_alert(
                            "universe_wipe",
                            f"{sid} 유니버스 전량탈락 {before}→0",
                            detail=f"history={hist_src} 교집합 제외 {dropped}",
                            level="critical",
                        )
                    except Exception:
                        pass

        if do_prof:
            self._prof_cand_post_ms = (time.perf_counter() - t_post0) * 1000.0
            self._prof_cand_slot_ms = slot_ms
        return candidates

    def _is_strategy_registered(self, mgr) -> bool:

        candidates: List[Dict] = []
        for tag in order:
            mgr = mgr_by_src.get(tag)
            if mgr is None:
                continue
            if not self._is_strategy_registered(mgr):
                continue
            try:
                universe = mgr.get_universe_for(self.strategy_id)
                # 매니저에 등록은 돼 있으나 아직 비어있음 → [] 이 정답 (폴백 금지).
                # (설정된 유니버스가 비어있을 수 있는 상태: 장 마감 후 등)
                if universe:
                    candidates = mgr.get_candidates_for(self.strategy_id) or []
                else:
                    candidates = []
                return self._post_filter_candidates(candidates)
            except Exception as e:
                self.logger.debug("%s 유니버스 로드 실패: %s", tag, e)

        # 매니저 모두 본 전략 설정 없음 → 레거시 DB 경로
        # (ls_condition 은 DB 폴백도 하지 않음 — 빈 후보)
        if self.universe_source == "ls_condition":
            return self._post_filter_candidates([])
        try:
            candidates = self.db.get_target_candidates() or []
        except Exception as e:
            self.logger.debug("target_candidates 조회 실패: %s", e)
            candidates = []
        return self._post_filter_candidates(candidates)

    def _post_filter_candidates(self, candidates: List[Dict]) -> List[Dict]:
        """매니저 결과 → ETN/비본주 자동 제외 + 전략별 후보 하드캡.

        - ``EXCLUDE_NON_STOCK=true`` (기본 true): ETN/ETF/스팩/우선주 등 비본주 자동 제외.
          ``kis_trader.utils.non_stock.is_non_stock`` (코드 끝 5/7/9·알파벳 + 이름).
        - ``{STRATEGY_ID}_CAND_LIMIT`` (기본 0=무제한): 매니저 원본 순서 보존하며 상위 N개만 사용.
          WS 구독 41 한도 안전 + cond/ranking 폭주 시 매수 체크 분당 회전율 보장.
          (조건검색 매니저는 신규 ENTER·t1859 스냅을 앞으로 두어 캡에 신규가 안 잘리게 함)
        """
        if not candidates:
            return []

        # 1) 비본주 필터
        if get_env_bool("EXCLUDE_NON_STOCK", True):
            filtered: List[Dict] = []
            dropped = 0
            for c in candidates:
                code = (c.get("code") or c.get("stk_cd") or "").strip()
                name = (c.get("name") or c.get("stk_nm") or "").strip()
                if _is_non_stock(name, code):
                    dropped += 1
                    continue
                filtered.append(c)
            if dropped:
                self.logger.debug("🛡 비본주 자동 제외 %d종목 (ETN/ETF/스팩/우선주)", dropped)
            candidates = filtered

        # 2) 전략별 후보 하드캡
        sid = self.strategy_id.upper()
        cap = get_env_int(f"{sid}_CAND_LIMIT", 0)
        if cap > 0 and len(candidates) > cap:
            self.logger.debug(
                "✂ 후보 하드캡 적용: %d → %d (%s_CAND_LIMIT=%d)",
                len(candidates), cap, sid, cap,
            )
            candidates = candidates[:cap]

        # 3) 백테 유니버스 슬롯 정합 — history 스냅샷 ∩ 실시간 후보
        #    ls_condition → ls_candidates_history / 그 외 → target_candidates_history
        from ..utils.live_portfolio_common import (
            filter_candidates_by_history_universe,
            live_universe_slot_align_enabled,
            resolve_live_universe_history_source,
        )
        if live_universe_slot_align_enabled(sid):
            before = len(candidates)
            univ_src = str(getattr(self, "universe_source", "") or "")
            hist_src = resolve_live_universe_history_source(
                sid, universe_source=univ_src,
            )
            candidates, dropped = filter_candidates_by_history_universe(
                candidates,
                self.db,
                sid,
                universe_source=univ_src,
                history_source=hist_src,
            )
            if dropped > 0:
                now_ts = time.time()
                last = getattr(self, "_universe_slot_log_ts", 0.0)
                if now_ts - last >= 120:
                    self._universe_slot_log_ts = now_ts
                    self.logger.info(
                        "🔍 [유니버스슬롯] %s 후보 %d→%d "
                        "(history=%s 교집합, 제외 %d)",
                        sid, before, len(candidates), hist_src, dropped,
                    )
                # 전량 탈락 = 운영 치명 (장중만 ops_alert 세션게이트)
                if before > 0 and len(candidates) == 0:
                    try:
                        from ..utils.ops_alert import ops_alert
                        ops_alert(
                            "universe_wipe",
                            f"{sid} 유니버스 전량탈락 {before}→0",
                            detail=f"history={hist_src} 교집합 제외 {dropped}",
                            level="critical",
                        )
                    except Exception:
                        pass

        return candidates

    def _is_strategy_registered(self, mgr) -> bool:


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 && : > logs/loop_profile.log && sleep 25 && python3 <<'PY'
from collections import defaultdict
import re
lines=open('logs/loop_profile.log',encoding='utf-8',errors='replace').read().splitlines()
print('lines', len(lines))
keys=['total','cand_load','cand_mgr','cand_post','cand_slot','cand_n','scan_pre','pre_filt','pre_guard','pre_cd','pre_gap','sleep_rej','sleep_ok','buy_sum','gap_skip']
pat=re.compile(r'\[LOOP_PROF\]\s+(\S+)\s+(.*)')
by=defaultdict(list)
for ln in lines:
    m=pat.search(ln)
    if not m: continue
    sid, rest=m.group(1), m.group(2)
    d={}
    for k in keys:
        mm=re.search(rf'{k}=([0-9.]+)', rest)
        if mm: d[k]=float(mm.group(1))
    src=re.search(r'cand_src=(\S+)', rest)
    if src: d['cand_src']=src.group(1)
    if d: by[sid].append(d)
for sid, rows in sorted(by.items()):
    if sid=='MOMENTUM' and len(rows)>50:
        # only rows with cand_n>0 or scan_pre>10
        rows=[r for r in rows if r.get('cand_n',0)>0 or r.get('scan_pre',0)>10 or r.get('cand_load',0)>50]
        if not rows:
            print(f'=== {sid} (active rows empty, skip detail) ===')
            continue
    print(f'=== {sid} n={len(rows)} ===')
    for k in keys:
        vals=[r[k] for r in rows if k in r]
        if not vals: continue
        print(f'  {k:12s} mean={sum(vals)/len(vals):7.1f}  p50={sorted(vals)[len(vals)//2]:7.1f}  max={max(vals):7.1f}')
    srcs=defaultdict(int)
    for r in rows:
        srcs[r.get('cand_src','?')]+=1
    if srcs: print('  src', dict(srcs))
    # one sample line
    for ln in lines:
        if f'[LOOP_PROF] {sid}' in ln and 'pre_gap=' in ln and 'cand_mgr=' in ln:
            # prefer high total
            if 'total=' in ln:
                tm=re.search(r'total=([0-9.]+)', ln)
                if tm and float(tm.group(1))>500:
                    print('  sample:', ln[ln.find('[LOOP_PROF]'):][:280])
                    break
PY

Compile, restart, sample fine-grained LOOP_PROF


### 4번이 뭐냐
계측이 끝나면 `LOOP_PROFILE_ENABLED=false` 하거나 `LOOP_PROFILE_EVERY_N`을 키워서 **로그·디스크 I/O 부담을 끄는 정리 작업**입니다. 매매 로직 변경 아님. 지금은 측정 중이니 그대로 두면 됩니다.

---

### 1·2 계측만 배포함 (매매 로직 변경 없음)
`logs/loop_profile.log`에 세분 필드가 추가됐고, ~25초 샘플 결과:

| 구간 | 실제 주범 | 비고 |
|------|-----------|------|
| **`cand_load` ≈ `cand_slot`** | 유니버스 슬롯 정합(history ∩ 후보) DB | `cand_mgr≈0` (키움 조건 RAM은 거의 공짜) |
| **`scan_pre` ≈ `pre_guard`** | `_live_portfolio_entry_guard` | `pre_gap≈0`, `pre_filt/cd≈0` |
| sleep / buy 엔진 | 미미 | `sleep_rej`·`buy_sum` 수 ms |

예: BREAKOUT p50 `total~106ms`인데도, 스파이크는 `pre_guard` 1.7s / `cand_slot` 0.6s 쪽이 큼.
**갭 ready(`pre_gap`)나 강제확정이 1초 사이클의 주범은 아님.**

다음 수정 후보(아직 안 함): guard가 왜 DB/락을 드는지, slot history 조회 TTL·캐시. 보고만 원하시면 그쪽 코드부터 짚겠습니다.


<timestamp>Thursday, Aug 13, 2026, 2:01 PM (UTC+9)</timestamp>
<user_query>
다음 수정 후보(아직 안 함): guard가 왜 DB/락을 드는지, slot history 조회 TTL·캐시. 보고만 원하시면 그쪽 코드부터 짚겠습니다.

이것도 계측만 우선 저 위에꺼도 시간열 추가해서 보여줘
</user_query>


guard·slot 내부 계측을 추가하고, 기존 세분 지표의 시간열도 함께 보여드리겠습니다.

Grep

def _live_portfolio_entry_guard|def filter_candidates_by_history_universe|def live_universe_slot_align

Shell

cd /home/hoon/kis_bot && python3 <<'PY'
# quick time series from current loop_profile.log (last N)
from collections import defaultdict
import re
from datetime import datetime
path='logs/loop_profile.log'
lines=open(path,encoding='utf-8',errors='replace').read().splitlines()
# keep lines with new fields
rows=[]
for ln in lines:
    if 'pre_guard=' not in ln or 'cand_slot=' not in ln: continue
    ts=ln[:23] if len(ln)>23 else ''
    m=re.search(r'\[LOOP_PROF\]\s+(\S+)\s+(.*)', ln)
    if not m: continue
    sid, rest=m.group(1), m.group(2)
    d={'ts':ts,'sid':sid}
    for k in ['total','cand_load','cand_slot','cand_mgr','pre_guard','pre_gap','scan_pre','buy_sum','sleep_rej','gap_skip','cand_n']:
        mm=re.search(rf'{k}=([0-9.]+)', rest)
        if mm: d[k]=float(mm.group(1))
    rows.append(d)
print('rows_with_fine', len(rows))
# bucket by 5s per strategy: mean total/cand_slot/pre_guard
by=defaultdict(list)
for r in rows:
    try:
        t=datetime.strptime(r['ts'][:19], '%Y-%m-%d %H:%M:%S')
        bucket=t.replace(second=(t.second//5)*5)
    except Exception:
        continue
    by[(r['sid'], bucket)].append(r)
print('\n=== 5초 버킷 시간열 (sid, time, n, total, cand_slot, pre_guard) ===')
for (sid,b), rs in sorted(by.items())[-40:]:
    def avg(k):
        vs=[x[k] for x in rs if k in x]
        return sum(vs)/len(vs) if vs else 0
    print(f"{b.strftime('%H:%M:%S')} {sid:10s} n={len(rs):3d} total={avg('total'):7.1f} slot={avg('cand_slot'):7.1f} guard={avg('pre_guard'):7.1f} gap_skip={avg('gap_skip'):4.1f}")
PY

Show current fine-metric time series from log


guard·slot 내부를 계측하고, 기존 지표 시간열도 함께 출력합니다.


StrReplace

"""
실매 ↔ 포트폴리오 백테 정합 — 유니버스 슬롯·총한도·매수금액.
백테 ``breakout_portfolio_backtest`` / ``backtest_portfolio_common`` 과 동일 분모.
"""
from __future__ import annotations

import time
from datetime import datetime as dt
from typing import Any, Dict, List, Optional, Set, Tuple

from ..backtest.backtest_portfolio_common import (
    STRATEGY_PORTFOLIO_KEYS,
    min_invest_ratio_of_slot,
    portfolio_exposure_krw,
    resolve_portfolio_params,
    target_qty_and_cost,
)
from ..utils.env import get_env_bool, get_env_from_db, get_env_int
from ..utils.position_sizing import invest_qty_for_price

# 자체 유니버스 — 기본 슬롯 필터 OFF (env 로 켤 수 있음)
_UNIVERSE_SLOT_SKIP_DEFAULT = frozenset({"UPDOW", "DBBAND"})

# 총한도·일1회 base 가드 스킵 (전략 자체 구현)
_PORTFOLIO_BUDGET_SKIP_DEFAULT = frozenset({"UPDOW"})

def _prof_add(prof: Optional[Dict[str, float]], key: str, t0: float) -> None:
    """LOOP_PROF 세분용 — 매매 로직 불변, ms 누적만."""
    if prof is None:
        return
    try:
        prof[key] = float(prof.get(key, 0.0) or 0.0) + (time.perf_counter() - t0) * 1000.0
    except Exception:
        pass

def portfolio_strategy_key(strategy_id: str) -> str:

"""
실매 ↔ 포트폴리오 백테 정합 — 유니버스 슬롯·총한도·매수금액.
백테 ``breakout_portfolio_backtest`` / ``backtest_portfolio_common`` 과 동일 분모.
"""
from __future__ import annotations

from datetime import datetime as dt
from typing import Any, Dict, List, Optional, Set, Tuple

from ..backtest.backtest_portfolio_common import (
    STRATEGY_PORTFOLIO_KEYS,
    min_invest_ratio_of_slot,
    portfolio_exposure_krw,
    resolve_portfolio_params,
    target_qty_and_cost,
)
from ..utils.env import get_env_bool, get_env_from_db, get_env_int
from ..utils.position_sizing import invest_qty_for_price

# 자체 유니버스 — 기본 슬롯 필터 OFF (env 로 켤 수 있음)
_UNIVERSE_SLOT_SKIP_DEFAULT = frozenset({"UPDOW", "DBBAND"})

# 총한도·일1회 base 가드 스킵 (전략 자체 구현)
_PORTFOLIO_BUDGET_SKIP_DEFAULT = frozenset({"UPDOW"})

def portfolio_strategy_key(strategy_id: str) -> str:

def history_universe_codes_at(
    db: Any,
    strategy_id: str,
    when: Optional[dt] = None,
    *,
    history_source: str = "kiwoom",
    universe_source: Optional[str] = None,
    prof: Optional[Dict[str, float]] = None,
) -> Optional[Set[str]]:
    """
    이력 스냅샷 — ``at_time`` 이전 최신 event_time 종목 집합.
    - kiwoom → ``target_candidates_history``
    - ls → ``ls_candidates_history``
    스냅샷 없으면 ``None`` (필터 생략 = 실시간 후보 유지).

    **당일 스냅샷만** 사용한다. 주말 재시작·키움 매니저 다운 뒤 남은
    며칠 전 history 와 sticky 후보를 교집합하면 전원 탈락(11→0) 한다.
    """
    when = when or dt.now()
    at_time = when.strftime("%Y-%m-%d %H:%M:%S")
    sid = (strategy_id or "").upper()
    src = str(history_source or "").strip().lower()
    if universe_source is not None:
        src = resolve_live_universe_history_source(
            sid, universe_source=universe_source,
        )
    elif src in ("", "auto"):
        src = resolve_live_universe_history_source(sid, universe_source=None)
    if src not in ("ls", "kiwoom"):
        src = "kiwoom"

    # 당일(캘린더) 스냅샷만 슬롯정합에 쓴다 — 낡은 history 전멸 방지
    t_et = time.perf_counter()
    et = _history_snapshot_event_time(db, sid, at_time, src)
    _prof_add(prof, "slot_et_ms", t_et)
    if not et:
        if prof is not None:
            prof["slot_miss"] = float(prof.get("slot_miss", 0) or 0) + 1.0
        return None
    if _event_time_ymd(et) != when.strftime("%Y-%m-%d"):
        if prof is not None:
            prof["slot_miss"] = float(prof.get("slot_miss", 0) or 0) + 1.0
        return None

    getter = getattr(db, "get_universe_at", None)
    if getter is None:
        if prof is not None:
            prof["slot_miss"] = float(prof.get("slot_miss", 0) or 0) + 1.0
        return None
    try:
        t_get = time.perf_counter()
        # TradeDBExt 는 history_source 지원. FakeDB 등 구시그니처는 무시.
        try:
            rows = getter(
                strategy_id=sid, at_time=at_time, history_source=src,
            ) or []
        except TypeError:
            rows = getter(strategy_id=sid, at_time=at_time) or []
        _prof_add(prof, "slot_get_ms", t_get)
    except Exception:
        if prof is not None:
            prof["slot_miss"] = float(prof.get("slot_miss", 0) or 0) + 1.0
        return None
    if not rows:
        if prof is not None:
            prof["slot_miss"] = float(prof.get("slot_miss", 0) or 0) + 1.0
        return None
    codes: Set[str] = set()
    for r in rows:
        code = str(r.get("code") or "").strip()
        if code:
            codes.add(code)
    if prof is not None:
        if codes:
            prof["slot_ok"] = float(prof.get("slot_ok", 0) or 0) + 1.0
            prof["slot_codes"] = float(len(codes))
        else:
            prof["slot_miss"] = float(prof.get("slot_miss", 0) or 0) + 1.0
    return codes if codes else None

def filter_candidates_by_history_universe(
    candidates: List[Dict],
    db: Any,
    strategy_id: str,
    *,
    when: Optional[dt] = None,
    history_source: Optional[str] = None,
    universe_source: Optional[str] = None,
    prof: Optional[Dict[str, float]] = None,
) -> Tuple[List[Dict], int]:
    """실시간 후보 ∩ history 스냅샷. (filtered, dropped_count)."""
    if not candidates:
        return [], 0
    hs = history_source
    if hs is None:
        hs = resolve_live_universe_history_source(
            strategy_id, universe_source=universe_source,
        )
    allowed = history_universe_codes_at(
        db,
        strategy_id,
        when,
        history_source=hs or "kiwoom",
        universe_source=None,
        prof=prof,
    )
    if allowed is None:
        return candidates, 0
    out: List[Dict] = []
    for c in candidates:
        code = (c.get("code") or c.get("stk_cd") or "").strip()
        if code and code in allowed:
            out.append(c)
    return out, len(candidates) - len(out)

def history_universe_codes_at(
    db: Any,
    strategy_id: str,
    when: Optional[dt] = None,
    *,
    history_source: str = "kiwoom",
    universe_source: Optional[str] = None,
) -> Optional[Set[str]]:
    """
    이력 스냅샷 — ``at_time`` 이전 최신 event_time 종목 집합.
    - kiwoom → ``target_candidates_history``
    - ls → ``ls_candidates_history``
    스냅샷 없으면 ``None`` (필터 생략 = 실시간 후보 유지).

    **당일 스냅샷만** 사용한다. 주말 재시작·키움 매니저 다운 뒤 남은
    며칠 전 history 와 sticky 후보를 교집합하면 전원 탈락(11→0) 한다.
    """
    when = when or dt.now()
    at_time = when.strftime("%Y-%m-%d %H:%M:%S")
    sid = (strategy_id or "").upper()
    src = str(history_source or "").strip().lower()
    if universe_source is not None:
        src = resolve_live_universe_history_source(
            sid, universe_source=universe_source,
        )
    elif src in ("", "auto"):
        src = resolve_live_universe_history_source(sid, universe_source=None)
    if src not in ("ls", "kiwoom"):
        src = "kiwoom"

    # 당일(캘린더) 스냅샷만 슬롯정합에 쓴다 — 낡은 history 전멸 방지
    et = _history_snapshot_event_time(db, sid, at_time, src)
    if not et:
        return None
    if _event_time_ymd(et) != when.strftime("%Y-%m-%d"):
        return None

    getter = getattr(db, "get_universe_at", None)
    if getter is None:
        return None
    try:
        # TradeDBExt 는 history_source 지원. FakeDB 등 구시그니처는 무시.
        try:
            rows = getter(
                strategy_id=sid, at_time=at_time, history_source=src,
            ) or []
        except TypeError:
            rows = getter(strategy_id=sid, at_time=at_time) or []
    except Exception:
        return None
    if not rows:
        return None
    codes: Set[str] = set()
    for r in rows:
        code = str(r.get("code") or "").strip()
        if code:
            codes.add(code)
    return codes if codes else None

def filter_candidates_by_history_universe(
    candidates: List[Dict],
    db: Any,
    strategy_id: str,
    *,
    when: Optional[dt] = None,
    history_source: Optional[str] = None,
    universe_source: Optional[str] = None,
) -> Tuple[List[Dict], int]:
    """실시간 후보 ∩ history 스냅샷. (filtered, dropped_count)."""
    if not candidates:
        return [], 0
    hs = history_source
    if hs is None:
        hs = resolve_live_universe_history_source(
            strategy_id, universe_source=universe_source,
        )
    allowed = history_universe_codes_at(
        db,
        strategy_id,
        when,
        history_source=hs or "kiwoom",
        universe_source=None,
    )
    if allowed is None:
        return candidates, 0
    out: List[Dict] = []
    for c in candidates:
        code = (c.get("code") or c.get("stk_cd") or "").strip()
        if code and code in allowed:
            out.append(c)
    return out, len(candidates) - len(out)

def live_daily_entry_count(
    db: Any,
    code: str,
    today: str,
    strategy_id: str,
    *,
    prof: Optional[Dict[str, float]] = None,
) -> int:
    try:
        t0 = time.perf_counter()
        trades = db.get_trades_by_date(today) or []
        _prof_add(prof, "guard_trades_db_ms", t0)
        if prof is not None:
            prof["guard_trades_n"] = float(prof.get("guard_trades_n", 0) or 0) + 1.0
        sid = (strategy_id or "").upper()
        return len([
            t for t in trades
            if t.get("code") == code
            and str(t.get("strategy", "")).upper().startswith(sid)
        ])
    except Exception:
        return 0

def resolve_live_buy_qty(
    curr_price: float,
    holdings: Dict[str, Dict],
    strategy_id: str,
    slot_money: float,
    *,
    max_stocks: int,
    invest_cap: Optional[float] = None,
) -> Tuple[int, float, Optional[str]]:
    """
    포트폴리오 백테 ``target_qty_and_cost`` + ``min_invest_ratio`` + ``total_budget`` 정합.
    Returns: (qty, invest_krw, reject_reason)
    """
    if curr_price <= 0:
        return 0, 0.0, "가격0"
    cap = float(invest_cap if invest_cap is not None else slot_money)
    if cap <= 0:
        return 0, 0.0, "투자캡0"
    exposure = portfolio_exposure_krw(holdings)
    total_budget = resolve_live_total_budget_krw(strategy_id, max_stocks, slot_money)
    remaining = max(0.0, total_budget - exposure)
    ratio = resolve_live_min_invest_ratio(strategy_id)
    target_qty, target_cost = target_qty_and_cost(curr_price, cap)
    min_required = target_cost * ratio
    if target_qty < 1 or remaining < min_required:
        return 0, 0.0, "소액/잔여부족"
    invest = min(cap, remaining, target_cost)
    qty = invest_qty_for_price(curr_price, invest)
    if qty < 1:
        return 0, 0.0, "수량0"
    cost = qty * curr_price
    if cost < min_required:
        return 0, 0.0, "소액"
    return qty, invest, None

def live_portfolio_entry_reject(
    db: Any,
    holdings: Dict[str, Dict],
    strategy_id: str,
    code: str,
    slot_money: float,
    max_stocks: int,
    *,
    prof: Optional[Dict[str, float]] = None,
) -> Optional[str]:
    """일일한도·총한도 — ``None`` 이면 통과."""
    t0 = time.perf_counter()
    enabled = live_portfolio_budget_align_enabled(strategy_id)
    _prof_add(prof, "guard_env_ms", t0)
    if not enabled:
        return None
    t1 = time.perf_counter()
    md = live_max_daily(strategy_id)
    _prof_add(prof, "guard_env_ms", t1)
    if md > 0:
        today = dt.now().strftime("%Y%m%d")
        t2 = time.perf_counter()
        cnt = live_daily_entry_count(db, code, today, strategy_id, prof=prof)
        _prof_add(prof, "guard_daily_ms", t2)
        if cnt >= md:
            return f"탈락-일일한도 daily={cnt}/{md}"
    t3 = time.perf_counter()
    total = resolve_live_total_budget_krw(strategy_id, max_stocks, slot_money)
    if total > 0 and portfolio_exposure_krw(holdings) >= total - 1e-6:
        _prof_add(prof, "guard_budget_ms", t3)
        return "탈락-총한도"
    _prof_add(prof, "guard_budget_ms", t3)
    return None

def live_daily_entry_count(
    db: Any,
    code: str,
    today: str,
    strategy_id: str,
) -> int:
    try:
        trades = db.get_trades_by_date(today) or []
        sid = (strategy_id or "").upper()
        return len([
            t for t in trades
            if t.get("code") == code
            and str(t.get("strategy", "")).upper().startswith(sid)
        ])
    except Exception:
        return 0

def resolve_live_buy_qty(
    curr_price: float,
    holdings: Dict[str, Dict],
    strategy_id: str,
    slot_money: float,
    *,
    max_stocks: int,
    invest_cap: Optional[float] = None,
) -> Tuple[int, float, Optional[str]]:
    """
    포트폴리오 백테 ``target_qty_and_cost`` + ``min_invest_ratio`` + ``total_budget`` 정합.
    Returns: (qty, invest_krw, reject_reason)
    """
    if curr_price <= 0:
        return 0, 0.0, "가격0"
    cap = float(invest_cap if invest_cap is not None else slot_money)
    if cap <= 0:
        return 0, 0.0, "투자캡0"
    exposure = portfolio_exposure_krw(holdings)
    total_budget = resolve_live_total_budget_krw(strategy_id, max_stocks, slot_money)
    remaining = max(0.0, total_budget - exposure)
    ratio = resolve_live_min_invest_ratio(strategy_id)
    target_qty, target_cost = target_qty_and_cost(curr_price, cap)
    min_required = target_cost * ratio
    if target_qty < 1 or remaining < min_required:
        return 0, 0.0, "소액/잔여부족"
    invest = min(cap, remaining, target_cost)
    qty = invest_qty_for_price(curr_price, invest)
    if qty < 1:
        return 0, 0.0, "수량0"
    cost = qty * curr_price
    if cost < min_required:
        return 0, 0.0, "소액"
    return qty, invest, None

def live_portfolio_entry_reject(
    db: Any,
    holdings: Dict[str, Dict],
    strategy_id: str,
    code: str,
    slot_money: float,
    max_stocks: int,
) -> Optional[str]:
    """일일한도·총한도 — ``None`` 이면 통과."""
    if not live_portfolio_budget_align_enabled(strategy_id):
        return None
    md = live_max_daily(strategy_id)
    if md > 0:
        today = dt.now().strftime("%Y%m%d")
        cnt = live_daily_entry_count(db, code, today, strategy_id)
        if cnt >= md:
            return f"탈락-일일한도 daily={cnt}/{md}"
    total = resolve_live_total_budget_krw(strategy_id, max_stocks, slot_money)
    if total > 0 and portfolio_exposure_krw(holdings) >= total - 1e-6:
        return "탈락-총한도"
    return None


    def _live_portfolio_entry_guard(self, code: str, max_stocks: Optional[int] = None) -> Optional[str]:
        from ..utils.live_portfolio_common import live_portfolio_entry_reject
        if not self._live_portfolio_budget_enabled():
            return None
        ms = max_stocks if max_stocks is not None else self._max_stocks()
        slot = float(getattr(self, "slot_money", 0) or get_env_int("SLOT_MONEY_DEFAULT", 3_000_000))
        prof = None
        if self._loop_profile_on():
            prof = getattr(self, "_prof_guard_acc", None)
            if prof is None:
                prof = {}
                self._prof_guard_acc = prof
        return live_portfolio_entry_reject(
            self.db, self.holdings, self.strategy_id, code, slot, ms,
            prof=prof,
        )

    def _live_portfolio_entry_guard(self, code: str, max_stocks: Optional[int] = None) -> Optional[str]:
        from ..utils.live_portfolio_common import live_portfolio_entry_reject
        if not self._live_portfolio_budget_enabled():
            return None
        ms = max_stocks if max_stocks is not None else self._max_stocks()
        slot = float(getattr(self, "slot_money", 0) or get_env_int("SLOT_MONEY_DEFAULT", 3_000_000))
        return live_portfolio_entry_reject(
            self.db, self.holdings, self.strategy_id, code, slot, ms,
        )

            f"pre_guard={row.get('pre_guard_ms', 0):.1f}",
            f"g_env={row.get('guard_env_ms', 0):.1f}",
            f"g_daily={row.get('guard_daily_ms', 0):.1f}",
            f"g_trdb={row.get('guard_trades_db_ms', 0):.1f}",
            f"g_tr_n={row.get('guard_trades_n', 0)}",
            f"g_budg={row.get('guard_budget_ms', 0):.1f}",
            f"pre_cd={row.get('pre_cd_ms', 0):.1f}",
            f"pre_gap={row.get('pre_gap_ms', 0):.1f}",
            f"sleep_rej={row.get('sleep_rej_ms', 0):.1f}",
            f"sleep_ok={row.get('sleep_ok_ms', 0):.1f}",
            f"sleep_fail={row.get('sleep_fail_ms', 0):.1f}",
            f"buy_n={row.get('buy_n', 0)}",
            f"buy_sum={row.get('buy_sum_ms', 0):.1f}",
            f"buy_max={row.get('buy_max_ms', 0):.1f}",
            f"trd_hit={row.get('trd_hit', 0)}",
            f"trd_miss={row.get('trd_miss', 0)}",
            f"trd_db={row.get('trd_db_ms', 0):.1f}",
            f"gap_skip={row.get('gap_skip', 0)}",
            f"slot_et={row.get('slot_et_ms', 0):.1f}",
            f"slot_get={row.get('slot_get_ms', 0):.1f}",
            f"slot_ok={row.get('slot_ok', 0)}",
            f"slot_miss={row.get('slot_miss', 0)}",
            f"overlay={row.get('overlay_ms', 0):.1f}",

            f"pre_guard={row.get('pre_guard_ms', 0):.1f}",
            f"pre_cd={row.get('pre_cd_ms', 0):.1f}",
            f"pre_gap={row.get('pre_gap_ms', 0):.1f}",
            f"sleep_rej={row.get('sleep_rej_ms', 0):.1f}",
            f"sleep_ok={row.get('sleep_ok_ms', 0):.1f}",
            f"sleep_fail={row.get('sleep_fail_ms', 0):.1f}",
            f"buy_n={row.get('buy_n', 0)}",
            f"buy_sum={row.get('buy_sum_ms', 0):.1f}",
            f"buy_max={row.get('buy_max_ms', 0):.1f}",
            f"trd_hit={row.get('trd_hit', 0)}",
            f"trd_miss={row.get('trd_miss', 0)}",
            f"trd_db={row.get('trd_db_ms', 0):.1f}",
            f"gap_skip={row.get('gap_skip', 0)}",
            f"overlay={row.get('overlay_ms', 0):.1f}",

                    row["pre_filt_ms"] = float(sc.get("pre_filt_ms", 0) or 0)
                    row["pre_guard_ms"] = float(sc.get("pre_guard_ms", 0) or 0)
                    row["pre_cd_ms"] = float(sc.get("pre_cd_ms", 0) or 0)
                    row["pre_gap_ms"] = float(sc.get("pre_gap_ms", 0) or 0)
                    row["sleep_rej_ms"] = float(sc.get("sleep_rej_ms", 0) or 0)
                    row["sleep_ok_ms"] = float(sc.get("sleep_ok_ms", 0) or 0)
                    row["sleep_fail_ms"] = float(sc.get("sleep_fail_ms", 0) or 0)
                    row["guard_env_ms"] = float(sc.get("guard_env_ms", 0) or 0)
                    row["guard_daily_ms"] = float(sc.get("guard_daily_ms", 0) or 0)
                    row["guard_trades_db_ms"] = float(sc.get("guard_trades_db_ms", 0) or 0)
                    row["guard_trades_n"] = int(sc.get("guard_trades_n", 0) or 0)
                    row["guard_budget_ms"] = float(sc.get("guard_budget_ms", 0) or 0)
                    row["slot_et_ms"] = float(getattr(self, "_prof_slot_et_ms", 0) or 0)
                    row["slot_get_ms"] = float(getattr(self, "_prof_slot_get_ms", 0) or 0)
                    row["slot_ok"] = int(getattr(self, "_prof_slot_ok", 0) or 0)
                    row["slot_miss"] = int(getattr(self, "_prof_slot_miss", 0) or 0)
                    row["trd_hit"] = int(self._prof_trd_hit)
                    row["trd_miss"] = int(self._prof_trd_miss)
                    row["trd_db_ms"] = float(self._prof_trd_db_ms)

                    row["pre_filt_ms"] = float(sc.get("pre_filt_ms", 0) or 0)
                    row["pre_guard_ms"] = float(sc.get("pre_guard_ms", 0) or 0)
                    row["pre_cd_ms"] = float(sc.get("pre_cd_ms", 0) or 0)
                    row["pre_gap_ms"] = float(sc.get("pre_gap_ms", 0) or 0)
                    row["sleep_rej_ms"] = float(sc.get("sleep_rej_ms", 0) or 0)
                    row["sleep_ok_ms"] = float(sc.get("sleep_ok_ms", 0) or 0)
                    row["sleep_fail_ms"] = float(sc.get("sleep_fail_ms", 0) or 0)
                    row["trd_hit"] = int(self._prof_trd_hit)
                    row["trd_miss"] = int(self._prof_trd_miss)
                    row["trd_db_ms"] = float(self._prof_trd_db_ms)

                self._prof_cand_mgr_ms = 0.0
                self._prof_cand_post_ms = 0.0
                self._prof_cand_slot_ms = 0.0
                self._prof_cand_src = "-"
                self._prof_cand_n = 0
                self._prof_slot_et_ms = 0.0
                self._prof_slot_get_ms = 0.0
                self._prof_slot_ok = 0
                self._prof_slot_miss = 0
                self._prof_guard_acc = {}
                self.reload_config()

                self._prof_cand_mgr_ms = 0.0
                self._prof_cand_post_ms = 0.0
                self._prof_cand_slot_ms = 0.0
                self._prof_cand_src = "-"
                self._prof_cand_n = 0
                self.reload_config()

        sleep_rej_ms = 0.0
        sleep_ok_ms = 0.0
        sleep_fail_ms = 0.0
        if prof_scan:
            self._prof_guard_acc = {}

        def _snap_scan():
            g = getattr(self, "_prof_guard_acc", None) or {}
            return {
                "buy_n": buy_n,
                "buy_sum_ms": buy_sum,
                "buy_max_ms": buy_max,
                "buy_max_code": buy_max_code,
                "gap_skip": gap_skip,
                "scan_name_ms": scan_name_ms,
                "scan_pre_ms": scan_pre_ms,
                "pre_filt_ms": pre_filt_ms,
                "pre_guard_ms": pre_guard_ms,
                "pre_cd_ms": pre_cd_ms,
                "pre_gap_ms": pre_gap_ms,
                "sleep_rej_ms": sleep_rej_ms,
                "sleep_ok_ms": sleep_ok_ms,
                "sleep_fail_ms": sleep_fail_ms,
                "guard_env_ms": float(g.get("guard_env_ms", 0) or 0),
                "guard_daily_ms": float(g.get("guard_daily_ms", 0) or 0),
                "guard_trades_db_ms": float(g.get("guard_trades_db_ms", 0) or 0),
                "guard_trades_n": int(g.get("guard_trades_n", 0) or 0),
                "guard_budget_ms": float(g.get("guard_budget_ms", 0) or 0),
            }

        sleep_rej_ms = 0.0
        sleep_ok_ms = 0.0
        sleep_fail_ms = 0.0

        def _snap_scan():
            return {
                "buy_n": buy_n,
                "buy_sum_ms": buy_sum,
                "buy_max_ms": buy_max,
                "buy_max_code": buy_max_code,
                "gap_skip": gap_skip,
                "scan_name_ms": scan_name_ms,
                "scan_pre_ms": scan_pre_ms,
                "pre_filt_ms": pre_filt_ms,
                "pre_guard_ms": pre_guard_ms,
                "pre_cd_ms": pre_cd_ms,
                "pre_gap_ms": pre_gap_ms,
                "sleep_rej_ms": sleep_rej_ms,
                "sleep_ok_ms": sleep_ok_ms,
                "sleep_fail_ms": sleep_fail_ms,
            }

            t_slot0 = time.perf_counter() if do_prof else 0.0
            slot_prof: Dict[str, float] = {} if do_prof else None  # type: ignore[assignment]
            candidates, dropped = filter_candidates_by_history_universe(
                candidates,
                self.db,
                sid,
                universe_source=univ_src,
                history_source=hist_src,
                prof=slot_prof,
            )
            if do_prof:
                slot_ms = (time.perf_counter() - t_slot0) * 1000.0
                self._prof_slot_et_ms = float((slot_prof or {}).get("slot_et_ms", 0) or 0)
                self._prof_slot_get_ms = float((slot_prof or {}).get("slot_get_ms", 0) or 0)
                self._prof_slot_ok = int((slot_prof or {}).get("slot_ok", 0) or 0)
                self._prof_slot_miss = int((slot_prof or {}).get("slot_miss", 0) or 0)

            t_slot0 = time.perf_counter() if do_prof else 0.0
            candidates, dropped = filter_candidates_by_history_universe(
                candidates,
                self.db,
                sid,
                universe_source=univ_src,
                history_source=hist_src,
            )
            if do_prof:
                slot_ms = (time.perf_counter() - t_slot0) * 1000.0


Shell

cd /home/hoon/kis_bot && python3 -m py_compile kis_trader/strategies/base.py kis_trader/utils/live_portfolio_common.py && sudo systemctl restart kis_trader_main.service && sleep 4 && systemctl is-active kis_trader_main.service && : > logs/loop_profile.log && sleep 28 && python3 <<'PY'
from collections import defaultdict
import re
from datetime import datetime
lines=open('logs/loop_profile.log',encoding='utf-8',errors='replace').read().splitlines()
print('lines', len(lines), 'with g_trdb', sum(1 for l in lines if 'g_trdb=' in l))
keys=['total','cand_slot','slot_et','slot_get','pre_guard','g_env','g_daily','g_trdb','g_tr_n','g_budg','pre_gap','buy_sum']
pat=re.compile(r'\[LOOP_PROF\]\s+(\S+)\s+(.*)')
rows=[]
for ln in lines:
    if 'g_trdb=' not in ln: continue
    m=pat.search(ln)
    if not m: continue
    sid, rest=m.group(1), m.group(2)
    d={'ts':ln[:19],'sid':sid}
    for k in keys:
        mm=re.search(rf'(?:^|\s){k}=([0-9.]+)', rest)
        if mm: d[k]=float(mm.group(1))
    rows.append(d)

# summary per sid
by=defaultdict(list)
for r in rows: by[r['sid']].append(r)
print('\n=== 요약 (g_*=guard 내부, slot_*=history) ===')
for sid, rs in sorted(by.items()):
    print(f'--- {sid} n={len(rs)} ---')
    for k in keys:
        vs=[r[k] for r in rs if k in r]
        if not vs: continue
        print(f'  {k:10s} mean={sum(vs)/len(vs):7.1f} p50={sorted(vs)[len(vs)//2]:7.1f} max={max(vs):7.1f}')

# 5s time series for SCALP + SHORT (active scanners)
print('\n=== 5초 시간열 (평균 ms) ===')
print(f"{'time':8s} {'sid':10s} {'n':>3} {'total':>7} {'slot':>6} {'et':>6} {'get':>6} {'guard':>6} {'g_trdb':>7} {'g_n':>4} {'g_daily':>7}")
buckets=defaultdict(list)
for r in rows:
    try:
        t=datetime.strptime(r['ts'], '%Y-%m-%d %H:%M:%S')
        b=t.replace(second=(t.second//5)*5, microsecond=0)
    except Exception:
        continue
    buckets[(r['sid'], b)].append(r)
# show last buckets for each sid of interest
for sid in ['SCALP','SHORT','BREAKOUT','MOMENTUM']:
    items=[(b,rs) for (s,b),rs in buckets.items() if s==sid]
    items=sorted(items)[-8:]
    for b, rs in items:
        def avg(k):
            vs=[x[k] for x in rs if k in x]
            return sum(vs)/len(vs) if vs else 0
        print(f"{b.strftime('%H:%M:%S')} {sid:10s} {len(rs):3d} {avg('total'):7.1f} {avg('cand_slot'):6.1f} {avg('slot_et'):6.1f} {avg('slot_get'):6.1f} {avg('pre_guard'):6.1f} {avg('g_trdb'):7.1f} {avg('g_tr_n'):4.0f} {avg('g_daily'):7.1f}")
PY

Restart and print guard/slot time series


### 4번 짧게
계측 끝나면 `LOOP_PROFILE` 끄거나 주기 키우는 **정리** — 지금은 측정용으로 유지.

---

### 계측 추가 (로직 변경 없음)
- **guard**: `g_env` / `g_daily` / `g_trdb` / `g_tr_n` / `g_budg`
- **slot**: `slot_et` / `slot_get` / `slot_ok` / `slot_miss`

재시작 직후 ~28초 샘플:

### 요약 — 주범 확정
| 구간 | 결과 |
|------|------|
| **`pre_guard` ≈ `g_daily` ≈ `g_trdb`** | 후보마다 `get_trades_by_date` 재조회 (공유 DB 락 대기) |
| **`g_tr_n`** | 가드까지 온 후보 수만큼 SELECT (예: BREAKOUT 13회/바퀴) |
| **`g_env` / `g_budg`** | ≈0 — 아님 |
| **`cand_slot`** | `slot_et`(MAX event_time) + `slot_get`(get_universe_at) 둘 다, 락 경합 시 수백 ms~1s |

즉 **당일거래 캐시(`_get_today_trades`)를 guard 경로가 안 씀** → 후보 N번 DB. slot은 **루프마다 history 2쿼리**.

### 5초 시간열 (새 필드, 재시작 직후)
```
time     sid        n  total  slot    et   get  guard  g_trdb  g_n
14:03:55 SCALP      2  3051   105    90    15   1798    1798    8
14:04:00 SCALP      1  4014   923   483   439   2610    2610    6
14:03:55 SHORT      3  1629  1061   791   270    280     280    2
14:04:00 SHORT      2  2220   456   226   229   1528    1528    2
14:03:55 BREAKOUT   1  4857   369   365     4   4097    4096   13
14:04:00 BREAKOUT   1  5255   787     8   779   4455    4453   13
```

### 이전 세분(pre_guard/cand_slot) 시간열 — 재시작 전 정상 회전 구간
```
14:00:20 SCALP  n~100  total~50   slot~10  guard~15
14:01:15 SCALP  n~40   total~133  slot~44  guard~35
14:01:35 SCALP  n~15   total~221  slot~110 guard~91   ← 스파이크
14:00:20 SHORT  n~126  total~39   slot~9   guard~16
14:01:15 SHORT  n~41   total~127  slot~63  guard~50
```
평소 tens of ms, 가끔 slot+guard가 같이 튀는 패턴(= 공유 DB 경합).

수정은 아직 안 함. 다음 후보(승인 후): guard를 `_get_today_trades` TTL 캐시로 통일, slot history TTL 캐시.


<timestamp>Thursday, Aug 13, 2026, 2:14 PM (UTC+9)</timestamp>
<user_query>
요약 — 주범 확정
구간	결과
pre_guard ≈ g_daily ≈ g_trdb
후보마다 get_trades_by_date 재조회 (공유 DB 락 대기)
g_tr_n
가드까지 온 후보 수만큼 SELECT (예: BREAKOUT 13회/바퀴)
g_env / g_budg
≈0 — 아님
cand_slot
slot_et(MAX event_time) + slot_get(get_universe_at) 둘 다, 락 경합 시 수백 ms~1s
이게  주범이라는거야? 고치면 어떻게 되는데 그니까 내가 걱정하는건 로직이 무너지거나 뭐 체크가 밀려서 잘못된다던가 스킵된다던가 그리고아직 이게 뭐하늗애인지도 모르겠어서 승인을 못하겠네
</user_query>


네. **지금 루프가 느린 주범은 맞습니다.**
다만 “매매 공식이 틀려서”가 아니라, **같은 검사를 후보마다 DB에 또 물어봐서** 느린 겁니다.

---

### 이게 뭐 하는 애들이냐 (초등 버전)

**1) `pre_guard` / 일일한도 (`g_daily` → `get_trades_by_date`)**
- 질문: “이 종목, 오늘 이 전략으로 **이미 몇 번 샀나?**”
- `MAX_DAILY`(일 최대 매수 횟수) 넘으면 → 매수 안 함.
- **정상 체크**입니다. 손절·신호식과 별개, **포트/횟수 안전장치**.

문제는 검사 자체가 아니라 **방법**:
- 후보 13개면 → “오늘 거래 내역 줘”를 **13번** DB에 물음.
- 답은 거의 매번 **같은 표(당일 trade_history)** 인데, 공유 DB 락 때문에 줄 서서 기다림 → `g_trdb`가 수 초.

이미 루프 다른 곳은 `_get_today_trades`로 **같은 표를 10초 TTL 캐시**하는데, **가드만 그걸 안 쓰고 매번 SELECT**.

**2) `cand_slot` (history ∩ 실시간 후보)**
- 질문: “지금 실시간 후보가, **백테가 쓰는 그 시각 유니버스 스냅샷** 안에 있나?”
- ON이면 history와 안 겹치는 종목은 스캔에서 빼서 **실매↔백테 후보 집합을 맞춤**.
- 매 루프마다 DB에
  - `slot_et`: “이 시각 직전 스냅샷 시각 뭐야?”
  - `slot_get`: “그 스냅샷에 종목 뭐 있었어?”
- 이것도 **의미는 맞고**, 루프마다 같은 슬롯이면 **답이 거의 안 바뀌는데** 또 물어봄 → 락 경합 시 수백 ms~1s.

(슬롯 정렬이 OFF인 전략은 `cand_slot` 부담이 없음. 지금 느린 전략들은 ON이라 찍힌 것.)

---

### “고치면 어떻게 되냐” / 걱정하는 사고

| 걱정 | 캐시/TTL로 고칠 때 |
|------|-------------------|
| 로직이 무너지나? | **검사 내용·조건식은 그대로.** “오늘 몇 번 샀나 / 슬롯에 있나”만 **같은 답을 RAM에서 재사용**. |
| 체크가 스킵되나? | **스킵 안 함.** 매 후보마다 한도·슬롯 판단은 함. DB만 매번 안 감. |
| 잘못된 매수/매도? | TTL 안(예: 10초)에 **방금 다른 곳에서 체결된 횟수**가 캐시에 안 보이면, 아주 짧게 한도가 느슨해질 **여지**는 있음. 반대로 슬롯도 TTL 안엔 스냅샷이 조금 늦을 수 있음. → 그래서 TTL을 짧게(수 초) 두고, **매수 체결 직후 캐시 무효화**하면 실무상 거의 동일. |
| 신호·손절·봉 T−1? | **안 건드림.** 이 두 개는 진입 **앞단 필터/한도**만. |

정리:
- **지금 느림 = 주범 맞음** (계측상 `pre_guard`≈`g_trdb`, `cand_slot`≈`et+get`).
- **고친다 = 더 싸게 같은 검사**이지, 검사 삭제·신호 변조가 아님.
- 리스크는 “캐시가 몇 초 낡음”뿐이고, **무효화 + 짧은 TTL**로 막을 수 있음.

---

### 아직 승인 안 해도 되는 이유
원하시면 다음에 **설계만** 짧게 다시 쓸 수 있습니다. 예:
1. guard → 기존 `_get_today_trades` 재사용 (+ 체결 시 invalidate)
2. slot → `(strategy, slot_key)` RAM TTL (예: 5~10초) + 루프당 1회만

**승인 전엔 코드 안 바꿉니다.**
지금은 “뭐 하는 애인지 / 왜 느린지 / 고치면 무엇이 같고 무엇이 조금 달라질 수 있는지”만 이해하시면 됩니다.


<timestamp>Thursday, Aug 13, 2026, 2:18 PM (UTC+9)</timestamp>
<user_query>
 pre_guard / 일일한도 (g_daily → get_trades_by_date)

질문: “이 종목, 오늘 이 전략으로 이미 몇 번 샀나?”
MAX_DAILY(일 최대 매수 횟수) 넘으면 → 매수 안 함.
정상 체크입니다. 손절·신호식과 별개, 포트/횟수 안전장치.

이건 아까한거랑 다른거야? 내가 쿼리한번 조회해서 코드에서 나눠주라고 하지 않았나?

cand_slot (history ∩ 실시간 후보)

질문: “지금 실시간 후보가, 백테가 쓰는 그 시각 유니버스 스냅샷 안에 있나?”
ON이면 history와 안 겹치는 종목은 스캔에서 빼서 실매↔백테 후보 집합을 맞춤.
매 루프마다 DB에
slot_et: “이 시각 직전 스냅샷 시각 뭐야?”
slot_get: “그 스냅샷에 종목 뭐 있었어?”
이것도 의미는 맞고, 루프마다 같은 슬롯이면 답이 거의 안 바뀌는데 또 물어봄 → 락 경합 시 수백 ms~1s.
(슬롯 정렬이 OFF인 전략은 cand_slot 부담이 없음. 지금 느린 전략들은 ON이라 찍힌 것.)

이건 정합용으로 꼭  켜야하는건데
</user_query>


맞는지 코드 경로만 짧게 확인합니다.


### 1) 일일한도 — 아까 한 거랑 같음 / 근데 **연결이 안 됨**

말씀하신 “한 번 조회해서 코드에서 나눠 써라”가 맞습니다.
그게 바로 **`_get_today_trades` TTL 캐시**입니다.

| 경로 | 뭐 쓰나 |
|------|---------|
| SCALP/MOM/SHORT **`check_buy` 안** | `_get_today_trades` ✅ (캐시) |
| 스캔 앞단 **`pre_guard` → `live_daily_entry_count`** | 아직도 `db.get_trades_by_date` ❌ (캐시 안 탐) |

그래서:
- **하는 일(MAX_DAILY)은 같은 종류**의 안전장치이고
- **아까 캐시한 그 표를 가드가 안 써서** `g_trdb`가 또 뜨는 겁니다.
새 기능이 아니라 **같은 일의 뒷문(미연결)** 입니다.

고치면: 가드도 `_get_today_trades`(또는 같은 공유 캐시)만 보게 하면 됨.
로직(몇 번 샀나)은 그대로, SELECT만 후보마다 → **바퀴당 0~1회**로.

---

### 2) `cand_slot` — **켜 둔 채 유지가 맞음**

정합용이라 **OFF 하자는 얘기가 아닙니다.**
끄면 안 되고, **ON 유지 + “같은 슬롯이면 DB를 매번 안 치기”**가 다음 후보입니다.

- 지금: 루프마다 `et` + `get` → 락 줄 서기
- 나중: 슬롯 키 같으면 RAM에서 집합 재사용 (TTL·체결/슬롯 바뀌면 갱신)
- **교집합 필터는 매 루프 그대로** 돌림. 질문만 DB→RAM.

---

**한 줄:** 일일한도는 이미 시킨 캐시의 **빠진 소비자**이고, 슬롯은 **끄지 말고 캐시만** 하면 됩니다.
원하시면 그 둘만 설계→승인 후 수정하겠습니다.


<timestamp>Thursday, Aug 13, 2026, 2:21 PM (UTC+9)</timestamp>
<user_query>
ttl이 정확히 뭐야? 후보도 내가 실시간 구독이랑 종목 그대로면 디비에 안넣는다랑 같은 맥락인가 아! 이쪽엔 딜레이 적나??
</user_query>


### TTL이 뭐냐
**Time To Live = “이 답을 몇 초 동안만 믿고, 그 안에 또 물으면 DB 안 가고 RAM 답을 쓴다.”**

예: TTL 10초
- 0초에 DB에서 “오늘 거래 목록” 받음 → RAM에 둠
- 3초·7초에 같은 질문 → RAM
- 11초 → 다시 DB 한 번 → RAM 갱신

만료되면 **검사를 스킵하는 게 아니라**, 그때만 다시 DB 다녀옵니다.

---

### “후보가 그대로면 DB 안 넣는다”랑 같냐?
**비슷한 느낌, 다른 층.**

| | 후보/구독 sticky | TTL 캐시(이번에 말하던 것) |
|--|------------------|---------------------------|
| 뭘 아끼나 | 조건검색·history **쓰기/재구독** | **읽기** SELECT 연타 |
| 조건 | 종목 집합이 안 바뀜 | **시간**이 N초 안 |
| 비유 | “문 앞에 선 애들 명단이 같으면 다시 안 적는다” | “방금 본 성적표를 N초간 다시 안 펼친다” |

둘 다 “같은 정보면 DB/네트워크를 또 안 친다”는 맥락은 같고,
TTL은 **쓰기 생략**이 아니라 **읽기 재사용**입니다.

슬롯도 “같은 분(슬롯 키)이면 스냅샷 집합이 거의 안 바뀜 → DB 재조회 생략”이라, 말씀하신 sticky랑 **더 가깝습니다.**
일일한도는 “오늘 표 전체”라 sticky 후보와는 덜 같고, **짧은 시간 동안 같은 표**를 나눠 쓰는 쪽에 가깝습니다.

---

### 딜레이 걱정 — 이쪽엔?
**매수 타점·봉 확정·손절 쪽 딜레이를 넣는 장치가 아닙니다.**

생길 수 있는 건 이런 **짧은 ‘정보 낡음’**뿐입니다.

1. **일일한도 캐시**
   - TTL 안에 방금 매수 체결이 캐시에 안 보이면 → 한도를 **아주 잠깐** 느슨하게 볼 수 있음.
   - **신호/손절이 밀리거나 스킵되지는 않음.**
   - 실무: TTL을 짧게 + **매수 체결 직후 캐시 비우기** → 사실상 딜레이 없음.

2. **슬롯 캐시**
   - 같은 슬롯이면 원래 history도 안 바뀜 → TTL이 있어도 **정합 의미가 거의 그대로**.
   - 슬롯이 바뀌는 순간(분이 넘어감)에는 키를 바꿔서 **새 조회**하면 됨.
   - “검사가 늦어서 매수를 놓친다”기보다, **불필요한 DB 대기를 줄여서** 스캔이 더 빨라짐.

**한 줄:** TTL = 잠깐 RAM에 적어 둔 유효시간. 후보 sticky와 철학은 비슷하고, 이쪽 딜레이는 타점 밀림이 아니라 **한도/슬롯 숫자가 최대 수 초 낡을 수 있는 정도**이며, 무효화하면 그것도 거의 없앱니다.


<timestamp>Thursday, Aug 13, 2026, 2:23 PM (UTC+9)</timestamp>
<user_query>
ttl이 그럼 램사용이라는건가? 진행해 초는 1초마다 체크 바뀌면 디비업데이트 하면 되겠나?
</user_query>


TTL은 RAM에 잠깐 두는 것이고, 1초마다 낡으면 DB에서 다시 읽어 갱신하는 방식으로 진행합니다.


TodoWrite

당일거래 공유캐시 모듈 + guard 연결 + TTL 1초 + 체결 무효화

슬롯 history RAM TTL 1초 (ON 유지, DB 연타만 제거)

ENV/스키마/DB 기본 1초 + 재시작·계측 확인


Write

"""당일 trade_history — 프로세스 공유 RAM + TTL.

check_buy·포트가드가 같은 표를 나눠 씀. SELECT 연타·공유 DB 락 대기 완화.
TTL 만료 또는 invalidate 시에만 DB 재조회 (DB 쓰기 아님).
"""
from __future__ import annotations

import threading
import time
from typing import Any, Dict, List, Optional, Tuple

_LOCK = threading.Lock()
_DAY: str = ""
_ROWS: List[Dict] = []
_TS: float = 0.0

def _ttl_sec() -> float:
    try:
        from .env import get_env_float
        return float(get_env_float("TODAY_TRADES_CACHE_TTL_SEC", 1.0) or 1.0)
    except Exception:
        return 1.0

def invalidate_today_trades_cache() -> None:
    """매수 체결 직후 등 — 다음 get 이 DB 재조회."""
    global _DAY, _ROWS, _TS
    with _LOCK:
        _DAY = ""
        _ROWS = []
        _TS = 0.0

def get_today_trades_cached(
    db: Any,
    today: Optional[str] = None,
) -> Tuple[List[Dict], bool, float]:
    """Returns: (rows, from_cache, db_ms).

    from_cache=True 이면 DB 미호출.
    """
    global _DAY, _ROWS, _TS
    from datetime import datetime as dt

    day = str(today or dt.now().strftime("%Y%m%d"))
    ttl = _ttl_sec()
    now = time.time()
    with _LOCK:
        if _DAY == day and (ttl <= 0 or (now - _TS) < ttl):
            return list(_ROWS), True, 0.0

    t0 = time.perf_counter()
    try:
        rows = list(db.get_trades_by_date(day) or [])
    except Exception:
        rows = []
    db_ms = (time.perf_counter() - t0) * 1000.0

    with _LOCK:
        _DAY = day
        _ROWS = rows
        _TS = time.time()
        return list(rows), False, db_ms


StrReplace

# 비본주 판별 — kis_trader.utils.non_stock 공용 (KISClient 랭킹 필터와 동일)
from ..utils.non_stock import is_non_stock as _is_non_stock
from ..utils.non_stock import is_unmanageable_holding as _is_unmanageable_holding

from ..engine.strategy_eod import (

# 비본주 판별 — kis_trader.utils.non_stock 공용 (KISClient 랭킹 필터와 동일)
from ..utils.non_stock import is_non_stock as _is_non_stock
from ..utils.non_stock import is_unmanageable_holding as _is_unmanageable_holding

# 당일 trade_history — 전략 간 공유 RAM (루프마다·종목마다 SELECT 금지)
_SHARED_TODAY_TRADES_LOCK = threading.Lock()
_SHARED_TODAY_TRADES_DAY: str = ""
_SHARED_TODAY_TRADES: List[Dict] = []
_SHARED_TODAY_TRADES_TS: float = 0.0

from ..engine.strategy_eod import (

    def _get_today_trades(self, today: Optional[str] = None) -> List[Dict]:
        """당일 trade_history — 전략 공유 RAM + TTL (종목·전략마다 SELECT 금지).

        일일 횟수/손익 게이트·pre_guard 가 동일 캐시 사용.
        TTL(기본 1초) 내·같은 날짜면 DB 안 감. 매수 체결 시 invalidate.
        """
        from ..utils.today_trades_cache import get_today_trades_cached

        day = str(today or dt.now().strftime("%Y%m%d"))
        rows, from_cache, db_ms = get_today_trades_cached(self.db, day)
        if from_cache:
            self._prof_trd_hit += 1
        else:
            self._prof_trd_miss += 1
            self._prof_trd_db_ms += float(db_ms or 0)
        self._today_trades_cache_day = day
        self._today_trades_cache = rows
        return rows

    def reload_config(self) -> None:

    def _get_today_trades(self, today: Optional[str] = None) -> List[Dict]:
        """당일 trade_history — 전략 공유 RAM + TTL (종목·전략마다 SELECT 금지).

        일일 횟수/손익 게이트용. 데이터는 하루 수 건 수준이라 조회 자체는 가볍고,
        느리면 MariaDB 락 대기. TTL 내·같은 날짜면 DB 안 감.
        """
        global _SHARED_TODAY_TRADES_DAY, _SHARED_TODAY_TRADES, _SHARED_TODAY_TRADES_TS
        day = str(today or dt.now().strftime("%Y%m%d"))
        try:
            from ..utils.env import get_env_float
            ttl = float(get_env_float("TODAY_TRADES_CACHE_TTL_SEC", 10.0) or 10.0)
        except Exception:
            ttl = 10.0
        now = time.time()
        with _SHARED_TODAY_TRADES_LOCK:
            if (
                _SHARED_TODAY_TRADES_DAY == day
                and (ttl <= 0 or (now - _SHARED_TODAY_TRADES_TS) < ttl)
            ):
                self._prof_trd_hit += 1
                self._today_trades_cache_day = day
                self._today_trades_cache = _SHARED_TODAY_TRADES
                return _SHARED_TODAY_TRADES

        t_db0 = time.perf_counter()
        try:
            rows = list(self.db.get_trades_by_date(day) or [])
        except Exception:
            rows = []
        db_ms = (time.perf_counter() - t_db0) * 1000.0
        self._prof_trd_miss += 1
        self._prof_trd_db_ms += db_ms

        with _SHARED_TODAY_TRADES_LOCK:
            _SHARED_TODAY_TRADES_DAY = day
            _SHARED_TODAY_TRADES = rows
            _SHARED_TODAY_TRADES_TS = time.time()
            self._today_trades_cache_day = day
            self._today_trades_cache = rows
            return rows

    def reload_config(self) -> None:


def live_daily_entry_count(
    db: Any,
    code: str,
    today: str,
    strategy_id: str,
    *,
    prof: Optional[Dict[str, float]] = None,
) -> int:
    try:
        from .today_trades_cache import get_today_trades_cached

        t0 = time.perf_counter()
        trades, from_cache, db_ms = get_today_trades_cached(db, today)
        if from_cache:
            _prof_add(prof, "guard_trades_cache_ms", t0)
            if prof is not None:
                prof["guard_trades_hit"] = float(prof.get("guard_trades_hit", 0) or 0) + 1.0
        else:
            if prof is not None:
                prof["guard_trades_db_ms"] = float(prof.get("guard_trades_db_ms", 0) or 0) + float(db_ms or 0)
                prof["guard_trades_n"] = float(prof.get("guard_trades_n", 0) or 0) + 1.0
        sid = (strategy_id or "").upper()
        return len([
            t for t in trades
            if t.get("code") == code
            and str(t.get("strategy", "")).upper().startswith(sid)
        ])
    except Exception:
        return 0

def live_daily_entry_count(
    db: Any,
    code: str,
    today: str,
    strategy_id: str,
    *,
    prof: Optional[Dict[str, float]] = None,
) -> int:
    try:
        t0 = time.perf_counter()
        trades = db.get_trades_by_date(today) or []
        _prof_add(prof, "guard_trades_db_ms", t0)
        if prof is not None:
            prof["guard_trades_n"] = float(prof.get("guard_trades_n", 0) or 0) + 1.0
        sid = (strategy_id or "").upper()
        return len([
            t for t in trades
            if t.get("code") == code
            and str(t.get("strategy", "")).upper().startswith(sid)
        ])
    except Exception:
        return 0

# 총한도·일1회 base 가드 스킵 (전략 자체 구현)
_PORTFOLIO_BUDGET_SKIP_DEFAULT = frozenset({"UPDOW"})

# 유니버스 슬롯 history — RAM TTL (정합 ON 유지, 루프마다 SELECT 금지)
_SLOT_UNIV_LOCK = threading.Lock()
# key=(sid, src, slot_key) → (codes|None, ts)  None codes = 스냅샷 없음(필터 생략)
_SLOT_UNIV_CACHE: Dict[Tuple[str, str, str], Tuple[Optional[Set[str]], float]] = {}

def _slot_univ_ttl_sec() -> float:
    try:
        return float(get_env_float("UNIVERSE_SLOT_CACHE_TTL_SEC", 1.0) or 1.0)
    except Exception:
        return 1.0

def invalidate_universe_slot_cache(strategy_id: Optional[str] = None) -> None:
    """슬롯 캐시 무효화. strategy_id 없으면 전체."""
    sid = (strategy_id or "").upper()
    with _SLOT_UNIV_LOCK:
        if not sid:
            _SLOT_UNIV_CACHE.clear()
            return
        for k in list(_SLOT_UNIV_CACHE.keys()):
            if k[0] == sid:
                del _SLOT_UNIV_CACHE[k]

def portfolio_strategy_key(strategy_id: str) -> str:

# 총한도·일1회 base 가드 스킵 (전략 자체 구현)
_PORTFOLIO_BUDGET_SKIP_DEFAULT = frozenset({"UPDOW"})

def portfolio_strategy_key(strategy_id: str) -> str:


from __future__ import annotations

import threading
import time
from datetime import datetime as dt
from typing import Any, Dict, List, Optional, Set, Tuple

from ..backtest.backtest_portfolio_common import (
    STRATEGY_PORTFOLIO_KEYS,
    min_invest_ratio_of_slot,
    portfolio_exposure_krw,
    resolve_portfolio_params,
    target_qty_and_cost,
)
from ..utils.env import get_env_bool, get_env_float, get_env_from_db, get_env_int
from ..utils.position_sizing import invest_qty_for_price

# 자체 유니버스 — 기본 슬롯 필터 OFF (env 로 켤 수 있음)
_UNIVERSE_SLOT_SKIP_DEFAULT = frozenset({"UPDOW", "DBBAND"})

# 총한도·일1회 base 가드 스킵 (전략 자체 구현)
_PORTFOLIO_BUDGET_SKIP_DEFAULT = frozenset({"UPDOW"})

# 유니버스 슬롯 history — RAM TTL (정합 ON 유지 · 루프마다 SELECT 금지)
_SLOT_UNIV_LOCK = threading.Lock()
# key=(sid, src, slot_key) → (codes|None, ts). codes=None → 스냅샷 없음(필터 생략)
_SLOT_UNIV_CACHE: Dict[Tuple[str, str, str], Tuple[Optional[Set[str]], float]] = {}

def _slot_univ_ttl_sec() -> float:
    try:
        return float(get_env_float("UNIVERSE_SLOT_CACHE_TTL_SEC", 1.0) or 1.0)
    except Exception:
        return 1.0

def invalidate_universe_slot_cache(strategy_id: Optional[str] = None) -> None:
    """슬롯 캐시 무효화. strategy_id 없으면 전체."""
    sid = (strategy_id or "").upper()
    with _SLOT_UNIV_LOCK:
        if not sid:
            _SLOT_UNIV_CACHE.clear()
            return
        for k in list(_SLOT_UNIV_CACHE.keys()):
            if k[0] == sid:
                del _SLOT_UNIV_CACHE[k]

def _prof_add(prof: Optional[Dict[str, float]], key: str, t0: float) -> None:

from __future__ import annotations

import time
from datetime import datetime as dt
from typing import Any, Dict, List, Optional, Set, Tuple

from ..backtest.backtest_portfolio_common import (
    STRATEGY_PORTFOLIO_KEYS,
    min_invest_ratio_of_slot,
    portfolio_exposure_krw,
    resolve_portfolio_params,
    target_qty_and_cost,
)
from ..utils.env import get_env_bool, get_env_from_db, get_env_int
from ..utils.position_sizing import invest_qty_for_price

# 자체 유니버스 — 기본 슬롯 필터 OFF (env 로 켤 수 있음)
_UNIVERSE_SLOT_SKIP_DEFAULT = frozenset({"UPDOW", "DBBAND"})

# 총한도·일1회 base 가드 스킵 (전략 자체 구현)
_PORTFOLIO_BUDGET_SKIP_DEFAULT = frozenset({"UPDOW"})

def _prof_add(prof: Optional[Dict[str, float]], key: str, t0: float) -> None:

def history_universe_codes_at(
    db: Any,
    strategy_id: str,
    when: Optional[dt] = None,
    *,
    history_source: str = "kiwoom",
    universe_source: Optional[str] = None,
    prof: Optional[Dict[str, float]] = None,
) -> Optional[Set[str]]:
    """
    이력 스냅샷 — ``at_time`` 이전 최신 event_time 종목 집합.
    - kiwoom → ``target_candidates_history``
    - ls → ``ls_candidates_history``
    스냅샷 없으면 ``None`` (필터 생략 = 실시간 후보 유지).

    **당일 스냅샷만** 사용한다. 주말 재시작·키움 매니저 다운 뒤 남은
    며칠 전 history 와 sticky 후보를 교집합하면 전원 탈락(11→0) 한다.

    RAM TTL(``UNIVERSE_SLOT_CACHE_TTL_SEC`` 기본 1초): 같은 슬롯키면 DB 재조회 생략.
    슬롯키가 바뀌거나 TTL 만료 시에만 DB → RAM 갱신 (정합 필터 ON 유지).
    """
    when = when or dt.now()
    at_time = when.strftime("%Y-%m-%d %H:%M:%S")
    sid = (strategy_id or "").upper()
    src = str(history_source or "").strip().lower()
    if universe_source is not None:
        src = resolve_live_universe_history_source(
            sid, universe_source=universe_source,
        )
    elif src in ("", "auto"):
        src = resolve_live_universe_history_source(sid, universe_source=None)
    if src not in ("ls", "kiwoom"):
        src = "kiwoom"

    iv = resolve_live_scan_interval_min(sid)
    slot_key = slot_key_from_dt(when, iv)
    cache_key = (sid, src, slot_key)
    ttl = _slot_univ_ttl_sec()
    now_ts = time.time()
    with _SLOT_UNIV_LOCK:
        hit = _SLOT_UNIV_CACHE.get(cache_key)
        if hit is not None:
            codes_cached, ts = hit
            if ttl <= 0 or (now_ts - ts) < ttl:
                if prof is not None:
                    prof["slot_cache_hit"] = float(prof.get("slot_cache_hit", 0) or 0) + 1.0
                if codes_cached is None:
                    if prof is not None:
                        prof["slot_miss"] = float(prof.get("slot_miss", 0) or 0) + 1.0
                    return None
                if prof is not None:
                    prof["slot_ok"] = float(prof.get("slot_ok", 0) or 0) + 1.0
                    prof["slot_codes"] = float(len(codes_cached))
                return set(codes_cached)

    # 당일(캘린더) 스냅샷만 슬롯정합에 쓴다 — 낡은 history 전멸 방지
    t_et = time.perf_counter()
    et = _history_snapshot_event_time(db, sid, at_time, src)
    _prof_add(prof, "slot_et_ms", t_et)
    if not et:
        with _SLOT_UNIV_LOCK:
            _SLOT_UNIV_CACHE[cache_key] = (None, time.time())
        if prof is not None:
            prof["slot_miss"] = float(prof.get("slot_miss", 0) or 0) + 1.0
            prof["slot_cache_miss"] = float(prof.get("slot_cache_miss", 0) or 0) + 1.0
        return None
    if _event_time_ymd(et) != when.strftime("%Y-%m-%d"):
        with _SLOT_UNIV_LOCK:
            _SLOT_UNIV_CACHE[cache_key] = (None, time.time())
        if prof is not None:
            prof["slot_miss"] = float(prof.get("slot_miss", 0) or 0) + 1.0
            prof["slot_cache_miss"] = float(prof.get("slot_cache_miss", 0) or 0) + 1.0
        return None

    getter = getattr(db, "get_universe_at", None)
    if getter is None:
        with _SLOT_UNIV_LOCK:
            _SLOT_UNIV_CACHE[cache_key] = (None, time.time())
        if prof is not None:
            prof["slot_miss"] = float(prof.get("slot_miss", 0) or 0) + 1.0
            prof["slot_cache_miss"] = float(prof.get("slot_cache_miss", 0) or 0) + 1.0
        return None
    try:
        t_get = time.perf_counter()
        # TradeDBExt 는 history_source 지원. FakeDB 등 구시그니처는 무시.
        try:
            rows = getter(
                strategy_id=sid, at_time=at_time, history_source=src,
            ) or []
        except TypeError:
            rows = getter(strategy_id=sid, at_time=at_time) or []
        _prof_add(prof, "slot_get_ms", t_get)
    except Exception:
        if prof is not None:
            prof["slot_miss"] = float(prof.get("slot_miss", 0) or 0) + 1.0
            prof["slot_cache_miss"] = float(prof.get("slot_cache_miss", 0) or 0) + 1.0
        return None
    if not rows:
        with _SLOT_UNIV_LOCK:
            _SLOT_UNIV_CACHE[cache_key] = (None, time.time())
        if prof is not None:
            prof["slot_miss"] = float(prof.get("slot_miss", 0) or 0) + 1.0
            prof["slot_cache_miss"] = float(prof.get("slot_cache_miss", 0) or 0) + 1.0
        return None
    codes: Set[str] = set()
    for r in rows:
        code = str(r.get("code") or "").strip()
        if code:
            codes.add(code)
    store: Optional[Set[str]] = set(codes) if codes else None
    with _SLOT_UNIV_LOCK:
        _SLOT_UNIV_CACHE[cache_key] = (store, time.time())
    if prof is not None:
        prof["slot_cache_miss"] = float(prof.get("slot_cache_miss", 0) or 0) + 1.0
        if codes:
            prof["slot_ok"] = float(prof.get("slot_ok", 0) or 0) + 1.0
            prof["slot_codes"] = float(len(codes))
        else:
            prof["slot_miss"] = float(prof.get("slot_miss", 0) or 0) + 1.0
    return codes if codes else None

def history_universe_codes_at(
    db: Any,
    strategy_id: str,
    when: Optional[dt] = None,
    *,
    history_source: str = "kiwoom",
    universe_source: Optional[str] = None,
    prof: Optional[Dict[str, float]] = None,
) -> Optional[Set[str]]:
    """
    이력 스냅샷 — ``at_time`` 이전 최신 event_time 종목 집합.
    - kiwoom → ``target_candidates_history``
    - ls → ``ls_candidates_history``
    스냅샷 없으면 ``None`` (필터 생략 = 실시간 후보 유지).

    **당일 스냅샷만** 사용한다. 주말 재시작·키움 매니저 다운 뒤 남은
    며칠 전 history 와 sticky 후보를 교집합하면 전원 탈락(11→0) 한다.
    """
    when = when or dt.now()
    at_time = when.strftime("%Y-%m-%d %H:%M:%S")
    sid = (strategy_id or "").upper()
    src = str(history_source or "").strip().lower()
    if universe_source is not None:
        src = resolve_live_universe_history_source(
            sid, universe_source=universe_source,
        )
    elif src in ("", "auto"):
        src = resolve_live_universe_history_source(sid, universe_source=None)
    if src not in ("ls", "kiwoom"):
        src = "kiwoom"

    # 당일(캘린더) 스냅샷만 슬롯정합에 쓴다 — 낡은 history 전멸 방지
    t_et = time.perf_counter()
    et = _history_snapshot_event_time(db, sid, at_time, src)
    _prof_add(prof, "slot_et_ms", t_et)
    if not et:
        if prof is not None:
            prof["slot_miss"] = float(prof.get("slot_miss", 0) or 0) + 1.0
        return None
    if _event_time_ymd(et) != when.strftime("%Y-%m-%d"):
        if prof is not None:
            prof["slot_miss"] = float(prof.get("slot_miss", 0) or 0) + 1.0
        return None

    getter = getattr(db, "get_universe_at", None)
    if getter is None:
        if prof is not None:
            prof["slot_miss"] = float(prof.get("slot_miss", 0) or 0) + 1.0
        return None
    try:
        t_get = time.perf_counter()
        # TradeDBExt 는 history_source 지원. FakeDB 등 구시그니처는 무시.
        try:
            rows = getter(
                strategy_id=sid, at_time=at_time, history_source=src,
            ) or []
        except TypeError:
            rows = getter(strategy_id=sid, at_time=at_time) or []
        _prof_add(prof, "slot_get_ms", t_get)
    except Exception:
        if prof is not None:
            prof["slot_miss"] = float(prof.get("slot_miss", 0) or 0) + 1.0
        return None
    if not rows:
        if prof is not None:
            prof["slot_miss"] = float(prof.get("slot_miss", 0) or 0) + 1.0
        return None
    codes: Set[str] = set()
    for r in rows:
        code = str(r.get("code") or "").strip()
        if code:
            codes.add(code)
    if prof is not None:
        if codes:
            prof["slot_ok"] = float(prof.get("slot_ok", 0) or 0) + 1.0
            prof["slot_codes"] = float(len(codes))
        else:
            prof["slot_miss"] = float(prof.get("slot_miss", 0) or 0) + 1.0
    return codes if codes else None


                self._prof_slot_et_ms = float((slot_prof or {}).get("slot_et_ms", 0) or 0)
                self._prof_slot_get_ms = float((slot_prof or {}).get("slot_get_ms", 0) or 0)
                self._prof_slot_ok = int((slot_prof or {}).get("slot_ok", 0) or 0)
                self._prof_slot_miss = int((slot_prof or {}).get("slot_miss", 0) or 0)
                self._prof_slot_cache_hit = int((slot_prof or {}).get("slot_cache_hit", 0) or 0)
                self._prof_slot_cache_miss = int((slot_prof or {}).get("slot_cache_miss", 0) or 0)

                self._prof_slot_et_ms = float((slot_prof or {}).get("slot_et_ms", 0) or 0)
                self._prof_slot_get_ms = float((slot_prof or {}).get("slot_get_ms", 0) or 0)
                self._prof_slot_ok = int((slot_prof or {}).get("slot_ok", 0) or 0)
                self._prof_slot_miss = int((slot_prof or {}).get("slot_miss", 0) or 0)

                self._prof_slot_et_ms = 0.0
                self._prof_slot_get_ms = 0.0
                self._prof_slot_ok = 0
                self._prof_slot_miss = 0
                self._prof_slot_cache_hit = 0
                self._prof_slot_cache_miss = 0
                self._prof_guard_acc = {}
                self.reload_config()

                self._prof_slot_et_ms = 0.0
                self._prof_slot_get_ms = 0.0
                self._prof_slot_ok = 0
                self._prof_slot_miss = 0
                self._prof_guard_acc = {}
                self.reload_config()

            f"slot_et={row.get('slot_et_ms', 0):.1f}",
            f"slot_get={row.get('slot_get_ms', 0):.1f}",
            f"slot_ok={row.get('slot_ok', 0)}",
            f"slot_miss={row.get('slot_miss', 0)}",
            f"slot_hit={row.get('slot_cache_hit', 0)}",
            f"slot_missc={row.get('slot_cache_miss', 0)}",
            f"g_hit={row.get('guard_trades_hit', 0)}",
            f"overlay={row.get('overlay_ms', 0):.1f}",

            f"slot_et={row.get('slot_et_ms', 0):.1f}",
            f"slot_get={row.get('slot_get_ms', 0):.1f}",
            f"slot_ok={row.get('slot_ok', 0)}",
            f"slot_miss={row.get('slot_miss', 0)}",
            f"overlay={row.get('overlay_ms', 0):.1f}",

                    row["slot_et_ms"] = float(getattr(self, "_prof_slot_et_ms", 0) or 0)
                    row["slot_get_ms"] = float(getattr(self, "_prof_slot_get_ms", 0) or 0)
                    row["slot_ok"] = int(getattr(self, "_prof_slot_ok", 0) or 0)
                    row["slot_miss"] = int(getattr(self, "_prof_slot_miss", 0) or 0)
                    row["slot_cache_hit"] = int(getattr(self, "_prof_slot_cache_hit", 0) or 0)
                    row["slot_cache_miss"] = int(getattr(self, "_prof_slot_cache_miss", 0) or 0)
                    row["guard_trades_hit"] = int(sc.get("guard_trades_hit", 0) or 0)
                    row["trd_hit"] = int(self._prof_trd_hit)
                    row["trd_miss"] = int(self._prof_trd_miss)
                    row["trd_db_ms"] = float(self._prof_trd_db_ms)

                    row["slot_et_ms"] = float(getattr(self, "_prof_slot_et_ms", 0) or 0)
                    row["slot_get_ms"] = float(getattr(self, "_prof_slot_get_ms", 0) or 0)
                    row["slot_ok"] = int(getattr(self, "_prof_slot_ok", 0) or 0)
                    row["slot_miss"] = int(getattr(self, "_prof_slot_miss", 0) or 0)
                    row["trd_hit"] = int(self._prof_trd_hit)
                    row["trd_miss"] = int(self._prof_trd_miss)
                    row["trd_db_ms"] = float(self._prof_trd_db_ms)

                "guard_env_ms": float(g.get("guard_env_ms", 0) or 0),
                "guard_daily_ms": float(g.get("guard_daily_ms", 0) or 0),
                "guard_trades_db_ms": float(g.get("guard_trades_db_ms", 0) or 0),
                "guard_trades_n": int(g.get("guard_trades_n", 0) or 0),
                "guard_trades_hit": int(g.get("guard_trades_hit", 0) or 0),
                "guard_budget_ms": float(g.get("guard_budget_ms", 0) or 0),
            }

                "guard_env_ms": float(g.get("guard_env_ms", 0) or 0),
                "guard_daily_ms": float(g.get("guard_daily_ms", 0) or 0),
                "guard_trades_db_ms": float(g.get("guard_trades_db_ms", 0) or 0),
                "guard_trades_n": int(g.get("guard_trades_n", 0) or 0),
                "guard_budget_ms": float(g.get("guard_budget_ms", 0) or 0),
            }

            self._capture_runtime_overlay()
            try:
                from ..utils.today_trades_cache import invalidate_today_trades_cache
                invalidate_today_trades_cache()
            except Exception:
                pass
        elif result.success and signal.get("use_limit_buy"):

            self._capture_runtime_overlay()
        elif result.success and signal.get("use_limit_buy"):


    "HOLDINGS_DB_SYNC_INTERVAL_SEC",
    # 당일 trade_history / 유니버스슬롯 history — RAM TTL(초). 0=캐시 끔(매번 DB)
    "TODAY_TRADES_CACHE_TTL_SEC",
    "UNIVERSE_SLOT_CACHE_TTL_SEC",
    # 틱매도 — WS 현재가 갱신 시 매도 검사 (기본 false · 루프 매도 유지 + 틱 보강)

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

            _f("HOLDINGS_DB_SYNC_INTERVAL_SEC", "보유 DB sync 안전망(초)", "int", default=60,
               hint="루프마다 get_active_trades 대신 N초마다만 · 매수/매도 체결은 RAM 즉시 · "
                    "0=매루프(레거시) · 재시작·지정가체결은 강제 sync"),
            _f("TODAY_TRADES_CACHE_TTL_SEC", "당일거래 RAM TTL(초)", "float", default=1.0,
               hint="check_buy·일일한도 가드 공유 · N초 내 DB 재조회 생략 · 체결 시 즉시 무효화 · 0=매번 DB"),
            _f("UNIVERSE_SLOT_CACHE_TTL_SEC", "유니버스슬롯 RAM TTL(초)", "float", default=1.0,
               hint="history∩후보 정합 ON 유지 · 같은 슬롯키면 N초 RAM · 슬롯 바뀌면 즉시 DB 재조회"),
            _f("TICK_SELL_ENABLED", "틱매도(공통)", "bool", default=False,

            _f("HOLDINGS_DB_SYNC_INTERVAL_SEC", "보유 DB sync 안전망(초)", "int", default=60,
               hint="루프마다 get_active_trades 대신 N초마다만 · 매수/매도 체결은 RAM 즉시 · "
                    "0=매루프(레거시) · 재시작·지정가체결은 강제 sync"),
            _f("TICK_SELL_ENABLED", "틱매도(공통)", "bool", default=False,


            self.db.close_trade(
                code=req.code,
                sell_price=sell_price,
                sell_reason=req.reason or "",
                strategy=req.strategy_id,
                realized_pnl_override=realized_pnl,
                sell_qty=int(delta_qty),
            )
            try:
                from kis_trader.utils.today_trades_cache import invalidate_today_trades_cache
                invalidate_today_trades_cache()
            except Exception:
                pass
            # 매수~매도 구간 1분봉 REST 백필 (백테 봉구멍·슬롯 좀비 방지) — 비동기 1회
            # 부분매도면 포지션 잔존 → 전량 청산 시에만 백필
            remain_after = self._active_qty(req.strategy_id, req.code)

            self.db.close_trade(
                code=req.code,
                sell_price=sell_price,
                sell_reason=req.reason or "",
                strategy=req.strategy_id,
                realized_pnl_override=realized_pnl,
                sell_qty=int(delta_qty),
            )
            # 매수~매도 구간 1분봉 REST 백필 (백테 봉구멍·슬롯 좀비 방지) — 비동기 1회
            # 부분매도면 포지션 잔존 → 전량 청산 시에만 백필
            remain_after = self._active_qty(req.strategy_id, req.code)

Shell

cd /home/hoon/kis_bot && python3 -m py_compile kis_trader/utils/today_trades_cache.py kis_trader/utils/live_portfolio_common.py kis_trader/strategies/base.py kis_trader/execution/order_manager.py && python3 <<'PY'
from kis_trader.backtest.param_search_apply_snapshot import apply_env_patch
from kis_trader.utils.env import get_env_from_db, invalidate_merged_env_cache
r = apply_env_patch({
    "TODAY_TRADES_CACHE_TTL_SEC": "1",
    "UNIVERSE_SLOT_CACHE_TTL_SEC": "1",
})
invalidate_merged_env_cache()
print("apply", r)
print("trades_ttl", get_env_from_db("TODAY_TRADES_CACHE_TTL_SEC", ""))
print("slot_ttl", get_env_from_db("UNIVERSE_SLOT_CACHE_TTL_SEC", ""))
PY
sudo systemctl restart kis_trader_main.service
sleep 4
systemctl is-active kis_trader_main.service
: > logs/loop_profile.log
sleep 20
python3 <<'PY'
from collections import defaultdict
import re
from datetime import datetime
lines=open('logs/loop_profile.log',encoding='utf-8',errors='replace').read().splitlines()
print('lines', len(lines), 'slot_hit=', sum(1 for l in lines if 'slot_hit=' in l))
rows=[]
for ln in lines:
    if 'slot_hit=' not in ln: continue
    m=re.search(r'\[LOOP_PROF\]\s+(\S+)\s+(.*)', ln)
    if not m: continue
    sid, rest=m.group(1), m.group(2)
    d={'ts':ln[:19],'sid':sid}
    for k in ['total','cand_slot','slot_et','slot_get','slot_hit','slot_missc','pre_guard','g_trdb','g_tr_n','g_hit','g_daily']:
        mm=re.search(rf'(?:^|\s){k}=([0-9.]+)', rest)
        if mm: d[k]=float(mm.group(1))
    rows.append(d)
by=defaultdict(list)
for r in rows: by[r['sid']].append(r)
print('\n=== 캐시 적용 후 요약 ===')
for sid, rs in sorted(by.items()):
    print(f'--- {sid} n={len(rs)} ---')
    for k in ['total','cand_slot','slot_et','slot_get','slot_hit','slot_missc','pre_guard','g_trdb','g_tr_n','g_hit']:
        vs=[r[k] for r in rs if k in r]
        if not vs: continue
        print(f'  {k:12s} mean={sum(vs)/len(vs):7.1f} p50={sorted(vs)[len(vs)//2]:7.1f} max={max(vs):7.1f}')
print('\n=== 5초 시간열 SCALP/SHORT ===')
print(f"{'time':8s} {'sid':10s} {'n':>3} {'total':>7} {'slot':>6} {'et':>5} {'get':>5} {'shit':>4} {'guard':>6} {'g_trdb':>7} {'g_n':>3} {'g_hit':>4}")
buckets=defaultdict(list)
for r in rows:
    try:
        t=datetime.strptime(r['ts'], '%Y-%m-%d %H:%M:%S')
        b=t.replace(second=(t.second//5)*5, microsecond=0)
    except Exception:
        continue
    buckets[(r['sid'], b)].append(r)
for sid in ['SCALP','SHORT','BREAKOUT']:
    items=sorted([(b,rs) for (s,b),rs in buckets.items() if s==sid])[-6:]
    for b, rs in items:
        def avg(k):
            vs=[x[k] for x in rs if k in x]
            return sum(vs)/len(vs) if vs else 0
        print(f"{b.strftime('%H:%M:%S')} {sid:10s} {len(rs):3d} {avg('total'):7.1f} {avg('cand_slot'):6.1f} {avg('slot_et'):5.1f} {avg('slot_get'):5.1f} {avg('slot_hit'):4.0f} {avg('pre_guard'):6.1f} {avg('g_trdb'):7.1f} {avg('g_tr_n'):3.0f} {avg('g_hit'):4.0f}")
PY

Compile, apply TTL=1, restart, sample metrics


네. **TTL = RAM에 잠깐 두는 것**이 맞습니다.
“바뀌면/1초 지나면 **DB에서 다시 읽어서 RAM을 갱신**”이지, DB에 쓰는 게 아닙니다.

### 적용 내용
| 항목 | 동작 |
|------|------|
| `TODAY_TRADES_CACHE_TTL_SEC=1` | 당일거래 공유 RAM · 가드도 같은 캐시 |
| `UNIVERSE_SLOT_CACHE_TTL_SEC=1` | 슬롯 정합 **ON 유지** · 같은 슬롯키면 1초 RAM |
| 슬롯 키 변경(분 넘어감) | 즉시 DB 재조회 |
| 매수/매도 체결 | 당일거래 캐시 즉시 무효화 |

### 재시작 직후 샘플 (체감)
- BREAKOUT: `pre_guard` **~0.4ms**, `g_hit=12`, `g_trdb=0` (예전 수 초 → 사실상 제거)
- `total` **~0.5s**대 (예전 4~5s 스파이크 대비)
- `slot_hit=1`이면 그 바퀴는 history SELECT 스킵

로그: `logs/loop_profile.log` (`g_hit` / `slot_hit` 확인)


<timestamp>Thursday, Aug 13, 2026, 2:27 PM (UTC+9)</timestamp>
<user_query>
	후보/구독 sticky	TTL 캐시(이번에 말하던 것)
뭘 아끼나
조건검색·history 쓰기/재구독
읽기 SELECT 연타
조건
종목 집합이 안 바뀜
시간이 N초 안
비유
“문 앞에 선 애들 명단이 같으면 다시 안 적는다”
“방금 본 성적표를 N초간 다시 안 펼친다”

이거 보면 후보쪽도 ttl 로 해야되지않아? 초단위로 아니다 이해 못해서
뭐가 좋은건지 모르겠다  ttl도 1초마다 바뀌었는지 확인하는거고 후보쪽도 바뀌었는지 확인하는데 이건 뭐 몇초단위가 아니라니 뭔 말이지? 그럼 뭐바뀐걸 언제 알아? 이건 초마다 체크할 필요가 없는건가 구독이랑 그쪽에서 줄때만 비교하면 되서 그런가 이게 맞다면 방금 고친건 뭐야?
</user_query>


핵심만 정리하면 이렇게입니다.

### 두 개는 “언제 다시 보나”가 다름

| | **후보/구독 sticky** | **방금 고친 TTL (당일거래·슬롯 history)** |
|--|----------------------|------------------------------------------|
| 무엇을 | “지금 볼 **종목 명단**” · WS 구독 | “**이미 DB에 있는 표**를 또 읽기” |
| 새 정보가 오는 방식 | 조건검색 **ENTER/EXIT 이벤트** (키움 WS 등) | 우리가 **루프마다** “표 줘” 하고 있었음 |
| 바뀐 걸 아는 시점 | **이벤트가 올 때** (몇 초마다 폴링이 아님) | **1초가 지났거나**, 매매 체결로 캐시 비울 때 |
| 왜 초마다 안 보나 | 구독/조건이 “줄 때”만 명단이 바뀜 → **그때 비교하면 됨** | 루프는 초당 여러 번 도는데, 표는 1초에 한 번만 다시 읽으면 충분 |

그래서
- sticky = “**푸시(이벤트) 기반** — 바뀌면 알려줌”
- TTL = “**폴링 기반** — 우리가 너무 자주 물어봐서, N초 안에 또 묻지 말자”

“후보도 TTL로?” → **후보 명단 자체는 sticky/이벤트면 충분**하고, TTL이 필요한 쪽은 “매 루프 DB SELECT”였던 **읽기**입니다.

---

### “바뀐 걸 언제 알아?” (후보)
조건/구독이 **ENTER·EXIT를 줄 때** 바로 알아요.
초마다 “명단 달라졌나?”를 DB에 물을 필요가 없습니다.
(그래서 “몇 초 단위가 아니다” = **타이머로 검사하는 구조가 아니다**는 뜻.)

---

### 방금 고친 건 뭐야?
후보 sticky를 새로 만든 게 **아닙니다.**

고친 것:
1. **일일한도 가드** — 후보마다 `get_trades_by_date` 치던 뒷문 → **공유 RAM + 1초 TTL** (check_buy와 같은 표)
2. **슬롯 history** — 정합 **ON 유지**, 같은 슬롯이면 **1초 안 DB 재조회 생략**

후보 명단 sticky는 원래 이벤트 쪽이고,
방금은 **“성적표를 루프마다 펼치던 습관”을 줄인 것**입니다.

---

### 한 줄
- 후보/구독: **바뀌면 알려주니까** 초마다 체크 불필요.
- TTL: **우리가 초마다(루프마다) DB를 물어봐서** 1초 동안은 RAM으로 막은 것.
둘 다 “불필요한 DB”를 아끼지만, **감지 방식이 이벤트 vs 시간**이라 다른 도구입니다.


<timestamp>Thursday, Aug 13, 2026, 2:29 PM (UTC+9)</timestamp>
<user_query>
아 내가 이해한게 맞는거네 그럼 이제 또 딜레이 있는곳이 또 뭐지? 초등생수준으로 함수 뭐하는애인지 몇초걸리는지 중요한지
</user_query>


캐시 적용 후 최신 로그로 남은 병목을 확인합니다.

cd /home/hoon/kis_bot && python3 <<'PY'
from collections import defaultdict
import re
from datetime import datetime, timedelta
path='logs/loop_profile.log'
lines=open(path,encoding='utf-8',errors='replace').read().splitlines()
# only after TTL fix ~14:26
rows=[]
keys=['total','reload','sync_hold','sync_skip','cand_load','cand_mgr','cand_slot','slot_et','slot_get','slot_hit','ws_sync','scan','scan_pre','pre_guard','g_trdb','g_hit','pre_gap','buy_sum','sleep_rej','lock_hold','sell_chk','pending']
for ln in lines[-8000:]:
    if '[LOOP_PROF]' not in ln: continue
    try:
        t=datetime.strptime(ln[:19], '%Y-%m-%d %H:%M:%S')
    except Exception:
        continue
    if t < datetime(2026,8,13,14,26,0):
        continue
    m=re.search(r'\[LOOP_PROF\]\s+(\S+)\s+(.*)', ln)
    if not m: continue
    sid, rest=m.group(1), m.group(2)
    d={'sid':sid,'ts':t}
    for k in keys:
        mm=re.search(rf'(?:^|\s){k}=([0-9.]+)', rest)
        if mm: d[k]=float(mm.group(1))
    rows.append(d)
print('rows', len(rows), 'from', rows[0]['ts'] if rows else None, 'to', rows[-1]['ts'] if rows else None)
by=defaultdict(list)
for r in rows: by[r['sid']].append(r)
for sid, rs in sorted(by.items()):
    print(f'\n=== {sid} n={len(rs)} ===')
    for k in keys:
        vs=[r[k] for r in rs if k in r]
        if not vs: continue
        p50=sorted(vs)[len(vs)//2]
        p90=sorted(vs)[int(len(vs)*0.9)]
        if p50<0.5 and p90<5 and max(vs)<50 and k not in ('total','cand_slot','pre_guard','buy_sum','scan','cand_load','lock_hold'):
            continue  # skip noise
        print(f'  {k:12s} p50={p50:7.1f} p90={p90:7.1f} max={max(vs):7.1f} mean={sum(vs)/len(vs):7.1f}')
PY

Summarize post-TTL LOOP_PROF bottlenecks


맞아요, 이해하신 거 맞습니다.
**평소는 이제 꽤 빨라졌고**, 가끔만 느린 곳이 남았습니다.

---

### 한 바퀴가 하는 일 (초등 버전)

전략 쓰레드가 **한 바퀴** 돌 때:

1. **설정 다시 읽기** (`reload`) — “손절 % 바뀌었나?” DB/캐시 확인
2. **보유 종목** (`sync`) — 60초에 한 번만 DB, 평소 RAM
3. **후보 명단** (`cand`) — 조건검색 sticky 명단 + (정합 ON이면) history 교집합
4. **매도 볼까** (`sell_chk`) — 들고 있는 애 손절/익절
5. **매수 볼까** (`scan` → `check_buy`) — 후보마다 “사도 돼?”
6. **잠깐 쉼** (`sleep`) — API/루프 숨 고르기

---

### 지금 시간 (TTL 적용 후, 14:27~)

| 이름 | 뭐 하는 애 | 평소(p50) | 가끔(max) | 중요? |
|------|------------|-----------|-----------|--------|
| **total** | 한 바퀴 전체 | SCALP ~**60ms**, BREAKOUT ~**26ms** | 가끔 1~3초 | 체감 속도 |
| **reload** | 설정 핫리로드 | 10~30ms | ~70ms | 보통 · 낮출 여지 있음 |
| **cand_slot / slot_et·get** | 백테랑 후보 맞추기(history) | **0ms** (`slot_hit=1`) | TTL 만료·락 때 **1~2초** | 정합 **필수 ON** · 가끔만 아픔 |
| **pre_guard / g_trdb** | 오늘 몇 번 샀나(일일한도) | **~0**, `g_hit`로 RAM | 캐시 미스·락 때 수백 ms~1초 | 안전장치 · 평소 해결됨 |
| **buy_sum** (`check_buy`) | 진짜 매수 공식 | 수 ms | ~50ms | **중요** · 이미 빠름 |
| **sell_chk** | 매도/손절 | SCALP ~15ms | ~100ms | **중요** · 아직 OK |
| **sleep_rej** | 탈락 후 일부러 쉼 | 수 ms | — | 의도된 대기 |
| **sync_hold** | 보유 DB sync | 평소 0 | 60초마다만 | 이미 하이브리드 |

**갭 강제확정 / 해외** — 한 바퀴 `total`의 주범 아님.

---

### “또 딜레이 있는 곳” 한 줄

1. **평소**: 큰 병목 거의 없음 (수십 ms).
2. **가끔**: `cand_slot`(1초마다 DB 다시 읽을 때 + 다른 전략과 DB 줄서기) · 드물게 `g_trdb` 미스.
3. **그다음 후보(덜 급함)**: `reload`를 더 줄이기, 슬롯을 “초” 말고 **슬롯 키가 바뀔 때만** DB(더 sticky에 가깝게).

매수 공식·손절이 밀리는 구조는 아니고, **DB 줄 서는 순간**만 한 바퀴가 길어집니다.


<timestamp>Thursday, Aug 13, 2026, 2:33 PM (UTC+9)</timestamp>
<user_query>
그다음 후보(덜 급함): reload를 더 줄이기, 슬롯을 “초” 말고 슬롯 키가 바뀔 때만 DB(더 sticky에 가깝게).
이게 줄어들수가 잇나 어차피 디비조회는 한번 해야되잖아

가끔: cand_slot(1초마다 DB 다시 읽을 때 + 다른 전략과 DB 줄서기) · 드물게 g_trdb 미스.
이것도 더 줄일수가 없잖아 디비조회 한번해야되는데

</user_query>