`get_trades_by_date` 용도부터 코드로 확인한 뒤, SHORT `reload`를 단계별로 계측해 14초가 어디서 나오는지 잡겠습니다.

cd /home/hoon/kis_bot && rg -n "def reload_config" kis_trader/strategies/tail_catch.py kis_trader/strategies/base.py && rg -n "reload_config\(|_engine_params|get_tail_defaults" kis_trader/strategies/tail_catch.py | head -40

Locate SHORT reload_config and usages


원인 구조는 파악됐습니다. SHORT `reload`를 단계별로 찍고, `get_trades_by_date`가 왜 매 종목마다 불리는지도 같이 설명하겠습니다.

StrReplace

    def reload_config(self) -> None:
        # 루프마다 호출 — 병목 시 [RELOAD_PROF] 로 단계 ms 기록
        _rp_t0 = time.perf_counter()
        _rp_last = _rp_t0
        _rp: Dict[str, float] = {}

        def _rp_mark(stage: str) -> None:
            nonlocal _rp_last
            now = time.perf_counter()
            _rp[stage] = (now - _rp_last) * 1000.0
            _rp_last = now

        self.min_price = get_env_float("MIN_STOCK_PRICE", 1000.0)
        self.stop_loss_pct = get_env_float("STOP_LOSS_PCT", -0.04)
        self.take_profit_pct = get_env_float("TAKE_PROFIT_PCT", 0.05)
        self.slot_money = (
            get_env_int("TAIL_SLOT_MONEY", 0)
            or get_env_int("SLOT_MONEY_DEFAULT", 3_000_000)
        )
        _rp_mark("env_basic")
        if te is not None:
            try:
                # ※ get_tail_defaults_from_db → db.get_merged_env_snapshot() 직접
                #    (SCALP 는 get_strategy_env_dict RAM 캐시 경로 · SHORT/MOM 은 매 루프 DB)
                p = te.get_tail_defaults_from_db(self.db)
                _rp_mark("tail_defaults")
                p["live_backtest_align"] = get_env_bool(
                    "SHORT_LIVE_BACKTEST_ALIGN", True,
                )
                p["live_signal_lookback_bars"] = get_env_int(
                    "SHORT_LIVE_SIGNAL_LOOKBACK_BARS", 1,
                )
                p["entry_mode"] = short_entry_mode()
                self._engine_params = p
                _rp_mark("tail_flags")
            except Exception as e:
                self.logger.debug("tail_engine defaults 조회 실패: %s", e)
                _rp_mark("tail_err")
        self.eod_enabled = get_env_bool("TAIL_EOD_ENABLED", True)
        self.eod_hm = get_env_from_db("TAIL_EOD_HM", "15:20")
        _rp_mark("eod")

        _rp_total = (time.perf_counter() - _rp_t0) * 1000.0
        # 500ms 이상만 — 장중 수 초 reload 원인 확정용
        if _rp_total >= 500.0:
            parts = [f"[RELOAD_PROF] SHORT total={_rp_total:.1f}"]
            for k, v in sorted(_rp.items(), key=lambda x: -x[1]):
                parts.append(f"{k}={v:.1f}")
            line = " ".join(parts)
            try:
                self.logger.info("%s", line)
            except Exception:
                pass
            try:
                path = str(get_env_from_db("LOOP_PROFILE_LOG_PATH", "logs/loop_profile.log") or "").strip()
                if path:
                    import os
                    from datetime import datetime as _dt
                    if not os.path.isabs(path):
                        root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
                        path = os.path.join(root, path)
                    os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
                    with open(path, "a", encoding="utf-8") as f:
                        f.write(_dt.now().strftime("%Y-%m-%d %H:%M:%S.%f")[:-3] + " " + line + "\n")
            except Exception:
                pass

    def _candidate_filter(self, candidate: Dict) -> bool:

    def reload_config(self) -> None:
        self.min_price = get_env_float("MIN_STOCK_PRICE", 1000.0)
        self.stop_loss_pct = get_env_float("STOP_LOSS_PCT", -0.04)
        self.take_profit_pct = get_env_float("TAKE_PROFIT_PCT", 0.05)
        self.slot_money = (
            get_env_int("TAIL_SLOT_MONEY", 0)
            or get_env_int("SLOT_MONEY_DEFAULT", 3_000_000)
        )
        if te is not None:
            try:
                p = te.get_tail_defaults_from_db(self.db)
                p["live_backtest_align"] = get_env_bool(
                    "SHORT_LIVE_BACKTEST_ALIGN", True,
                )
                p["live_signal_lookback_bars"] = get_env_int(
                    "SHORT_LIVE_SIGNAL_LOOKBACK_BARS", 1,
                )
                p["entry_mode"] = short_entry_mode()
                self._engine_params = p
            except Exception as e:
                self.logger.debug("tail_engine defaults 조회 실패: %s", e)
        self.eod_enabled = get_env_bool("TAIL_EOD_ENABLED", True)
        self.eod_hm = get_env_from_db("TAIL_EOD_HM", "15:20")

    def _candidate_filter(self, candidate: Dict) -> bool:


Shell

cd /home/hoon/kis_bot && python3 -m py_compile kis_trader/strategies/tail_catch.py && sudo systemctl restart kis_trader_main.service && sleep 4 && systemctl is-active kis_trader_main.service && echo "=== reload-prof $(date -Iseconds) ===" >> logs/loop_profile.log && sleep 55 && rg "RELOAD_PROF|LOOP_PROF\] SHORT" logs/loop_profile.log | awk '/reload-prof/{p=1;next} p' | tail -n 25

Restart; collect SHORT RELOAD_PROF samples


rg "RELOAD_PROF|reload-prof" /home/hoon/kis_bot/logs/loop_profile.log | tail -n 30; echo '---'; journalctl -u kis_trader_main.service --since '2026-08-13 13:04:00' --no-pager 2>/dev/null | rg 'RELOAD_PROF' | tail -n 20; echo '---'; journalctl -u kis_trader_main.service --since '2026-08-13 13:04:00' --no-pager 2>/dev/null | rg 'LOOP_PROF\] SHORT' | tail -n 10

Fetch RELOAD_PROF and SHORT loop lines


sleep 45; rg "RELOAD_PROF" /home/hoon/kis_bot/logs/loop_profile.log | tail -n 15; journalctl -u kis_trader_main.service --since '2 min ago' --no-pager 2>/dev/null | rg 'RELOAD_PROF' | tail -n 15

Collect more SHORT RELOAD_PROF samples


### `get_trades_by_date`는 뭐 하는 애?
당일 `trade_history`를 통째로 가져와서, **그 종목 오늘 몇 번 매매했는지(`daily_cnt`)** · SHORT는 **당일 실현손익(`daily_pnl_krw`)** 까지 세려고 `check_buy`마다 호출합니다.

용도 자체는 맞습니다 (일일 횟수/손실 한도 게이트).
이상한 점은:

- **종목마다** 당일 전체 거래를 다시 SELECT
- 쿼리 단독은 ~0.6ms인데, 장중엔 DB 락 줄 서며 **~1초**로 보임

---

### SHORT reload 14초 — 계측 결과
`[RELOAD_PROF]` 샘플 (방금):

| total | 거의 전부 |
|-------|-----------|
| 8172ms | **`tail_defaults=8172`** |
| 9271ms | **`tail_defaults=9271`** |
| 9998ms | **`tail_defaults=9998`** |
| env_basic / eod / flags | **0.0~0.1ms** |

즉 reload 수 초 = **`get_tail_defaults_from_db()` → `db.get_merged_env_snapshot()` 한 방**입니다.
다른 단계는 무죄.

왜 이렇게 되어 있나:

- 웹에서 파라미터 바꾸면 루프마다 반영하려고 **매 바퀴 reload**
- SHORT/MOM은 그때 **캐시 없는 full snapshot**을 직접 호출
- SCALP만 `get_strategy_env_dict` (RAM TTL)라 reload가 ~10ms대
- 전략들이 같은 MariaDB 락을 두드리면, 한가할 때 30ms짜리가 **7~10초 wall-clock**로 팽창

피드 로그 사고와 **같은 계열**입니다. (핫패스에서 snapshot 직접 호출)

---

원하면 다음 수정은 (승인 후):
1. SHORT/MOM defaults도 SCALP처럼 **RAM 캐시 경로**
2. `get_trades_by_date`는 **루프당 1회 캐시** 후 종목만 필터


<timestamp>Thursday, Aug 13, 2026, 1:14 PM (UTC+9)</timestamp>
<user_query>
get_trades_by_date는 루프당 1회 캐시 후 종목만 필터 일일 거래를 왜 가져오는데 가져와서 뭐하는데? 매수체크에 필요해? 아 일일한도계산? 흠 이거 줄일 방법은 없나 하루짜리 보는데 뭐 얼마를 가져오길래 느리냐 하루치만 보면 되는걸
그리고
즉 reload 수 초 = get_tail_defaults_from_db() → db.get_merged_env_snapshot() 한 방입니다.
다른 단계는 무죄.

왜 이렇게 되어 있나:

웹에서 파라미터 바꾸면 루프마다 반영하려고 매 바퀴 reload
SHORT/MOM은 그때 캐시 없는 full snapshot을 직접 호출
SCALP만 get_strategy_env_dict (RAM TTL)라 reload가 ~10ms대
전략들이 같은 MariaDB 락을 두드리면, 한가할 때 30ms짜리가 7~10초 wall-clock로 팽창
이건 왜 꼬리랑 모맨텀만 이거고 스캘프는 또 ram이야? 근데 다시 읽어오려면 ram에선 최신값 못가져오지않나? 그리고 돌파는?
</user_query>


일일거래·reload 경로를 전략별로 바로 대조해, 왜 SCALP만 RAM인지·돌파는 어떤지·하루치인데 느린 이유를 정확히 답하겠습니다.


### 1) `get_trades_by_date` — 왜 가져오나?

맞습니다. **일일 한도용**입니다.

매수 체크에서 엔진에 넘기는 `state["daily_cnt"]` (SHORT는 `daily_pnl_krw`도)를 채우려고:

- SCALP/MOM/SHORT: 이 종목 **오늘 몇 번 돌렸나** ≥ `*_MAX_DAILY` 이면 탈락
- SHORT: 당일 실현손익으로 **재진입/손실예산** 게이트

신호(RSI·봉) 계산용이 아닙니다.

### 하루치만 보는데 왜 느리냐?

데이터량이 문제가 **아닙니다.**

| | 실제 |
|--|------|
| 오늘 건수 | **약 4건** |
| 단독 조회 | **~0.6ms** |
| 장중 `trades_db` | **~1초** |

하루 전체를 몇만 건 긁는 게 아니라, SHORT가 매 루프 `get_merged_env_snapshot`으로 DB 락을 붙잡고 있을 때 **줄 서서 대기**한 시간입니다.
줄이는 방법: 루프당 1회만 읽어서 RAM에 두고 종목 필터 / 또는 매수·매도 체결 시 `daily_cnt`만 메모리에 +1 (하루치 SELECT 자체를 거의 안 함).

---

### 2) 왜 SCALP만 RAM이고 꼬리·모멘텀은 snapshot이냐? 돌파는?

**설계 철학이 갈라진 게 아니라, 리팩터 미완료**입니다.

| 전략 | reload가 쓰는 길 | 장중 reload |
|------|-------------------|-------------|
| **SCALP** | `get_strategy_env_dict` → **RAM TTL** | ~10ms |
| **SHORT** | `get_tail_defaults_from_db` → **snapshot 직접** | **7~10초** |
| **MOMENTUM** | `get_momentum_defaults_from_db` → **snapshot 직접** | 락 대기 시 튐 |
| **BREAKOUT** | `get_env_int/float/from_db`만 → **RAM TTL** | ~수 ms |

SCALP defaults만 나중에 RAM 경로로 바꿨고, 꼬리·모멘텀 defaults는 예전 방식 그대로입니다. 돌파는 defaults 거대 함수 없이 env 헬퍼만 써서 처음부터 캐시 경로입니다.

### RAM이면 최신값 못 가져오나?

가져옵니다. 다만 **최대 TTL(기본 60초)** 지연이 있을 수 있고, 웹에서 설정 저장 시 `invalidate_merged_env_cache()` 하면 **즉시** 다시 DB→RAM 갱신됩니다.
SCALP/돌파가 이미 그 방식이고, “웹 반영”과 “매 루프 full snapshot”은 트레이드오프인데, **매 루프 snapshot은 과합니다.**

---

정리: 일일 한도는 필요하되 **종목마다 재조회할 필요는 없고**, SHORT/MOM reload 수 초는 **SCALP/돌파와 같은 RAM 경로로 맞추면** 사라지는 종류의 문제입니다.
고치려면 승인 받고 SHORT/MOM defaults → `get_strategy_env_dict` + trades 루프 캐시 진행하면 됩니다.


<timestamp>Thursday, Aug 13, 2026, 1:18 PM (UTC+9)</timestamp>
<user_query>
하루 전체를 몇만 건 긁는 게 아니라, SHORT가 매 루프 get_merged_env_snapshot으로 DB 락을 붙잡고 있을 때 줄 서서 대기한 시간입니다.
줄이는 방법: 루프당 1회만 읽어서 RAM에 두고 종목 필터 / 또는 매수·매도 체결 시 daily_cnt만 메모리에 +1 (하루치 SELECT 자체를 거의 안 함).
그래 내가 생각한거랑 같네 싹가져와서 코드에서 분리하면되지 뭐
RAM이면 최신값 못 가져오나?
가져옵니다. 다만 최대 TTL(기본 60초) 지연이 있을 수 있고, 웹에서 설정 저장 시 invalidate_merged_env_cache() 하면 즉시 다시 DB→RAM 갱신됩니다.
SCALP/돌파가 이미 그 방식이고, “웹 반영”과 “매 루프 full snapshot”은 트레이드오프인데, 매 루프 snapshot은 과합니다.

이것도 굳이 바로 안되도 되긴하지 중요 변수도 아니고 뭐 중요학긴하지만 이걸로 뭐 몇십만원 손해보고 그런건 아니잖아 매수체크 임계값인데 그리고 종목별로 다 다르게 되어있는 이유는 또 뭐야? 이것도 위에처럼 한번 조회해서 뿌리면안되나?

</user_query>


맞습니다. 종목마다 따로 조회할 이유가 없습니다. 루프당 1회 캐시 + SHORT/MOM을 SCALP처럼 RAM 경로로 맞추겠습니다.


StrReplace

        # 루프 숙제별 ms 계측 (LOOP_PROFILE_ENABLED)
        self._loop_prof_i = 0
        self._tick_sell_lock_miss = 0
        self._loop_prof_scan: Optional[Dict[str, Any]] = None
        # 당일 trade_history — 루프당 1회 DB, 종목 check_buy 는 RAM 필터만
        self._today_trades_cache_day: str = ""
        self._today_trades_cache: List[Dict] = []

        self._sync_holdings_from_db(log_restore=True)

        # 루프 숙제별 ms 계측 (LOOP_PROFILE_ENABLED)
        self._loop_prof_i = 0
        self._tick_sell_lock_miss = 0
        self._loop_prof_scan: Optional[Dict[str, Any]] = None

        self._sync_holdings_from_db(log_restore=True)

                # 설정 리로드 (DB env_config 실시간 반영)
                t0 = time.perf_counter() if do_prof else 0.0
                # 일일거래 캐시 — 이 바퀴에서 check_buy 들이 공유 (종목마다 SELECT 금지)
                self._today_trades_cache_day = ""
                self._today_trades_cache = []
                self.reload_config()
                if do_prof:
                    row["reload_ms"] = (time.perf_counter() - t0) * 1000.0

                # 설정 리로드 (DB env_config 실시간 반영)
                t0 = time.perf_counter() if do_prof else 0.0
                self.reload_config()
                if do_prof:
                    row["reload_ms"] = (time.perf_counter() - t0) * 1000.0


    def on_new_day(self) -> None:
        """날짜 변경 훅 (서브클래스 오버라이드 가능)."""
        pass

    def _get_today_trades(self, today: Optional[str] = None) -> List[Dict]:
        """당일 trade_history — 루프당 1회 DB 조회 후 RAM 재사용.

        check_buy 종목마다 get_trades_by_date 호출 금지(동일 SELECT + DB 락 대기).
        루프 시작 시 캐시 비움 → 그 바퀴 첫 호출만 DB.
        """
        day = str(today or dt.now().strftime("%Y%m%d"))
        if self._today_trades_cache_day == day:
            return self._today_trades_cache
        try:
            rows = list(self.db.get_trades_by_date(day) or [])
        except Exception:
            rows = []
        self._today_trades_cache_day = day
        self._today_trades_cache = rows
        return rows

    def reload_config(self) -> None:
        """설정 리로드 훅 (서브클래스 오버라이드)."""
        pass

    def on_new_day(self) -> None:
        """날짜 변경 훅 (서브클래스 오버라이드 가능)."""
        pass

    def reload_config(self) -> None:
        """설정 리로드 훅 (서브클래스 오버라이드)."""
        pass

            try:
                today_trades = self._get_today_trades(today)
                daily_cnt = len([
                    t for t in today_trades
                    if t.get("code") == code and str(t.get("strategy", "")).startswith("SCALP")
                ])
            except Exception:
                daily_cnt = 0
            self._cb_prof_mark(_cb, "trades_db")

            try:
                today_trades = self.db.get_trades_by_date(today)
                daily_cnt = len([
                    t for t in today_trades
                    if t.get("code") == code and str(t.get("strategy", "")).startswith("SCALP")
                ])
            except Exception:
                daily_cnt = 0
            self._cb_prof_mark(_cb, "trades_db")

            try:
                today_trades = self._get_today_trades(today)
                daily_cnt = len([
                    t for t in today_trades
                    if t.get("code") == code and str(t.get("strategy", "")).upper() == "MOMENTUM"
                ])
            except Exception:
                daily_cnt = 0
            self._cb_prof_mark(_cb, "trades_db")

            try:
                today_trades = self.db.get_trades_by_date(today)
                daily_cnt = len([
                    t for t in today_trades
                    if t.get("code") == code and str(t.get("strategy", "")).upper() == "MOMENTUM"
                ])
            except Exception:
                daily_cnt = 0
            self._cb_prof_mark(_cb, "trades_db")

            try:
                today_trades = self._get_today_trades(today)
                code_trades = [
                    t for t in today_trades
                    if t.get("code") == code and str(t.get("strategy", "")).startswith("SHORT")
                ]
                daily_cnt = len(code_trades)
                daily_pnl_krw = sum(
                    float(t.get("realized_pnl") or 0) for t in code_trades
                )
            except Exception:
                daily_cnt = 0
                daily_pnl_krw = 0.0
            self._cb_prof_mark(_cb, "trades_db")

            try:
                today_trades = self.db.get_trades_by_date(today)
                code_trades = [
                    t for t in today_trades
                    if t.get("code") == code and str(t.get("strategy", "")).startswith("SHORT")
                ]
                daily_cnt = len(code_trades)
                daily_pnl_krw = sum(
                    float(t.get("realized_pnl") or 0) for t in code_trades
                )
            except Exception:
                daily_cnt = 0
                daily_pnl_krw = 0.0
            self._cb_prof_mark(_cb, "trades_db")

        else:
            if db is None:
                from database import TradeDB
                own_db = TradeDB()
                db = own_db
            # SCALP 와 동일 — RAM TTL(get_strategy_env_dict). 매 호출 full snapshot 금지.
            try:
                from kis_trader.utils.env import get_strategy_env_dict
                r = get_strategy_env_dict("SHORT") or {}
            except Exception:
                r = {}
            if not r:
                if hasattr(db, "get_merged_env_snapshot"):
                    r = db.get_merged_env_snapshot()
                elif hasattr(db, "get_latest_env"):
                    latest = db.get_latest_env()
                    r = dict((latest or {}).get("snapshot") or {})
                else:
                    row = db.conn.execute(
                        "SELECT * FROM env_config ORDER BY id DESC LIMIT 1"
                    ).fetchone()
                    r = dict(row) if row else {}

        else:
            if db is None:
                from database import TradeDB
                own_db = TradeDB()
                db = own_db
            if hasattr(db, "get_merged_env_snapshot"):
                r = db.get_merged_env_snapshot()
            elif hasattr(db, "get_latest_env"):
                latest = db.get_latest_env()
                r = dict((latest or {}).get("snapshot") or {})
            else:
                row = db.conn.execute(
                    "SELECT * FROM env_config ORDER BY id DESC LIMIT 1"
                ).fetchone()
                r = dict(row) if row else {}

        else:
            if db is None:
                from database import TradeDB
                own_db = TradeDB()
                db = own_db
            # SCALP 와 동일 — RAM TTL. 매 루프 full snapshot 금지(장중 DB 락 팽창).
            try:
                from kis_trader.utils.env import get_strategy_env_dict
                r = get_strategy_env_dict("MOMENTUM") or {}
            except Exception:
                r = {}
            if not r:
                if hasattr(db, "get_merged_env_snapshot"):
                    r = db.get_merged_env_snapshot()
                elif hasattr(db, "get_latest_env"):
                    latest = db.get_latest_env()
                    r = dict((latest or {}).get("snapshot") or {})
                else:
                    row = db.conn.execute(
                        "SELECT * FROM env_config ORDER BY id DESC LIMIT 1"
                    ).fetchone()
                    r = dict(row) if row else {}

        else:
            if db is None:
                from database import TradeDB
                own_db = TradeDB()
                db = own_db
            if hasattr(db, "get_merged_env_snapshot"):
                r = db.get_merged_env_snapshot()
            elif hasattr(db, "get_latest_env"):
                latest = db.get_latest_env()
                r = dict((latest or {}).get("snapshot") or {})
            else:
                row = db.conn.execute(
                    "SELECT * FROM env_config ORDER BY id DESC LIMIT 1"
                ).fetchone()
                r = dict(row) if row else {}


        else:
            # SCALP 와 동일 — RAM TTL(get_strategy_env_dict). 매 호출 full snapshot 금지.
            try:
                from kis_trader.utils.env import get_strategy_env_dict
                r = get_strategy_env_dict("SHORT") or {}
            except Exception:
                r = {}
            if not r:
                if db is None:
                    from database import TradeDB
                    own_db = TradeDB()
                    db = own_db
                if hasattr(db, "get_merged_env_snapshot"):
                    r = db.get_merged_env_snapshot()
                elif hasattr(db, "get_latest_env"):
                    latest = db.get_latest_env()
                    r = dict((latest or {}).get("snapshot") or {})
                else:
                    row = db.conn.execute(
                        "SELECT * FROM env_config ORDER BY id DESC LIMIT 1"
                    ).fetchone()
                    r = dict(row) if row else {}

        else:
            if db is None:
                from database import TradeDB
                own_db = TradeDB()
                db = own_db
            # SCALP 와 동일 — RAM TTL(get_strategy_env_dict). 매 호출 full snapshot 금지.
            try:
                from kis_trader.utils.env import get_strategy_env_dict
                r = get_strategy_env_dict("SHORT") or {}
            except Exception:
                r = {}
            if not r:
                if hasattr(db, "get_merged_env_snapshot"):
                    r = db.get_merged_env_snapshot()
                elif hasattr(db, "get_latest_env"):
                    latest = db.get_latest_env()
                    r = dict((latest or {}).get("snapshot") or {})
                else:
                    row = db.conn.execute(
                        "SELECT * FROM env_config ORDER BY id DESC LIMIT 1"
                    ).fetchone()
                    r = dict(row) if row else {}

        else:
            # SCALP 와 동일 — RAM TTL. 매 루프 full snapshot 금지(장중 DB 락 팽창).
            try:
                from kis_trader.utils.env import get_strategy_env_dict
                r = get_strategy_env_dict("MOMENTUM") or {}
            except Exception:
                r = {}
            if not r:
                if db is None:
                    from database import TradeDB
                    own_db = TradeDB()
                    db = own_db
                if hasattr(db, "get_merged_env_snapshot"):
                    r = db.get_merged_env_snapshot()
                elif hasattr(db, "get_latest_env"):
                    latest = db.get_latest_env()
                    r = dict((latest or {}).get("snapshot") or {})
                else:
                    row = db.conn.execute(
                        "SELECT * FROM env_config ORDER BY id DESC LIMIT 1"
                    ).fetchone()
                    r = dict(row) if row else {}

        else:
            if db is None:
                from database import TradeDB
                own_db = TradeDB()
                db = own_db
            # SCALP 와 동일 — RAM TTL. 매 루프 full snapshot 금지(장중 DB 락 팽창).
            try:
                from kis_trader.utils.env import get_strategy_env_dict
                r = get_strategy_env_dict("MOMENTUM") or {}
            except Exception:
                r = {}
            if not r:
                if hasattr(db, "get_merged_env_snapshot"):
                    r = db.get_merged_env_snapshot()
                elif hasattr(db, "get_latest_env"):
                    latest = db.get_latest_env()
                    r = dict((latest or {}).get("snapshot") or {})
                else:
                    row = db.conn.execute(
                        "SELECT * FROM env_config ORDER BY id DESC LIMIT 1"
                    ).fetchone()
                    r = dict(row) if row else {}

Shell

cd /home/hoon/kis_bot && python3 -m py_compile kis_trader/strategies/base.py kis_trader/strategies/scalping.py kis_trader/strategies/momentum.py kis_trader/strategies/tail_catch.py kis_trader/engine/tail_engine.py kis_trader/engine/momentum_engine.py && sudo systemctl restart kis_trader_main.service && sleep 4 && systemctl is-active kis_trader_main.service && echo "=== ram-cache-fix $(date -Iseconds) ===" >> logs/loop_profile.log && sleep 50 && echo '--- RELOAD ---' && rg "RELOAD_PROF" logs/loop_profile.log | awk '/ram-cache-fix/{p=1;next} p' | tail -n 8; echo '--- CHECK_BUY trades ---' && rg "CHECK_BUY_PROF" logs/loop_profile.log | awk '/ram-cache-fix/{p=1;next} p' | tail -n 12; echo '--- LOOP SHORT ---' && journalctl -u kis_trader_main.service --since '1 min ago' --no-pager 2>/dev/null | rg 'LOOP_PROF\] SHORT' | tail -n 5

Compile, restart, verify reload/trades speed


맞습니다. **종목마다 다르게 조회할 이유가 없습니다.** 그냥 `check_buy(code)`마다 “오늘 거래 가져와”를 각자 호출해 둔 겁니다. 하루치 한 번 읽고 코드로 걸러도 결과는 같습니다.

RAM TTL(최대 ~60초, 웹 저장 시 invalidate면 즉시)도 임계값 반영용으로는 충분합니다. 매 루프 full snapshot은 과했습니다.

### 방금 반영
1. **당일 거래** — 루프당 1회 DB → RAM, 종목은 필터만
2. **SHORT/MOM defaults** — SCALP처럼 `get_strategy_env_dict` (RAM TTL)

### 재측정 (SHORT)
| | 수정 전 | 수정 후 |
|--|---------|---------|
| `reload` | **7~10초** | **~3~8ms** |
| `RELOAD_PROF` (≥500ms) | 매 루프 찍힘 | **안 찍힘** (빠름) |

남은 `sync_hold`/`cand`/`scan`은 별건이고, 말씀하신 두 병목(일일거래 연타 + SHORT snapshot)은 이렇게 정리된 상태입니다.


<timestamp>Thursday, Aug 13, 2026, 1:21 PM (UTC+9)</timestamp>
<user_query>
0112]: [13:20:03] [kis_trader.cond] 🔄 [SHORT] +2 / -0  (현재 2종목, push/pop)
Aug 13 13:20:03 ubuntu2404 python[1370112]: [13:20:03] [kis_trader.cond]    ENTER: 003350(한국화장품제조), 033560(블루콤)
Aug 13 13:20:03 ubuntu2404 python[1370112]: [13:20:03] [kis_trader.kwcond] ✅ 키움 실시간 등록 (seq=1) 초기 매칭 2종목
Aug 13 13:20:03 ubuntu2404 python[1370112]: [13:20:03] [kis_trader.cond] 🔄 [MOMENTUM] +1 / -0  (현재 1종목, push/pop)
Aug 13 13:20:03 ubuntu2404 python[1370112]: [13:20:03] [kis_trader.cond]    ENTER: 006660(삼성공조)
Aug 13 13:20:03 ubuntu2404 python[1370112]: [13:20:03] [kis_trader.kwcond] ✅ 키움 실시간 등록 (seq=2) 초기 매칭 1종목
Aug 13 13:20:06 ubuntu2404 python[1370112]: [13:20:06] [kis_trader.kwcond] ⚠ 키움 CNSRREQ 미응답 seq=['4'] → 3s 후 재발송 (1/30)
Aug 13 13:20:07 ubuntu2404 python[1370112]: [13:20:07] [kis_trader.cond] 🔄 [SCALP] +18 / -0  (현재 18종목, push/pop)
Aug 13 13:20:07 ubuntu2404 python[1370112]: [13:20:07] [kis_trader.cond]    ENTER: 0009K0(0009K0), 001390(001390), 001540(안국약품), 015860(일진홀딩스), 036200(036200) …
Aug 13 13:20:10 ubuntu2404 python[1370112]: [13:20:10] [kis_trader.kwcond] ⚠ 키움 CNSRREQ 미응답 seq=['4'] → 3s 후 재발송 (2/30)
Aug 13 13:20:10 ubuntu2404 python[1370112]: [13:20:10] [kis_trader.kwcond] ✅ 키움 실시간 등록 (seq=3) 초기 매칭 18종목
Aug 13 13:20:13 ubuntu2404 python[1370112]: [13:20:13] [kis_trader.cond] 🔄 [SCALP] +0 / -1  (현재 17종목, push/pop)
Aug 13 13:20:13 ubuntu2404 python[1370112]: [13:20:13] [kis_trader.cond]    EXIT : 039200
Aug 13 13:20:14 ubuntu2404 python[1370112]: [13:20:14] [kis_trader.kwcond] ⚠ 키움 CNSRREQ 미응답 seq=['4'] → 3s 후 재발송 (3/30)
Aug 13 13:20:18 ubuntu2404 python[1370112]: [13:20:18] [kis_trader.cond] 🔄 [BREAKOUT] +11 / -0  (현재 11종목, push/pop)
Aug 13 13:20:18 ubuntu2404 python[1370112]: [13:20:18] [kis_trader.cond]    ENTER: 0039P0(0039P0), 005090(005090), 006360(GS건설), 090710(휴림로봇), 122640(예스티) …
Aug 13 13:20:20 ubuntu2404 python[1370112]: [13:20:20] [kis_trader.kwcond] ✅ 키움 실시간 등록 (seq=4) 초기 매칭 11종목
Aug 13 13:20:20 ubuntu2404 python[1370112]: [13:20:20] [kis_trader.main] 💰 [예수금캐시] 기동 API 선동기화 완료: 468,155,952원
Aug 13 13:20:20 ubuntu2404 python[1370112]: [13:20:20] [kis_trader.strategy.SCALP] 📂 [DB 복원] 보유 1종목 (SCALP)
Aug 13 13:20:20 ubuntu2404 python[1370112]: [13:20:20] [kis_trader.main] ✅ [전략 등록] SCALP (kiwoom_cond · scalp_re(seq=3))
Aug 13 13:20:21 ubuntu2404 python[1370112]: [13:20:21] [kis_trader.main] ✅ [전략 등록] SHORT (Tail Catch) (kiwoom_cond · tail(seq=1))
Aug 13 13:20:21 ubuntu2404 python[1370112]: [13:20:21] [kis_trader.strategy.MOMENTUM] 📂 [DB 복원] 보유 2종목 (MOMENTUM)
Aug 13 13:20:21 ubuntu2404 python[1370112]: [13:20:21] [kis_trader.main] ✅ [전략 등록] MOMENTUM (kiwoom_cond · momentum(seq=2))
Aug 13 13:20:23 ubuntu2404 python[1370112]: [13:20:23] [kis_trader.cond] 🔄 [SCALP] +0 / -1  (현재 16종목, push/pop)
Aug 13 13:20:23 ubuntu2404 python[1370112]: [13:20:23] [kis_trader.cond]    EXIT : 251270
Aug 13 13:20:24 ubuntu2404 python[1370112]: [13:20:24] [kis_trader.main] ✅ [전략 등록] US_MOMENTUM (permanent US · dry-run 기본)
Aug 13 13:20:24 ubuntu2404 python[1370112]: [13:20:24] [kis_trader.strategy.BREAKOUT] 📂 [DB 복원] 보유 1종목 (BREAKOUT)
Aug 13 13:20:24 ubuntu2404 python[1370112]: [13:20:24] [kis_trader.main] ✅ [전략 등록] BREAKOUT (kiwoom_cond · breakout(seq=4))
Aug 13 13:20:24 ubuntu2404 python[1370112]: [13:20:24] [kis_trader.main] ⏸ [전략 비활성] RANGE_BREAK
Aug 13 13:20:24 ubuntu2404 python[1370112]: [13:20:24] [kis_trader.main] ⏸ [전략 비활성] UPDOW
Aug 13 13:20:24 ubuntu2404 python[1370112]: [13:20:24] [kis_trader.main] ⏸ [전략 비활성] DBBAND
Aug 13 13:20:24 ubuntu2404 python[1370112]: [13:20:24] [kis_trader.main] ⏸ [전략 비활성] DART
Aug 13 13:20:24 ubuntu2404 python[1370112]: [13:20:24] [kis_trader.main] ℹ [일일익절·마스터] 손익 감시 OFF — 목표 달성 시에도 신규매수 차단 없음
Aug 13 13:20:24 ubuntu2404 python[1370112]: [13:20:24] [kis_trader.main] 🌎 해외 WS → US_MOMENTUM 전략 1개 주입
Aug 13 13:20:24 ubuntu2404 python[1370112]: [13:20:24] [kis_trader.strategy.SCALP] 🚀 전략 쓰레드 시작 [SCALP]
Aug 13 13:20:24 ubuntu2404 python[1370112]: [13:20:24] [kis_trader.main] ▶ 쓰레드 기동: Strat-SCALP
Aug 13 13:20:24 ubuntu2404 python[1370112]: [13:20:24] [kis_trader.strategy.SCALP] 📡 [틱매도] 리스너 등록 ON (SCALP_TICK_SELL / TICK_SELL)
이 재시도 로그는 뭐지 왜 3번이나 실패했지?
</user_query>


키움 조건검색 등록(`CNSRREQ`) 재시도 로그입니다. 코드에서 seq=4가 무엇인지·왜 3번 도는지 확인합니다.


버그라기보다 **키움 조건검색 실시간 등록(CNSRREQ) 응답 대기 재시도**입니다.

### 뭐하는 로그인가
재시작 때 전략별 조건식을 키움 WS에 등록합니다.

| seq | 전략 (로그 기준) |
|-----|------------------|
| 1 | SHORT (tail) |
| 2 | MOMENTUM |
| 3 | SCALP |
| 4 | BREAKOUT |

`CNSRREQ` = “이 조건식 실시간으로 밀어줘” 요청.
응답이 안 오면 `seq=['4']`만 **3초 간격으로 다시 보냄** (상한 30회).

### 왜 3번이나?
1→2→3은 빨리 ACK 왔고, **seq=4(BREAKOUT)만** 응답이 늦었습니다.
연속 등록·CLR→REQ·키움 쪽 밀림 때 **가끔 응답이 늦게/누락**되는 걸 대비한 로직입니다.

타임라인:
- 13:20:06 / 10 / 14 → 미응답 재시도 1·2·3회
- 13:20:20 → `✅ 키움 실시간 등록 (seq=4) 초기 매칭 11종목` **결국 성공**

### 걱정할 일인가
이번처럼 **나중에 OK 뜨면 정상 복구**입니다.
`최대 재시도 초과`까지 가면 그때 그 조건식 유니버스가 안 잡히는 문제고, 지금은 아닙니다.

매매 루프 슬로우(DB snapshot)랑은 **다른 축**(키움 WS 등록)입니다.


<timestamp>Thursday, Aug 13, 2026, 1:23 PM (UTC+9)</timestamp>
<user_query>
지금도 만족할만큼의 속도는 아닌데 다시 재측정해서 더 세밀하게 쪼개서 측정 가능한가? 지금 측정한 함수 안쪽에 잇는거까지 ram영역은 뭐 더 줄일수없을거같긴한데 계산 복잡한거 아니면
</user_query>


남은 병목을 최신 계측으로 확인한 뒤, `sync_hold`·`cand`·`scan` 안쪽을 더 쪼개 재측정하겠습니다.

cd /home/hoon/kis_bot && journalctl -u kis_trader_main.service --since '5 min ago' --no-pager 2>/dev/null | rg 'LOOP_PROF\]' | tail -n 40 | python3 <<'PY'
import sys,re
from collections import defaultdict
rows=[]
for line in sys.stdin:
    if 'LOOP_PROF' not in line: continue
    sid=None
    if 'strategy.' in line:
        sid=line.split('strategy.')[1].split(']')[0]
    kv={k:float(v) for k,v in re.findall(r'(\w+)=([\d.]+)', line)}
    if kv.get('total'): rows.append((sid or '?', kv))
by=defaultdict(list)
for s,k in rows: by[s].append(k)
print('samples', len(rows))
keys=['total','reload','sync_hold','cand','scan','buy_sum','buy_max','buy_n','ws_sync','pending','sleep','lock_wait','sell_chk']
for sid,items in sorted(by.items()):
    print(f'\n{sid} n={len(items)}')
    for k in keys:
        vs=[x.get(k,0) for x in items]
        if not any(vs): continue
        print(f'  {k:12} avg={sum(vs)/len(vs):7.1f}  max={max(vs):7.1f}  p50={sorted(vs)[len(vs)//2]:7.1f}')
PY
rg "CHECK_BUY_PROF" /home/hoon/kis_bot/logs/loop_profile.log | tail -n 20
echo '---'
# what does cand / sync_hold cover in base
rg -n "cand_ms|sync_hold|_load_candidates|_sync_holdings" kis_trader/strategies/base.py | head -40

Summarize recent LOOP_PROF; locate cand/sync code


`trades_db`가 여전히 ~1초로 나옵니다. 캐시 이후에도 첫 DB 호출·`sync_hold`/`cand`를 더 쪼개 재측정합니다.


StrReplace

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 (

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 (

        # 당일 trade_history — 루프당 1회 DB, 종목 check_buy 는 RAM 필터만
        self._today_trades_cache_day: str = ""
        self._today_trades_cache: List[Dict] = []
        # 세밀 계측 카운터 (루프마다 리셋)
        self._prof_trd_hit = 0
        self._prof_trd_miss = 0
        self._prof_trd_db_ms = 0.0
        self._prof_sync_db_ms = 0.0
        self._prof_sync_merge_ms = 0.0
        self._prof_cand_load_ms = 0.0
        self._prof_cand_note_ms = 0.0

        self._sync_holdings_from_db(log_restore=True)

        # 당일 trade_history — 루프당 1회 DB, 종목 check_buy 는 RAM 필터만
        self._today_trades_cache_day: str = ""
        self._today_trades_cache: List[Dict] = []

        self._sync_holdings_from_db(log_restore=True)

            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"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"sync_db={row.get('sync_db_ms', 0):.1f}",
            f"sync_merge={row.get('sync_merge_ms', 0):.1f}",
            f"gap_skip={row.get('gap_skip', 0)}",
            f"overlay={row.get('overlay_ms', 0):.1f}",
            f"sleep={row.get('sleep_ms', 0):.1f}",
            f"tick_lock_miss={row.get('tick_lock_miss', 0)}",
            f"sum_parts={row.get('sum_parts_ms', 0):.1f}",
        ]

            f"cand={row.get('cand_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"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"gap_skip={row.get('gap_skip', 0)}",
            f"overlay={row.get('overlay_ms', 0):.1f}",
            f"sleep={row.get('sleep_ms', 0):.1f}",
            f"tick_lock_miss={row.get('tick_lock_miss', 0)}",
            f"sum_parts={row.get('sum_parts_ms', 0):.1f}",
        ]


                # 설정 리로드 (DB env_config 실시간 반영)
                t0 = time.perf_counter() if do_prof else 0.0
                # 세밀 계측 카운터 리셋 (당일거래 공유캐시는 루프마다 비우지 않음)
                self._prof_trd_hit = 0
                self._prof_trd_miss = 0
                self._prof_trd_db_ms = 0.0
                self._prof_sync_db_ms = 0.0
                self._prof_sync_merge_ms = 0.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

                # 보유 목록 = DB 진실 + _runtime 오버레이 (poll 체결·재시작 정합)
                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)

                # 설정 리로드 (DB env_config 실시간 반영)
                t0 = time.perf_counter() if do_prof else 0.0
                # 일일거래 캐시 — 이 바퀴에서 check_buy 들이 공유 (종목마다 SELECT 금지)
                self._today_trades_cache_day = ""
                self._today_trades_cache = []
                self.reload_config()
                if do_prof:
                    row["reload_ms"] = (time.perf_counter() - t0) * 1000.0

                # 보유 목록 = DB 진실 + _runtime 오버레이 (poll 체결·재시작 정합)
                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

                # ── [2] 후보 구독 동기화 (공유 WS) ────────────────
                t0 = time.perf_counter() if do_prof else 0.0
                t_c0 = time.perf_counter()
                candidates = self._load_candidates()
                self._prof_cand_load_ms = (time.perf_counter() - t_c0) * 1000.0
                # 중분 편입 시가 애매 가드용 — 후보 ENTER 시각(초) 기록
                t_n0 = time.perf_counter()
                self._note_candidate_enters(candidates)
                self._prof_cand_note_ms = (time.perf_counter() - t_n0) * 1000.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)

                # ── [2] 후보 구독 동기화 (공유 WS) ────────────────
                t0 = time.perf_counter() if do_prof else 0.0
                candidates = self._load_candidates()
                # 중분 편입 시가 애매 가드용 — 후보 ENTER 시각(초) 기록
                self._note_candidate_enters(candidates)
                if do_prof:
                    row["cand_ms"] = (time.perf_counter() - t0) * 1000.0

                if do_prof:
                    row["scan_ms"] = (time.perf_counter() - t0) * 1000.0
                    sc = getattr(self, "_loop_prof_scan", None) or {}
                    row["buy_n"] = int(sc.get("buy_n", 0) or 0)
                    row["buy_sum_ms"] = float(sc.get("buy_sum_ms", 0) or 0)
                    row["buy_max_ms"] = float(sc.get("buy_max_ms", 0) or 0)
                    row["buy_max_code"] = sc.get("buy_max_code") or ""
                    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["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)

                if do_prof:
                    row["scan_ms"] = (time.perf_counter() - t0) * 1000.0
                    sc = getattr(self, "_loop_prof_scan", None) or {}
                    row["buy_n"] = int(sc.get("buy_n", 0) or 0)
                    row["buy_sum_ms"] = float(sc.get("buy_sum_ms", 0) or 0)
                    row["buy_max_ms"] = float(sc.get("buy_max_ms", 0) or 0)
                    row["buy_max_code"] = sc.get("buy_max_code") or ""
                    row["gap_skip"] = int(sc.get("gap_skip", 0) or 0)

    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 _get_today_trades(self, today: Optional[str] = None) -> List[Dict]:
        """당일 trade_history — 루프당 1회 DB 조회 후 RAM 재사용.

        check_buy 종목마다 get_trades_by_date 호출 금지(동일 SELECT + DB 락 대기).
        루프 시작 시 캐시 비움 → 그 바퀴 첫 호출만 DB.
        """
        day = str(today or dt.now().strftime("%Y%m%d"))
        if self._today_trades_cache_day == day:
            return self._today_trades_cache
        try:
            rows = list(self.db.get_trades_by_date(day) or [])
        except Exception:
            rows = []
        self._today_trades_cache_day = day
        self._today_trades_cache = rows
        return rows

    def reload_config(self) -> None:

        try:
            prefix = self.strategy_id.split("_")[0] if "_" in self.strategy_id else self.strategy_id
            t_db0 = time.perf_counter()
            rows = self.db.get_active_trades(strategy_prefix=prefix)
            self._prof_sync_db_ms = (time.perf_counter() - t_db0) * 1000.0
            t_m0 = time.perf_counter()
            skipped_non_stock: list[str] = []
            new_holdings: Dict[str, dict] = {}
            for code, t in rows.items():
                if t.get("strategy") and t["strategy"] != self.strategy_id:
                    continue
                if get_env_bool("EXCLUDE_NON_STOCK", True):
                    name = (t.get("name") or "").strip()
                    # 우선주(is_non_stock)는 매도 정리 위해 holdings 유지.
                    # ETF/ETN/스팩만 자동 제외.
                    if _is_unmanageable_holding(name, code):
                        skipped_non_stock.append(f"{code}({name})")
                        continue
                avg_bp = float(t.get("avg_buy_price", 0) or t.get("buy_price", 0) or 0)
                qty = int(t.get("current_qty", 0) or t.get("qty", 0) or 0)
                if qty <= 0 or avg_bp <= 0:
                    continue
                db_max = float(t.get("max_price") or 0)
                db_sess = float(t.get("session_low") or 0)
                max_p, sess_low = self._merge_runtime_overlay(code, avg_bp, db_max, db_sess)
                holding = {
                    "buy_price": avg_bp,
                    "qty": qty,
                    "stop_price": t.get("stop_price", 0),
                    "target_price": t.get("target_price", 0),
                    "max_price": max_p,
                    "session_low": sess_low,
                    "atr_entry": t.get("atr_at_entry", t.get("atr_entry", 0)),
                    "buy_time": t.get("buy_date", dt.now().strftime("%Y-%m-%d %H:%M:%S")),
                    "name": t.get("name", code),
                    "size_class": t.get("size_class", ""),
                }
                self._apply_runtime_extra_fields(code, holding)
                new_holdings[code] = holding

            db_codes = set(new_holdings.keys())
            for code in list(self._runtime.keys()):
                if code not in db_codes:
                    del self._runtime[code]

            prev_codes = set(self.holdings.keys())
            self.holdings.clear()
            self.holdings.update(new_holdings)
            self._after_holdings_sync()
            self._prof_sync_merge_ms = (time.perf_counter() - t_m0) * 1000.0

            if log_restore and self.holdings:

        try:
            prefix = self.strategy_id.split("_")[0] if "_" in self.strategy_id else self.strategy_id
            rows = self.db.get_active_trades(strategy_prefix=prefix)
            skipped_non_stock: list[str] = []
            new_holdings: Dict[str, dict] = {}
            for code, t in rows.items():
                if t.get("strategy") and t["strategy"] != self.strategy_id:
                    continue
                if get_env_bool("EXCLUDE_NON_STOCK", True):
                    name = (t.get("name") or "").strip()
                    # 우선주(is_non_stock)는 매도 정리 위해 holdings 유지.
                    # ETF/ETN/스팩만 자동 제외.
                    if _is_unmanageable_holding(name, code):
                        skipped_non_stock.append(f"{code}({name})")
                        continue
                avg_bp = float(t.get("avg_buy_price", 0) or t.get("buy_price", 0) or 0)
                qty = int(t.get("current_qty", 0) or t.get("qty", 0) or 0)
                if qty <= 0 or avg_bp <= 0:
                    continue
                db_max = float(t.get("max_price") or 0)
                db_sess = float(t.get("session_low") or 0)
                max_p, sess_low = self._merge_runtime_overlay(code, avg_bp, db_max, db_sess)
                holding = {
                    "buy_price": avg_bp,
                    "qty": qty,
                    "stop_price": t.get("stop_price", 0),
                    "target_price": t.get("target_price", 0),
                    "max_price": max_p,
                    "session_low": sess_low,
                    "atr_entry": t.get("atr_at_entry", t.get("atr_entry", 0)),
                    "buy_time": t.get("buy_date", dt.now().strftime("%Y-%m-%d %H:%M:%S")),
                    "name": t.get("name", code),
                    "size_class": t.get("size_class", ""),
                }
                self._apply_runtime_extra_fields(code, holding)
                new_holdings[code] = holding

            db_codes = set(new_holdings.keys())
            for code in list(self._runtime.keys()):
                if code not in db_codes:
                    del self._runtime[code]

            prev_codes = set(self.holdings.keys())
            self.holdings.clear()
            self.holdings.update(new_holdings)
            self._after_holdings_sync()

            if log_restore and self.holdings:


Shell

cd /home/hoon/kis_bot && python3 -m py_compile kis_trader/strategies/base.py && sudo systemctl restart kis_trader_main.service && sleep 4 && systemctl is-active kis_trader_main.service && echo "=== fine-prof $(date -Iseconds) ===" >> logs/loop_profile.log && sleep 55 && journalctl -u kis_trader_main.service --since '1 min ago' --no-pager 2>/dev/null | rg 'LOOP_PROF\]' | tail -n 24

Restart and sample fine-grained LOOP_PROF


가능해서 더 쪼개 재측정했습니다. **엔진/봉 계산은 이미 거의 0**이고, 남은 건 DB 대기 쪽입니다.

### 지금 LOOP_PROF에 추가된 축
| 키 | 의미 |
|----|------|
| `sync_db` / `sync_merge` | 보유동기화 = `get_active_trades` vs 메모리 merge |
| `cand_load` / `cand_note` | 후보 로드 vs ENTER 시각 기록 |
| `scan_other` | `scan - buy_sum` (check_buy **밖**) |
| `trd_hit` / `trd_miss` / `trd_db` | 일일거래 캐시 히트 / DB 실조회 |

일일거래는 **전략 공유 RAM + TTL 10초**로도 바꿨습니다 (루프마다 비우던 실수 제거).

### 재측정 결과 (요약)
```
buy_sum ≈ 0.5~6ms   engine급으로 빠름
trd_hit 다수, trd_db≈0  → 일일거래·매수계산 OK

sync_db  = 가끔 1ms, 자주 370~800ms  → active_trades SELECT 락 대기
cand_load= 가끔 3ms, 자주 400~1200ms → 후보 로드 구간 대기
scan_other= 800~3400ms 인데 buy_sum=1~3ms
  → 매수식 말고 후보 루프 바깥(이름조회·로그 등)이 대부분
```

예: BREAKOUT `scan=3417 buy_sum=3 scan_other=3414 buy_n=9`
→ 종목당 check_buy는 瞬时인데, **사이사이가 초 단위**.

### 해석
말씀하신 대로 **RAM·계산 복잡도는 더 줄일 게 거의 없음**.
남은 체감 슬로우는:

1. **매 루프 `get_active_trades` (sync_db)** — 전략 4개가 같은 DB 락
2. **`scan_other`** — 유력 후보는 종목마다 `resolve_stock_display_name` 같은 DB/이름 조회
3. **`cand_load` 스파이크** — 후보 로드 경로의 락/조회

원하면 다음으로 `scan_other`만 더 쪼개서 (이름해석 / 필터 / 로그) 어디에 ms가 붙는지 확정한 뒤, 이름 캐시·`active_trades` 캐시 쪽을 보고 드리겠습니다.


<timestamp>Thursday, Aug 13, 2026, 1:29 PM (UTC+9)</timestamp>
<user_query>
다시 초등생 수준으로 서명 맴수체크 안쪽이랑 바깥쪽 나눠서 함수명 뭐하는애인지  왜 느린지
</user_query>


초등생 버전으로만 정리합니다.

---

## 한 바퀴가 하는 일 (큰 그림)

전략 하나가 **한 바퀴** 돌 때:

1. 설정 다시 읽기
2. **내가 뭐 들고 있는지** DB에서 확인
3. **오늘 살 후보 명단** 가져오기
4. 명단 하나씩 **살까 말까** 검사
5. 잠깐 쉬고 다시 1번

느린 건 대부분 **4번 안쪽 계산이 아니라, 2·3번이랑 4번 사이사이 DB 줄 서기**입니다.

---

## 매수체크 = `check_buy` (안쪽)

**함수 이름:** `check_buy`
**하는 일:** “이 종목, 지금 사도 돼?” 한 종목만 판단.

안쪽에서 하는 것들:

| 쉬운 말 | 코드 쪽 | 지금 속도 |
|---------|---------|-----------|
| 봉 차트 보기 | `candles` | 엄청 빠름 |
| 지표·조건 계산 | `engine` | 엄청 빠름 |
| 오늘 몇 번 샀나 | `trades_db` (캐시) | 이제 거의 瞬간 |
| 가격 맞추기 | `align` | 빠름 |

→ **매수체크 안쪽은 이미 만족할 만큼 빠름.** (보통 1ms도 안 걸림)

---

## 매수체크 바깥쪽 (같은 바퀴, 그런데 check_buy 밖)

여기가 아직도 느린 곳입니다.

### 1) `sync_hold` / `sync_db` → `get_active_trades`
- **뭐 함:** “지금 계좌에 뭐 들고 있지?” DB에 물어봄
- **왜 느림:** 질문 자체는 가벼운데, 전략 4명이 **같은 DB 문 하나**에 줄 서서 기다림 (가끔 0.4~0.8초)

### 2) `cand` / `cand_load` → `_load_candidates`
- **뭐 함:** “오늘 조건검색에 들어온 후보 명단” 가져옴
- **왜 느림:** 가끔은 瞬간, 가끔 0.4~1초. 명단 계산이 복잡해서가 아니라 **기다리는 시간**이 큼

### 3) `scan` / `scan_other` → `_scan_and_buy` 안의 **check_buy 사이사이**
- **뭐 함:** 후보 여러 명을 차례로 돌면서
  - 이름 찾기 (`resolve_stock_display_name` 등)
  - 이미 들고 있나 / 쿨다운인가 걸러내기
  - 로그 찍기
  - 그다음에야 `check_buy` 호출
- **왜 느림:**
  - `buy_sum`(진짜 check_buy 합) = 거의 0
  - `scan_other`(그 사이사이) = **1~3초**
  → 숙제는 “살까 계산”이 아니라 **명단 돌면서 이름/DB/로그** 쪽

---

## 한 줄로

| 구역 | 쉬운 말 | 지금 |
|------|---------|------|
| **안쪽** `check_buy` | 살까 말까 계산 | 빠름 |
| **바깥** sync / 후보 / scan_other | 들고 있는 거 확인, 명단, 이름·줄서기 | **여기가 느림** |

비유하면:
**시험 문제 푸는 시간(안쪽)은 1초인데, 교실 들어가려고 복도에서 줄 서는 시간(바깥)이 몇 초**인 상황입니다.


<timestamp>Thursday, Aug 13, 2026, 1:32 PM (UTC+9)</timestamp>
<user_query>
이름 찾기 (resolve_stock_display_name 등)
이미 들고 있나 / 쿨다운인가 걸러내기
로그 찍기
그다음에야 check_buy 호출

아니 이중에 쓸모없는것도 잇나? 이름은 굳이
</user_query>


이름 조회가 꼭 필요한지, 코드에서 어디에 쓰이는지 바로 확인하겠습니다.


맞습니다. **이름은 매수 판단에 필요 없습니다.**

### 하나씩

| 하는 일 | 쓸모 | 비고 |
|---------|------|------|
| **이름 찾기** (`resolve_stock_display_name`) | **없어도 됨** | 로그만 예뻐지려고. 키움 후보는 이름이 코드로만 와서, 없으면 DB 뒤짐 → 줄 서면 느려짐 |
| **이미 들고 있나** | **필요** | 또 사면 안 됨 |
| **쿨다운** | **필요** | 방금 판 거 바로 재매수 막기 |
| **갭보정 준비됐나** | **필요** | 봉 덜 찬 채로 사면 안 됨 |
| **로그 찍기** | 있으면 좋음 / 없어도 매매는 됨 | 탈락 이유 볼 때용. 매수식 아님 |
| **`check_buy`** | **핵심** | 진짜 살까 말까 |

### 한 줄
이름은 **사람이 로그 보기 좋게** 하는 장식이고, **사도 되는지 계산과는 무관**합니다.
느리게 만들 바엔 코드만 찍거나, **진짜 시그널 났을 때만** 이름 찾으면 됩니다.

원하면 매수체크 루프에서는 이름 DB 조회를 빼고(또는 캐시 hit만), 주문/알림 직전에만 조회하도록 바꾸면 됩니다.


<timestamp>Thursday, Aug 13, 2026, 1:34 PM (UTC+9)</timestamp>
<user_query>
그래 주문알림쪽만 넣고 일단 이것만 이름쪽만 진행
아무리 초등생 수준이라지만 얼마나 걸리는지는 알려줘야지 각각
</user_query>


매수체크 루프에서 이름 DB 조회를 빼고, 주문/알림 경로만 남긴 뒤 구간별 ms를 다시 재겠습니다.


StrReplace

        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,
            }

        prof_scan = self._loop_profile_on()
        buy_n = 0
        buy_sum = 0.0
        buy_max = 0.0
        buy_max_code = ""
        gap_skip = 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,
                    }
                return
            code = c.get("code") or c.get("stk_cd", "")
            name = c.get("name") or c.get("stk_nm", code)
            if name == code or not str(name or "").strip():
                try:
                    from ..utils.stock_name import resolve_stock_display_name

                    name = resolve_stock_display_name(self.db, code, name)
                except Exception:
                    name = code
            if not code or code in self.holdings:
                continue
            if code in self.untradable_skip:
                continue
            if not self._candidate_filter(c):
                continue
            guard = self._live_portfolio_entry_guard(code, max_stocks)
            if guard:
                self.logger.info("🔍 [%s] %s(%s)", guard, name, code)
                continue
            # 재진입 쿨다운
            cooldown_sec = self._reentry_cooldown_sec()
            elapsed = time.time() - self.recently_sold.get(code, 0)
            if elapsed < cooldown_sec:
                continue

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

            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,
                    }
                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,
            }


                    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["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["trd_hit"] = int(self._prof_trd_hit)

            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"scan={row.get('scan_ms', 0):.1f}",
            f"scan_other={row.get('scan_other_ms', 0):.1f}",
            f"buy_n={row.get('buy_n', 0)}",


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 && sleep 50 && journalctl -u kis_trader_main.service --since '55 sec ago' --no-pager 2>/dev/null | rg 'LOOP_PROF\]' | python3 <<'PY'
import sys,re
from collections import defaultdict
rows=defaultdict(list)
for line in sys.stdin:
    if 'LOOP_PROF' not in line: continue
    sid=line.split('strategy.')[1].split(']')[0] if 'strategy.' in line else '?'
    kv={k:float(v) for k,v in re.findall(r'(\w+)=([\d.]+)', line)}
    if kv.get('total') is not None: rows[sid].append(kv)
print('=== 이름조회 제거 후 (ms) ===')
for sid,items in sorted(rows.items()):
    def am(k):
        vs=[x.get(k,0) for x in items]
        return sum(vs)/len(vs), max(vs), sorted(vs)[len(vs)//2]
    print(f'\n{sid} n={len(items)}')
    for k in ['total','sync_db','cand_load','scan','scan_other','scan_name','scan_pre','buy_sum','buy_n','reload']:
        a,m,p=am(k)
        print(f'  {k:12} avg={a:7.1f}  p50={p:7.1f}  max={m:7.1f}')
PY

Restart; measure scan_name/pre after name removal


journalctl -u kis_trader_main.service --since '2 min ago' --no-pager 2>/dev/null | rg 'LOOP_PROF\]' | tail -n 20; echo '---'; journalctl -u kis_trader_main.service --since '2 min ago' --no-pager 2>/dev/null | rg 'LOOP_PROF\]' > /tmp/lp.txt; python3 -c '
import re
from collections import defaultdict
rows=defaultdict(list)
for line in open("/tmp/lp.txt"):
    if "LOOP_PROF" not in line: continue
    sid=line.split("strategy.")[1].split("]")[0] if "strategy." in line else "?"
    kv={k:float(v) for k,v in re.findall(r"(\w+)=([\d.]+)", line)}
    if kv.get("total") is not None: rows[sid].append(kv)
print("samples", sum(len(v) for v in rows.values()))
for sid,items in sorted(rows.items()):
    def am(k):
        vs=[x.get(k,0) for x in items]
        if not vs: return 0,0,0
        return sum(vs)/len(vs), max(vs), sorted(vs)[len(vs)//2]
    print(f"\n{sid} n={len(items)}")
    for k in ["total","sync_db","cand_load","scan","scan_other","scan_name","scan_pre","buy_sum","buy_n","reload"]:
        a,m,p=am(k)
        print(f"  {k:12} avg={a:7.1f}  p50={p:7.1f}  max={m:7.1f}")
'

Parse LOOP_PROF after name-resolve removal


이름 DB 조회는 매수체크에서 뺐고, **주문/알림은 기존처럼 `OrderManager`에서만** 이름을 붙입니다.

### 초등생용 + 시간(ms)

지금 한 바퀴에서 대략 이렇게 나옵니다. (방금 재측정, 전략마다 조금씩 다름)

| 어디 | 뭐 함 | 대략 걸리는 시간 |
|------|--------|------------------|
| **이름 찾기** (`scan_name`) | 한글 이름 DB 조회 | **0 ms** (뺌 · 예전엔 여기가 scan 사이사이를 잡아먹음) |
| **매수체크 안쪽** (`buy_sum` / `check_buy`) | 살까 말까 계산 | **보통 1~3 ms**, 가끔 캐시 미스 시 ~400 ms |
| **필터·쿨다운·갭** (`scan_pre`) | 들고있나 / 쿨다운 / 봉 준비됐나 | **평균 ~0.8~3초**, 심하면 ~7초 |
| **보유 확인** (`sync_db`) | DB에 뭐 들고 있지? | **평균 ~0.5~0.8초**, 심하면 ~1.5초 |
| **후보 명단** (`cand_load`) | 조건검색 후보 가져오기 | **평균 ~0.9~1.5초**, 심하면 ~2.5초 |
| **설정 리로드** (`reload`) | 파라미터 다시 읽기 | **약 5~8 ms** (이제 빠름) |

### 이번에 한 일
- 매수체크 루프: 이름 DB **안 함** → `scan_name=0.0` 확인됨
- 주문·텔레그램 알림: `OrderManager._resolve_order_display_name` **그대로**

### 아직 느린 것 (이름 아님)
`scan_pre`·`sync_db`·`cand_load` — DB 줄 서기 / 후보·보유 조회 쪽.
다음으로 손대려면 이 셋을 보면 됩니다.


<timestamp>Thursday, Aug 13, 2026, 1:38 PM (UTC+9)</timestamp>
<user_query>
저중에 디빕 튜닝으로 가능한것도 있나 아니면 비동기화나
</user_query>


가능합니다. 다만 **MySQL 인덱스만으로는 한계**가 있고, 지금 구조에서는 **앱이 DB를 한 줄로 세우는 것**이 더 큽니다.

### 지금 느린 3개

| 구간 | 하는 일 | DB 튜닝? | 비동기? | 더 나은 쪽 |
|------|---------|----------|---------|------------|
| **`sync_db`** (~0.5~1.5초) | 보유 `active_trades` 조회 | 인덱스·컬럼 줄이기는 **약간** 도움 | 백그라운드 갱신 가능(주의) | **RAM TTL 캐시** (일일거래처럼) |
| **`cand_load`** (~0.9~2.5초) | 조건검색 후보 | 원래 RAM이어야 함 → 느리면 **같은 DB 락에 줄 선 것**일 확률 큼 | 큰 의미 없음 | 락 줄이기 / 캐시 |
| **`scan_pre`** (~1~7초) | 쿨다운·갭·필터 | 거의 해당 없음 | 해당 없음 | 안쪽 더 쪼개서(갭/가드/로그) 확인 |

### 핵심 (초등생 버전)
DB가 “하루치 몇만 건”이라 느린 게 아닙니다.
전략 4명이 **문 하나(`TradeDB` 연결 + 파이썬 Lock)** 로만 드나들어서,
가벼운 SELECT도 **줄 서서 0.4~1초** 됩니다.

그래서:
- **DB 튜닝(인덱스 등)** → 조금은 됨, 체감 혁명은 어려움
- **비동기** → 보유 동기화는 가능하지만, “방금 산 거”와 안 맞을 위험 → 설계 필요
- **제일 효과 큰 것** → `active_trades`도 **짧은 RAM 캐시**(1~2초) + (여유 있으면) 연결/락 분리

이름은 이미 빼서 **0 ms**입니다.
다음으로 손대면 가성비는 **`sync_db` RAM 캐시**가 가장 큽니다. 그거부터 할까요?


<timestamp>Thursday, Aug 13, 2026, 1:43 PM (UTC+9)</timestamp>
<user_query>
오케이 내가 설계해보자면 재시작시에만 가져오고 구 후부터는 매매 있을 때 계산해서 ram에  업데이트 하면 되지 않을까 싶은데 더 좋은 대안은? 너가 생각했던 방식은?
</user_query>


말씀하신 방식이 **방향은 맞고**, 제가 생각했던 것보다 **더 깔끔한 편**입니다. 다만 “구멍”만 막으면 됩니다.

### 당신 설계 (이벤트 방식)
- **재시작 때** DB → RAM 1번
- **이후** 매수/매도 성공할 때마다 RAM만 고침

장점: 루프마다 `sync_db` 0.5~1.5초 **사라짐** → 체감 가장 큼.
단점: **봇이 모르는 변화**는 RAM이 낡음.

놓치기 쉬운 구멍:
1. 체결이 늦게 오는 경우 (주문만 넣고, poll로 나중에 체결)
2. 재시작·복구·고아주문 정리
3. HTS에서 사람이 직접 판 경우
4. 다른 코드 경로가 `active_trades`만 고치고 RAM을 안 고치는 경우

→ “매매 있을 때”를 **주문 접수**가 아니라 **체결 확정 / DB 반영되는 모든 출입구**로 잡아야 함.

---

### 내가 처음에 생각했던 것 (TTL 캐시)
- 루프마다 DB 대신 **1~2초짜리 RAM 캐시**
- 만료되면 가끔만 DB

장점: 구현 쉽고, HTS 매도 같은 것도 최대 1~2초 뒤 따라감.
단점: 그래도 가끔 DB 줄 서기는 남음 (지금보단 훨씬 적음).

---

### 더 좋은 대안 (추천 = 둘 섞기)

| 층 | 하는 일 |
|----|---------|
| **1. 메인** | 당신 방식 — 재시작 로드 + **체결/청산 확정 시** RAM 갱신 |
| **2. 안전망** | 드물게만 DB와 맞춰보기 (예: **30~60초 1회**, 또는 후보 없을 때) |
| **3. 선택** | 브로커 잔고와 안 맞으면 그때만 DB/API로 강제 동기화 |

이렇게 하면:
- 평소 루프는 **거의 DB 안 침** (당신 설계의 이득)
- HTS 매도·빠진 경로가 있어도 **늦어도 1분 안에** 교정

TTL만(1~2초)보다 평소가 더 빠르고, 순수 이벤트만보다 **덜 위험**합니다.

---

### 한 줄
- **당신 설계 = 본게임 (정답에 가까움)**
- **내 TTL = 쉬운 임시방편**
- **추천 = 이벤트 갱신 + 가끔 안전망 sync**

이 하이브리드로 갈까요? 가면 “RAM 갱신해야 하는 출입구 목록”부터 짧게 찍고 승인받은 뒤 코딩하겠습니다.


<timestamp>Thursday, Aug 13, 2026, 1:45 PM (UTC+9)</timestamp>
<user_query>
오케이 그럼 하이브리드로 갑시다 안전망 sync는 일단 60초 매매빈도가 그리 많진 않은듯 지금
</user_query>


하이브리드로 진행합니다. 보유 RAM 갱신 출입구를 먼저 찾은 뒤, 루프는 60초 안전망만 DB를 보도록 바꾸겠습니다.


매수/매도 시 이미 RAM을 갱신하므로, 루프 DB sync만 60초 안전망으로 줄이겠습니다.

StrReplace

        # 보유 종목 — 재시작·이벤트·안전망(기본 60초) DB sync. 루프마다 SELECT 금지.
        self.holdings: Dict[str, dict] = {}
        # 장중 고점·세션저점·전략별 부가키 — DB sync 로 덮어쓰지 않음 (래칫/어깨 퇴행 방지)
        self._runtime: Dict[str, dict] = {}
        self._holdings_db_sync_ts: float = 0.0
        self._prof_sync_skipped: int = 0

        # 보유 종목 — 매 루프 DB active_trades 와 동기화 (진실의 원천 = DB)
        self.holdings: Dict[str, dict] = {}
        # 장중 고점·세션저점·전략별 부가키 — DB sync 로 덮어쓰지 않음 (래칫/어깨 퇴행 방지)
        self._runtime: Dict[str, dict] = {}

            f"sync_hold={row.get('sync_hold_ms', 0):.1f}",
            f"sync_db={row.get('sync_db_ms', 0):.1f}",
            f"sync_merge={row.get('sync_merge_ms', 0):.1f}",
            f"sync_skip={row.get('sync_skip', 0)}",
            f"halt={row.get('halt_ms', 0):.1f}",

            f"sync_hold={row.get('sync_hold_ms', 0):.1f}",
            f"halt={row.get('halt_ms', 0):.1f}",


Shell

cd /home/hoon/kis_bot && rg -n "HOLDINGS|get_active_trades" docs/like_mcp.md/MODIFICATION_GUIDE.md | head -40; rg -n "ENV.*공통|loop|동기" docs/like_mcp.md/MODIFICATION_GUIDE.md | head -30

MODIFICATION_GUIDE env grep for holdings


StrReplace

            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"sync_db={row.get('sync_db_ms', 0):.1f}",
            f"sync_merge={row.get('sync_merge_ms', 0):.1f}",
            f"gap_skip={row.get('gap_skip', 0)}",

            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_sync_db_ms = 0.0
                self._prof_sync_merge_ms = 0.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

                # 보유 목록 = DB 진실 + _runtime 오버레이 (poll 체결·재시작 정합)
                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)

                self._prof_sync_db_ms = 0.0
                self._prof_sync_merge_ms = 0.0
                self._prof_sync_skipped = 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)

    def _load_holdings_from_db(self, *, log_restore: bool = False) -> None:
        """DB → holdings 동기화 (지정가 체결 등 이벤트 시 호출)."""
        self._sync_holdings_from_db(log_restore=log_restore)

    def _drop_local_position(self, code: str) -> None:
        """매도·유령정리 후 메모리 보유·런타임 오버레이 제거."""
        self.holdings.pop(code, None)
        self._runtime.pop(code, None)

    def _load_holdings_from_db(self, *, log_restore: bool = False) -> None:
        """DB → holdings 강제 동기화 (지정가 체결·복원 등 이벤트 시)."""
        self._sync_holdings_from_db(log_restore=log_restore, force=True)

    def _drop_local_position(self, code: str) -> None:
        """매도·유령정리 후 메모리 보유·런타임 오버레이 제거."""
        self.holdings.pop(code, None)
        self._runtime.pop(code, None)

    def _should_skip_holdings_db_sync(self, *, force: bool, log_restore: bool) -> bool:
        """하이브리드 안전망: 간격 미경과면 DB get_active_trades 생략.

        HOLDINGS_DB_SYNC_INTERVAL_SEC=0 → 매 루프 sync(레거시).
        force/log_restore → 항상 수행.
        """
        if force or log_restore:
            return False
        try:
            interval = max(0, int(get_env_int("HOLDINGS_DB_SYNC_INTERVAL_SEC", 60) or 0))
        except Exception:
            interval = 60
        if interval <= 0:
            return False
        last = float(getattr(self, "_holdings_db_sync_ts", 0.0) or 0.0)
        if last <= 0.0:
            return False
        if (time.time() - last) < float(interval):
            self._prof_sync_db_ms = 0.0
            self._prof_sync_merge_ms = 0.0
            self._prof_sync_skipped = 1
            return True
        return False

    def _sync_holdings_from_db(self, *, log_restore: bool = False) -> None:
        """DB active_trades → holdings (진실의 원천). 장중 고점은 _runtime 과 merge.

        - DB에 없는 종목은 holdings·_runtime 에서 제거 (양방향 정합)
        - poll_pending 매수 체결·재시작 후에도 다음 루프에 자동 반영
        - ETF/ETN/스팩 등 시세 불가 종목만 보유 루프에서 제외.
          우선주는 매수 차단 대상이지만, 이미 산 경우 매도 가능하도록 유지.
        """
        try:
            prefix = self.strategy_id.split("_")[0] if "_" in self.strategy_id else self.strategy_id
            t_db0 = time.perf_counter()
            rows = self.db.get_active_trades(strategy_prefix=prefix)
            self._prof_sync_db_ms = (time.perf_counter() - t_db0) * 1000.0
            t_m0 = time.perf_counter()
            skipped_non_stock: list[str] = []
            new_holdings: Dict[str, dict] = {}
            for code, t in rows.items():
                if t.get("strategy") and t["strategy"] != self.strategy_id:
                    continue
                if get_env_bool("EXCLUDE_NON_STOCK", True):
                    name = (t.get("name") or "").strip()
                    # 우선주(is_non_stock)는 매도 정리 위해 holdings 유지.
                    # ETF/ETN/스팩만 자동 제외.
                    if _is_unmanageable_holding(name, code):
                        skipped_non_stock.append(f"{code}({name})")
                        continue
                avg_bp = float(t.get("avg_buy_price", 0) or t.get("buy_price", 0) or 0)
                qty = int(t.get("current_qty", 0) or t.get("qty", 0) or 0)
                if qty <= 0 or avg_bp <= 0:
                    continue
                db_max = float(t.get("max_price") or 0)
                db_sess = float(t.get("session_low") or 0)
                max_p, sess_low = self._merge_runtime_overlay(code, avg_bp, db_max, db_sess)
                holding = {
                    "buy_price": avg_bp,
                    "qty": qty,
                    "stop_price": t.get("stop_price", 0),
                    "target_price": t.get("target_price", 0),
                    "max_price": max_p,
                    "session_low": sess_low,
                    "atr_entry": t.get("atr_at_entry", t.get("atr_entry", 0)),
                    "buy_time": t.get("buy_date", dt.now().strftime("%Y-%m-%d %H:%M:%S")),
                    "name": t.get("name", code),
                    "size_class": t.get("size_class", ""),
                }
                self._apply_runtime_extra_fields(code, holding)
                new_holdings[code] = holding

            db_codes = set(new_holdings.keys())
            for code in list(self._runtime.keys()):
                if code not in db_codes:
                    del self._runtime[code]

            prev_codes = set(self.holdings.keys())
            self.holdings.clear()
            self.holdings.update(new_holdings)
            self._after_holdings_sync()
            self._prof_sync_merge_ms = (time.perf_counter() - t_m0) * 1000.0

            if log_restore and self.holdings:
                self.logger.info(
                    "📂 [DB 복원] 보유 %d종목 (%s)",
                    len(self.holdings), self.strategy_id,
                )
            elif not log_restore:
                added = db_codes - prev_codes
                if added:
                    self.logger.info(
                        "📂 [DB동기화] +%d종목 (%s) poll/체결 반영: %s",
                        len(added), self.strategy_id, ",".join(sorted(added)[:5]),
                    )
                removed = prev_codes - db_codes
                if removed:
                    self.logger.debug(
                        "📂 [DB동기화] -%d종목 (%s) 청산 반영: %s",
                        len(removed), self.strategy_id, ",".join(sorted(removed)[:5]),
                    )

            if skipped_non_stock:
                self.logger.warning(
                    "⚠ ETF/ETN/스팩 보유 자동 제외(시세불가 — 한투 HTS에서 직접 처분 권장): %s",
                    ", ".join(skipped_non_stock),
                )
        except Exception as e:
            self.logger.warning("DB holdings 동기화 실패: %s", e)

    def _sync_holdings_from_db(self, *, log_restore: bool = False, force: bool = False) -> None:
        """DB active_trades → holdings. 하이브리드: 이벤트 RAM + 주기 안전망.

        - 재시작(log_restore) / force / 지정가 체결(_load_holdings_from_db): 즉시 DB
        - 루프: HOLDINGS_DB_SYNC_INTERVAL_SEC(기본 60)마다만 DB (공유 conn 락 완화)
        - 시장가 매수·매도 성공 시 RAM은 이미 갱신 (_submit_buy / _drop_local_position)
        - 장중 고점은 _runtime 과 merge. DB에 없는 종목은 holdings·_runtime 제거
        - ETF/ETN/스팩 등 시세 불가 종목만 보유 루프에서 제외.
          우선주는 매수 차단 대상이지만, 이미 산 경우 매도 가능하도록 유지.
        """
        if self._should_skip_holdings_db_sync(force=force, log_restore=log_restore):
            return
        self._prof_sync_skipped = 0
        try:
            prefix = self.strategy_id.split("_")[0] if "_" in self.strategy_id else self.strategy_id
            t_db0 = time.perf_counter()
            rows = self.db.get_active_trades(strategy_prefix=prefix)
            self._prof_sync_db_ms = (time.perf_counter() - t_db0) * 1000.0
            t_m0 = time.perf_counter()
            skipped_non_stock: list[str] = []
            new_holdings: Dict[str, dict] = {}
            for code, t in rows.items():
                if t.get("strategy") and t["strategy"] != self.strategy_id:
                    continue
                if get_env_bool("EXCLUDE_NON_STOCK", True):
                    name = (t.get("name") or "").strip()
                    # 우선주(is_non_stock)는 매도 정리 위해 holdings 유지.
                    # ETF/ETN/스팩만 자동 제외.
                    if _is_unmanageable_holding(name, code):
                        skipped_non_stock.append(f"{code}({name})")
                        continue
                avg_bp = float(t.get("avg_buy_price", 0) or t.get("buy_price", 0) or 0)
                qty = int(t.get("current_qty", 0) or t.get("qty", 0) or 0)
                if qty <= 0 or avg_bp <= 0:
                    continue
                db_max = float(t.get("max_price") or 0)
                db_sess = float(t.get("session_low") or 0)
                max_p, sess_low = self._merge_runtime_overlay(code, avg_bp, db_max, db_sess)
                holding = {
                    "buy_price": avg_bp,
                    "qty": qty,
                    "stop_price": t.get("stop_price", 0),
                    "target_price": t.get("target_price", 0),
                    "max_price": max_p,
                    "session_low": sess_low,
                    "atr_entry": t.get("atr_at_entry", t.get("atr_entry", 0)),
                    "buy_time": t.get("buy_date", dt.now().strftime("%Y-%m-%d %H:%M:%S")),
                    "name": t.get("name", code),
                    "size_class": t.get("size_class", ""),
                }
                self._apply_runtime_extra_fields(code, holding)
                new_holdings[code] = holding

            db_codes = set(new_holdings.keys())
            for code in list(self._runtime.keys()):
                if code not in db_codes:
                    del self._runtime[code]

            prev_codes = set(self.holdings.keys())
            self.holdings.clear()
            self.holdings.update(new_holdings)
            self._after_holdings_sync()
            self._prof_sync_merge_ms = (time.perf_counter() - t_m0) * 1000.0
            self._holdings_db_sync_ts = time.time()

            if log_restore and self.holdings:
                self.logger.info(
                    "📂 [DB 복원] 보유 %d종목 (%s)",
                    len(self.holdings), self.strategy_id,
                )
            elif not log_restore:
                added = db_codes - prev_codes
                if added:
                    self.logger.info(
                        "📂 [DB동기화] +%d종목 (%s) poll/체결 반영: %s",
                        len(added), self.strategy_id, ",".join(sorted(added)[:5]),
                    )
                removed = prev_codes - db_codes
                if removed:
                    self.logger.debug(
                        "📂 [DB동기화] -%d종목 (%s) 청산 반영: %s",
                        len(removed), self.strategy_id, ",".join(sorted(removed)[:5]),
                    )

            if skipped_non_stock:
                self.logger.warning(
                    "⚠ ETF/ETN/스팩 보유 자동 제외(시세불가 — 한투 HTS에서 직접 처분 권장): %s",
                    ", ".join(skipped_non_stock),
                )
        except Exception as e:
            self.logger.warning("DB holdings 동기화 실패: %s", e)


    def _sync_holdings_from_db(self, *, log_restore: bool = False, force: bool = False) -> None:
        """DRY_RUN: active_trades_paper 만. 실매 테이블과 교차 금지.

        주의: Base 는 strategy_id ``US_MOMENTUM`` → prefix ``US`` 로 LIKE 조회하는데
        paper 는 strategy 정확 일치로만 로드한다.
        ※ init 시점엔 reload_config 전이라 self._dry_run 이 없을 수 있음 → env 직접 조회.
        """
        dry = get_env_bool("US_MOMENTUM_DRY_RUN", True)
        self._dry_run = dry
        if not dry:
            return super()._sync_holdings_from_db(log_restore=log_restore, force=force)
        if self._should_skip_holdings_db_sync(force=force, log_restore=log_restore):
            return
        self._prof_sync_skipped = 0
        try:
            rows = paper_store.get_active_trades_paper(
                self.db, strategy_id=self.strategy_id
            )
            new_holdings: Dict[str, dict] = {}
            for code, t in rows.items():
                avg_bp = float(t.get("avg_buy_price") or t.get("buy_price") or 0)
                db_max = float(t.get("max_price") or 0)
                db_sess = float(t.get("session_low") or avg_bp or 0)
                max_p, sess_low = self._merge_runtime_overlay(code, avg_bp, db_max, db_sess)
                holding = {
                    "buy_price": avg_bp,
                    "qty": int(t.get("current_qty") or t.get("qty") or 0),
                    "stop_price": float(t.get("stop_price") or 0),
                    "target_price": float(t.get("target_price") or 0),
                    "max_price": max_p,
                    "session_low": sess_low,
                    "atr_entry": float(t.get("atr_at_entry") or t.get("atr_entry") or 0),
                    "buy_time": t.get("buy_date") or "",
                    "name": t.get("name") or code,
                    "size_class": t.get("size_class") or "",
                    "paper": True,
                }
                self._apply_runtime_extra_fields(code, holding)
                if holding["qty"] > 0 and avg_bp > 0:
                    new_holdings[code] = holding
            for gone in set(self.holdings.keys()) - set(new_holdings.keys()):
                self._runtime.pop(gone, None)
            self.holdings.clear()
            self.holdings.update(new_holdings)
            self._after_holdings_sync()
            self._holdings_db_sync_ts = time.time()
            if log_restore and self.holdings:
                self.logger.info(
                    "📂 [PAPER] holdings 복원 %d종 [%s]",
                    len(self.holdings), self.strategy_id,
                )
        except Exception as e:
            self.logger.error("paper holdings sync 실패: %s", e)

    def _sync_holdings_from_db(self, *, log_restore: bool = False) -> None:
        """DRY_RUN: active_trades_paper 만. 실매 테이블과 교차 금지.

        주의: Base 는 strategy_id ``US_MOMENTUM`` → prefix ``US`` 로 LIKE 조회하는데
        paper 는 strategy 정확 일치로만 로드한다.
        ※ init 시점엔 reload_config 전이라 self._dry_run 이 없을 수 있음 → env 직접 조회.
        """
        dry = get_env_bool("US_MOMENTUM_DRY_RUN", True)
        self._dry_run = dry
        if not dry:
            return super()._sync_holdings_from_db(log_restore=log_restore)
        try:
            rows = paper_store.get_active_trades_paper(
                self.db, strategy_id=self.strategy_id
            )
            new_holdings: Dict[str, dict] = {}
            for code, t in rows.items():
                avg_bp = float(t.get("avg_buy_price") or t.get("buy_price") or 0)
                db_max = float(t.get("max_price") or 0)
                db_sess = float(t.get("session_low") or avg_bp or 0)
                max_p, sess_low = self._merge_runtime_overlay(code, avg_bp, db_max, db_sess)
                holding = {
                    "buy_price": avg_bp,
                    "qty": int(t.get("current_qty") or t.get("qty") or 0),
                    "stop_price": float(t.get("stop_price") or 0),
                    "target_price": float(t.get("target_price") or 0),
                    "max_price": max_p,
                    "session_low": sess_low,
                    "atr_entry": float(t.get("atr_at_entry") or t.get("atr_entry") or 0),
                    "buy_time": t.get("buy_date") or "",
                    "name": t.get("name") or code,
                    "size_class": t.get("size_class") or "",
                    "paper": True,
                }
                self._apply_runtime_extra_fields(code, holding)
                if holding["qty"] > 0 and avg_bp > 0:
                    new_holdings[code] = holding
            for gone in set(self.holdings.keys()) - set(new_holdings.keys()):
                self._runtime.pop(gone, None)
            self.holdings.clear()
            self.holdings.update(new_holdings)
            self._after_holdings_sync()
            if log_restore and self.holdings:
                self.logger.info(
                    "📂 [PAPER] holdings 복원 %d종 [%s]",
                    len(self.holdings), self.strategy_id,
                )
        except Exception as e:
            self.logger.error("paper holdings sync 실패: %s", e)

    "LOOP_PROFILE_ENABLED",
    "LOOP_PROFILE_EVERY_N",
    "LOOP_PROFILE_LOG_PATH",
    "CHECK_BUY_PROF_MIN_MS",
    # 보유 RAM 하이브리드 — 이벤트 갱신 + N초마다만 get_active_trades (0=매루프 레거시)
    "HOLDINGS_DB_SYNC_INTERVAL_SEC",
    # 틱매도 — WS 현재가 갱신 시 매도 검사 (기본 false · 루프 매도 유지 + 틱 보강)

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

            _f("CHECK_BUY_PROF_MIN_MS", "check_buy 상세 로그 하한(ms)", "int", default=500,
               hint="이 ms 이상인 check_buy 만 [CHECK_BUY_PROF] 단계별 기록 · LOOP_PROFILE_ENABLED 필요"),
            _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,
               hint="ON=WS 현재가 갱신마다 매도검사(백테 틱청산에 근접) · OFF=루프(~0.1초)만 · "
                    "전략별 *_TICK_SELL_ENABLED 가 비어있지 않으면 그쪽 우선 · 기본 OFF"),

            _f("CHECK_BUY_PROF_MIN_MS", "check_buy 상세 로그 하한(ms)", "int", default=500,
               hint="이 ms 이상인 check_buy 만 [CHECK_BUY_PROF] 단계별 기록 · LOOP_PROFILE_ENABLED 필요"),
            _f("TICK_SELL_ENABLED", "틱매도(공통)", "bool", default=False,
               hint="ON=WS 현재가 갱신마다 매도검사(백테 틱청산에 근접) · OFF=루프(~0.1초)만 · "
                    "전략별 *_TICK_SELL_ENABLED 가 비어있지 않으면 그쪽 우선 · 기본 OFF"),


Shell

cd /home/hoon/kis_bot && python3 -m py_compile kis_trader/strategies/base.py kis_trader/strategies/us_momentum.py && python3 <<'PY'
from database import TradeDB, apply_env_patch, invalidate_merged_env_cache, get_env_from_db

# schema check not needed for env apply
r = apply_env_patch({"HOLDINGS_DB_SYNC_INTERVAL_SEC": "60"})
invalidate_merged_env_cache()
v = get_env_from_db("HOLDINGS_DB_SYNC_INTERVAL_SEC", "")
print("apply", r, "get", repr(v))
PY

Compile and apply HOLDINGS sync env


Grep

def apply_env|def set_env|def upsert_env|patch_env


Shell

cd /home/hoon/kis_bot && python3 <<'PY'
from kis_trader.backtest.param_search_apply_snapshot import apply_env_patch
from database import get_env_from_db, invalidate_merged_env_cache
r = apply_env_patch({"HOLDINGS_DB_SYNC_INTERVAL_SEC": "60"})
invalidate_merged_env_cache()
print("apply", r, "get", repr(get_env_from_db("HOLDINGS_DB_SYNC_INTERVAL_SEC", "")))
print("classify ok", "HOLDINGS_DB_SYNC_INTERVAL_SEC" in __import__("database").ENV_CONFIG_KEYS)
PY

Apply HOLDINGS_DB_SYNC_INTERVAL_SEC=60 to DB


cd /home/hoon/kis_bot && python3 <<'PY'
from kis_trader.backtest.param_search_apply_snapshot import apply_env_patch
from kis_trader.utils.env import get_env_from_db, invalidate_merged_env_cache
import database as dbmod
r = apply_env_patch({"HOLDINGS_DB_SYNC_INTERVAL_SEC": "60"})
invalidate_merged_env_cache()
print("apply", r)
print("get", repr(get_env_from_db("HOLDINGS_DB_SYNC_INTERVAL_SEC", "")))
print("in ENV_CONFIG_KEYS", "HOLDINGS_DB_SYNC_INTERVAL_SEC" in dbmod.ENV_CONFIG_KEYS)
PY

Apply and verify holdings sync env key


cd /home/hoon/kis_bot && sudo systemctl restart kis_trader_main.service && sleep 3 && systemctl is-active kis_trader_main.service && wc -l logs/loop_profile.log; : > logs/loop_profile.log; sleep 8; rg -n "sync_skip=|sync_db=" logs/loop_profile.log | head -40

Restart live bot; sample sync_skip profile


sleep 15; wc -l /home/hoon/kis_bot/logs/loop_profile.log; rg "sync_skip=" /home/hoon/kis_bot/logs/loop_profile.log | head -20; echo '---'; rg "sync_skip=0" /home/hoon/kis_bot/logs/loop_profile.log | head -10; echo 'skip1 count'; rg -c "sync_skip=1" /home/hoon/kis_bot/logs/loop_profile.log || true; echo 'skip0 count'; rg -c "sync_skip=0" /home/hoon/kis_bot/logs/loop_profile.log || true; journalctl -u kis_trader_main.service -n 30 --no-pager | rg -i "error|fail|DB 복원|HOLDINGS|Traceback" || true

Sample loop_profile sync_skip after restart


sleep 55; echo 'skip0:'; rg -c "sync_skip=0" /home/hoon/kis_bot/logs/loop_profile.log || echo 0; echo 'sample skip0:'; rg "sync_skip=0" /home/hoon/kis_bot/logs/loop_profile.log | head -5; echo 'recent strategies:'; rg -o "\[LOOP_PROF\] [A-Z_]+" /home/hoon/kis_bot/logs/loop_profile.log | sort | uniq -c | sort -rn | head

Confirm 60s safety-net sync fires once


하이브리드 보유 sync 적용했고, 실매 재시작까지 반영했습니다.

### 동작
- **재시작** / **지정가 체결** (`_load_holdings_from_db`) → DB 강제 sync
- **시장가 매수·매도 성공** → 기존처럼 RAM 즉시 갱신
- **루프** → `HOLDINGS_DB_SYNC_INTERVAL_SEC=60` 마다만 `get_active_trades` (DB `60` 저장 완료)
- `0`으로 두면 예전처럼 매 루프 sync

### 계측 (`logs/loop_profile.log`)
- 평소: `sync_skip=1` · `sync_db≈0` · 루프 `total≈3ms` 수준
- ~60초마다: `sync_skip=0` · 전략별 `sync_db≈0.4~1.1s` 1회 (공유 DB 락은 안전망 창에서만)

### 영향 범위
- **실매만** (웹백테·Optuna 무관)
- 잔여: `cand_load` / `scan_pre` 는 그대로 — sync 병목은 대부분 제거된 상태입니다.


<timestamp>Thursday, Aug 13, 2026, 1:49 PM (UTC+9)</timestamp>

<user_query>Briefly inform the user about the task result and perform any follow-up actions (if needed). If there's no follow-ups needed, don't explicitly say that.</user_query>


재시작 직후 샘플 셸이 `exit 1`로 끝난 건, 로그를 비운 뒤 8초 안에 `sync_skip` 줄이 아직 없어서 `rg`가 매칭 실패했기 때문입니다. 서비스는 `active`였고, 이후 샘플에서 `sync_skip=1`(평소)·60초마다 `sync_skip=0` 안전망이 정상 확인됐습니다.


<timestamp>Thursday, Aug 13, 2026, 1:51 PM (UTC+9)</timestamp>
<user_query>

Aug 13 13:46:50 ubuntu2404 python[1383758]: [13:46:50] [KISWebSocket] ⏱ [봉강제확정] 454910 3M 202608131342 C=79000 — 다음 체결 없음(봉주기 경과) → 즉시 확정
Aug 13 13:46:50 ubuntu2404 python[1383758]: [13:46:50] [KISWebSocket] ⏱ [봉강제확정] 006360 1M 202608131343 C=36500 — 다음 체결 없음(봉주기 경과) → 즉시 확정
Aug 13 13:46:50 ubuntu2404 python[1383758]: [13:46:50] [KISWebSocket] ⏱ [봉강제확정] 006360 3M 202608131342 C=36550 — 다음 체결 없음(봉주기 경과) → 즉시 확정
Aug 13 13:46:50 ubuntu2404 python[1383758]: [13:46:50] [KISWebSocket] ⏱ [봉강제확정] 189330 1M 202608131343 C=17510 — 다음 체결 없음(봉주기 경과) → 즉시 확정
Aug 13 13:46:50 ubuntu2404 python[1383758]: [13:46:50] [KISWebSocket] ⏱ [봉강제확정] 189330 3M 202608131342 C=17480 — 다음 체결 없음(봉주기 경과) → 즉시 확정
Aug 13 13:46:50 ubuntu2404 python[1383758]: [13:46:50] [KISWebSocket] ⏱ [봉강제확정] 059120 1M 202608131343 C=7600 — 다음 체결 없음(봉주기 경과) → 즉시 확정
Aug 13 13:46:50 ubuntu2404 python[1383758]: [13:46:50] [KISWebSocket] ⏱ [봉강제확정] 059120 3M 202608131342 C=7600 — 다음 체결 없음(봉주기 경과) → 즉시 확정
Aug 13 13:46:50 ubuntu2404 python[1383758]: [13:46:50] [KISWebSocket] ⏱ [봉강제확정] 119850 1M 202608131343 C=55400 — 다음 체결 없음(봉주기 경과) → 즉시 확정
Aug 13 13:46:50 ubuntu2404 python[1383758]: [13:46:50] [KISWebSocket] ⏱ [봉강제확정] 119850 3M 202608131342 C=55400 — 다음 체결 없음(봉주기 경과) → 즉시 확정
Aug 13 13:46:50 ubuntu2404 python[1383758]: [13:46:50] [KISWebSocket] ⏱ [봉강제확정] 005090 1M 202608131343 C=63500 — 다음 체결 없음(봉주기 경과) → 즉시 확정
Aug 13 13:46:50 ubuntu2404 python[1383758]: [13:46:50] [KISWebSocket] ⏱ [봉강제확정] 005090 3M 202608131342 C=63600 — 다음 체결 없음(봉주기 경과) → 즉시 확정
Aug 13 13:46:50 ubuntu2404 python[1383758]: [13:46:50] [KISWebSocket] ⏱ [봉강제확정] 003350 1M 202608131343 C=11510 — 다음 체결 없음(봉주기 경과) → 즉시 확정
Aug 13 13:46:50 ubuntu2404 python[1383758]: [13:46:50] [KISWebSocket] ⏱ [봉강제확정] 003350 3M 202608131342 C=11510 — 다음 체결 없음(봉주기 경과) → 즉시 확정
Aug 13 13:46:50 ubuntu2404 python[1383758]: [13:46:50] [KISWebSocket] ⏱ [봉강제확정] 139480 1M 202608131343 C=77300 — 다음 체결 없음(봉주기 경과) → 즉시 확정
Aug 13 13:46:50 ubuntu2404 python[1383758]: [13:46:50] [KISWebSocket] ⏱ [봉강제확정] 139480 3M 202608131342 C=77300 — 다음 체결 없음(봉주기 경과) → 즉시 확정
Aug 13 13:46:50 ubuntu2404 python[1383758]: [13:46:50] [KISWebSocket] ⏱ [봉강제확정] 090710 1M 202608131343 C=7740 — 다음 체결 없음(봉주기 경과) → 즉시 확정
Aug 13 13:46:50 ubuntu2404 python[1383758]: [13:46:50] [KISWebSocket] ⏱ [봉강제확정] 090710 3M 202608131342 C=7740 — 다음 체결 없음(봉주기 경과) → 즉시 확정
Aug 13 13:46:50 ubuntu2404 python[1383758]: [13:46:50] [KISWebSocket] ⏱ [봉강제확정] 281820 1M 202608131343 C=72900 — 다음 체결 없음(봉주기 경과) → 즉시 확정
Aug 13 13:46:50 ubuntu2404 python[1383758]: [13:46:50] [KISWebSocket] ⏱ [봉강제확정] 281820 3M 202608131342 C=72900 — 다음 체결 없음(봉주기 경과) → 즉시 확정
Aug 13 13:46:50 ubuntu2404 python[1383758]: [13:46:50] [kis_trader.strategy.SCALP] 🔍 [T:kiwoom|O:kiwoom|탈락-최소가격] 099430 099430: 3630 < 6000
강제 봉확정은 딜레이 없지? 잊고은 그리고 해외 전략쪽때문에 딜레이는 안생기나?
</user_query>


봉강제확정 경로와 해외 전략과의 상호작용을 코드에서 확인합니다.

Grep

다음 체결 없음|봉주기 경과


**의도상 딜레이를 없애는 장치**입니다. “다음 틱이 와야 봉 확정”이면 저유동은 수 분~십수 분 밀릴 수 있어서, 봉주기 끝나면 **다음 체결 없이** 확정합니다.

### 타이밍
| 항목 | 값 |
|------|-----|
| grace | `WS_CANDLE_FORCE_CONFIRM_GRACE_SEC` **기본 0** → 봉 끝나는 시각부터 추가 대기 없음 |
| 점검 주기 | `WS_CANDLE_STALE_CHECK_INTERVAL_SEC` **기본 2초** (CandleDBWriter 스레드) |
| 설계상 최대 인지 지연 | 봉 종료 후 **대략 ≤ 점검 주기(~2초)** |

정상 확정(다음 틱 롤오버)과 강제확정 모두 같은 `_confirm` + freeze 경로입니다. OHLC를 늘리거나 변조하지 않고, **“언제 확정으로 인지하냐”만** 앞당깁니다.

### 해외 전략
- 이 로그는 **국내 `kis_ws` CandleAggregator** 전용입니다.
- `kis_ws_overseas`에는 강제확정 로직이 **없습니다**.
- 해외 루프가 이 강제확정을 기다리거나, 강제확정이 해외 때문에 의도적으로 밀리게 설계된 부분은 **없습니다**.

### 참고 (로그 해석)
`13:46:50`에 `…1343` 1분봉을 한꺼번에 찍은 건, 설계상 “봉 끝나자마자 2초 안”이 아니라 **그때 writer가 stale 점검을 돌린 시점**에 미확정 봉이 많이 쌓여 있었다는 뜻에 가깝습니다(재시작·배치/락 경합·틱 공백 등). 해외 전략 때문이 아닙니다.

원하시면 그 시각 전후 writer/재시작·락 대기를 로그로 짧게 짚어볼 수 있습니다.


<timestamp>Thursday, Aug 13, 2026, 1:53 PM (UTC+9)</timestamp>
<user_query>
이거랑 또 남은 부분이 뭔지 알려줘 그리고 갭보정 부분이 아니더라도 전략 한사이클이 1초정도 걸리는거같네 느린편이라 아직
</user_query>


최근 루프 계측으로 남은 병목을 확인하고, 완료/잔여를 정리합니다.

Shell

cd /home/hoon/kis_bot && python3 <<'PY'
from collections import defaultdict
import re
path='logs/loop_profile.log'
# last ~800 lines
lines=open(path,encoding='utf-8',errors='replace').read().splitlines()[-800:]
keys=['total','reload','sync_hold','sync_db','sync_skip','cand_load','scan','scan_other','scan_pre','buy_sum','lock_hold','sell_chk','ws_sync','pending','trd_db']
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))
    if d: by[sid].append(d)

def pct(vals,p):
    if not vals: return None
    s=sorted(vals)
    i=min(len(s)-1, max(0, int(round((p/100)*(len(s)-1)))))
    return s[i]

for sid, rows in sorted(by.items()):
    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={pct(vals,50):7.1f}  p90={pct(vals,90):7.1f}  max={max(vals):7.1f}')
    # among sync_skip=1 only
    sk=[r for r in rows if r.get('sync_skip',0)>=0.5]
    nsk=[r for r in rows if r.get('sync_skip',0)<0.5]
    if sk:
        t=[r['total'] for r in sk if 'total' in r]
        print(f'  [skip=1] n={len(sk)} total mean={sum(t)/len(t):.1f} p50={pct(t,50):.1f} p90={pct(t,90):.1f}')
    if nsk:
        t=[r['total'] for r in nsk if 'total' in r]
        print(f'  [skip=0] n={len(nsk)} total mean={sum(t)/len(t):.1f} p50={pct(t,50):.1f} p90={pct(t,90):.1f}')
PY

Summarize recent LOOP_PROF stage timings


Grep

def is_gap_ready|def get_candidates_for|def get_universe_for


### 봉강제확정 vs 사이클 느림
둘은 **거의 무관**합니다. 강제확정은 CandleDBWriter가 ~2초마다 “끝난 봉”만 확정하는 경로이고, 전략 1바퀴 `total`을 1초대로 만드는 주범이 아닙니다. 해외 전략과도 연결 없습니다.

---

### 이미 끝난 것
| 항목 | 효과 |
|------|------|
| 피드 로거 DB 스냅샷 | `after` 수 초급 제거 |
| SHORT/MOM reload RAM | reload ~ms |
| 당일거래 TTL 캐시 | trades DB 연타↓ |
| 스캔 이름 DB 조회 제거 | `scan_name=0` |
| **보유 sync 하이브리드 60초** | 평소 `sync_skip=1`, `sync_db≈0` |
| 틱매도·루프 계측 | 병목 가시화 |

---

### 아직 남은 병목 (방금 로그 기준)
체감 “한 사이클 ~1초+”는 **맞습니다**. holdings sync는 거의 빠졌고, **후보 로드 + 스캔 앞단**이 남았습니다.

| 구간 | SCALP/BREAKOUT (대략) | 의미 |
|------|----------------------|------|
| **`cand_load`** | p50 ~0.5~0.8s, 스파이크 1~2s | 유니버스/후보 로드 — 공유 DB 락·대기 가능 |
| **`scan_pre`** | p50 ~3s대 | check_buy **전**: 필터·쿨다운·포트가드·**갭 ready** 등 |
| **`buy_sum`(엔진)** | 보통 수 ms~수백 ms | 이미 상대적으로 작음 |
| **`sync_db`** | 평소 0 / 60초마다만 | 사실상 해결 |

참고: `scan_other ≈ scan - buy_sum` 이라 **탈락 sleep**도 여기 포함되지만, 지금 숫자는 **`scan_pre`가 대부분**입니다. “갭보정 API 자체”가 매 루프 REST를 도는 게 아니라, **`is_gap_ready`로 스킵·대기하는 시간 + 후보 순회**가 `scan_pre`에 잡힌 상태에 가깝습니다.

MOMENTUM은 후보/스캔이 비면 p50 ~3ms로 가볍고, SCALP·BREAKOUT·SHORT가 느린 편입니다.

---

### 다음에 손댈 후보 (아직 미착수)
1. **`scan_pre` 세분** — gap / guard / filter / sleep 중 뭐가 초 단위인지 확정
2. **`cand_load`** — 후보 리스트 TTL·락 대기 줄이기 (매 루프 풀로드 완화)
3. (선택) 강제확정 INFO 폭주 줄이기 — **체감 사이클과는 별개**
4. 계측 끝나면 `LOOP_PROFILE` 끄거나 주기↑

원하시면 다음 턴에 **1→2 순으로 계측만 더 쪼개서** “갭 ready인지 / 후보 DB인지 / reject sleep인지”부터 찍고 보고한 뒤, 승인 후 수정하겠습니다.


<timestamp>Thursday, Aug 13, 2026, 1:57 PM (UTC+9)</timestamp>
<user_query>
1번2번 일단 측정만 4번은 뭔데
</user_query>


1·2는 계측만 세분하고, 4번은 짧게 설명합니다.