맞아요. **“DB를 영원히 안 한다”는 불가능**하고, 줄일 수 있는 건 **몇 번·누가 줄 서서**입니다.

### 오해 풀기
한 번 조회 자체(수 ms~수십 ms)는 싸요.
아픈 건:

- 전략 **4개가 동시에** “나 캐시 만료!” → **같은 MariaDB 락에 줄**
- 그래서 **한 번이 아니라 대기 포함 1~2초**로 보임

즉 “조회 1회는 필수” ✓
“초마다 × 전략마다 × 줄서기”는 필수가 아님 ✗

---

### 슬롯을 “키 바뀔 때만”이면?
지금은 **1초마다** 만료 → 분당 대략 전략당 ~60번 재조회 기회.
슬롯 키(보통 **1분**)가 바뀔 때만이면:

- 분당 **전략당 약 1번** (+ 키 바뀌는 순간만)
- 같은 `(전략, 슬롯키)`면 그 1분 안은 RAM만

**정합 검사는 매 루프 그대로** (교집합은 RAM으로).
DB는 “새 분 스냅샷 받을 때”만.
→ 조회 **횟수·동시 줄서기**가 줄어듦. “0번”이 아님.

---

### `g_trdb` 미스 / `reload`
- **g_trdb**: 1초에 한 번·체결 시 무효화는 이미 합리적. 더 줄이면 한도가 조금 더 낡음. **필수는 아님.**
- **reload**: “설정 바뀌었나”를 매 루프 DB/머지하는 비용. 설정도 TTL·이벤트면 줄일 수 있지만, **지금 병목 주인공 아님.**

---

### 한 줄
맞음: **언젠가는 DB 한 번.**
가능한 것: **같은 답을 초마다·네 줄이 동시에 다시 물어보지 않기.**
그래서 “덜 급함”이고, 안 해도 평소는 이미 괜찮고, **가끔 스파이크만** 더 깎는 옵션입니다.


<timestamp>Thursday, Aug 13, 2026, 2:35 PM (UTC+9)</timestamp>
<user_query>
가능한 것: 같은 답을 초마다·네 줄이 동시에 다시 물어보지 않기.
그러게 다른것도 다 조회 한번해서 나눠주는걸로 했는데 얘만 전략대로 다 도네 근데 구조가 가능한 구조인가 이게
</user_query>


가능합니다. 다만 **당일거래랑 “한 번에 전 전략 공통 1장”은 구조가 다릅니다.**

### 왜 당일거래는 한 번이면 되나
`trade_history` **오늘 표는 전 전략이 같은 표**입니다.
→ RAM 1장 → SCALP/SHORT/…가 **나눠 보면** 됩니다. (이미 그렇게 함)

### 왜 슬롯은 전략마다 답이 다르냐
history는 **`strategy_id`별 스냅샷**입니다.
같은 14:35라도

- SCALP 후보 집합 ≠ BREAKOUT 후보 집합

→ **전 전략이 한 장으로 나눠 쓰기 불가.**
캐시 키는 이미 `(전략, 소스, 슬롯키)`라서, “전략마다 돈다”는 **정답 구조**에 가깝습니다.

### 그럼 “조회 한 번 나눠주기”는 어디에 쓰이나

| 범위 | 가능? | 의미 |
|------|--------|------|
| 같은 전략 · 같은 분(슬롯키) · 여러 루프 | ✅ 이미 | TTL/캐시로 SELECT 안 함 |
| 같은 전략 · 같은 키 · 동시에 두 번 DB | ✅ 더 가능 | **single-flight** (한 놈만 DB, 나머지는 그 결과 대기) |
| 다른 전략끼리 유니버스 1장 공유 | ❌ | 답이 다름 |
| 분당 전략당 1번만 DB | ✅ | 슬롯키 바뀔 때만 조회 (1초 TTL 대신) |

지금 아픈 패턴은 “답이 4개라 4번”이 아니라,
**4개 쓰레드가 비슷한 초에 각자 TTL 만료 → 락에 줄** 입니다.

### 한 줄
- **구조적으로**: 슬롯은 전략별 캐시가 맞고, 당일거래식 “전 전략 1장”은 안 됨.
- **더 줄이려면**: (1) 슬롯키 변경 시에만 DB, (2) 같은 키는 동시에 DB 1번(single-flight).
원하시면 그 설계로 수정 들어가겠습니다.


<timestamp>Thursday, Aug 13, 2026, 2:38 PM (UTC+9)</timestamp>
<user_query>
그러니까 캔디느 안되고 임계값 슬롯만 가능하는거?
</user_query>


거의 맞게 이해하셨어요. 말만 조금 고치면:

### 후보(캔디) — “전 전략이 한 명단”
**불가.** SCALP 후보 ≠ BREAKOUT 후보.
(후보는 원래 전략별·조건검색 sticky/이벤트.)

### 슬롯 history — “전 전략이 한 스냅샷”
**불가.** 이것도 전략별로 집합이 다름.

### 슬롯에서 **가능한** 것
**같은 전략** 안에서만:
- 같은 슬롯키면 RAM 재사용
- 슬롯키 바뀔 때만 DB
- 같은 키로 동시에 두 번 DB 안 치기

### 전 전략이 한 장으로 되는 것
**당일거래 표**처럼 “표가 하나인 것”만. (이미 함)

**한 줄:** 후보·슬롯 둘 다 “전 전략 공유 1장”은 안 되고, 슬롯은 **전략별 + 분이 바뀔 때만 DB**가 가능한 쪽입니다.


<timestamp>Thursday, Aug 13, 2026, 2:39 PM (UTC+9)</timestamp>
<user_query>
아 그니까 ttl로 바꾼다는거? 히스토리 정합쪽도 그렇고 임계값env  체크쪽도 그렇고?
</user_query>


맞아요, 큰 그림은 **“매 루프 DB 말고 RAM + 언제 다시 읽을지”** 입니다. 다만 **이미 한 것 / 아직 안 한 것**을 나누면:

### 이미 TTL로 한 것
| 뭐 | env | 지금 |
|----|-----|------|
| 당일거래·일일한도 가드 | `TODAY_TRADES_CACHE_TTL_SEC=1` | ✅ |
| 히스토리 슬롯 정합 | `UNIVERSE_SLOT_CACHE_TTL_SEC=1` | ✅ (정합 ON 유지, 1초마다만 DB 기회) |

### 다음에 말한 “더 줄이기” (아직 안 함)
| 뭐 | 제안 | TTL이냐 |
|----|------|---------|
| **히스토리 슬롯** | 1초 TTL 대신 **슬롯키(분) 바뀔 때만** DB | TTL보다 **sticky(키 변경)** 에 가까움 |
| **임계값/설정 env (`reload`)** | 매 루프 설정 다시 보기 → **N초 TTL** 또는 변경 시에만 | 이건 **env 쪽 TTL** (아직 미적용) |

### 한 줄
- **정합(슬롯)·당일거래**: 이미 TTL.
- **env/임계값 핫리로드**: “그렇게 바꿀 수 **있는** 후보”이지, 아직 안 바꿈.
- 슬롯을 더 줄이려면 TTL 초를 키우는 것보다 **분이 바뀔 때만**이 더 sticky에 가깝습니다.


<timestamp>Thursday, Aug 13, 2026, 2:41 PM (UTC+9)</timestamp>
<user_query>
둘 중에 더 많이 잡아먹고 잇는건 뭐야?
히스토리 슬롯 정합용인데 그렇게 하면 덜 맞아지는거 아냐?

</user_query>


Shell

cd /home/hoon/kis_bot && python3 <<'PY'
from collections import defaultdict
import re
from datetime import datetime
lines=open('logs/loop_profile.log',encoding='utf-8',errors='replace').read().splitlines()[-12000:]
rows=[]
for ln in lines:
    if '[LOOP_PROF]' not in ln: continue
    try:
        t=datetime.strptime(ln[:19], '%Y-%m-%d %H:%M:%S')
    except Exception: continue
    if t < datetime(2026,8,13,14,26): continue
    m=re.search(r'\[LOOP_PROF\]\s+(\S+)\s+(.*)', ln)
    if not m: continue
    sid, rest=m.group(1), m.group(2)
    d={'sid':sid}
    for k in ['total','reload','cand_slot','slot_et','slot_get','slot_hit','slot_missc','pre_guard','g_trdb']:
        mm=re.search(rf'(?:^|\s){k}=([0-9.]+)', rest)
        if mm: d[k]=float(mm.group(1))
    rows.append(d)
print('n', len(rows))
# overall contribution: sum of ms and fraction of total, also spikes >500
for label, key in [('reload','reload'), ('cand_slot','cand_slot'), ('pre_guard','pre_guard')]:
    vs=[r[key] for r in rows if key in r]
    tot=[r['total'] for r in rows if 'total' in r and key in r]
    spikes=sum(1 for v in vs if v>=200)
    spikes5=sum(1 for v in vs if v>=500)
    print(f'{label:10s} mean={sum(vs)/len(vs):6.1f} p50={sorted(vs)[len(vs)//2]:6.1f} p90={sorted(vs)[int(len(vs)*0.9)]:6.1f} max={max(vs):7.1f}  sum_ms={sum(vs)/1000:.1f}s  spikes>=200ms={spikes} >=500={spikes5}')
# when slot_missc=1 vs hit
miss=[r for r in rows if r.get('slot_missc',0)>=1]
hit=[r for r in rows if r.get('slot_hit',0)>=1 and r.get('slot_missc',0)<1]
print('slot miss loops', len(miss), 'mean cand_slot', sum(r.get('cand_slot',0) for r in miss)/max(1,len(miss)))
print('slot hit-only loops', len(hit), 'mean cand_slot', sum(r.get('cand_slot',0) for r in hit)/max(1,len(hit)))
print('reload vs slot: mean reload', sum(r.get('reload',0) for r in rows)/len(rows), 'mean slot', sum(r.get('cand_slot',0) for r in rows)/len(rows))
PY

Compare reload vs cand_slot time share


### 1) 둘 중 뭐가 더 잡아먹나 (지금 로그)

| | 평소(p50) | 합산 시간 | 스파이크(≥500ms) |
|--|-----------|-----------|------------------|
| **히스토리 슬롯** (`cand_slot`) | ~0 (캐시 히트) | **훨씬 큼** (~1067s 상당) | **많음** (~719회) |
| **env/reload** | ~12ms | 작음 | **거의 없음** |

캐시 미스 때만 슬롯 평균 ~**750ms**.
→ **많이 잡아먹는 쪽 = 히스토리 슬롯(미스·줄서기).** reload는 잔잔함.

---

### 2) 슬롯키 바뀔 때만 DB → 정합이 깨지나?

**보통은 덜 맞는 게 아니라, 백테 슬롯이랑 더 비슷한 쪽입니다.**

- 백테도 “그 **분(슬롯)** 의 유니버스”로 맞춤.
- 지금 1초 TTL = 같은 분 안에서도 DB를 여러 번 다시 봄 (백테가 초마다 스냅샷 바꾸는 게 아님).
- **슬롯키 변경 시에만** = 그 분의 집합을 RAM에 두고 교집합은 매 루프 그대로 → **필터는 계속 ON.**

달라질 수 있는 점:
같은 분 **도중에** history에 새 종목이 더 쌓이면, 1초마다 읽으면 조금 더 빨리 반영되고, 키 변경만이면 **다음 분까지** 그 추가는 안 봄.
실시간 **후보 ENTER**는 조건검색 sticky가 따로 하고, 슬롯은 “백테 집합과 교집합”용이라, 실매 타점 정합 목적에는 **분 단위가 맞는 편**입니다.

**한 줄:** 지금 더 무거운 건 **슬롯**. 키 변경만 DB로 바꿔도 정합 OFF가 아니고, **초 단위로 맞추던 걸 분(슬롯) 단위로 맞추는 것**에 가깝습니다.


<timestamp>Thursday, Aug 13, 2026, 2:43 PM (UTC+9)</timestamp>
<user_query>
아니 시발 기억났다 분단위로 했다각 좆되서 초단위로 한건데 다시 분단위 가라고?
</user_query>


맞습니다. **분단위로 되돌리자는 제안은 철회합니다.**
예전에 좆된 이유가 있으면 그걸 무시하고 다시 가자고 한 제가 잘못이었습니다.

### 정리
- **지금 유지:** 슬롯 정합 ON + **초단위 TTL(1초)** — 그대로 두는 게 맞음.
- **하지 말 것:** 슬롯키(분) 바뀔 때만 DB.

### 그럼 남은 스파이크는?
1초마다 “다시 읽기” 자체가 아니라, **여러 전략이 같은 초에 미스 나며 DB 락에 줄 서는 것** 쪽입니다.
그건 **분으로 안 가도** 줄일 수 있는 축이 다름:
- 같은 `(전략, 슬롯키)` **single-flight** (동시에 DB 1번만)
- 미스 시각 **살짝 흩기** (전략마다 TTL 오프셋)

원하시면 **분단위 없이** 그거만 설계/적용하고, 예전에 분단위로 뭐가 깨졌는지 기억나는 증상만 짧게 알려주시면 그 함정도 체크리스트에 넣겠습니다.


<timestamp>Thursday, Aug 13, 2026, 2:44 PM (UTC+9)</timestamp>
<user_query>
아마 이거 룰에 이미 잇을건데 이렇게 하지말라고
그럼 아까 수정한것들도 이럴게 병신되게 한거 있냐?
</user_query>


룰·문서에 분단위/슬롯 관련 금지와, 이번 수정이 그 함정에 걸렸는지 확인합니다.


맞아요. 룰/문서에 이미 있습니다.

`docs/정합성.md`: **유니버스 = 초단위 `event_time` · 분 슬롯 ±1 해킹 금지**
백테 쪽도 1분 슬롯이면 **편입 최대 ~1분 지연** 난다고 초단위 타임라인으로 바꾼 이력이 있습니다.
제가 “슬롯키(분) 바뀔 때만 DB” 한 건 **그 함정으로 다시 가자고 한 병신 제안**이 맞습니다. 철회 유지.

---

### 아까 실제로 넣은 것들 — 그 짓이냐?

| 수정 | 분단위 병신과 같나? | 왜 |
|------|---------------------|-----|
| **슬롯 TTL 1초** (`UNIVERSE_SLOT_CACHE_TTL_SEC=1`) | **아님** | 최대 낡음 ~1초. 매 미스 때 `at_time=now`로 다시 읽음. 분 동결 아님. |
| **당일거래 TTL 1초 + 가드 공유** | **아님** | 유니버스/타점 정합 아님. 일일한도용 표. |
| **보유 sync 60초 + 체결 시 RAM** | **아님** (다른 리스크) | 유니버스 지연 아님. HTS 수동매도면 최대 ~60초 유령 가능 → `broker_no_position`으로 정리. 승인하신 하이브리드. |
| **(제안만) 분·슬롯키 변경 시에만 DB** | **그거임 → 안 함** | 초단위 event_time 정합 깨는 쪽. |

**한 줄:** 지금 들어간 코드는 “분으로 유니버스 얼리기”가 아닙니다.
병신 같은 건 **분단위로 다시 가자고 말한 제안**이고, 그건 적용 안 했습니다.


<timestamp>Thursday, Aug 13, 2026, 2:45 PM (UTC+9)</timestamp>
<user_query>
그럼 12 ms인 리로드쪽 이건 몇초마다 디비를 날리고있지/
</user_query>


cd /home/hoon/kis_bot && python3 <<'PY'
from kis_trader.utils.env import get_env_from_db
print('ENV_MERGED from db', repr(get_env_from_db('ENV_MERGED_CACHE_TTL_SEC','')))
import os
print('ENV_MERGED from os', os.environ.get('ENV_MERGED_CACHE_TTL_SEC'))
PY

Check ENV_MERGED_CACHE_TTL_SEC value


### 짧은 답
- **`reload_config()` 자체**: 루프 **매 바퀴** 호출 (초마다 1번이 아님. 바퀴가 빠르면 초당 여러 번).
- **공통 env 스냅샷 DB**: `ENV_MERGED_CACHE_TTL_SEC` **기본 60초**에 한 번 (지금은 os/DB에 값 없어서 **60초**).
- 그런데 SCALP 등은 `get_scalping_defaults_from_db()` → `get_strategy_env_dict()`가 **전략 `config_*` 행을 매 바퀴 DB에서** 또 읽음 → 그래서 reload가 **평소 ~12ms**로 보임.

### 정리
| 뭐 | 주기 |
|----|------|
| `reload()` 함수 호출 | **매 루프** |
| 병합 env (`get_merged_env_dict`) DB | **~60초** |
| 전략 config 스냅샷 | **매 루프에 가깝게** (여기가 12ms 쪽) |

웹에서 설정 저장하면 `invalidate_merged_env_cache()`로 바로 갱신됩니다.


<timestamp>Thursday, Aug 13, 2026, 2:47 PM (UTC+9)</timestamp>
<user_query>
그럼 이걸 아까 그 함정에 안걸리게 바꿀 수 있나?
</user_query>


가능합니다. 유니버스(초단위 event_time)와 무관한 **설정 읽기 캐시**라 분 슬롯 함정과 다릅니다. `get_strategy_env_dict`에 짧은 TTL을 넣어 매 루프 DB를 끊겠습니다.


StrReplace

# get_merged_env_snapshot() — TTL 내 재사용 (실매: 웹에서 MAX_DAILY 등 변경 즉시 반영)
_merged_env_cache: Optional[Dict[str, str]] = None
_merged_env_cache_ts: float = 0.0
# get_strategy_env_dict — 전략 config_* 스냅샷 RAM (매 루프 DB 금지)
# 유니버스/봉 정합과 무관. 임계값·한도만. 기본 1초 · 분단위 동결 금지.
_strategy_env_cache: Dict[str, Any] = {}
# env 캐시 세대 카운터 — invalidate 시 +1. 파생 캐시(예: whipsaw 파라미터)가
# 이 값으로 무효화를 감지해 안전하게 재계산한다(값은 그대로, 재계산 시점만 동일).
_env_generation: int = 0

def _get_db():
    """TradeDB 싱글톤. 최초 호출 시 MariaDB 연결."""
    global _db_instance
    if _db_instance is None:
        try:
            from database import TradeDB  # 프로젝트 루트의 기존 모듈
            _db_instance = TradeDB()
        except Exception as e:
            logger.warning("TradeDB 초기화 실패(%s) → env_config 조회 불가 → os.environ 폴백", e)
            _db_instance = False  # 실패 기록 (None과 구분)
    return _db_instance or None

def set_db(db_obj) -> None:
    """외부에서 이미 만든 TradeDB 인스턴스를 재사용할 때 주입."""
    global _db_instance
    _db_instance = db_obj

def invalidate_merged_env_cache() -> None:
    """env/config 저장 후 스냅샷 캐시 무효화 (insert_env_snapshot 등)."""
    global _merged_env_cache, _merged_env_cache_ts, _env_generation, _strategy_env_cache
    _merged_env_cache = None
    _merged_env_cache_ts = 0.0
    _strategy_env_cache = {}
    _env_generation += 1

def _merged_env_cache_ttl_sec() -> float:
    """DB env 스냅샷 메모리 TTL(초). os.environ 만 사용(순환 import 방지)."""
    try:
        return max(0.0, float(os.environ.get("ENV_MERGED_CACHE_TTL_SEC", "60")))
    except (ValueError, TypeError):
        return 60.0

def _strategy_env_cache_ttl_sec() -> float:
    """전략 config_* RAM TTL(초). 기본 1 — 유니버스 분슬롯과 무관.

    웹 저장 시 invalidate_merged_env_cache 로 즉시 무효화.
    """
    try:
        return max(0.0, float(os.environ.get("STRATEGY_ENV_CACHE_TTL_SEC", "1")))
    except (ValueError, TypeError):
        return 1.0

def env_cache_generation() -> int:

# get_merged_env_snapshot() — TTL 내 재사용 (실매: 웹에서 MAX_DAILY 등 변경 즉시 반영)
_merged_env_cache: Optional[Dict[str, str]] = None
_merged_env_cache_ts: float = 0.0
# env 캐시 세대 카운터 — invalidate 시 +1. 파생 캐시(예: whipsaw 파라미터)가
# 이 값으로 무효화를 감지해 안전하게 재계산한다(값은 그대로, 재계산 시점만 동일).
_env_generation: int = 0

def _get_db():
    """TradeDB 싱글톤. 최초 호출 시 MariaDB 연결."""
    global _db_instance
    if _db_instance is None:
        try:
            from database import TradeDB  # 프로젝트 루트의 기존 모듈
            _db_instance = TradeDB()
        except Exception as e:
            logger.warning("TradeDB 초기화 실패(%s) → env_config 조회 불가 → os.environ 폴백", e)
            _db_instance = False  # 실패 기록 (None과 구분)
    return _db_instance or None

def set_db(db_obj) -> None:
    """외부에서 이미 만든 TradeDB 인스턴스를 재사용할 때 주입."""
    global _db_instance
    _db_instance = db_obj

def invalidate_merged_env_cache() -> None:
    """env/config 저장 후 스냅샷 캐시 무효화 (insert_env_snapshot 등)."""
    global _merged_env_cache, _merged_env_cache_ts, _env_generation
    _merged_env_cache = None
    _merged_env_cache_ts = 0.0
    _env_generation += 1

def _merged_env_cache_ttl_sec() -> float:
    """DB env 스냅샷 메모리 TTL(초). os.environ 만 사용(순환 import 방지)."""
    try:
        return max(0.0, float(os.environ.get("ENV_MERGED_CACHE_TTL_SEC", "60")))
    except (ValueError, TypeError):
        return 60.0

def env_cache_generation() -> int:

def get_strategy_env_dict(strategy_id: str) -> dict:
    """전략 config_* 테이블 + 공통 env 병합 (전략 키 우선).

    SCALP → config_scalp, MOMENTUM → config_momentum, SHORT → config_short …
    실매(get_env_from_db) · 웹 · 파라서치가 동일 소스를 쓰도록 한다.

    RAM TTL(``STRATEGY_ENV_CACHE_TTL_SEC`` 기본 1초): 매 루프 config_* SELECT 금지.
    유니버스 event_time/분슬롯과 무관 — 손절·한도 등 **설정값**만.
    웹 저장 → ``invalidate_merged_env_cache`` 즉시 반영.
    """
    global _strategy_env_cache
    sid = str(strategy_id or "").strip().upper() or "_"
    ttl = _strategy_env_cache_ttl_sec()
    now = time.time()
    gen = _env_generation
    hit = _strategy_env_cache.get(sid)
    if (
        isinstance(hit, dict)
        and hit.get("gen") == gen
        and (ttl <= 0 or (now - float(hit.get("ts") or 0)) < ttl)
        and isinstance(hit.get("data"), dict)
    ):
        return hit["data"]

    merged = get_merged_env_dict()
    db = _get_db()
    out = dict(merged)
    if db is not None:
        try:
            if hasattr(db, "get_strategy_config_snapshot"):
                strat = db.get_strategy_config_snapshot(strategy_id)
                if strat:
                    out.update(strat)
        except Exception as e:
            logger.debug("strategy env 조회 실패 (%s): %s", strategy_id, e)
    _strategy_env_cache[sid] = {"data": out, "ts": now, "gen": gen}
    return out

def get_env_from_db(key: str, default: str = "") -> str:

def get_strategy_env_dict(strategy_id: str) -> dict:
    """전략 config_* 테이블 + 공통 env 병합 (전략 키 우선).

    SCALP → config_scalp, MOMENTUM → config_momentum, SHORT → config_short …
    실매(get_env_from_db) · 웹 · 파라서치가 동일 소스를 쓰도록 한다.
    """
    merged = get_merged_env_dict()
    db = _get_db()
    if db is None:
        return merged
    try:
        if hasattr(db, "get_strategy_config_snapshot"):
            strat = db.get_strategy_config_snapshot(strategy_id)
            if strat:
                out = dict(merged)
                out.update(strat)
                return out
    except Exception as e:
        logger.debug("strategy env 조회 실패 (%s): %s", strategy_id, e)
    return merged

def get_env_from_db(key: str, default: str = "") -> str:

    "ENV_MERGED_CACHE_TTL_SEC",
    # 전략 config_* RAM TTL(초) — reload 매루프 SELECT 완화 · 기본 1 · 유니버스 분슬롯 금지와 별개
    "STRATEGY_ENV_CACHE_TTL_SEC",
    "TODAY_TRADES_CACHE_TTL_SEC",
    "UNIVERSE_SLOT_CACHE_TTL_SEC",

    "ENV_MERGED_CACHE_TTL_SEC",
    "TODAY_TRADES_CACHE_TTL_SEC",
    "UNIVERSE_SLOT_CACHE_TTL_SEC",


            _f("TODAY_TRADES_CACHE_TTL_SEC", "당일거래 RAM TTL(초)", "float", default=1.0,
               hint="check_buy·일일한도 가드 공유 · N초 내 DB 재조회 생략 · 체결 시 즉시 무효화 · 0=매번 DB"),
            _f("STRATEGY_ENV_CACHE_TTL_SEC", "전략설정 RAM TTL(초)", "float", default=1.0,
               hint="config_* reload 매루프 SELECT 완화 · 기본 1초 · 웹 저장 시 즉시 무효화 · "
                    "유니버스/봉 분슬롯과 무관(임계값만)"),
            _f("UNIVERSE_SLOT_CACHE_TTL_SEC", "유니버스슬롯 RAM TTL(초)", "float", default=1.0,
               hint="history∩후보 정합 ON 유지 · 같은 슬롯키면 N초 RAM · 슬롯 바뀌면 즉시 DB 재조회"),

            _f("TODAY_TRADES_CACHE_TTL_SEC", "당일거래 RAM TTL(초)", "float", default=1.0,
               hint="check_buy·일일한도 가드 공유 · N초 내 DB 재조회 생략 · 체결 시 즉시 무효화 · 0=매번 DB"),
            _f("UNIVERSE_SLOT_CACHE_TTL_SEC", "유니버스슬롯 RAM TTL(초)", "float", default=1.0,
               hint="history∩후보 정합 ON 유지 · 같은 슬롯키면 N초 RAM · 슬롯 바뀌면 즉시 DB 재조회"),

    # 당일 trade_history / 유니버스슬롯 history — RAM TTL(초). 0=캐시 끔(매번 DB)
    "TODAY_TRADES_CACHE_TTL_SEC",
    "UNIVERSE_SLOT_CACHE_TTL_SEC",
    # 전략 config_* RAM TTL — reload 매루프 SELECT 완화 (유니버스 분슬롯 금지와 별개 · 기본 1초)
    "STRATEGY_ENV_CACHE_TTL_SEC",
    # 틱매도 — WS 현재가 갱신 시 매도 검사 (기본 false · 루프 매도 유지 + 틱 보강)

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


Shell

cd /home/hoon/kis_bot && python3 -m py_compile kis_trader/utils/env.py && python3 <<'PY'
from kis_trader.backtest.param_search_apply_snapshot import apply_env_patch
from kis_trader.utils.env import get_env_from_db, invalidate_merged_env_cache, get_strategy_env_dict
import time
r=apply_env_patch({"STRATEGY_ENV_CACHE_TTL_SEC":"1"})
invalidate_merged_env_cache()
# smoke: second call should be cache
t0=time.perf_counter(); get_strategy_env_dict("SCALP"); m1=(time.perf_counter()-t0)*1000
t0=time.perf_counter(); get_strategy_env_dict("SCALP"); m2=(time.perf_counter()-t0)*1000
print("apply", r, "ttl", get_env_from_db("STRATEGY_ENV_CACHE_TTL_SEC",""), "ms1", round(m1,2), "ms2", round(m2,2))
PY
sudo systemctl restart kis_trader_main.service
sleep 4
systemctl is-active kis_trader_main.service
: > logs/loop_profile.log
sleep 18
python3 <<'PY'
from collections import defaultdict
import re
from datetime import datetime
lines=open('logs/loop_profile.log',encoding='utf-8',errors='replace').read().splitlines()
rows=[]
for ln in lines:
    if 'reload=' not in ln: continue
    m=re.search(r'\[LOOP_PROF\]\s+(\S+)\s+(.*)', ln)
    if not m: continue
    sid,rest=m.group(1),m.group(2)
    d={'sid':sid}
    for k in ['total','reload','cand_slot','pre_guard']:
        mm=re.search(rf'{k}=([0-9.]+)', rest)
        if mm: d[k]=float(mm.group(1))
    rows.append(d)
print('n',len(rows))
by=defaultdict(list)
for r in rows: by[r['sid']].append(r)
for sid,rs in sorted(by.items()):
    vs=[r['reload'] for r in rs if 'reload' in r]
    ts=[r['total'] for r in rs if 'total' in r]
    print(f'{sid:10s} n={len(rs):4d} reload p50={sorted(vs)[len(vs)//2]:6.1f} mean={sum(vs)/len(vs):6.1f} max={max(vs):6.1f}  total p50={sorted(ts)[len(ts)//2]:6.1f}')
PY

Apply strategy env TTL, restart, compare reload ms


wc -l /home/hoon/kis_bot/logs/loop_profile.log; rg -c "LOOP_PROF" /home/hoon/kis_bot/logs/loop_profile.log | head -1; journalctl -u kis_trader_main.service -n 15 --no-pager | rg -i "LOOP_PROF|reload|error|Traceback|started" || true; python3 <<'PY'
from kis_trader.utils.env import get_env_from_db, get_env_bool
print('LOOP_PROFILE', get_env_from_db('LOOP_PROFILE_ENABLED',''), get_env_bool('LOOP_PROFILE_ENABLED', False))
PY
sleep 12
wc -l /home/hoon/kis_bot/logs/loop_profile.log
rg "reload=" /home/hoon/kis_bot/logs/loop_profile.log | head -3
python3 <<'PY'
import re
from collections import defaultdict
lines=open('/home/hoon/kis_bot/logs/loop_profile.log',encoding='utf-8',errors='replace').read().splitlines()
by=defaultdict(list)
for ln in lines:
    m=re.search(r'\[LOOP_PROF\]\s+(\S+).*?\breload=([0-9.]+).*?\btotal=([0-9.]+)', ln)
    if not m:
        m=re.search(r'\[LOOP_PROF\]\s+(\S+)\s+total=([0-9.]+).*?reload=([0-9.]+)', ln)
        if m:
            by[m.group(1)].append((float(m.group(3)), float(m.group(2))))
        continue
    by[m.group(1)].append((float(m.group(2)), float(m.group(3))))
# fix parse - use simpler
by=defaultdict(list)
for ln in lines:
    if '[LOOP_PROF]' not in ln: continue
    m=re.search(r'\[LOOP_PROF\]\s+(\S+)\s+(.*)', ln)
    if not m: continue
    sid,rest=m.group(1),m.group(2)
    r=re.search(r'reload=([0-9.]+)', rest)
    t=re.search(r'total=([0-9.]+)', rest)
    if r and t: by[sid].append((float(r.group(1)), float(t.group(1))))
print('parsed', sum(len(v) for v in by.values()))
for sid,vs in sorted(by.items()):
    reloads=[a for a,b in vs]
    totals=[b for a,b in vs]
    print(f'{sid:10s} n={len(vs)} reload p50={sorted(reloads)[len(reloads)//2]:.1f} mean={sum(reloads)/len(reloads):.1f} total p50={sorted(totals)[len(totals)//2]:.1f}')
PY

Check profile log and reload after strategy env cache


가능해서 **그렇게 바꿨습니다.** 유니버스 분슬롯 함정과 무관합니다.

### 왜 안전한가
- 이건 **손절·한도 등 설정값** RAM이지, 후보/history `event_time`이 아님.
- TTL **1초** (`STRATEGY_ENV_CACHE_TTL_SEC`) — 분 단위 동결 아님.
- 웹 설정 저장 → 기존 `invalidate_merged_env_cache`로 **즉시** 비움.

### 결과
| | 이전 | 지금 |
|--|------|------|
| reload p50 | ~12ms | **~0.1ms** |
| SCALP total p50 | ~60ms | **~13ms** |

스모크: 첫 `get_strategy_env_dict` ~32ms → 캐시 히트 **0.01ms**.


<timestamp>Thursday, Aug 13, 2026, 2:50 PM (UTC+9)</timestamp>
<user_query>
뭐 자주 바꾸는 값도 아닌데 이걸 매번 다시 가져올 필요는 없지 재시작하면 어차피 바뀌는 값들이잖아 바꾸더라도 장시간 끝나고 옵투나 돌려서 바꿀텐데 램적재가 이렇게나 좋은거군 굿이야 굿 이제는 갭보정쪽 한군데 남은건가
</user_query>


cd /home/hoon/kis_bot && python3 <<'PY'
from collections import defaultdict
import re
from datetime import datetime
lines=open('logs/loop_profile.log',encoding='utf-8',errors='replace').read().splitlines()[-15000:]
rows=[]
for ln in lines:
    if '[LOOP_PROF]' not in ln: continue
    try:
        t=datetime.strptime(ln[:19],'%Y-%m-%d %H:%M:%S')
    except Exception: continue
    if t < datetime(2026,8,13,14,49,0): continue
    m=re.search(r'\[LOOP_PROF\]\s+(\S+)\s+(.*)', ln)
    if not m: continue
    sid,rest=m.group(1),m.group(2)
    d={'sid':sid}
    for k in ['total','reload','cand_slot','slot_hit','slot_missc','pre_guard','g_trdb','buy_sum','scan','sell_chk','gap_skip','sleep_rej','ws_sync','pending']:
        mm=re.search(rf'(?:^|\s){k}=([0-9.]+)', rest)
        if mm: d[k]=float(mm.group(1))
    rows.append(d)
print('n',len(rows))
# contribution
keys=['reload','cand_slot','pre_guard','buy_sum','sell_chk','scan','ws_sync']
print('\n전체 합산 ms / spike>=200')
for k in keys:
    vs=[r[k] for r in rows if k in r]
    if not vs: continue
    print(f'  {k:12s} mean={sum(vs)/len(vs):6.1f} p50={sorted(vs)[len(vs)//2]:6.1f} p90={sorted(vs)[int(0.9*len(vs))]:6.1f} max={max(vs):7.1f} sum_s={sum(vs)/1000:6.1f} n>=200={sum(1 for v in vs if v>=200)}')
# gap_skip rate
gs=[r.get('gap_skip',0) for r in rows]
print(f'\ngap_skip: mean={sum(gs)/len(gs):.2f} p50={sorted(gs)[len(gs)//2]:.0f} max={max(gs):.0f} loops_with_gap={sum(1 for g in gs if g>0)}/{len(gs)}')
# total p50 by sid
by=defaultdict(list)
for r in rows: by[r['sid']].append(r.get('total',0))
print('\ntotal p50 by sid')
for sid,vs in sorted(by.items()):
    print(f'  {sid:10s} p50={sorted(vs)[len(vs)//2]:6.1f} p90={sorted(vs)[int(0.9*len(vs))]:6.1f} max={max(vs):7.1f}')
PY

Post-cache bottleneck: slot vs gap vs others


맞아요, 자주 안 바꾸는 값은 RAM이 이득입니다. 굿.

### 갭보정이 “한 군데 남은” 병목이냐?
**루프 체감 기준으로는 아니요.**

지금(캐시 이후) 로그:
| | 평소 | 가끔 |
|--|------|------|
| **total** | SCALP ~8ms, BREAKOUT ~18ms | 드물게 수 초 |
| **gap_skip** | 거의 0 (15000바퀴 중 73번만) | 갭 때문에 매수 스킵이 주 패턴 아님 |
| **남은 스파이크** | — | 주로 **`cand_slot` TTL 미스·DB 줄서기** (23회≥200ms), 드물게 scan |

갭보정은 **백그라운드 REST 웜업**이라 “루프가 느린 주범”이랑 층이 다릅니다.
봉 준비 안 됐을 때만 `gap_skip`으로 그 종목 매수를 미루는 안전장치고, **지금은 그 비중이 작음**.

### 한 줄
- 루프: 평소 충분, 남은 건 **슬롯 history 가끔 줄서기**.
- 갭: **남은 한 방 병목은 아님** (별도 인프라·재접속/신규 ENTER 때 이슈면 그때 봄).

원하면 다음에 갭이 아니라 **슬롯 single-flight**(분 동결 없이, 같은 키 DB 1번만)만 보면 됩니다.


<timestamp>Thursday, Aug 13, 2026, 2:53 PM (UTC+9)</timestamp>
<user_query>
 REST 웜업이라 “ 이거 말한거 지금 로그에 갭보정이라 강제 봉확정로그가 안보이네 가꾼 로그창 멈추는거같고  슬롯 single-flight(분 동결 없이, 같은 키 DB 1번만)만 보면 됩니다. 이거는 뭐야
</user_query>


### 1) 갭보정·강제확정 로그가 안 보이는 이유

둘은 **다른 애**입니다.

| 로그 | 뭐 하는 애 | 언제 찍히나 |
|------|------------|-------------|
| **갭보정** (REST 웜업) | 신규/재접속 종목 분봉을 REST로 채워 RAM에 넣음 | ENTER·재시작·장시작 refill 때. **매 초마다 아님** |
| **봉강제확정** | 저유동, 다음 틱 없이 봉주기 끝 → 확정 | 봉이 끝났는데 틱이 안 올 때 (~2초마다 점검) |

지금 장중·후보 이미 웜업 끝났으면 **갭보정 INFO는 거의 안 나옵니다.**
강제확정도 체결 많은 종목은 다음 틱으로 이미 확정돼서 **안 찍힐 수 있습니다.**

“로그창이 가끔 멈춘 것 같음”
→ 예전에 `cand_slot`/`reload`가 DB 락에 줄 서면 **journal도 한꺼번에 밀렸다가** 쏟아질 수 있음.
지금은 루프가 빨라져서 그 체감이 줄었을 가능성이 큼. (완전 무관하진 않지만, 갭 로그 부재 ≠ 갭이 멈춘 것.)

---

### 2) 슬롯 single-flight가 뭐냐

**분 동결 아님.** 지금처럼 **1초 TTL·초단위 `now` 조회** 유지.

상황:
- SCALP 루프 A: “슬롯 캐시 만료 → DB 읽자”
- 거의 동시에 SCALP 루프 B(또는 같은 키를 쓰는 경로): “나도 만료 → DB 읽자”

지금은 **둘 다 DB** → 락 줄 → 가끔 1~2초.

**single-flight** = 같은 캐시 키 `(전략, 소스, …)`에 대해
- **한 놈만** DB 감
- 나머지는 “그 결과 올 때까지 기다렸다가 **같은 RAM 답** 씀”

→ DB는 **한 번**, 정합은 그대로, **분단위로 안 얼림**.

**한 줄:** 갭 로그 없는 건 평소엔 정상일 수 있음. single-flight는 “같은 슬롯 조회를 동시에 두 번 치지 말자”는 줄서기 방지입니다.


<timestamp>Thursday, Aug 13, 2026, 2:55 PM (UTC+9)</timestamp>
<user_query>
2) 슬롯 single-flight가 뭐냐
분 동결 아님. 지금처럼 1초 TTL·초단위 now 조회 유지.

상황:

SCALP 루프 A: “슬롯 캐시 만료 → DB 읽자”
거의 동시에 SCALP 루프 B(또는 같은 키를 쓰는 경로): “나도 만료 → DB 읽자”
지금은 둘 다 DB → 락 줄 → 가끔 1~2초.

single-flight = 같은 캐시 키 (전략, 소스, …)에 대해

한 놈만 DB 감
나머지는 “그 결과 올 때까지 기다렸다가 같은 RAM 답 씀”
→ DB는 한 번, 정합은 그대로, 분단위로 안 얼림.

한 줄: 갭 로그 없는 건 평소엔 정상일 수 있음. single-flight는 “같은 슬롯 조회를 동시에 두 번 치지 말자”는 줄서기 방지입니다.

아직 이해못함 그리고 신규진입 갭보정로그랑 강제 봉확정 로그가 없어졌어 이거 체크가 빨라져서인가
</user_query>


single-flight는 더 쉽게 풀고, 갭·강제확정 로그가 실제로 사라졌는지 저널에서 확인합니다.

journalctl -u kis_trader_main.service --since "2026-08-13 14:00:00" --no-pager 2>/dev/null | rg -c "갭보정|봉강제확정|gap_fill|강제확정" || true; echo '--- recent gap ---'; journalctl -u kis_trader_main.service --since "2026-08-13 14:40:00" --no-pager 2>/dev/null | rg "갭보정|봉강제확정" | tail -20; echo '--- counts by hour-ish ---'; journalctl -u kis_trader_main.service --since "2026-08-13 13:00:00" --no-pager 2>/dev/null | rg -o "갭보정|봉강제확정" | sort | uniq -c; echo '--- restarts ---'; journalctl -u kis_trader_main.service --since "2026-08-13 13:00:00" --no-pager 2>/dev/null | rg "Started|Stopped|kis_trader" | tail -15

Count gap-fill and force-confirm logs today


echo 'force confirm last 3 min:'; journalctl -u kis_trader_main.service --since "3 min ago" --no-pager 2>/dev/null | rg -c "봉강제확정" || echo 0; echo 'gap last 3 min:'; journalctl -u kis_trader_main.service --since "3 min ago" --no-pager 2>/dev/null | rg -c "갭보정" || echo 0; echo 'LOOP_PROF last 3 min:'; journalctl -u kis_trader_main.service --since "3 min ago" --no-pager 2>/dev/null | rg -c "LOOP_PROF" || echo 0

Compare log volume: force vs gap vs LOOP_PROF


### single-flight — 식당 비유만

같은 테이블(같은 캐시 키)에서 **동시에** “김치찌개 주문”이 두 번 나감.

- **지금:** 웨이터 둘이 **각자** 주방으로 감 → 주방(DB) 줄 김
- **single-flight:** 한 명만 주방 감, 다른 한 명은 “그 접시 오면 같이 먹자”고 **같은 결과**만 받음

분 동안 메뉴판을 얼리는 게 **아님**.
“같은 주문을 **같은 순간에 두 번** 안 한다”만.

(아직 코드로 안 넣음. 이해용.)

---

### 갭·강제확정 로그 — **안 없어짐**

방금 저널 기준:

| 최근 3분 | 건수 |
|----------|------|
| **LOOP_PROF** | **6666** |
| 갭보정 | 251 |
| 봉강제확정 | 98 |

루프가 빨라져서 **계측 로그가 폭주** → journal이 LOOP_PROF로 뒤덮임 →
갭/강제확정이 **안 보이는 것처럼** 보임. 체크가 꺼진 게 아님.

14:55대에도 신규 ENTER 갭보정(`166090`, `015860` 등) **정상 출력** 중이었습니다.

보려면 예:
```bash
journalctl -u kis_trader_main.service -f | rg "갭보정|봉강제확정"
```
또는 `LOOP_PROFILE_EVERY_N`을 키우거나 끄면 화면이 훨씬 조용해집니다.


<timestamp>Thursday, Aug 13, 2026, 2:58 PM (UTC+9)</timestamp>
<user_query>
LOOP_PROFILE_EVERY_N 이것도 env 값인가 끄면 재시작 안해도 적용?
single-flight — 식당 비유만
같은 테이블(같은 캐시 키)에서 동시에 “김치찌개 주문”이 두 번 나감.

지금: 웨이터 둘이 각자 주방으로 감 → 주방(DB) 줄 김
single-flight: 한 명만 주방 감, 다른 한 명은 “그 접시 오면 같이 먹자”고 같은 결과만 받음
분 동안 메뉴판을 얼리는 게 아님.
“같은 주문을 같은 순간에 두 번 안 한다”만.

(아직 코드로 안 넣음. 이해용.)

이건 설계한고 설명이 필요함
</user_query>


### `LOOP_PROFILE_EVERY_N` / 끄기

| | |
|--|--|
| **env?** | 예. `LOOP_PROFILE_ENABLED`, `LOOP_PROFILE_EVERY_N` (DB/운영설정) |
| **재시작?** | **불필요.** 매 루프 `get_env_*`로 읽음 |
| **반영 속도** | 웹에서 설정 저장 → `invalidate_merged_env_cache`면 **바로**. invalidate 없이 DB만 고치면 병합 env TTL(**기본 ~60초**) 안에 반영 |

끄려면: `LOOP_PROFILE_ENABLED=false` (저장).
줄이려면: `LOOP_PROFILE_EVERY_N=10` 등 (10바퀴에 1번만 로그).

---

### 슬롯 single-flight — 설계 (아직 코드 없음 · 승인용)

**목적:** TTL 1초·`at_time=now` **유지** (분 동결 금지).
같은 캐시 키로 **동시에 DB를 두 번** 치는 것만 막음.

**키 (지금과 동일)**
`(strategy_id, history_source, slot_key)`
※ 전략마다 답이 다름 → 전 전략 1장 공유 아님.

**흐름**
1. TTL 안이면 → 지금처럼 RAM 반환 (flight 없음).
2. TTL 만료 + **이 키로 이미 누가 DB 조회 중**이면 → 새 SELECT 안 함, 그 Future/이벤트 기다렸다가 **같은 결과** 사용.
3. 아무도 없으면 → 내가 “비행” 시작 → `et` + `get_universe_at(now)` → RAM 채움 → 대기자들에게 동일 결과 전달 → flight 종료.

**하는 것 / 안 하는 것**
| 함 | 안 함 |
|----|--------|
| 동시 중복 SELECT·락 줄 감소 | 분·슬롯키 끝날 때까지 집합 고정 |
| 정합 필터(교집합) 매 루프 유지 | 유니버스 event_time을 분 단위로 맞춤 |
| 최대 낡음 ≈ TTL(1초) + 잠깐 대기 | 편입 최대 1분 지연 클래스 |

**리스크**
대기자가 flight 끝난 직후 답을 받으므로, “내가 직접 now로 조회”와 **같은 스냅샷**을 공유함. 초단위 정합과 충돌하지 않음. 데드락 없게 flight는 **DB 락과 별도**, 타임아웃 후 재시도.

**검증**
`slot_missc` 동시 다발·`cand_slot` 스파이크 감소, `slot_et/get`은 키당 동시 1회.

원하시면 이 설계로만 구현 들어가겠습니다.


<timestamp>Thursday, Aug 13, 2026, 3:00 PM (UTC+9)</timestamp>
<user_query>
이노디비인데도 이게 셀렉트락이 걸린다고? 신기하네 봉강제확정도 1초로 줄이면 안되나 slot_missc 동시 다발·cand_slot 스파이크 감소, slot_et/get은 키당 동시 1회 이것들은 다 뭐야 뭐 3개나 돼
</user_query>


InnoDB와 우리 쪽 락·계측 필드·강제확정 주기를 코드 기준으로 짧게 확인합니다.


### 1) InnoDB인데도 락?
InnoDB **행 잠금(SELECT FOR UPDATE)** 얘기가 아닙니다.

우리 봇은 MariaDB 연결을 **파이썬 락 하나**로 감쌉니다 (`database.py` `conn.execute` → `with self._lock`).
전략 4개가 동시에 SELECT하면:

- InnoDB는 읽기끼리 대체로 괜찮음
- 하지만 **한 줄로 들어가서** → 앞에서 history 무거운 조회가 0.5~1초면, 뒤는 **파이썬에서 줄 서서** 그만큼 기다림

그래서 “InnoDB 셀렉트 락”이 아니라 **공유 커넥션 줄서기**에 가깝습니다.

---

### 2) 봉강제확정 1초로?
가능합니다. env: `WS_CANDLE_STALE_CHECK_INTERVAL_SEC` (지금 기본 **2**).

- **1로 바꿔도 됨** (더 자주 “끝난 봉 있나?”만 봄. OHLC 변조 아님).
- 다만 writer 스레드가 **기동 때 주기를 읽어 두는** 형태라, **재시작해야** 確実 반영될 수 있음.
(원하면 루프마다 env 다시 읽게 바꿀 수도 있음.)

---

### 3) 그 이름들 — 기능 3개가 아님
**슬롯 history 조회 한 줄기**를 계측한 조각입니다.

| 이름 | 뜻 |
|------|-----|
| **`cand_slot`** | 후보 로드 중 “history ∩ 실시간” **전체 걸린 시간** |
| **`slot_et` / `slot_get`** | 그 안에서 DB 두 방 — 최신 event_time / 종목 목록 |
| **`slot_missc`** | 캐시 미스 횟수 (그래서 DB 간 횟수). `slot_hit`은 캐시 히트 |

single-flight가 줄이려는 것 = **같은 키로 `slot_et+get`이 동시에 두 번** → `cand_slot` 스파이크.

**한 줄:** 락은 InnoDB라기보다 **우리 쪽 한 줄 DB**. 강제확정은 **1초 env로 가능**(재시작 필요할 수 있음). 계측 이름은 **한 기능의 부품 3개**입니다.


<timestamp>Thursday, Aug 13, 2026, 3:03 PM (UTC+9)</timestamp>
<user_query>
봉확정 1초로 줄이고  “InnoDB 셀렉트 락”이 아니라 공유 커넥션 줄서기에 가깝습니다. 이거 커넥션은 여러개 만들면 어때 그럼?

TTL 안이면 → 지금처럼 RAM 반환 (flight 없음).
TTL 만료 + 이 키로 이미 누가 DB 조회 중이면 → 새 SELECT 안 함, 그 Future/이벤트 기다렸다가 같은 결과 사용.
아무도 없으면 → 내가 “비행” 시작 → et + get_universe_at(now) → RAM 채움 → 대기자들에게 동일 결과 전달 → flight 종료.
이건 코드상으로 어떻게 구현하는건데 대답만 우선
</user_query>


If the available MCP tools do not fully support what the user asked you to do, complete the work you can with the current tool set. In your work summary, include what you were unable to do with MCP and why. Do not use browser automation to work around missing or unavailable MCP tools unless the user explicitly asks you to use the browser.


봉확정 주기는 1초로 적용하고, 커넥션 다중·single-flight 구현은 설명만 하겠습니다.

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
r=apply_env_patch({"WS_CANDLE_STALE_CHECK_INTERVAL_SEC":"1"})
invalidate_merged_env_cache()
print(r, get_env_from_db("WS_CANDLE_STALE_CHECK_INTERVAL_SEC",""))
PY

Set WS_CANDLE_STALE_CHECK_INTERVAL_SEC=1 in DB


StrReplace

            if now - last_stale_check >= stale_check_interval:
                last_stale_check = now
                # 핫리로드 — 재시작 없이 WS_CANDLE_STALE_CHECK_INTERVAL_SEC 반영
                try:
                    stale_check_interval = max(
                        0.2,
                        float(get_env_int("WS_CANDLE_STALE_CHECK_INTERVAL_SEC", 2) or 2),
                    )
                except Exception:
                    pass
                # 락 밖: env 핫플래그 갱신 (on_tick 경로에서 get_env 금지)
                self._refresh_runtime_flags()
                try:
                    self.flush_stale_current_candles()
                except Exception as e:
                    logger.debug("flush_stale_current_candles 실패(무시): %s", e)

            if now - last_stale_check >= stale_check_interval:
                last_stale_check = now
                # 락 밖: env 핫플래그 갱신 (on_tick 경로에서 get_env 금지)
                self._refresh_runtime_flags()
                try:
                    self.flush_stale_current_candles()
                except Exception as e:
                    logger.debug("flush_stale_current_candles 실패(무시): %s", e)

Shell

sudo systemctl restart kis_trader_main.service && sleep 2 && systemctl is-active kis_trader_main.service && python3 -c "from kis_trader.utils.env import get_env_from_db; print('stale', get_env_from_db('WS_CANDLE_STALE_CHECK_INTERVAL_SEC',''))"

Restart live bot to apply stale check 1s


### 적용한 것
- `WS_CANDLE_STALE_CHECK_INTERVAL_SEC=1` DB 반영 + 실매 재시작(`active`).
- writer 루프에서 매 stale 점검마다 값을 다시 읽도록 해서, 이후엔 재시작 없이도 조정 가능.

---

### 커넥션 여러 개면?
**도움은 됩니다.** 지금 병목은 InnoDB 행락이 아니라 **프로세스 안 공유 1커넥션 + Python lock 줄서기**라서, 전략/용도별로 커넥션을 나누면 `cand_slot`끼리 서로 막는 시간이 줄 수 있습니다.

다만:
- 무한정 늘리면 MariaDB `max_connections`·서버 CPU만 올라가고, **같은 SELECT를 5전략이 동시에 5번** 치는 문제는 그대로입니다.
- 틱 writer 전용 커넥션은 이미 있는 편이 맞고, **슬롯 유니버스 조회 전용 풀(2~4)** 정도는 합리적입니다.
- **single-flight + TTL**이 “같은 키 DB 한 번만”을 해결하니, 커넥션 증설보다 **먼저·또는 같이** 쓰는 편이 낫습니다. 커넥션만 늘리면 “줄서기”는 줄고 “중복 SELECT”는 남을 수 있습니다.

---

### Single-flight 코드로 어떻게?
개념은 **캐시 키 하나당 in-flight 슬롯 하나**입니다. (의사코드)

```python
# key = (strategy_id, source, slot_key)  # 지금 슬롯 캐시와 동일
_cache: dict[key, (expires_at, result)]
_flight: dict[key, Event]          # 또는 Future / threading.Condition
_lock = threading.Lock()

def history_universe_codes_at(...):
    key = (...)
    now = time.time()

    with _lock:
        hit = _cache.get(key)
        if hit and hit[0] > now:          # TTL 안
            return hit[1]

        ev = _flight.get(key)
        if ev is not None:                # 이미 누가 DB 조회 중
            waiter = True
        else:
            waiter = False
            ev = threading.Event()
            _flight[key] = ev             # 내가 flight 시작

    if waiter:
        ev.wait(timeout=...)              # SELECT 안 함
        with _lock:
            return _cache[key][1]         # 리더가 채운 결과

    # --- leader ---
    try:
        et = ...
        result = get_universe_at(now)     # 실제 DB (at_time=now 유지)
        with _lock:
            _cache[key] = (now + TTL, result)
    finally:
        with _lock:
            _flight.pop(key, None)
        ev.set()                          # 대기자 깨움

    return result
```

포인트만:
- **TTL hit** → RAM만, flight 없음.
- **miss + flight 있음** → SELECT 없이 `Event.wait` → 같은 `result`.
- **miss + flight 없음** → 한 스레드만 DB → RAM 채움 → `set()` → 대기자 공유.
- 분 단위 `slot_key` 고정 freeze가 아니라, **같은 초·같은 키로 겹친 요청만 합치는** 구조입니다.

원하시면 다음 턴에 `live_portfolio_common.py` 슬롯 캐시에 이 패턴만 붙이면 됩니다.


<timestamp>Thursday, Aug 13, 2026, 3:06 PM (UTC+9)</timestamp>
<user_query>
오케이 뭐 크겍 바뀌는건 없네 그럼일단 이건 승인 그리고 질문 전략별로 커넥션 하나씩 주면 유의미하게 빨라지나?
</user_query>


Single-flight 적용하고, 전략별 커넥션 효과는 계측 기준으로 짧게 답하겠습니다.


StrReplace

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

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

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

# 유니버스 슬롯 history — RAM TTL (정합 ON 유지 · 루프마다 SELECT 금지)
_SLOT_UNIV_LOCK = threading.Lock()
# key=(sid, src, slot_key) → (codes|None, ts). codes=None → 스냅샷 없음(필터 생략)
_SLOT_UNIV_CACHE: Dict[Tuple[str, str, str], Tuple[Optional[Set[str]], float]] = {}
# 같은 키 TTL 만료 시 DB SELECT 1회만 (대기자는 Event 공유) — 분슬롯 freeze 아님
_SLOT_UNIV_FLIGHT: Dict[Tuple[str, str, str], threading.Event] = {}

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

def _slot_univ_flight_wait_sec() -> float:
    """single-flight 대기 상한(초). 리더 실패/타임아웃 후 재시도용."""
    try:
        return float(get_env_float("UNIVERSE_SLOT_FLIGHT_WAIT_SEC", 5.0) or 5.0)
    except Exception:
        return 5.0

def invalidate_universe_slot_cache(strategy_id: Optional[str] = None) -> None:
    """슬롯 캐시 무효화. strategy_id 없으면 전체. in-flight 대기자도 깨움."""
    sid = (strategy_id or "").upper()
    wake: List[threading.Event] = []
    with _SLOT_UNIV_LOCK:
        if not sid:
            _SLOT_UNIV_CACHE.clear()
            wake = list(_SLOT_UNIV_FLIGHT.values())
            _SLOT_UNIV_FLIGHT.clear()
        else:
            for k in list(_SLOT_UNIV_CACHE.keys()):
                if k[0] == sid:
                    del _SLOT_UNIV_CACHE[k]
            for k in list(_SLOT_UNIV_FLIGHT.keys()):
                if k[0] == sid:
                    wake.append(_SLOT_UNIV_FLIGHT.pop(k))
    for ev in wake:
        try:
            ev.set()
        except Exception:
            pass

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

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

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

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

    RAM TTL(``UNIVERSE_SLOT_CACHE_TTL_SEC`` 기본 1초): 같은 슬롯키면 DB 재조회 생략.
    TTL 만료 시 **single-flight**: 같은 ``(sid,src,slot_key)`` 는 DB SELECT 1회만,
    동시 호출은 Event 대기 후 동일 RAM 결과 (분 단위 freeze / at_time 고정 아님).
    """
    when = when or dt.now()
    at_time = when.strftime("%Y-%m-%d %H:%M:%S")
    sid = (strategy_id or "").upper()
    src = str(history_source or "").strip().lower()
    if universe_source is not None:
        src = resolve_live_universe_history_source(
            sid, universe_source=universe_source,
        )
    elif src in ("", "auto"):
        src = resolve_live_universe_history_source(sid, universe_source=None)
    if src not in ("ls", "kiwoom"):
        src = "kiwoom"

    iv = resolve_live_scan_interval_min(sid)
    slot_key = slot_key_from_dt(when, iv)
    cache_key = (sid, src, slot_key)
    ttl = _slot_univ_ttl_sec()
    day_ymd = when.strftime("%Y-%m-%d")

    def _from_cache_locked(
        *, as_hit: bool, from_flight: bool = False,
    ) -> Optional[Optional[Set[str]]]:
        """유효 캐시면 Set|None 반환. 미스면 센티널 객체 대신 … 실제로는 특수.

        반환: ('ok', value) 패턴 대신 — hit 시 codes(또는 None 스냅샷없음),
        miss 시 Ellipsis 로 표시.
        """
        hit = _SLOT_UNIV_CACHE.get(cache_key)
        if hit is None:
            return Ellipsis  # type: ignore[return-value]
        codes_cached, ts = hit
        now_ts = time.time()
        if not (ttl <= 0 or (now_ts - ts) < ttl):
            return Ellipsis  # type: ignore[return-value]
        if as_hit and prof is not None:
            prof["slot_cache_hit"] = float(prof.get("slot_cache_hit", 0) or 0) + 1.0
            if from_flight:
                prof["slot_flight_wait"] = float(
                    prof.get("slot_flight_wait", 0) or 0
                ) + 1.0
        if codes_cached is None:
            if prof is not None:
                prof["slot_miss"] = float(prof.get("slot_miss", 0) or 0) + 1.0
            return None
        if prof is not None:
            prof["slot_ok"] = float(prof.get("slot_ok", 0) or 0) + 1.0
            prof["slot_codes"] = float(len(codes_cached))
        return set(codes_cached)

    def _store_locked(store: Optional[Set[str]]) -> None:
        _SLOT_UNIV_CACHE[cache_key] = (
            set(store) if store else None,
            time.time(),
        )

    def _fetch_db() -> Optional[Set[str]]:
        # 당일(캘린더) 스냅샷만 슬롯정합에 쓴다 — 낡은 history 전멸 방지
        t_et = time.perf_counter()
        et = _history_snapshot_event_time(db, sid, at_time, src)
        _prof_add(prof, "slot_et_ms", t_et)
        if not et or _event_time_ymd(et) != day_ymd:
            with _SLOT_UNIV_LOCK:
                _store_locked(None)
            if prof is not None:
                prof["slot_miss"] = float(prof.get("slot_miss", 0) or 0) + 1.0
                prof["slot_cache_miss"] = float(
                    prof.get("slot_cache_miss", 0) or 0
                ) + 1.0
            return None
        getter = getattr(db, "get_universe_at", None)
        if getter is None:
            with _SLOT_UNIV_LOCK:
                _store_locked(None)
            if prof is not None:
                prof["slot_miss"] = float(prof.get("slot_miss", 0) or 0) + 1.0
                prof["slot_cache_miss"] = float(
                    prof.get("slot_cache_miss", 0) or 0
                ) + 1.0
            return None
        try:
            t_get = time.perf_counter()
            # TradeDBExt 는 history_source 지원. FakeDB 등 구시그니처는 무시.
            try:
                rows = getter(
                    strategy_id=sid, at_time=at_time, history_source=src,
                ) or []
            except TypeError:
                rows = getter(strategy_id=sid, at_time=at_time) or []
            _prof_add(prof, "slot_get_ms", t_get)
        except Exception:
            if prof is not None:
                prof["slot_miss"] = float(prof.get("slot_miss", 0) or 0) + 1.0
                prof["slot_cache_miss"] = float(
                    prof.get("slot_cache_miss", 0) or 0
                ) + 1.0
            return None
        codes: Set[str] = set()
        for r in rows or []:
            code = str(r.get("code") or "").strip()
            if code:
                codes.add(code)
        store: Optional[Set[str]] = set(codes) if codes else None
        with _SLOT_UNIV_LOCK:
            _store_locked(store)
        if prof is not None:
            prof["slot_cache_miss"] = float(
                prof.get("slot_cache_miss", 0) or 0
            ) + 1.0
            if codes:
                prof["slot_ok"] = float(prof.get("slot_ok", 0) or 0) + 1.0
                prof["slot_codes"] = float(len(codes))
            else:
                prof["slot_miss"] = float(prof.get("slot_miss", 0) or 0) + 1.0
        return codes if codes else None

    # 1) TTL hit / 2) flight 대기 / 3) 리더 SELECT
    leader = False
    flight_ev: Optional[threading.Event] = None
    with _SLOT_UNIV_LOCK:
        cached = _from_cache_locked(as_hit=True)
        if cached is not Ellipsis:
            return cached  # type: ignore[return-value]
        flight_ev = _SLOT_UNIV_FLIGHT.get(cache_key)
        if flight_ev is None:
            flight_ev = threading.Event()
            _SLOT_UNIV_FLIGHT[cache_key] = flight_ev
            leader = True

    if not leader and flight_ev is not None:
        flight_ev.wait(timeout=max(0.05, _slot_univ_flight_wait_sec()))
        with _SLOT_UNIV_LOCK:
            cached = _from_cache_locked(as_hit=True, from_flight=True)
            if cached is not Ellipsis:
                return cached  # type: ignore[return-value]
            # 리더 실패·타임아웃 → 내가 재비행
            if cache_key not in _SLOT_UNIV_FLIGHT:
                flight_ev = threading.Event()
                _SLOT_UNIV_FLIGHT[cache_key] = flight_ev
                leader = True
            else:
                flight_ev = _SLOT_UNIV_FLIGHT[cache_key]
        if not leader and flight_ev is not None:
            flight_ev.wait(timeout=max(0.05, _slot_univ_flight_wait_sec()))
            with _SLOT_UNIV_LOCK:
                cached = _from_cache_locked(as_hit=True, from_flight=True)
                if cached is not Ellipsis:
                    return cached  # type: ignore[return-value]
                if cache_key not in _SLOT_UNIV_FLIGHT:
                    flight_ev = threading.Event()
                    _SLOT_UNIV_FLIGHT[cache_key] = flight_ev
                    leader = True
                else:
                    # 여전히 비행 중이면 중복 SELECT 대신 스냅샷 없음과 동일(필터 생략)
                    if prof is not None:
                        prof["slot_miss"] = float(
                            prof.get("slot_miss", 0) or 0
                        ) + 1.0
                    return None

    if not leader:
        return None

    assert flight_ev is not None
    try:
        return _fetch_db()
    finally:
        with _SLOT_UNIV_LOCK:
            _SLOT_UNIV_FLIGHT.pop(cache_key, None)
        try:
            flight_ev.set()
        except Exception:
            pass


Ellipsis 센티널이 지저분해서 반환을 `(hit, value)`로 정리합니다.


    ttl = _slot_univ_ttl_sec()
    day_ymd = when.strftime("%Y-%m-%d")

    def _from_cache_locked(
        *, as_hit: bool, from_flight: bool = False,
    ) -> Tuple[bool, Optional[Set[str]]]:
        """(hit, codes). hit=False 이면 미스. codes=None 은 스냅샷 없음(필터 생략)."""
        hit = _SLOT_UNIV_CACHE.get(cache_key)
        if hit is None:
            return False, None
        codes_cached, ts = hit
        now_ts = time.time()
        # TTL 0 이하 = 캐시 끔(스키마·ENV 설명과 동일) → 항상 미스
        if ttl <= 0 or (now_ts - ts) >= ttl:
            return False, None
        if as_hit and prof is not None:
            prof["slot_cache_hit"] = float(prof.get("slot_cache_hit", 0) or 0) + 1.0
            if from_flight:
                prof["slot_flight_wait"] = float(
                    prof.get("slot_flight_wait", 0) or 0
                ) + 1.0
        if codes_cached is None:
            if prof is not None:
                prof["slot_miss"] = float(prof.get("slot_miss", 0) or 0) + 1.0
            return True, None
        if prof is not None:
            prof["slot_ok"] = float(prof.get("slot_ok", 0) or 0) + 1.0
            prof["slot_codes"] = float(len(codes_cached))
        return True, set(codes_cached)

    def _store_locked(store: Optional[Set[str]]) -> None:
        _SLOT_UNIV_CACHE[cache_key] = (
            set(store) if store else None,
            time.time(),
        )

    def _fetch_db() -> Optional[Set[str]]:
        # 당일(캘린더) 스냅샷만 슬롯정합에 쓴다 — 낡은 history 전멸 방지
        t_et = time.perf_counter()
        et = _history_snapshot_event_time(db, sid, at_time, src)
        _prof_add(prof, "slot_et_ms", t_et)
        if not et or _event_time_ymd(et) != day_ymd:
            with _SLOT_UNIV_LOCK:
                _store_locked(None)
            if prof is not None:
                prof["slot_miss"] = float(prof.get("slot_miss", 0) or 0) + 1.0
                prof["slot_cache_miss"] = float(
                    prof.get("slot_cache_miss", 0) or 0
                ) + 1.0
            return None
        getter = getattr(db, "get_universe_at", None)
        if getter is None:
            with _SLOT_UNIV_LOCK:
                _store_locked(None)
            if prof is not None:
                prof["slot_miss"] = float(prof.get("slot_miss", 0) or 0) + 1.0
                prof["slot_cache_miss"] = float(
                    prof.get("slot_cache_miss", 0) or 0
                ) + 1.0
            return None
        try:
            t_get = time.perf_counter()
            # TradeDBExt 는 history_source 지원. FakeDB 등 구시그니처는 무시.
            try:
                rows = getter(
                    strategy_id=sid, at_time=at_time, history_source=src,
                ) or []
            except TypeError:
                rows = getter(strategy_id=sid, at_time=at_time) or []
            _prof_add(prof, "slot_get_ms", t_get)
        except Exception:
            if prof is not None:
                prof["slot_miss"] = float(prof.get("slot_miss", 0) or 0) + 1.0
                prof["slot_cache_miss"] = float(
                    prof.get("slot_cache_miss", 0) or 0
                ) + 1.0
            return None
        codes: Set[str] = set()
        for r in rows or []:
            code = str(r.get("code") or "").strip()
            if code:
                codes.add(code)
        store: Optional[Set[str]] = set(codes) if codes else None
        with _SLOT_UNIV_LOCK:
            _store_locked(store)
        if prof is not None:
            prof["slot_cache_miss"] = float(
                prof.get("slot_cache_miss", 0) or 0
            ) + 1.0
            if codes:
                prof["slot_ok"] = float(prof.get("slot_ok", 0) or 0) + 1.0
                prof["slot_codes"] = float(len(codes))
            else:
                prof["slot_miss"] = float(prof.get("slot_miss", 0) or 0) + 1.0
        return codes if codes else None

    # 1) TTL hit / 2) flight 대기 / 3) 리더 SELECT
    leader = False
    flight_ev: Optional[threading.Event] = None
    with _SLOT_UNIV_LOCK:
        ok, cached = _from_cache_locked(as_hit=True)
        if ok:
            return cached
        flight_ev = _SLOT_UNIV_FLIGHT.get(cache_key)
        if flight_ev is None:
            flight_ev = threading.Event()
            _SLOT_UNIV_FLIGHT[cache_key] = flight_ev
            leader = True

    if not leader and flight_ev is not None:
        flight_ev.wait(timeout=max(0.05, _slot_univ_flight_wait_sec()))
        with _SLOT_UNIV_LOCK:
            ok, cached = _from_cache_locked(as_hit=True, from_flight=True)
            if ok:
                return cached
            # 리더 실패·타임아웃 → 내가 재비행
            if cache_key not in _SLOT_UNIV_FLIGHT:
                flight_ev = threading.Event()
                _SLOT_UNIV_FLIGHT[cache_key] = flight_ev
                leader = True
            else:
                flight_ev = _SLOT_UNIV_FLIGHT[cache_key]
        if not leader and flight_ev is not None:
            flight_ev.wait(timeout=max(0.05, _slot_univ_flight_wait_sec()))
            with _SLOT_UNIV_LOCK:
                ok, cached = _from_cache_locked(as_hit=True, from_flight=True)
                if ok:
                    return cached
                if cache_key not in _SLOT_UNIV_FLIGHT:
                    flight_ev = threading.Event()
                    _SLOT_UNIV_FLIGHT[cache_key] = flight_ev
                    leader = True
                else:
                    # 여전히 비행 중이면 중복 SELECT 대신 스냅샷 없음과 동일(필터 생략)
                    if prof is not None:
                        prof["slot_miss"] = float(
                            prof.get("slot_miss", 0) or 0
                        ) + 1.0
                    return None

    if not leader:
        return None

    assert flight_ev is not None
    try:
        return _fetch_db()
    finally:
        with _SLOT_UNIV_LOCK:
            _SLOT_UNIV_FLIGHT.pop(cache_key, None)
        try:
            flight_ev.set()
        except Exception:
            pass

    ttl = _slot_univ_ttl_sec()
    day_ymd = when.strftime("%Y-%m-%d")

    def _from_cache_locked(
        *, as_hit: bool, from_flight: bool = False,
    ) -> Optional[Optional[Set[str]]]:
        """유효 캐시면 Set|None 반환. 미스면 센티널 객체 대신 … 실제로는 특수.

        반환: ('ok', value) 패턴 대신 — hit 시 codes(또는 None 스냅샷없음),
        miss 시 Ellipsis 로 표시.
        """
        hit = _SLOT_UNIV_CACHE.get(cache_key)
        if hit is None:
            return Ellipsis  # type: ignore[return-value]
        codes_cached, ts = hit
        now_ts = time.time()
        if not (ttl <= 0 or (now_ts - ts) < ttl):
            return Ellipsis  # type: ignore[return-value]
        if as_hit and prof is not None:
            prof["slot_cache_hit"] = float(prof.get("slot_cache_hit", 0) or 0) + 1.0
            if from_flight:
                prof["slot_flight_wait"] = float(
                    prof.get("slot_flight_wait", 0) or 0
                ) + 1.0
        if codes_cached is None:
            if prof is not None:
                prof["slot_miss"] = float(prof.get("slot_miss", 0) or 0) + 1.0
            return None
        if prof is not None:
            prof["slot_ok"] = float(prof.get("slot_ok", 0) or 0) + 1.0
            prof["slot_codes"] = float(len(codes_cached))
        return set(codes_cached)

    def _store_locked(store: Optional[Set[str]]) -> None:
        _SLOT_UNIV_CACHE[cache_key] = (
            set(store) if store else None,
            time.time(),
        )

    def _fetch_db() -> Optional[Set[str]]:
        # 당일(캘린더) 스냅샷만 슬롯정합에 쓴다 — 낡은 history 전멸 방지
        t_et = time.perf_counter()
        et = _history_snapshot_event_time(db, sid, at_time, src)
        _prof_add(prof, "slot_et_ms", t_et)
        if not et or _event_time_ymd(et) != day_ymd:
            with _SLOT_UNIV_LOCK:
                _store_locked(None)
            if prof is not None:
                prof["slot_miss"] = float(prof.get("slot_miss", 0) or 0) + 1.0
                prof["slot_cache_miss"] = float(
                    prof.get("slot_cache_miss", 0) or 0
                ) + 1.0
            return None
        getter = getattr(db, "get_universe_at", None)
        if getter is None:
            with _SLOT_UNIV_LOCK:
                _store_locked(None)
            if prof is not None:
                prof["slot_miss"] = float(prof.get("slot_miss", 0) or 0) + 1.0
                prof["slot_cache_miss"] = float(
                    prof.get("slot_cache_miss", 0) or 0
                ) + 1.0
            return None
        try:
            t_get = time.perf_counter()
            # TradeDBExt 는 history_source 지원. FakeDB 등 구시그니처는 무시.
            try:
                rows = getter(
                    strategy_id=sid, at_time=at_time, history_source=src,
                ) or []
            except TypeError:
                rows = getter(strategy_id=sid, at_time=at_time) or []
            _prof_add(prof, "slot_get_ms", t_get)
        except Exception:
            if prof is not None:
                prof["slot_miss"] = float(prof.get("slot_miss", 0) or 0) + 1.0
                prof["slot_cache_miss"] = float(
                    prof.get("slot_cache_miss", 0) or 0
                ) + 1.0
            return None
        codes: Set[str] = set()
        for r in rows or []:
            code = str(r.get("code") or "").strip()
            if code:
                codes.add(code)
        store: Optional[Set[str]] = set(codes) if codes else None
        with _SLOT_UNIV_LOCK:
            _store_locked(store)
        if prof is not None:
            prof["slot_cache_miss"] = float(
                prof.get("slot_cache_miss", 0) or 0
            ) + 1.0
            if codes:
                prof["slot_ok"] = float(prof.get("slot_ok", 0) or 0) + 1.0
                prof["slot_codes"] = float(len(codes))
            else:
                prof["slot_miss"] = float(prof.get("slot_miss", 0) or 0) + 1.0
        return codes if codes else None

    # 1) TTL hit / 2) flight 대기 / 3) 리더 SELECT
    leader = False
    flight_ev: Optional[threading.Event] = None
    with _SLOT_UNIV_LOCK:
        cached = _from_cache_locked(as_hit=True)
        if cached is not Ellipsis:
            return cached  # type: ignore[return-value]
        flight_ev = _SLOT_UNIV_FLIGHT.get(cache_key)
        if flight_ev is None:
            flight_ev = threading.Event()
            _SLOT_UNIV_FLIGHT[cache_key] = flight_ev
            leader = True

    if not leader and flight_ev is not None:
        flight_ev.wait(timeout=max(0.05, _slot_univ_flight_wait_sec()))
        with _SLOT_UNIV_LOCK:
            cached = _from_cache_locked(as_hit=True, from_flight=True)
            if cached is not Ellipsis:
                return cached  # type: ignore[return-value]
            # 리더 실패·타임아웃 → 내가 재비행
            if cache_key not in _SLOT_UNIV_FLIGHT:
                flight_ev = threading.Event()
                _SLOT_UNIV_FLIGHT[cache_key] = flight_ev
                leader = True
            else:
                flight_ev = _SLOT_UNIV_FLIGHT[cache_key]
        if not leader and flight_ev is not None:
            flight_ev.wait(timeout=max(0.05, _slot_univ_flight_wait_sec()))
            with _SLOT_UNIV_LOCK:
                cached = _from_cache_locked(as_hit=True, from_flight=True)
                if cached is not Ellipsis:
                    return cached  # type: ignore[return-value]
                if cache_key not in _SLOT_UNIV_FLIGHT:
                    flight_ev = threading.Event()
                    _SLOT_UNIV_FLIGHT[cache_key] = flight_ev
                    leader = True
                else:
                    # 여전히 비행 중이면 중복 SELECT 대신 스냅샷 없음과 동일(필터 생략)
                    if prof is not None:
                        prof["slot_miss"] = float(
                            prof.get("slot_miss", 0) or 0
                        ) + 1.0
                    return None

    if not leader:
        return None

    assert flight_ev is not None
    try:
        return _fetch_db()
    finally:
        with _SLOT_UNIV_LOCK:
            _SLOT_UNIV_FLIGHT.pop(cache_key, None)
        try:
            flight_ev.set()
        except Exception:
            pass

    "UNIVERSE_SLOT_CACHE_TTL_SEC",
    # 전략 config_* RAM TTL — reload 매루프 SELECT 완화 (유니버스 분슬롯 금지와 별개 · 기본 1초)
    "STRATEGY_ENV_CACHE_TTL_SEC",

    "UNIVERSE_SLOT_CACHE_TTL_SEC",
    "UNIVERSE_SLOT_FLIGHT_WAIT_SEC",
    # 전략 config_* RAM TTL — reload 매루프 SELECT 완화 (유니버스 분슬롯 금지와 별개 · 기본 1초)
    "STRATEGY_ENV_CACHE_TTL_SEC",

            _f("UNIVERSE_SLOT_CACHE_TTL_SEC", "유니버스슬롯 RAM TTL(초)", "float", default=1.0,
               hint="history∩후보 정합 ON 유지 · 같은 슬롯키면 N초 RAM · 슬롯 바뀌면 즉시 DB 재조회"),

            _f("UNIVERSE_SLOT_CACHE_TTL_SEC", "유니버스슬롯 RAM TTL(초)", "float", default=1.0,
               hint="history∩후보 정합 ON 유지 · 같은 슬롯키면 N초 RAM · 슬롯 바뀌면 즉시 DB 재조회"),
            _f("UNIVERSE_SLOT_FLIGHT_WAIT_SEC", "유니버스슬롯 flight대기(초)", "float", default=5.0,
               hint="TTL 만료 시 같은 키 DB SELECT 1회(single-flight) · 대기자 Event 상한 · "
                    "분슬롯 freeze 아님"),

Shell

cd /home/hoon/kis_bot && python3 -m py_compile kis_trader/utils/live_portfolio_common.py && python3 <<'PY'
# single-flight smoke: 5 threads, 1 fake slow DB
import threading, time
from datetime import datetime
from kis_trader.utils import live_portfolio_common as m

m.invalidate_universe_slot_cache()
calls = {'n': 0}
lock = threading.Lock()

class FakeDB:
    def get_universe_at(self, strategy_id=None, at_time=None, history_source=None):
        with lock:
            calls['n'] += 1
        time.sleep(0.15)
        return [{'code': '005930'}]

# patch helpers used before get
_orig_et = m._history_snapshot_event_time
m._history_snapshot_event_time = lambda db, sid, at, src: datetime.now().strftime('%Y-%m-%d %H:%M:%S')
m.resolve_live_scan_interval_min = lambda sid: 1

db = FakeDB()
out = []
def worker():
    out.append(m.history_universe_codes_at(db, 'MOMENTUM', datetime.now(), history_source='kiwoom'))

ths = [threading.Thread(target=worker) for _ in range(5)]
for t in ths: t.start()
for t in ths: t.join()
print('db_calls', calls['n'], 'results', [sorted(x) if x else x for x in out])
assert calls['n'] == 1, calls
assert all(x == {'005930'} for x in out)
print('OK single-flight')
m._history_snapshot_event_time = _orig_et
PY

Compile and smoke-test single-flight


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
r=apply_env_patch({"UNIVERSE_SLOT_FLIGHT_WAIT_SEC":"5"})
invalidate_merged_env_cache()
print(r, get_env_from_db("UNIVERSE_SLOT_FLIGHT_WAIT_SEC",""))
PY
sudo systemctl restart kis_trader_main.service
sleep 2
systemctl is-active kis_trader_main.service

Persist flight wait env and restart bot


systemctl is-active kis_trader_main.service; systemctl show kis_trader_main.service -p ActiveState,SubState,MainPID --no-pager

Check kis_trader_main service state


### 적용
슬롯 유니버스에 **single-flight** 넣었습니다. 스모크에서 동시 5스레드 → DB 1회. 실매 재시작 `active`.

---

### 전략별 커넥션 하나씩 → 유의미한가?

**가끔 스파이크에는 체감, 평소 TTL 히트에는 거의 없음.**

| 상황 | 효과 |
|------|------|
| 같은 전략·같은 키가 동시에 여러 번 | single-flight로 이미 해결 → **커넥션 추가 이득 거의 없음** |
| TTL 만료 순간에 **전략 여러 개가 동시에** SELECT | 지금은 공유 1커넥션+락이라 **대기 시간이 합산**. 전략별 커넥션이면 **병렬** → `cand_slot` 꼬리 지연은 줄어들 수 있음 |
| 평소 (1초 TTL 히트) | DB 안 가므로 **체감 거의 0** |

캐시 키에 `strategy_id`가 들어 있어서, 전략이 다르면 flight가 안 합쳐집니다. 그때만 “줄서기”가 남고, 그게 커넥션 분리의 주 효과입니다.

정리하면: **먼저 single-flight로 충분하고**, 이후 프로파일에 **전략 간 `cand_slot`이 서로 밀리는** 패턴이 남으면 그때 전략별(또는 읽기 전용 소수 풀) 커넥션을 검토하면 됩니다. 지금 단계에서 전략마다 하나씩 늘리는 건 이득 대비 복잡도가 큽니다.


<timestamp>Thursday, Aug 13, 2026, 3:13 PM (UTC+9)</timestamp>
<user_query>
캐시키에 전략명을 넣엇으면 커넥션 분리랑 똑같네 뭐 맞아? 지금 오류없이 돌고잇나?
</user_query>


캐시 키와 커넥션 분리는 다릅니다. 실매 상태·로그를 바로 확인합니다.

systemctl is-active kis_trader_main.service; systemctl show kis_trader_main.service -p ActiveState,SubState,MainPID,NRestarts --no-pager; journalctl -u kis_trader_main.service --since "5 min ago" --no-pager -p err..alert 2>/dev/null | tail -40; echo '---'; echo "LOOP_PROF recent:"; tail -n 5 /home/hoon/kis_bot/logs/loop_profile.log 2>/dev/null; echo '---'; journalctl -u kis_trader_main.service --since "3 min ago" --no-pager 2>/dev/null | rg -i "traceback|exception|error|single.flight|UNIVERSE|LOOP_PROF|started|초기화" | tail -30

Check bot health and recent errors


journalctl -u kis_trader_main.service --since "5 min ago" --no-pager 2>/dev/null | rg -i "Traceback|ERROR|CRITICAL|Exception" | rg -v "LOOP_PROF" | tail -20; echo exit:$?; python3 - <<'PY'
# quick multi-strat miss timing from recent log
import re
from collections import defaultdict
p='/home/hoon/kis_bot/logs/loop_profile.log'
rows=[]
with open(p) as f:
    for line in f:
        if '[LOOP_PROF]' not in line: continue
        m=re.search(r'\[LOOP_PROF\] (\S+) .*cand_slot=([0-9.]+).*slot_hit=(\d+).*slot_missc=(\d+)', line)
        if m:
            rows.append((m.group(1), float(m.group(2)), int(m.group(3)), int(m.group(4))))
# last 200
rows=rows[-200:]
by=defaultdict(list)
for s,cs,h,mc in rows:
    by[s].append((cs,h,mc))
for s,vs in sorted(by.items()):
    slots=[v[0] for v in vs]
    hits=sum(v[1] for v in vs); miss=sum(v[2] for v in vs)
    print(f'{s}: n={len(vs)} cand_slot p50={sorted(slots)[len(slots)//2]:.1f} max={max(slots):.1f} hit={hits} missc={miss}')
PY

Check errors and slot cache stats


### 캐시 키에 전략명 ≠ 커넥션 분리

**다릅니다.**

| | 캐시 키에 `sid` | 커넥션 분리 |
|--|----------------|-------------|
| 하는 일 | 전략마다 **RAM 결과 따로** 저장 | 전략마다 **DB 줄 따로** |
| 같은 전략이 동시에 여러 번 | single-flight로 SELECT 1번 | 거의 무관 |
| 전략 A·B가 동시에 TTL 만료 | **각자 SELECT** → 공유 1커넥션에 **줄 서서** 합산 대기 | 병렬 가능 |

키에 전략을 넣는 이유는 “모멘텀 유니버스를 스캘프에 잘못 쓰지 않기”입니다.
속도 측면에서 **전략 간 DB 줄서기를 없애 주진 않습니다.**

방금 로그에도 그게 보입니다: `BREAKOUT`/`SCALP` miss 한 번에 `cand_slot` ~800ms대, `SHORT`도 miss 때 최대 ~770ms. 히트일 때는 `cand_slot≈0`.

---

### 지금 상태

- `kis_trader_main` **active / running**, 재시작 카운트 0
- 최근 5분 journal **ERROR/Traceback 없음**
- 루프는 정상 돌고, 히트 구간은 `total≈0.5ms`, `slot_hit=1`

오류 없이 돌고 있습니다.


<timestamp>Thursday, Aug 13, 2026, 3:18 PM (UTC+9)</timestamp>
<user_query>
방금 로그에도 그게 보입니다: BREAKOUT/SCALP miss 한 번에 cand_slot ~800ms대, SHORT도 miss 때 최대 ~770ms. 히트일 때는 cand_slot≈0.

그럼 커넥션 분이가 유의미한데? 이거 커넥션 분리말고는 답이 없지? 그리고 내가 오류없이라고 하는건 매수체크 구독 히스토리 매도체크 호가 체크 전부 잘 돌고 잇는가야
</user_query>


커넥션 분리 외 대안이 있는지 정리하고, 매수·구독·히스토리·매도·호가까지 실매 상태를 확인합니다.

systemctl is-active kis_trader_main.service; MainPID=$(systemctl show kis_trader_main.service -p MainPID --value); echo PID=$MainPID; echo '=== recent warn/err (no LOOP_PROF) ==='; journalctl -u kis_trader_main.service --since "10 min ago" --no-pager 2>/dev/null | rg -i "Traceback|ERROR|CRITICAL|Exception|실패|구독|subscribe|orderbook|호가|history|매수|매도|ws_|disconnect|timeout" | rg -v "LOOP_PROF|DEBUG" | tail -50; echo '=== strategy loop heartbeats ==='; journalctl -u kis_trader_main.service --since "3 min ago" --no-pager 2>/dev/null | rg "\[LOOP_PROF\] (SHORT|MOMENTUM|BREAKOUT|SCALP|RANGE|UPDOW)" | awk '{print $NF}' | head -1; journalctl -u kis_trader_main.service --since "2 min ago" --no-pager 2>/dev/null | rg -o "\[LOOP_PROF\] (SHORT|MOMENTUM|BREAKOUT|SCALP|RANGE_BREAK|UPDOW|DBBAND)" | sort | uniq -c; echo '=== slot miss spikes last 3m from file ==='; python3 - <<'PY'
import re, datetime
from collections import defaultdict
cut = datetime.datetime.now() - datetime.timedelta(minutes=3)
p='/home/hoon/kis_bot/logs/loop_profile.log'
by=defaultdict(lambda: {'n':0,'hit':0,'miss':0,'slot_max':0.0,'total_max':0.0,'miss_slots':[]})
with open(p) as f:
    for line in f:
        if '[LOOP_PROF]' not in line: continue
        ts=line[:19]
        try:
            t=datetime.datetime.strptime(ts,'%Y-%m-%d %H:%M:%S')
        except Exception:
            continue
        if t < cut: continue
        m=re.search(r'\[LOOP_PROF\] (\S+) total=([0-9.]+).*cand_slot=([0-9.]+).*slot_hit=(\d+).*slot_missc=(\d+)', line)
        if not m: continue
        s,tot,cs,h,mc=m.group(1),float(m.group(2)),float(m.group(3)),int(m.group(4)),int(m.group(5))
        d=by[s]; d['n']+=1; d['hit']+=h; d['miss']+=mc; d['slot_max']=max(d['slot_max'],cs); d['total_max']=max(d['total_max'],tot)
        if mc: d['miss_slots'].append(cs)
for s,d in sorted(by.items()):
    print(s, {k:d[k] for k in d if k!='miss_slots'}, 'miss_cand_slot', d['miss_slots'][:5])
PY

Broader live health and slot miss check


echo '=== sell / orderbook / history (2m) ==='; journalctl -u kis_trader_main.service --since "2 min ago" --no-pager 2>/dev/null | rg -i "매도|check_sell|부분체결|호가|orderbook|H0STASP|history|event_time|AFR|조건검색|insert.*history|후보" | rg -v "LOOP_PROF|탈락-RSI|갭보정" | tail -40; echo '=== traceback any today since restart ==='; journalctl -u kis_trader_main.service --since "15:11:00" --no-pager 2>/dev/null | rg "Traceback|CRITICAL" | tail -15; echo '=== kiwoom/ls ws alive hints ==='; journalctl -u kis_trader_main.service --since "2 min ago" --no-pager 2>/dev/null | rg -i "재연결|disconnect|연결 끊|pong|heartbeat|KiwoomWS|LS WS|구독 추가|호가" | rg -v "LOOP_PROF|한도 초과" | tail -25; echo '=== MOMENTUM weird total ==='; rg "\[LOOP_PROF\] MOMENTUM" /home/hoon/kis_bot/logs/loop_profile.log | tail -5; rg "\[LOOP_PROF\] MOMENTUM total=[0-9]{5,}" /home/hoon/kis_bot/logs/loop_profile.log | tail -5

Verify sell/orderbook/history/WS paths


### 커넥션 분리밖에 없나?

**전략 간 miss가 겹칠 때의 줄서기**에는 맞습니다. 다만 **전략마다 하나씩**만 답은 아닙니다.

| 방법 | 역할 |
|------|------|
| **읽기 풀 소수(3~4)** | miss가 동시에 와도 병렬. 전략 N개만큼 안 늘려도 됨 ← 보통 이게 적정 |
| **백그라운드 선갱신** | TTL 끝나기 직전에 미리 SELECT → 핫패스 miss 자체를 줄임 |
| **전략별 1커넥션** | 위와 비슷하지만 연결·관리 비용만 더 큼 |

캐시 키에 `sid` 넣은 것과는 **다른 축**입니다. 키 분리 ≠ DB 병렬.

그리고 지금 로그상 **더 큰 병목**이 있습니다. MOMENTUM `total` 60~120초인데 `cand_slot`은 0.3~0.6초고, 대부분은 `submit_sell` / `lock_hold`입니다. 슬롯 커넥션만 갈라도 이 구간은 안 없어집니다.

---

### “오류 없이 잘 도냐” (매수·구독·히스토리·매도·호가)

**프로세스 crash / Traceback 없음 = 전부 OK는 아닙니다.** 지금 기준으로:

| 항목 | 상태 |
|------|------|
| 매수체크 | SCALP 등 후보 루프·RSI 탈락 로그 정상 |
| 히스토리∩슬롯 | `유니버스슬롯 … history=kiwoom 교집합` 동작 중 |
| 구독 | 추가/해제 돌아감. 다만 **41/41 한도 → 거부** 반복 (설계상 방어, “여유 있게 다 구독”은 아님) |
| 매도 | 부분체결 재매도 등 동작. 그러나 MOMENTUM이 **`submit_sell` 수십 초~2분**으로 루프를 오래 잡고 `tick_lock_miss` 다수 → **매도/락 쪽 이상 징후** |
| 호가 | 이번 스냅에 호가 전용 성공 로그는 거의 안 보임. 구독 role=full이면 포함 가능하나, **호가만 따로 “정상”이라고 단정하진 않음** |

한 줄: **죽지는 않았고 매수·슬롯·구독 순환은 돌아가지만, “전 구간 건강”은 아닙니다.** 특히 MOMENTUM 매도 제출 지연은 커넥션 분리와 별개로 봐야 합니다.

원하시면 다음으로 (1) 읽기 풀 3~4 설계 보고 후 승인받거나, (2) MOMENTUM `submit_sell` 왜 60초+인지 먼저 파겠습니다.


<timestamp>Thursday, Aug 13, 2026, 3:20 PM (UTC+9)</timestamp>
<user_query>
파헤쳐봐 명명백백히 초딩수준 설명 한글로
</user_query>


MOMENTUM이 수십 초~2분 멈춘 원인을 `submit_sell`·락부터 로그·코드로 짚겠습니다.

echo '=== MOMENTUM long loops (since restart) ==='; rg "\[LOOP_PROF\] MOMENTUM total=" /home/hoon/kis_bot/logs/loop_profile.log | awk -F'total=' '{print}' | python3 - <<'PY'
import re,sys
rows=[]
for line in open('/home/hoon/kis_bot/logs/loop_profile.log'):
    if '[LOOP_PROF] MOMENTUM' not in line: continue
    if line < '2026-08-13 15:11': continue
    m=re.search(r'(\d{2}:\d{2}:\d{2}).*total=([0-9.]+).*lock_wait=([0-9.]+).*lock_hold=([0-9.]+).*sell_chk=([0-9.]+).*prefetch=([0-9.]+).*submit_sell=([0-9.]+).*cand_slot=([0-9.]+).*tick_lock_miss=(\d+)', line)
    if not m: continue
    t,tot,lw,lh,sc,pf,ss,cs,tlm=m.groups()
    tot=float(tot)
    if tot>=5000:
        rows.append((t,tot,float(lw),float(lh),float(sc),float(pf),float(ss),float(cs),int(tlm)))
print('long_n',len(rows))
for r in rows[-15:]:
    print('t=%s total=%.0f lock_wait=%.0f lock_hold=%.0f sell_chk=%.0f prefetch=%.0f submit_sell=%.0f slot=%.0f tick_lock_miss=%d' % r)
PY
echo '=== around 15:17 sell/order logs ==='
journalctl -u kis_trader_main.service --since "15:15:00" --until "15:19:00" --no-pager 2>/dev/null | rg -i "MOMENTUM|부분체결|매도|submit|주문|timeout|429|유량|체결|엔알비|475230|order_mgr|_sell" | rg -v "LOOP_PROF|탈락-|갭보정|구독" | tail -60

Correlate MOMENTUM stall with sell logs


journalctl -u kis_trader_main.service --since "15:13:00" --until "15:20:00" --no-pager 2>/dev/null | rg "\[kis_trader\.(order_mgr|strategy\.MOMENTUM)\]|매도|SELL|부분체결|잔고|inquire|주문응답|EGW|초당|429|재시도|timeout|sleep" | rg -v "LOOP_PROF|봉강제|구독|갭보정|탈락" | head -80

Find MOMENTUM sell order logs 15:13-20


### 한 줄 결론

**DB 커넥션 문제가 아닙니다.**
MOMENTUM이 **엔알비(475230) 손절 매도**를 하다가, 모의서버가 **조금씩만 체결**하고 · **잔고 API가 늦거나 거짓말을 해서**, 매도 자물쇠를 **1~2분씩 붙잡고** 있었던 겁니다.

---

### 초딩 비유

교실에 **문 자물쇠 1개**가 있어요. (`_sell_lock`)

1. MOMENTUM 선생님이 “엔알비 팔자!” 하고 **문을 잠근 채** 증권사에 전화합니다.
2. 증권사(모의)가 “64주 중에 **20주만** 팔렸어요” 합니다.
3. 선생님이 “나머지 또 팔자!” → 또 전화 → 또 **조금만** 체결 → 또 재매도…
4. 가끔 증권사가 “**잔고 없어요**”(40240000) / **전화 안 받음(Timeout)** / **500 에러** 합니다.
5. 그동안 **문은 계속 잠겨** 있어서, 틱으로 매도하려는 애들은 다 튕깁니다. (`tick_lock_miss`)
6. 그 사이 스톱워치가 `submit_sell=90000ms`처럼 찍힙니다.

슬롯 캐시 miss 0.5초는 **옆에서 책 찾는 시간**이고,
지금 말한 1~2분은 **문 잠그고 전화 붙잡은 시간**입니다. 다른 이야기입니다.

---

### 로그가 증명하는 타임라인 (엔알비)

| 시각 | 무슨 일 |
|------|---------|
| 15:13:49 | 손절 매도 → **20/64주**만 체결 (부분체결) |
| 15:14:07 | **부분체결재매도** 44주 |
| 15:14:08 | 실패: `모의투자 잔고내역이 없습니다` |
| 15:14:41 | 또 **1/42주**만 체결 |
| 15:15:10 | 또 재매도 → 또 잔고없음 |
| 15:15:42 | API **Timeout** (`inquire-psbl-order`) |
| 15:16:10 | **Timeout** (`inquire-balance`) |
| 15:16:48~17:44 | 또 조금씩 체결 → 또 재매도 반복 |
| 15:17:21 | 잔고조회 **HTTP 500** → 백오프 재시도 |
| 15:18:18 | 잔량 4주 재매도 |
| 15:18:37 | 드디어 **유령잔고 정리** (브로커 0주) |

프로파일과 숫자도 맞습니다.

- 15:14:09 `submit_sell≈97초` ← 위 재매도·대기 구간
- 15:17:16 `submit_sell≈113초` ← Timeout/500/재매도 겹친 구간
- 그 루프들 `tick_lock_miss` 수십~200+ ← 문 잠긴 동안 틱매도 실패 횟수

지투파워(388050) 익절도 **1/33 → 나중에 32주**로 부분체결이 있었지만, 엔알비처럼 오래 꼬이지는 않았습니다.

---

### 왜 이렇게 오래 걸리냐 (원인 3층)

1. **모의투자 특성**: 전량 안 주고고 조각으로 체결되는 일이 잦음.
2. **우리 로직**: 긴급(손절)이면 부분체결 나면 **바로 시장가 재매도** (`부분체결재매도`).
3. **그 전체가 매도 락 안**에서 동기 실행 + 잔고/체결 API **Timeout·500 재시도**까지 합쳐짐.

그래서 `prefetch`(잔고 미리보기)도 수 초~십수 초, `submit_sell`은 수십 초가 됩니다.

---

### “그럼 봇이 망가졌나?”

- **죽지는 않음.** 프로세스 active, Traceback 없음.
- SCALP 매수체크·구독 순환·슬롯 교집합은 **다른 전략 줄**에서 계속 돌아감.
- 다만 MOMENTUM은 그 동안 **매도 문 잠긴 채**라 루프가 멈춘 것처럼 보임.
- 구독 41/41 거부는 **한도 방어**(별 이슈).
- 슬롯 ~800ms 줄서기는 **또 다른 이슈**(커넥션/풀).

---

### 정리

| 오해 | 실제 |
|------|------|
| 커넥션 없어서 2분 | ❌ |
| 슬롯 DB 느려서 2분 | ❌ (그건 수백 ms) |
| 엔알비 손절 + 모의 부분체결 지옥 + API 지연을 **매도 락 잡고** 처리 | ✅ |

고치려면(아직 수정 안 함, 원인만):
재매도/체결확인을 **락 밖으로** 빼거나, 모의 부분체결 루프에 **쿨다운·상한**, Timeout 동안 전략 루프를 막지 않게 하는 쪽이 근본에 가깝습니다.

원하시면 다음으로 **설계안만** 짧게 올리고 승인 후에 손대겠습니다.


<timestamp>Thursday, Aug 13, 2026, 3:49 PM (UTC+9)</timestamp>
<user_query>
초재는 로그 꺼줘 일단 위에껀 좀 생각을 해봐야겠다 비동기든 락밖으로 빼든 코드분석을 좀 더 해봐야겟따 위치나 알려줘
</user_query>


루프 프로파일 로그를 끄고, 매도 락·재매도 관련 코드 위치를 정리합니다.


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, get_env_bool
r=apply_env_patch({"LOOP_PROFILE_ENABLED":"false"})
invalidate_merged_env_cache()
print('patch', r)
print('db', get_env_from_db('LOOP_PROFILE_ENABLED',''))
print('bool', get_env_bool('LOOP_PROFILE_ENABLED', False))
PY
# confirm journal quieting: wait ~3s and count new LOOP_PROF
sleep 3
journalctl -u kis_trader_main.service --since "10 sec ago" --no-pager 2>/dev/null | rg -c "LOOP_PROF" || echo "LOOP_PROF_count=0"

Disable LOOP_PROFILE_ENABLED in DB


### 계측 로그
`LOOP_PROFILE_ENABLED=false` 반영됨. 최근 journal에 `LOOP_PROF` 0건. 재시작 없이 꺼진 상태입니다. (`logs/loop_profile.log` 파일은 남아 있고, 새 줄만 안 씀)

---

### 나중에 볼 코드 위치 (매도 락 / 부분체결)

| 뭘 보나 | 파일 | 대략 |
|--------|------|------|
| **문 자물쇠** `_sell_lock` 정의 | `kis_trader/strategies/base.py` | ~164 |
| **루프 매도** (blocking 락 → 검사 → prefetch → submit) | 같은 파일 `_run_sell_section_locked` | ~449–480 |
| **틱 매도** (락 못 잡으면 miss) | 같은 파일 `_on_tick_sell` 근처 | ~253–272 |
| **주문 던지는 입구** | 같은 파일 `_submit_sell` → `order_mgr.place` | ~1197–1221 |
| **매도 본체** (잔고검증·지정가/시장가) | `kis_trader/execution/order_manager.py` `_place_sell` | ~2152~ |
| **시장가 + 체결 대기** | 같은 파일 `_submit_market_sell_and_confirm` | ~368–412 |
| **부분체결 확정 + 긴급 재매도 호출** | 같은 파일 `_finalize_sell_fill` 끝 | ~700~ / 재매도 **~882–886** |
| **부분체결재매도 실행** | 같은 파일 `_escalate_urgent_sell_market` | ~414–436 |
| **잔고 미리조회** (prefetch) | 같은 파일 `prefetch_broker_holdings` | ~223 |
| **체결 폴링 대기** | `kis_trader/execution/kis_client.py` `get_execution_by_odno` | ~1544 |
| **긴급사유 판별** (손절이면 재매도) | `is_urgent_market_sell_reason` (order_manager import 쪽 grep) | — |

관련 env (읽기만): `REAL_BALANCE_VERIFY_BEFORE_SELL`, `ORDER_FILL_WAIT_SEC`, `SELL_PENDING_REORDER_ON_EXPIRE`, `STRICT_FILL_VERIFY`.

핵심 줄기만 외우면 됩니다.

`base._run_sell_section_locked` → `_submit_sell` → `order_mgr._place_sell` → (체결) `_finalize_sell_fill` → **같은 콜스택에서** `_escalate_urgent_sell_market` (부분체결재매도).

설계 고민하실 때 “락을 어디서 잡고 / 재매도가 그 안에 또 들어가는지”는 **base 449** + **order_manager 882** 두 곳이 중심입니다.


<timestamp>Thursday, Aug 13, 2026, 3:54 PM (UTC+9)</timestamp>
<user_query>
이 대화내용 파일 어딨어? 로컬에 잇나
</user_query>


ls -la /home/hoon/.cursor/projects/home-hoon-kis-bot/agent-transcripts/4ee957e7-9710-4ee8-b688-4fd63cc90853/ 2>/dev/null; ls -lt /home/hoon/.cursor/projects/home-hoon-kis-bot/agent-transcripts/ 2>/dev/null | head -8; du -h /home/hoon/.cursor/projects/home-hoon-kis-bot/agent-transcripts/4ee957e7-9710-4ee8-b688-4fd63cc90853/*.jsonl 2>/dev/null | head -3

Locate this chat agent transcript file


네, **이 서버(로컬)에** 있습니다. 워크스페이스 `kis_bot` 안이 아니라 Cursor 프로젝트 캐시 쪽입니다.

이 대화 본문:

`/home/hoon/.cursor/projects/home-hoon-kis-bot/agent-transcripts/4ee957e7-9710-4ee8-b688-4fd63cc90853/4ee957e7-9710-4ee8-b688-4fd63cc90853.jsonl`

- 형식: JSONL (줄마다 이벤트)
- 크기: 약 2.1MB
- 형제 대화들: 같은 `agent-transcripts/` 아래 UUID 폴더들

채팅 UI용이지, 앱 코드/배포 산출물은 아닙니다.