위메이드는 손절 미도달(전일대비 −3.65% 오인)이고, 호가 표시는 API `ratio`/`or_ratio`와 UI `mid_ratio`/`whale_ratio` 키 불일치입니다. UI를 맞추겠습니다.

StrReplace

function _entryObCellHtml(ob) {
  if (!ob) return '-';
  const mr = (ob.mid_ratio || 0).toFixed(1);
  const wr = (ob.whale_ratio || 0).toFixed(1);
  return `M:${mr}%<br>W:${wr}%`;
}

function _exitObCellHtml(ob, isOpen) {
  if (isOpen) return '보유중';
  if (!ob) return '-';
  const mr = (ob.mid_ratio || 0).toFixed(1);
  const wr = (ob.whale_ratio || 0).toFixed(1);
  return `M:${mr}%<br>W:${wr}%`;
}

/** 호가셀 — enrich 는 ratio(L3잔량비)·or_ratio(전체잔량비). 구키 mid_ratio/whale_ratio 도 허용. */
function _obMwCellHtml(ob) {
  if (!ob) return '-';
  const mr = Number(ob.mid_ratio != null ? ob.mid_ratio : ob.ratio);
  const wr = Number(ob.whale_ratio != null ? ob.whale_ratio : ob.or_ratio);
  const m = Number.isFinite(mr) ? mr.toFixed(2) : '-';
  const w = Number.isFinite(wr) ? wr.toFixed(2) : '-';
  // 잔량비(배수) — % 아님 (0.68 = 매수L3/매도L3)
  return `M:${m}<br>W:${w}`;
}

function _entryObCellHtml(ob) {
  return _obMwCellHtml(ob);
}

function _exitObCellHtml(ob, isOpen) {
  if (isOpen) return '보유중';
  return _obMwCellHtml(ob);
}

    return {
        "side": side,
        "snap_time": str(row.get("snap_time") or "")[:14],
        "source": src,
        "strategy": row.get("strategy"),
        "best_bid": int(bid) if bid else 0,
        "best_ask": int(ask) if ask else 0,
        "spread_pct": round(_spread_pct(bid, ask) or 0.0, 3),
        "bid_qty_l3": bid_l3,
        "ask_qty_l3": ask_l3,
        "ratio": round(ratio, 3) if ratio is not None else None,
        "total_bid_qty": tot_bid,
        "total_ask_qty": tot_ask,
        "or_ratio": round(or_r, 3) if or_r is not None else None,
        "reject_code": rej,
        "reject_msg": msg,
        "verdict": verdict,
        "delta_sec": int(delta_sec),
        "matched": True,
        "near_only": bool(near_only),
        "lim_spread_pct": float(lim_spread_pct),
        "lim_ratio": float(lim_ratio),
        "lim_or_ratio": float(lim_or_ratio),
    }

    # ratio = L3 매수/매도 잔량비, or_ratio = 전체 잔량비
    # mid_ratio/whale_ratio = 웹 표(M/W) 호환 별칭 (동일 값)
    ratio_r = round(ratio, 3) if ratio is not None else None
    or_r_r = round(or_r, 3) if or_r is not None else None
    return {
        "side": side,
        "snap_time": str(row.get("snap_time") or "")[:14],
        "source": src,
        "strategy": row.get("strategy"),
        "best_bid": int(bid) if bid else 0,
        "best_ask": int(ask) if ask else 0,
        "spread_pct": round(_spread_pct(bid, ask) or 0.0, 3),
        "bid_qty_l3": bid_l3,
        "ask_qty_l3": ask_l3,
        "ratio": ratio_r,
        "mid_ratio": ratio_r,
        "total_bid_qty": tot_bid,
        "total_ask_qty": tot_ask,
        "or_ratio": or_r_r,
        "whale_ratio": or_r_r,
        "reject_code": rej,
        "reject_msg": msg,
        "verdict": verdict,
        "delta_sec": int(delta_sec),
        "matched": True,
        "near_only": bool(near_only),
        "lim_spread_pct": float(lim_spread_pct),
        "lim_ratio": float(lim_ratio),
        "lim_or_ratio": float(lim_or_ratio),
    }

Shell

cd /home/hoon/kis_bot && python3 - <<'PY'
# update tooltip text in all matching th — count first
from pathlib import Path
p=Path('templates/backtest.html')
t=p.read_text()
old='title="매수 시점 호가 (M:중간비율 W:고래비율)"'
new='title="매수 시점 호가 (M:L3잔량비 W:전체잔량비 OR)"'
old2='title="매도 시점 호가 (M:중간비율 W:고래비율)"'
new2='title="매도 시점 호가 (M:L3잔량비 W:전체잔량비 OR)"'
print('buy', t.count(old), 'sell', t.count(old2))
t2=t.replace(old,new).replace(old2,new2)
p.write_text(t2)
print('done', t2.count(new), t2.count(new2))
PY
sudo systemctl restart kis_backtest_web.service
sleep 2
systemctl is-active kis_backtest_web.service
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:5050/

Update tooltips and restart backtest web


CallMcpTool

(function(){
  const r=document.querySelector('input[name=act_strategy][value=MOMENTUM]');
  if(r){ r.checked=true; }
  if(typeof loadActual==='function'){ loadActual(); return 'ok'; }
  return 'no loadActual';
})()


new Promise(r=>setTimeout(()=>{
  const tb=document.getElementById('act_tbody');
  const html=tb?tb.innerHTML:'';
  const text=tb?tb.innerText:'';
  const errs=[];
  // console errors not available easily
  r({hasM0: /M:0\.0%/.test(html), hasMpct: /M:\d/.test(html)|| /M:-/.test(html), sample: text.slice(0,500), has068: html.includes('M:0.68')||html.includes('M:0.681'), htmlSnippet: html.match(/M:[^<]+/)?.[0], W: html.match(/W:[^<]+/)?.[0]});
}, 2500))


new Promise(r=>{
  const rad=document.querySelector('input[name=act_strategy][value=SCALP]');
  if(rad) rad.checked=true;
  loadActual();
  setTimeout(()=>{
    const tb=document.getElementById('act_tbody');
    const text=tb?tb.innerText:'';
    r({text:text.slice(0,600), m: (tb&&tb.innerHTML.match(/M:[^<]+/))?.[0], w:(tb&&tb.innerHTML.match(/W:[^<]+/))?.[0],
      cons: window.__lastErr || null});
  }, 2500);
})

(function(){
  const logs=[];
  // check if any script error flags
  return {href:location.href, hasUncaught: typeof window.onerror};
})()


## 결론

**위메이드 손절 버그 아님.** 화면의 −3.6%대는 **전일종가 대비(당일 등락)** 이고, 손절 기준인 **매수가 대비는 약 +0.3%** 라서 2.5% 손절선에 안 닿았습니다.
**매수/매도호가 M:0% W:0%는 UI 키 불일치 버그**였고, 방금 고쳤습니다.

---

### 1) 위메이드(112040) — 왜 안 팔렸나

| 항목 | 값 |
|---|---|
| 매수가 | 16,836 |
| 손절가 (`SCALP_STOP_LOSS_PCT=2.5%`) | **16,419** |
| 매수 이후 1분봉 저가 | **16,810** (−0.15%) |
| 현재가 근처 | ~16,880 (+0.3%) |

화면 예: `-3.82%` + 아래 `당일 · 매수 +0.26%`
→ 큰 숫자는 **당일(전일 17,560 대비)**, 작은 숫자가 **매수 대비**. 손절은 매수 대비만 봅니다.

틱·WS는 들어오고 있고, 손절/익절 로직 자체는 정상입니다. 가격이 16,419 아래로 가면 그때 청산됩니다.

---

### 2) 매수호가/매도호가 M:0.0% W:0.0%

원인: API는 `ratio` / `or_ratio`를 주는데, JS만 `mid_ratio` / `whale_ratio`를 읽어 **항상 0**이 됐습니다. (`-`가 아니라 `M:0`인 이유)

수정 후 브라우저 확인 (`http://192.168.0.149:5050/` · 실거래 분석):

- 빅솔론: `M:0.68 / W:0.12` · 매도 `M:0.25 / W:0.16` (호가컷)
- 위메이드: `M:9.21 / W:7.42` · 매도호가 `보유중`

(M=L3 잔량비, W=전체 잔량비 OR — 배수라 `%` 표기는 제거)

---

### 3) 영향 분류

| 항목 | 실매 | 웹표시 |
|---|---|---|
| 위메이드 미청산 | 손절 미도달(정상) | 당일% vs 매수% 혼동 가능 |
| 호가 M/W 0 표시 | 없음 | **수정됨** (백테웹 JS + enrich 별칭) |

실매 봇은 재시작하지 않았습니다. 백테웹만 재시작했고 (`active` · HTTP 200), 콘솔 쪽 표기 오류는 확인 범위에서 없었습니다.


<timestamp>Thursday, Aug 13, 2026, 11:44 AM (UTC+9)</timestamp>
<user_query>
실매쪽은? 로그가 너무 안올라오는데 1초에 한개도 아닌거 같은데 마이크로초로 체크해야되는데 이거 뭐 grep 해서 전략별로 몇초에 하나씩 체크하는지 봐봐 너무 느린거같아 이거 틱오는 속도랑 똑같이 올라가야되는데
</user_query>


실매 루프·틱매도·전략별 sleep 간격을 로그와 env로 대조해, 초당 체크 빈도를 측정합니다.

Shell

cd /home/hoon/kis_bot && python3 - <<'PY'
from kis_trader.utils.env import invalidate_merged_env_cache, get_env_from_db, get_env_bool, get_env_float, get_env_int
invalidate_merged_env_cache()
keys=[
 'TICK_SELL_ENABLED','TICK_SELL_MIN_INTERVAL_MS',
 'SCALP_TICK_SELL_ENABLED','MOMENTUM_TICK_SELL_ENABLED','BREAKOUT_TICK_SELL_ENABLED','SHORT_TICK_SELL_ENABLED','TAIL_TICK_SELL_ENABLED',
 'STRATEGY_LOOP_SLEEP_MIN','STRATEGY_LOOP_SLEEP_MAX',
 'SCAN_REJECT_SLEEP_MIN','SCAN_REJECT_SLEEP_MAX',
 'SCAN_BUY_OK_SLEEP_MIN','SCAN_BUY_OK_SLEEP_MAX',
 'SCALP_LOOP_SLEEP_MIN','MOMENTUM_LOOP_SLEEP_MIN','BREAKOUT_LOOP_SLEEP_MIN','SHORT_LOOP_SLEEP_MIN',
]
for k in keys:
  v=get_env_from_db(k)
  print(f'{k}={v!r}')
print('TICK_SELL_ENABLED bool', get_env_bool('TICK_SELL_ENABLED', False))
print('TICK_SELL_MIN_INTERVAL_MS', get_env_int('TICK_SELL_MIN_INTERVAL_MS', 50))
PY

Read tick-sell and loop sleep env from DB


루프 sleep은 0인데 스캘프만 틱매도 OFF입니다. 저널에서 전략별 실제 로그 간격을 재겠습니다.

cd /home/hoon/kis_bot && python3 - <<'PY'
"""Measure journal log inter-arrival by strategy tag over last N minutes."""
import subprocess, re
from collections import defaultdict
from datetime import datetime

since = "2026-08-13 11:30:00"
cmd = ["journalctl", "-u", "kis_trader_main.service", f"--since={since}", "--no-pager", "-o", "short-iso"]
raw = subprocess.check_output(cmd, text=True, errors="replace")

# Parse timestamps + strategy tags
pat_ts = re.compile(r"^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})")
pat_strat = re.compile(r"kis_trader\.strategy\.(\w+)")
pat_kind = re.compile(r"(매수체크|탈락-|시그널|매도|청산|틱매도|보유|ENTER|EXIT|봉강제확정|갭보정)")

by = defaultdict(list)  # strategy -> list of epoch floats for ALL strategy lines
by_kind = defaultdict(list)  # (strat, kind) -> times
buy_check = defaultdict(list)
tick_lines = []
all_ts = []

for line in raw.splitlines():
    m = pat_ts.match(line)
    if not m:
        continue
    try:
        dt = datetime.strptime(m.group(1), "%Y-%m-%dT%H:%M:%S")
    except Exception:
        continue
    ts = dt.timestamp()
    all_ts.append(ts)
    sm = pat_strat.search(line)
    if sm:
        sid = sm.group(1)
        by[sid].append(ts)
        if "매수체크" in line:
            buy_check[sid].append(ts)
        if "탈락-" in line:
            by_kind[(sid, "탈락")].append(ts)
        if "시그널" in line or "매도" in line or "청산" in line:
            by_kind[(sid, "매도/시그널")].append(ts)
    if "봉강제확정" in line and "112040" in line:
        tick_lines.append(ts)

def stats(arr, label):
    if len(arr) < 2:
        print(f"{label}: n={len(arr)} (간격 계산 불가)")
        return
    gaps = [arr[i]-arr[i-1] for i in range(1, len(arr))]
    gaps.sort()
    def pct(p):
        i = int(len(gaps)*p/100)
        return gaps[min(i, len(gaps)-1)]
    span = arr[-1]-arr[0]
    rate = (len(arr)-1)/span if span>0 else 0
    print(f"{label}: n={len(arr)} span={span:.0f}s  rate={rate:.2f}/s  "
          f"gap med={pct(50):.3f}s p90={pct(90):.3f}s p99={pct(99):.3f}s min={gaps[0]:.3f}s max={gaps[-1]:.3f}s")

print(f"=== journal since {since} total lines with ts: {len(all_ts)} ===")
stats(all_ts, "ALL journal lines")
print()
print("--- strategy.* any log ---")
for sid in sorted(by, key=lambda s: -len(by[s])):
    stats(by[sid], f"strategy.{sid}")
print()
print("--- 매수체크 only ---")
for sid in sorted(buy_check, key=lambda s: -len(buy_check[s])):
    stats(buy_check[sid], f"{sid} 매수체크")
print()
print("--- 탈락 ---")
for k in sorted(by_kind, key=lambda x: -len(by_kind[x])):
    if k[1]=="탈락":
        stats(by_kind[k], f"{k[0]} 탈락")
PY

Measure per-strategy journal log intervals

cd /home/hoon/kis_bot && python3 - <<'PY'
from database import TradeDB
from datetime import datetime
db=TradeDB()
# tick rate for held codes last 5 min
codes=['112040','272210']
now='20260813114400'
lo='20260813113900'
for code in codes:
  rows=db.conn.execute(
    "SELECT tick_time FROM ws_ticks WHERE code=%s AND tick_time>=%s AND tick_time<%s ORDER BY tick_time",
    (code, lo, now),
  ).fetchall()
  ts=[]
  for r in rows:
    s=str(r['tick_time'])
    # formats YYYYMMDDHHMMSS
    try:
      dt=datetime.strptime(s[:14], '%Y%m%d%H%M%S')
      ts.append(dt.timestamp())
    except Exception:
      pass
  if len(ts)<2:
    print(code, 'ticks', len(ts))
    continue
  gaps=[ts[i]-ts[i-1] for i in range(1,len(ts)) if ts[i]>ts[i-1]]
  gaps.sort()
  span=ts[-1]-ts[0]
  print(f"{code}: ticks={len(ts)} span={span:.0f}s rate={len(ts)/span:.2f}/s med_gap={gaps[len(gaps)//2]:.3f}s p90={gaps[int(len(gaps)*0.9)]:.3f}s")

# also count ticks per second buckets for 112040 last 2 min
rows=db.conn.execute(
  "SELECT LEFT(tick_time,12) sec, COUNT(*) n FROM ws_ticks WHERE code=%s AND tick_time>=%s GROUP BY LEFT(tick_time,12) ORDER BY sec DESC LIMIT 20",
  ("112040","20260813114200"),
).fetchall()
print('112040 per-second buckets (last):')
for r in rows:
  print(dict(r))
db.close()
PY

Measure WS tick rates for held stocks


cd /home/hoon/kis_bot && python3 - <<'PY'
# Fast tick rate via SQL COUNT buckets only
from database import TradeDB
db=TradeDB()
for code in ('112040','272210','093190'):
  rows=db.conn.execute(
    "SELECT COUNT(*) n, MIN(tick_time) a, MAX(tick_time) b FROM ws_ticks WHERE code=%s AND tick_time>=%s AND tick_time<%s",
    (code,'20260813114000','20260813114500'),
  ).fetchone()
  print(code, dict(rows))
  buckets=db.conn.execute(
    "SELECT SUBSTR(tick_time,1,12) ym, COUNT(*) n FROM ws_ticks WHERE code=%s AND tick_time>=%s AND tick_time<%s GROUP BY SUBSTR(tick_time,1,12) ORDER BY ym DESC LIMIT 10",
    (code,'20260813114300','20260813114500'),
  ).fetchall()
  print('  per-min-ish (YYYYMMDDhhmm):', [dict(b) for b in buckets])
db.close()

# Check if loop has any timing log; grep recent "루프" or duration
import subprocess
out=subprocess.check_output(
  "journalctl -u kis_trader_main.service --since '2 min ago' --no-pager | rg -i '틱매도|루프|매도체크|check_sell|보유 1/' | head -40",
  shell=True, text=True, errors='replace')
print('--- recent sell-related ---')
print(out)
PY

Fast tick counts and recent sell logs


# journal only — no heavy DB
journalctl -u kis_trader_main.service --since "2026-08-13 03:20:00" --until "2026-08-13 03:30:00" --no-pager 2>/dev/null | rg "틱매도|TICK_SELL|전략 쓰레드 시작" | head -40
echo '===='
# last 3 min: gap between consecutive SCALP lines with microsecond if available
journalctl -u kis_trader_main.service --since "3 min ago" -o short-precise --no-pager 2>/dev/null | rg "strategy\.(SCALP|MOMENTUM|BREAKOUT|SHORT)" | head -50
echo '==== tick recv for 112040 last 3m (journal)'
journalctl -u kis_trader_main.service --since "3 min ago" --no-pager 2>/dev/null | rg "112040" | rg -v "봉강제확정|갭보정|탈락-" | head -30
echo '==== subscribe state hints'
journalctl -u kis_trader_main.service --since "2026-08-13 09:50:00" --no-pager 2>/dev/null | rg "구독 (추가|해제): 112040" | tail -20

Journal tick-sell startup and SCALP timing


cd /home/hoon/kis_bot && python3 - <<'PY'
import subprocess, re
from collections import defaultdict
from datetime import datetime

raw = subprocess.check_output(
  ["journalctl","-u","kis_trader_main.service","--since","2026-08-13 09:00:00","-o","short-precise","--no-pager"],
  text=True, errors='replace')

# precise ts: 2026-08-13 11:45:50.623664
pat = re.compile(r"^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d+)\s+\S+\s+\S+\[\d+\]:\s+\[.*?\]\s+\[kis_trader\.strategy\.(\w+)\]\s+(.*)$")

buy = defaultdict(list)
hold = defaultdict(list)
any_ = defaultdict(list)

for line in raw.splitlines():
  m = pat.match(line)
  if not m:
    # try without capturing message from bracket time inside
    if 'kis_trader.strategy.' not in line:
      continue
    m2 = re.search(r'^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d+).+kis_trader\.strategy\.(\w+)\]\s+(.*)$', line)
    if not m2:
      continue
    ts_s, sid, msg = m2.group(1), m2.group(2), m2.group(3)
  else:
    ts_s, sid, msg = m.group(1), m.group(2), m.group(3)
  try:
    ts = datetime.strptime(ts_s[:26], '%Y-%m-%d %H:%M:%S.%f').timestamp()
  except Exception:
    continue
  any_[sid].append(ts)
  if '매수체크' in msg:
    buy[sid].append(ts)
  if '보유중' in msg:
    hold[sid].append(ts)

def report(title, d):
  print(f'\n=== {title} ===')
  for sid in sorted(d, key=lambda s: -len(d[s])):
    arr=d[sid]
    if len(arr)<2:
      print(f'{sid}: n={len(arr)}')
      continue
    # use last 30 intervals for recent feel
    gaps=[arr[i]-arr[i-1] for i in range(1,len(arr))]
    recent=gaps[-30:] if len(gaps)>30 else gaps
    recent_sorted=sorted(recent)
    med=recent_sorted[len(recent_sorted)//2]
    print(f'{sid}: n={len(arr)} recent_n={len(recent)} med={med:.3f}s '
          f'min={min(recent):.3f}s max={max(recent):.3f}s '
          f'mean={sum(recent)/len(recent):.3f}s → ~{1/med:.2f}/s if med>0')

report('매수체크 간격 (=루프 한바퀴에 가깝, 매도도 같은 주기)', buy)
report('보유중 하트비트', hold)

# SCALP last 10 buy-check timestamps
print('\nSCALP 매수체크 최근 10개 시각/간격:')
arr=buy.get('SCALP',[])
for i,t in enumerate(arr[-10:]):
  prev=arr[-10+i-1] if i>0 or len(arr)>10 else None
  from datetime import datetime as dt
  s=dt.fromtimestamp(t).strftime('%H:%M:%S.%f')[:-3]
  if i==0:
    print(' ', s)
  else:
    print(f'  {s}  +{t-arr[-10:][i-1]:.3f}s')

print('\nENV 요약 (이미 조회):')
print('STRATEGY_LOOP_SLEEP=0.0 (DB)')
print('SCALP_TICK_SELL_ENABLED=false → 스캘프 청산=루프만')
print('MOMENTUM/BREAKOUT/SHORT_TICK_SELL=true → 틱마다 매도검사(min 50ms)')
PY

Microsecond-precise buy-check intervals by strategy


cd /home/hoon/kis_bot && python3 - <<'PY'
import subprocess, re
from collections import defaultdict
from datetime import datetime

raw = subprocess.check_output(
  ["journalctl","-u","kis_trader_main.service","--since","2026-08-13 09:00:00","-o","short-precise","--no-pager"],
  text=True, errors='replace')

# Aug 13 11:45:50.623664 ...
pat = re.compile(
  r"^(\w{3})\s+(\d+)\s+(\d{2}:\d{2}:\d{2}\.\d+)\s+\S+\s+\S+\[\d+\]:.*?\[kis_trader\.strategy\.(\w+)\]\s+(.*)$"
)
month={'Jan':1,'Feb':2,'Mar':3,'Apr':4,'May':5,'Jun':6,'Jul':7,'Aug':8,'Sep':9,'Oct':10,'Nov':11,'Dec':12}
buy=defaultdict(list)
hold=defaultdict(list)
any_=defaultdict(list)
year=2026

for line in raw.splitlines():
  m=pat.match(line)
  if not m:
    continue
  mon, day, hms, sid, msg = m.groups()
  ts=datetime.strptime(f"{year}-{month[mon]:02d}-{int(day):02d} {hms}", "%Y-%m-%d %H:%M:%S.%f").timestamp()
  any_[sid].append(ts)
  if '매수체크' in msg and '스킵' not in msg:
    buy[sid].append(ts)
  if '[보유중]' in msg or '|보유중]' in msg or '보유중]' in msg:
    hold[sid].append(ts)

def report(title, d, recent_n=40):
  print(f'\n=== {title} ===')
  for sid in sorted(d, key=lambda s: -len(d[s])):
    arr=d[sid]
    if len(arr)<2:
      print(f'{sid}: n={len(arr)}')
      continue
    gaps=[arr[i]-arr[i-1] for i in range(1,len(arr))]
    recent=gaps[-recent_n:]
    rs=sorted(recent)
    med=rs[len(rs)//2]
    print(f'{sid}: events={len(arr)} gaps_recent={len(recent)} '
          f'med={med:.3f}s mean={sum(recent)/len(recent):.3f}s '
          f'min={min(recent):.3f}s p90={rs[int(len(rs)*0.9)]:.3f}s max={max(recent):.3f}s')

report('매수체크 (루프 한 바퀴 프록시 — 매도검사도 루프 선두)', buy)
report('보유중 상태로그', hold)

print('\n--- SCALP 매수체크 최근 12개 ---')
arr=buy.get('SCALP',[])
for i,t in enumerate(arr[-12:]):
  s=datetime.fromtimestamp(t).strftime('%H:%M:%S.%f')[:-3]
  if i==0:
    print(s)
  else:
    print(f'{s}  Δ={t-arr[-12:][i-1]:.3f}s')

print('\n--- MOMENTUM 매수체크 최근 8 ---')
arr=buy.get('MOMENTUM',[])
for i,t in enumerate(arr[-8:]):
  s=datetime.fromtimestamp(t).strftime('%H:%M:%S.%f')[:-3]
  gap='' if i==0 else f'  Δ={t-arr[-8:][i-1]:.3f}s'
  print(s+gap)

print('\n--- BREAKOUT 보유중 최근 8 ---')
arr=hold.get('BREAKOUT',[])
for i,t in enumerate(arr[-8:]):
  s=datetime.fromtimestamp(t).strftime('%H:%M:%S.%f')[:-3]
  gap='' if i==0 else f'  Δ={t-arr[-8:][i-1]:.3f}s'
  print(s+gap)

# 112040 ticks last hour quick count with LIKE bind
from database import TradeDB
db=TradeDB()
r=db.conn.execute(
  "SELECT COUNT(*) n, MIN(tick_time) a, MAX(tick_time) b FROM ws_ticks WHERE code=%s AND tick_time LIKE %s",
  ("112040","2026081311%"),
).fetchone()
print('\n112040 ticks today 11시:', dict(r))
# per minute last 20 min
rows=db.conn.execute(
  "SELECT SUBSTR(tick_time,1,12) ym, COUNT(*) n FROM ws_ticks WHERE code=%s AND tick_time LIKE %s GROUP BY SUBSTR(tick_time,1,12) ORDER BY ym DESC LIMIT 25",
  ("112040","2026081311%"),
).fetchall()
print('per-minute:')
for x in rows:
  print(dict(x))
db.close()
PY

Precise strategy loop intervals + 112040 ticks


## 결론

**로그가 느린 것 ≠ 틱 속도로 매도 검사 중.**
지금 스캘프는 **틱매도 OFF**라서, 보이는 `매수체크` 간격(최근 **수십 초~수 분**)이 곧 **청산 재검사 주기**에 가깝습니다. 마이크로초/틱 동기와는 구조가 다릅니다.

---

### 1) DB에 켜져 있는 것

| 키 | 값 | 의미 |
|---|---|---|
| `STRATEGY_LOOP_SLEEP_MIN/MAX` | **0.0** | sleep 때문에 느린 게 아님 |
| `TICK_SELL_ENABLED` | false | 공통 OFF |
| `SCALP_TICK_SELL_ENABLED` | **false** | 스캘프 청산 = **루프만** |
| `MOMENTUM/BREAKOUT/SHORT_TICK_SELL_ENABLED` | **true** | 이 셋은 틱 리스너 ON (최소 50ms) |

기동 로그(03:24)에도 SHORT/MOMENTUM/BREAKOUT만 `📡 [틱매도] 리스너 등록 ON` 이고, **SCALP는 없음**.

---

### 2) 전략별 “보이는” 주기 (저널 µs 측정, 09:00~)

`매수체크` = 루프 한 바퀴 끝무렵 로그 → **루프형 매도검사도 같은 주기**.

| 전략 | 최근 간격(중앙값) | 최근 체감 | 비고 |
|---|---|---|---|
| SCALP | ~2.6s (이상적) | **최근 50~100초, 최대 477초** | 틱매도 OFF |
| MOMENTUM | ~4s | 최근 24~55초, **520초 공백** | 틱매도 ON이지만 루프 로그는 이렇게 뜸 |
| SHORT | ~4.5s | 비슷 | 틱매도 ON |
| BREAKOUT | ~8s | 더 김 | 틱매도 ON / `보유중` 로그만 **~60~100초** |

SCALP `매수체크` 최근 예:

`11:35:19 → 11:36:59 (+100s) → 11:37:52 (+53s) → 11:45:50 (+478s)`

같은 시간대에 전 전략 로그·위메이드 틱이 같이 끊기다가 **11:45:06 WS 일괄 재구독**이 보입니다. “1초에 하나씩도 안 나온다”는 체감과 맞습니다.

---

### 3) 왜 틱 속도처럼 안 올라가 보이나

1. **매도 검사는 매 틱마다 로그를 안 찍음** (조건 미충족이면 조용함).
2. **스캘프는 틱매도 OFF** → 청산은 루프 선두 `check_sell_signals`만. 루프는 뒤에 **후보 매수스캔·WS sync**가 붙어 길어지면 청산도 같이 밀림.
3. 클래스 기본 `loop_min_sleep`는 SCALP/MOM 1.0초 등이지만, 지금은 DB sleep=0이라 **병목은 sleep이 아니라 루프 안의 일**.
4. 위메이드 틱: 11시 분당 수십 건 → **11:39~11:45는 거의 공백** (구독/재연결 구간).

---

### 4) 실매 영향 정리

| 구분 | 상태 |
|---|---|
| 표시/로그 | 느려 보임 (정상 설계 + 루프 지연) |
| 스캘프 청산 반응 | **루프 주기(최근 수 십 초~수 분)** — 틱 동기 아님 |
| 모멘텀/돌파/꼬리 청산 | 틱매도 ON (틱 오면 검사). 틱 공백·루프 스톨 시엔 같이 약해짐 |

---

원하시면 **승인 후** 할 수 있는 것:

1. `SCALP_TICK_SELL_ENABLED=true` (위메이드처럼 보유 중일 때 틱마다 손절 검사 — **실매 즉시 영향**)
2. 루프에서 **매도만 틱/짧은 주기**, 매수스캔은 분리 (설계 변경 — 선보고 필수)
3. 스캘프에도 돌파처럼 `보유중` 상태 로그(60초)만 추가 (표시용, 속도 무관)

지금은 코드/DB를 건드리지 않았습니다. 스캘프 틱매도 ON부터 켤까요?


<timestamp>Thursday, Aug 13, 2026, 11:50 AM (UTC+9)</timestamp>
<user_query>
원하시면 승인 후 할 수 있는 것:

SCALP_TICK_SELL_ENABLED=true (위메이드처럼 보유 중일 때 틱마다 손절 검사 — 실매 즉시 영향)
루프에서 매도만 틱/짧은 주기, 매수스캔은 분리 (설계 변경 — 선보고 필수)
스캘프에도 돌파처럼 보유중 상태 로그(60초)만 추가 (표시용, 속도 무관)
지금은 코드/DB를 건드리지 않았습니다. 스캘프 틱매도 ON부터 켤까요?

초등생버전으로 다시 설명 그리고 틱매도는 언제부터 꺼진거야 켜졌던적은 있나? 그리고 매도가 아니고 매수도 개느린데 지금 틱 속도대로 올라오질 않는데
</user_query>


초등생 버전으로 다시 풀고, 스캘프 틱매도가 언제 꺼졌는지·매수가 느린 이유도 DB/로그 이력으로 확인합니다.

cd /home/hoon/kis_bot && python3 - <<'PY'
from database import TradeDB
db=TradeDB()
# schema for env history
for t in ['env_config','env_config_history','config_scalp','config_change_log','env_snapshots']:
  try:
    cols=[r['Field'] for r in db.conn.execute(f'SHOW COLUMNS FROM {t}').fetchall()]
    print(t, cols[:20])
  except Exception as e:
    print(t, 'NO', e)
db.close()
PY

Find env history tables for tick-sell

# when tick-sell messages appeared historically
journalctl -u kis_trader_main.service --since "2026-07-01" --no-pager 2>/dev/null | rg "틱매도|TICK_SELL" | head -40
echo '===='
journalctl -u kis_trader_main.service --since "2026-07-01" --no-pager 2>/dev/null | rg "SCALP.*틱매도|틱매도.*SCALP|SCALP_TICK_SELL" | head -20
echo '==== git blame / log'
cd /home/hoon/kis_bot && git log -S 'SCALP_TICK_SELL_ENABLED' --oneline --all --date=short --format='%h %ad %s' -- . 2>/dev/null | head -20
git log -S 'TICK_SELL_ENABLED' --oneline --all --date=short --format='%h %ad %s' -- database.py kis_trader/strategies/base.py 2>/dev/null | head -15
PY

History of tick-sell in journal and git


cd /home/hoon/kis_bot && python3 - <<'PY'
from database import TradeDB
from kis_trader.utils.env import invalidate_merged_env_cache, get_env_from_db
invalidate_merged_env_cache()
db=TradeDB()

# show columns related tick sell in config tables
for t in ['config_scalp','config_momentum','config_breakout','config_short','env_config']:
  cols=[r['Field'] for r in db.conn.execute(f'SHOW COLUMNS FROM {t}').fetchall()]
  tick=[c for c in cols if 'TICK_SELL' in c or c=='TICK_SELL_ENABLED' or 'LOOP_SLEEP' in c]
  print(t, tick)

# latest row values
for t, keys in [
  ('config_scalp', ['SCALP_TICK_SELL_ENABLED','id','created_at']),
  ('config_momentum', ['MOMENTUM_TICK_SELL_ENABLED','id','created_at']),
  ('config_breakout', ['BREAKOUT_TICK_SELL_ENABLED','id','created_at']),
]:
  cols=[r['Field'] for r in db.conn.execute(f'SHOW COLUMNS FROM {t}').fetchall()]
  use=[k for k in keys if k in cols]
  # also any tick
  use += [c for c in cols if 'TICK_SELL' in c and c not in use]
  if not use:
    print(t,'no tick cols'); continue
  row=db.conn.execute(f"SELECT {', '.join(use)} FROM {t} ORDER BY id DESC LIMIT 1").fetchone()
  print('latest', t, dict(row) if row else None)
  # count distinct values over history if many rows
  if 'SCALP_TICK_SELL_ENABLED' in cols:
    dist=db.conn.execute("SELECT SCALP_TICK_SELL_ENABLED v, COUNT(*) n, MIN(created_at) a, MAX(created_at) b FROM config_scalp GROUP BY SCALP_TICK_SELL_ENABLED").fetchall()
    print('scalp history', [dict(x) for x in dist])
  if t=='config_momentum' and 'MOMENTUM_TICK_SELL_ENABLED' in cols:
    dist=db.conn.execute("SELECT MOMENTUM_TICK_SELL_ENABLED v, COUNT(*) n, MIN(created_at) a, MAX(created_at) b FROM config_momentum GROUP BY MOMENTUM_TICK_SELL_ENABLED").fetchall()
    print('mom history', [dict(x) for x in dist])

# env_config TICK_SELL
cols=[r['Field'] for r in db.conn.execute('SHOW COLUMNS FROM env_config').fetchall()]
tick=[c for c in cols if 'TICK' in c or 'LOOP_SLEEP' in c]
print('env_config tick-ish', tick)
if tick:
  row=db.conn.execute(f"SELECT id, created_at, {', '.join(tick)} FROM env_config ORDER BY id DESC LIMIT 1").fetchone()
  print('env latest', dict(row))
  if 'TICK_SELL_ENABLED' in cols:
    dist=db.conn.execute("SELECT TICK_SELL_ENABLED v, COUNT(*) n, MIN(created_at) a, MAX(created_at) b FROM env_config GROUP BY TICK_SELL_ENABLED").fetchall()
    print('TICK_SELL history', [dict(x) for x in dist])

print('get_env SCALP', get_env_from_db('SCALP_TICK_SELL_ENABLED'))
print('get_env common', get_env_from_db('TICK_SELL_ENABLED'))
db.close()
PY
# journal: any SCALP tick sell ever?
journalctl -u kis_trader_main.service --since "2026-07-01" --no-pager 2>/dev/null | rg "strategy\.SCALP.*(틱매도|TICK_SELL)|SCALP.*(틱매도)" | head -20
echo exit:$?
# first introduction of column
rg -n "SCALP_TICK_SELL_ENABLED" /home/hoon/kis_bot/database.py | head -5
cd /home/hoon/kis_bot && git log -p -S 'SCALP_TICK_SELL_ENABLED' --date=short --format='%h %ad %s' -- database.py 2>/dev/null | head -80
PY

DB history of SCALP_TICK_SELL values


cd /home/hoon/kis_bot && python3 - <<'PY'
from database import TradeDB
db=TradeDB()
# where is TICK_SELL_ENABLED column
for t in ['env_config','config_scalp','config_momentum','config_short','config_breakout']:
  cols=[r['Field'] for r in db.conn.execute(f'SHOW COLUMNS FROM {t}').fetchall()]
  hit=[c for c in cols if c=='TICK_SELL_ENABLED' or 'TICK_SELL' in c]
  print(t, hit)

# classify
from database import classify_config_key
for k in ['TICK_SELL_ENABLED','SCALP_TICK_SELL_ENABLED','STRATEGY_LOOP_SLEEP_MIN']:
  try:
    print(k, '->', classify_config_key(k))
  except Exception as e:
    print(k, e)

# SHORT history
cols=[r['Field'] for r in db.conn.execute('SHOW COLUMNS FROM config_short').fetchall()]
if 'SHORT_TICK_SELL_ENABLED' in cols:
  dist=db.conn.execute("SELECT SHORT_TICK_SELL_ENABLED v, COUNT(*) n, MIN(created_at) a, MAX(created_at) b FROM config_short GROUP BY SHORT_TICK_SELL_ENABLED").fetchall()
  print('short', [dict(x) for x in dist])
if True:
  cols=[r['Field'] for r in db.conn.execute('SHOW COLUMNS FROM config_breakout').fetchall()]
  if 'BREAKOUT_TICK_SELL_ENABLED' in cols:
    dist=db.conn.execute("SELECT BREAKOUT_TICK_SELL_ENABLED v, COUNT(*) n, MIN(created_at) a, MAX(created_at) b FROM config_breakout GROUP BY BREAKOUT_TICK_SELL_ENABLED").fetchall()
    print('bo', [dict(x) for x in dist])

# first false write around 7/22 — what else changed same row time
row=db.conn.execute(
  "SELECT id, created_at, SCALP_TICK_SELL_ENABLED FROM config_scalp WHERE SCALP_TICK_SELL_ENABLED IS NOT NULL ORDER BY id ASC LIMIT 3"
).fetchall()
print('first explicit scalp rows', [dict(r) for r in row])

# env_config TICK_SELL - maybe overflow / json
# search INFORMATION_SCHEMA
r=db.conn.execute(
  "SELECT TABLE_NAME, COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND COLUMN_NAME LIKE %s",
  ("%TICK_SELL%",),
).fetchall()
print('all tick_sell cols', [dict(x) for x in r])
db.close()
PY
# git when tick sell feature landed
cd /home/hoon/kis_bot && git log --all --date=short --format='%h %ad %s' -S '_tick_sell_enabled' -- kis_trader/strategies/base.py 2>/dev/null | head -10
git log --all --date=short --format='%h %ad %s' --grep='틱매도' 2>/dev/null | head -15
PY

Confirm SCALP never true; find TICK_SELL column home


cd /home/hoon/kis_bot && python3 - <<'PY'
# where does false for TICK_SELL_ENABLED come from?
from kis_trader.utils.env import get_env_from_db, get_merged_env_dict, invalidate_merged_env_cache
invalidate_merged_env_cache()
m=get_merged_env_dict()
for k in sorted(m):
  if 'TICK_SELL' in k or k.startswith('STRATEGY_LOOP'):
    print(k, '=', repr(m.get(k)))
print('raw get', repr(get_env_from_db('TICK_SELL_ENABLED')))
# env_config physical?
from database import TradeDB
db=TradeDB()
cols=[r['Field'] for r in db.conn.execute('SHOW COLUMNS FROM env_config').fetchall()]
print('has TICK_SELL_ENABLED col', 'TICK_SELL_ENABLED' in cols)
# overflow?
for t in ['env_config_overflow','config_overflow','env_extra']:
  try:
    n=db.conn.execute(f'SELECT COUNT(*) n FROM {t}').fetchone()
    print(t, n)
  except Exception as e:
    print(t, type(e).__name__)
db.close()
PY

Locate TICK_SELL_ENABLED storage source


## 초등생 버전

주식 가격이 **틱**으로 막 옵니다. 초마다 여러 번 “띵!” 하고 숫자가 바뀌는 초인종이에요.

봇은 두 가지 일을 해요.

1. **살까?** (매수) — 후보 종목을 **줄 서서** 하나씩 검사
2. **팔까?** (매도) — 이미 산 것만 검사

지금은 둘 다 **틱 초인종마다** 하는 게 아니라,
**큰 바퀴(루프)** 한 바퀴 돌 때마다 해요.
바퀴가 후보 검사하느라 30초~몇 분 걸리면, **사는 것도·(스캘프는) 파는 것도** 그만큼 느려 보여요.

로그는 “검사할 때마다”가 아니라 **“줄 검사 끝났을 때 / 탈락했을 때”** 주로 찍혀서, 틱처럼 안 올라옵니다.

---

## 틱매도, 언제 꺼진 거야? 켜진 적 있어?

**스캘프(`SCALP_TICK_SELL`)는 DB 기록상 한 번도 `true`가 없습니다.**
“켜졌다가 꺼진” 게 아니라, **처음부터 꺼진 채**입니다.

| 기간 | 스캘프 틱매도 |
|---|---|
| ~7/22 오전 | 값 없음(NULL) → 공통 기본이라 **꺼진 것과 같음** |
| **7/22 20:57 ~ 오늘** | 명시적으로 **`false`** (134번 저장, `true` 0건) |

같은 날(7/22 밤) 다른 전략은 이렇게 갈라졌어요.

- 꼬리(SHORT): 잠깐 false → **21:14부터 true** (계속 ON)
- 모멘텀/돌파: 잠깐 false → **23:19부터 true** (계속 ON)
- **스캘프만 false로 남김**

재시작 로그(8/5~오늘)에도 SHORT/MOMENTUM/BREAKOUT만 `틱매도 ON`이고, **SCALP ON 로그는 한 줄도 없음**.

---

## 매수도 느린 이유 (틱 속도로 안 오는 이유)

맞아요. **매수는 원래 틱 속도가 아닙니다.**

- 틱 = 가격 소식
- 매수 = “후보 여러 종목 검사하는 숙제” → 루프 한 바퀴
- 최근 스캘프 `매수체크` 간격: 짧게는 몇 초, **길게는 50~100초, 심하면 8분 공백**
→ 로그가 틱처럼 안 뜨는 게 정상 구조 + 루프가 밀린 것

틱매도를 켜도 **매수 속도는 안 빨라집니다.**
틱매도는 “산 뒤 **팔지** 검사”만 틱에 붙입니다.

---

## 정리

| 궁금한 것 | 답 |
|---|---|
| 왜 로그가 느리나 | 틱마다 안 찍고, 루프/탈락 때만 찍음 |
| 스캘프 틱매도 | **꺼진 적 없음 = 원래부터 OFF**, 7/22부터 DB에 false 고정 |
| 매수 느림 | 틱 연동 아님. 후보 스캔 루프가 병목 |
| 틱매도 ON하면 | **매도만** 빨라질 수 있음. 매수는 별도 설계 |

스캘프 틱매도 ON이나, 매수/매도 루프 분리는 **실매에 바로 영향**이라 아직 안 건드렸습니다.
원하면 다음으로 **① 스캘프 틱매도만 ON** / **② 매수도 빠르게(설계 보고 후)** 중 뭐부터 할지 말해 주세요.


<timestamp>Thursday, Aug 13, 2026, 11:56 AM (UTC+9)</timestamp>
<user_query>
틱 매도 켜주고 매수 설계해줘 이거 뭐 기회 다 날려버리네 일단 예전 코드중엔 존나 빨리 올라갔던때가 잇는데 뭘가 바귀었길래 이따구로 됐는지부터 찾아야될 것 같은데 한 6울쯤 코드?
</user_query>


6월 전후 루프/매수 속도 회귀 원인부터 찾고, 틱매도 ON + 매수 설계를 계획으로 묶겠습니다.

cd /home/hoon/kis_bot && git log --since=2026-05-01 --until=2026-08-01 --date=short --format='%h %ad %s' -- kis_trader/strategies/base.py | head -40
echo '===='
git log --since=2026-05-01 --until=2026-08-01 --date=short --format='%h %ad %s' --all --grep='sleep\|루프\|scan\|매수체크\|TICK_SELL\|틱매도\|LOOP_SLEEP' | head -40
echo '==== blame loop sleeps'
git log -p --since=2026-05-01 --until=2026-08-15 -S 'loop_min_sleep' --date=short --format='%h %ad %s' -- kis_trader/strategies/ | head -100
echo '==== STRATEGY_LOOP'
git log -p --since=2026-05-01 --until=2026-08-15 -S 'STRATEGY_LOOP_SLEEP' --date=short --format='%h %ad %s' -- . 2>/dev/null | head -80
PY

Git history for loop/scan speed changes

Task

Explore buy scan bottlenecks

Prompt:
In /home/hoon/kis_bot, investigate why live buy scanning feels slow vs tick rate. Focus on:

1. kis_trader/strategies/base.py `_run_loop`, `_scan_and_buy`, `_scan_sleep`
2. Per-strategy loop_min_sleep (scalping, momentum, breakout, tail_catch)
3. What work happens per buy candidate (API, orderbook, candles) that blocks the loop
4. Any SCAN_INTERVAL, universe debounce, reject sleep that slows buys
5. Whether buy was ever tick-driven or always loop-based

Return: concrete file:line references, the main bottlenecks in order of impact, and any June-July 2026 related clues from comments/docs if found. Do NOT modify files.

Find June speed regression

Prompt:
In /home/hoon/kis_bot git history, find what changed around May-July 2026 that would make strategy buy/sell loops or logs much slower. Search:
- git log -S for: STRATEGY_LOOP_SLEEP, SCAN_REJECT_SLEEP, loop_min_sleep, _scan_and_buy, reload_config, _sync_holdings, sync_targets, 매수체크, random.sleep, time.sleep in strategies
- commits mentioning 유량, 429, sleep, 세마포어, 탈락, 호가필터
- Compare approximate timeline: when did tick sell land? when did orderbook filter enter buy path?

Return top 5 most likely regression commits with hash, date, one-line why they'd slow buys. Read-only.


Shell

cd /home/hoon/kis_bot && python3 - <<'PY'
# readonly: current sleep/cand env
from kis_trader.utils.env import invalidate_merged_env_cache, get_env_from_db
invalidate_merged_env_cache()
keys=['STRATEGY_LOOP_SLEEP_MIN','STRATEGY_LOOP_SLEEP_MAX','SCAN_REJECT_SLEEP_MIN','SCAN_REJECT_SLEEP_MAX',
 'SCAN_BUY_OK_SLEEP_MIN','SCALP_TICK_SELL_ENABLED','SCALP_CAND_LIMIT','MOMENTUM_CAND_LIMIT','BREAKOUT_CAND_LIMIT','SHORT_CAND_LIMIT',
 'TICK_SELL_ENABLED']
for k in keys:
  print(f'{k}={get_env_from_db(k)!r}')
PY

Read current sleep/cand limit env values


매수 쪽 설계가 갈라져서, 진행 전에 하나만 확정하겠습니다.

CreatePlan

틱매도·매수속도

6월이 아니라 7월 메가커밋이 체감 둔화의 본체입니다. 스캘프 틱매도를 ON 하고, 매수는 “틱 우선 후보 검사 + 루프 핫패스 정리”로 기회를 놓치지 않게 만듭니다.

# 틱매도 ON + 매수 속도 복구

## 초등생 한 줄

예전엔 “줄이 짧아서” 빨리 돌았고, 지금은 **한 바퀴에 할 일이 늘어** 느려진 겁니다. 6월에 갑자기 깨진 게 아니라 **7월에 살찌운 코드**가 원인에 가깝습니다.

## 근본원인 (6월이 아님)

```mermaid
flowchart LR
  subgraph jun [May_Jun]
    A[루프 가볍움]
  end
  subgraph jul6 [Jul6_61c72a8]
    B[호가필터_탈락증가]
    C[루프_DB동기화]
  end
  subgraph jul30 [Jul30_67eab24]
    D[중분편입게이트]
    E[LS_키움_이중구독]
    F[틱매도코드_스캘프만OFF]
  end
  jun --> jul6 --> jul30
```

| 시점 | 사실 |
|---|---|
| **6월** | 실매 루프 관련 커밋 **거의 0** |
| **7/6** [`61c72a8`](kis_trader/strategies/base.py) 계열 | 매수 TRIGGER에 **호가필터**, `_scan_sleep`, 매 바퀴 무거운 동기화 유입 |
| **7/17** | 루프 sleep 기본을 오히려 **짧게**(0.1s) — “느려진 원인” 반대 |
| **7/22** | DB: MOM/BO/SHORT 틱매도 **true**, **SCALP만 false 고정**(한 번도 true 없음) |
| **7/30** [`67eab24`](kis_trader/strategies/base.py) | 틱매도 리스너 도입(기본 OFF), **중분 편입 게이트**, LS/키움 히스토리 이중 적재 |

지금 DB는 이미 `STRATEGY_LOOP_SLEEP=0`, `SCAN_REJECT_SLEEP=0` 이라 **sleep이 병목이 아님**.
체감 둔화는 **후보마다 `check_buy` 순차 작업**(봉/갭/중분게이트/`get_trades_by_date` 등) + **매수체크가 틱이 아닌 루프 끝 로그** 구조입니다. 매수는 원래부터 틱 콜백이 아닙니다.

## 실매 영향 분류

| 작업 | 영향 |
|---|---|
| `SCALP_TICK_SELL_ENABLED=true` | **실매 즉시** (청산 반응↑). 리스너는 이미 등록됨 → env만 true면 콜백이 살아남 |
| 매수 틱우선 큐 + 핫패스 | **실매 즉시** (진입 기회↑). API 폭주 방지 가드 필수 |
| 백테/Optuna | 엔진 신호식 불변이면 **무영향**. 신규 env 키만 DB/스키마 등록 |

## 구현 계획

### 1) 스캘프 틱매도 ON (즉시)

- DB `config_scalp.SCALP_TICK_SELL_ENABLED = true` (apply_env / 설정저장 경로로 저장·`get_env_from_db` 재조회 검증)
- 재시작 없이 핫반영 가능한지 확인: [`_tick_sell_enabled`](kis_trader/strategies/base.py)가 콜백마다 env 읽음 → **ON이면 바로 틱 청산 검사**
- 기동 로그에 `SCALP … 틱매도 … ON` 시그니처 확인 (재시작 시에만 INFO; 핫ON은 동작으로 검증)

### 2) 매수 설계 — “틱 우선 후보 검사” (신규, 루프 대체 아님)

목표: **가격 틱이 온 후보만 먼저 `check_buy`**, 전체 줄 순회는 백업.

- [`base.py`](kis_trader/strategies/base.py) `_on_ws_price_tick` 확장:
  - 보유 중 → 기존 틱매도
  - 미보유 + 현재 후보 집합에 있음 → **매수 검사 큐에 code push** (중복 압축)
- 드레인: `_sell_lock`과 충돌 없게 **짧은 try-lock / 별도 buy 간격**:
  - env: `TICK_BUY_SCAN_ENABLED` (기본 true로 국내 SCALP/MOM/BO/SHORT에 맞춤 — 코드 default + DB 등록 세트)
  - `TICK_BUY_MIN_INTERVAL_MS` (기존 틱매도 50ms와 유사, `get_env_int`)
  - 전략별 `{SID}_TICK_BUY_SCAN_ENABLED` 덮어쓰기 (틱매도와 동일 패턴)
- 큐에서 `check_buy` → 기존 `_submit_buy` / 1회 성공 시 과집중 가드 유지
- **풀 스윕 `_scan_and_buy`는 유지**(유니버스 신규·틱 공백 대비). 다만 방금 틱으로 본 코드는 같은 바퀴에서 스킵해 이중 검사 감소

### 3) 루프 핫패스 정리 (7월 살 빼기, 신호식은 유지)

- 스캘프/모멘텀/꼬리: 루프(또는 스캔)당 **`get_trades_by_date(today)` 1회 캐시** — [`scalping.py`](kis_trader/strategies/scalping.py) 188행 부근 후보마다 DB 조회 제거
- `[매수체크]` 로그에서 매 줄 `get_merged_env_snapshot()` 반복 호출 제거(루프 선두 1회)
- 중분 게이트·호가필터·갭 ready는 **유지**(정합/안전). “빠르게” = 불필요 DB/중복 검사 제거이지 필터 삭제 아님

### 4) 검증 (실매 재시작 최소화)

- env 재조회: `SCALP_TICK_SELL_ENABLED=true`, 틱매수 키 존재
- 장중: 위메이드(또는 보유) 틱 유입 시 스캘프 청산 검사가 루프 `매수체크` 간격(수십 초)보다 **촘촘한지** 로그/동작으로 확인
- 매수: 후보 틱 직후 `check_buy`/탈락 로그가 **초 단위로 붙는지** (예전 “빨리 올라오던” 체감)
- API: 재큐·REST 폭주 없는지 짧은 구간 journal 확인 (한도 무시 연타 금지)
- 웹/백테 재시작은 **이번 실매 env·전략 코드만이면 불필요**. UI에 틱매수 스위치를 넣을 때만 [`live_config_schema.py`](kis_trader/web/live_config_schema.py) + 백테웹 재시작·`http://192.168.0.149:5050/` 확인

## 하지 않을 것

- 호가필터/중분게이트/`SKIP_HTS`/`WS_CANDLE_FREEZE` 끄기로 속도 맞추기
- 매수 sleep을 무작정 더 깎기(이미 0)만으로 “해결” 단정
- ±1봉 보정으로 기회 복구

## 완료 보고 최소치

- 원인: 6월 공백 / 7/6·7/30 요약
- SCALP 틱매도 적용 여부·재조회 값
- 매수 틱우선 큐 동작 여부·로그 간격 전후
- 실매 영향: 청산·진입 / 백테 무영향 여부

7월 회귀(호가·동기화·중분·이중구독) vs 6월 무관 사실을 완료 보고에 명시

SCALP_TICK_SELL_ENABLED=true DB 적용 + get_env 재조회 검증

base.py 틱→매수검사 큐 + TICK_BUY_* env(코드 default+DB 키 등록)

get_trades_by_date 스캔당 1회 캐시 + 매수체크 로그 env 스냅샷 1회화

장중 틱매도/틱매수 로그 간격·API 폭주 여부 스모크 후 보고


<timestamp>Thursday, Aug 13, 2026, 12:10 PM (UTC+9)</timestamp>
<user_query>
@틱매도·매수속도_1d2ad6c7.plan.md (48-64) 이해가 잘 안됨 기존거 망가지는거 아냐? 6월 코드랑 비교하라고 했는데 그때 뭐 한게 없다고 하면 그게 비교냐? 지금 코드랑 비교를 해달라고 저것도 초등생 수준으로 설명해줘 빨랐던때 코드를 찾아서 비교를 해야지 이거 뭐 느린코드끼리 비교하면 뭐 그리고 느린 근본원인을 찾아야지 또 땜빵하려고 하네 이거
</user_query>


맞습니다. 느린 구간끼리 비교하면 안 됩니다. 빨랐던 시점 코드를 찾아 지금과 대조하고, 계획의 ‘틱매수 큐’가 땜빵인지부터 다시 짚겠습니다.

Shell

cd /home/hoon/kis_bot && git log --before=2026-07-01 --after=2026-04-01 --date=short --format='%h %ad %s' -- kis_trader/strategies/base.py | head -30
echo '===='
git log --before=2026-07-07 --after=2026-01-01 --date=short --format='%h %ad %s' -- kis_trader/strategies/base.py | head -40
echo '==== may file exists?'
git show f61c471:kis_trader/strategies/base.py 2>&1 | head -5
git ls-tree -r f61c471 --name-only 2>/dev/null | rg 'strategies/(base|scalping|momentum)' | head
echo '==== parent of 61c72a8'
git rev-list --max-count=1 61c72a8^ 2>/dev/null
git log -1 --date=short --format='%h %ad %s' 61c72a8^
git ls-tree -r 61c72a8^ --name-only 2>/dev/null | rg 'strategies/base.py' | head
PY

Find pre-July base.py commits for fast era

cd /home/hoon/kis_bot && python3 - <<'PY'
import subprocess
# Find oldest commit that has _scan_and_buy or run loop in base
for rev in ['f61c471', '61c72a8^', '61c72a8', '78edb75', 'fc27e72', '67eab24', 'HEAD']:
  try:
    out=subprocess.check_output(['git','show',f'{rev}:kis_trader/strategies/base.py'], stderr=subprocess.STDOUT, text=True)
  except Exception as e:
    print(rev, 'NO FILE', str(e)[:80])
    continue
  lines=out.splitlines()
  print(f'\n=== {rev} lines={len(lines)} ===')
  for needle in ['def _run_loop','def _scan_and_buy','def _scan_sleep','loop_min_sleep','SCAN_REJECT','is_gap_ready','orderbook','_sync_holdings','_on_ws_price_tick','time.sleep']:
    hits=[i+1 for i,l in enumerate(lines) if needle in l]
    print(f'  {needle}: {hits[:8]}{"..." if len(hits)>8 else ""} n={len(hits)}')
PY

Compare key symbols across historical base.py


cd /home/hoon/kis_bot && git show f61c471:kis_trader/strategies/base.py | sed -n '70,90p'; echo '---- RUN LOOP ----'; git show f61c471:kis_trader/strategies/base.py | sed -n '156,270p'
echo '======== HEAD RUN LOOP skeleton ========'
sed -n '73,76p;298,410p;506,615p' kis_trader/strategies/base.py | head -200
PY

Extract May vs HEAD run loop bodies

cd /home/hoon/kis_bot && git show f61c471:kis_trader/strategies/scalping.py 2>/dev/null | rg -n 'check_buy|get_trades|get_candles|fill_gap|orderbook|sleep|loop_min' | head -40
echo '===='
rg -n 'check_buy|get_trades|get_candles|fill_gap|orderbook|loop_min|_defer' kis_trader/strategies/scalping.py | head -40
echo '==== diff stat may->head base'
git diff --stat f61c471 HEAD -- kis_trader/strategies/base.py kis_trader/strategies/scalping.py
echo '==== check_buy length may vs head'
python3 - <<'PY'
import subprocess,re
for rev,path in [('f61c471','kis_trader/strategies/scalping.py'),('HEAD','kis_trader/strategies/scalping.py')]:
  if rev=='HEAD':
    text=open(path).read()
  else:
    text=subprocess.check_output(['git','show',f'{rev}:{path}'],text=True)
  # find check_buy method approximate size
  m=re.search(r'def check_buy\([\s\S]*?)(?=\n    def |\nclass |\Z)', text)
  print(rev, 'check_buy chars', len(m.group(0)) if m else None, 'file lines', text.count(chr(10)))
PY
PY

Compare May vs HEAD scalping buy path


Write

---
name: 틱매도·매수속도
overview: 빨랐던 코드(5/5~7/6 오전 base.py)와 지금을 줄 단위로 비교해 근본 살찐 지점을 확정한 뒤, 스캘프 틱매도 ON + 루프를 빠른 시절 구조에 가깝게 되돌린다. 틱매수 큐 땜빵은 폐기.
todos:
  - id: may-vs-head-diff
    content: f61c471(빠른시절)~HEAD 루프/check_buy 대조표를 완료 보고에 고정
    status: pending
  - id: loop-timing-proof
    content: 장중 1바퀴 단계별 ms 계측(sync_holdings/sell/ws/scan/check_buy)으로 살찐 구간 수치 확정
    status: pending
  - id: scalp-tick-sell-on
    content: SCALP_TICK_SELL_ENABLED=true DB 적용 + 재조회(매도만, 매수 경로 불변)
    status: pending
  - id: slim-loop-to-may
    content: 계측으로 확인된 매바퀴 군살 제거/TTL화(빠른시절 경로 복원 방향, 신호식·필수 가드 유지)
    status: pending
  - id: live-verify
    content: 매수체크 간격·틱매도 동작·API 폭주 여부 스모크 후 보고
    status: pending
isProject: false
---

# 틱매도 ON + 매수 속도 — 근본원인 우선 (계획 수정)

## 지적에 대한 답 (초등생)

**「6월에 커밋 없다」만으로는 비교가 아닙니다.**
6월 내내 **같은 가벼운 코드가 그대로 돌고 있었다**는 뜻입니다.
비교 대상은 “6월 커밋”이 아니라, **그때 디스크에 있던 파일**입니다.

| 이름 | 깃 커밋 | 의미 |
|---|---|---|
| **빠른 시절** | [`f61c471`](kis_trader/strategies/base.py) (2026-05-05) ≈ [`d8ba01a`](kis_trader/strategies/base.py) (2026-07-06 오전, `61c72a8` 직전) | `base.py` **537줄**, 구조 동일 |
| **느려진 시절** | 7/6 `61c72a8` → 오늘 HEAD | `base.py` **800→1215줄**, 한 바퀴 일이 크게 증가 |

즉 **5월~7월 6일 오전까지가 “빨랐던 코드”**이고, 6월은 그 코드가 **안 바뀐 채 유지**된 구간입니다.

---

## 빠른 시절 vs 지금 — 한 바퀴가 뭐가 다른가

### 초등생 비유

- **빠른 시절**: 교실 한 바퀴 = (1) 설정 보고 (2) 팔지 보고 (3) 후보 불러 구독 (4) 살지 보고 (5) 잠깐 쉼
- **지금**: 같은 교실인데 **매 바퀴마다** 출석부 DB 다시 읽고, 주문 만료 정리하고, 전략 스위치·일일익절·중분 기록·WS 쪼개 구독·잔고 프리패치…를 **추가 숙제**로 넣고 돔

숙제(신호: 호가·손절·중분)를 빼자는 게 아니라, **매 바퀴 군살**이 기회 줄을 늘리는지부터 숫자로 확정합니다.

### 코드 대조 (실제로 읽은 차이)

**빠른 시절 `_run_loop` (May/`d8ba01a`)** — 대략:

1. `reload_config`
2. `check_sell_signals` → sell (락/잔고프리패치 없음)
3. `_load_candidates` + `ws.sync_targets` (단순)
4. `_scan_and_buy`
5. `sleep(loop_min~max)` 클래스 기본 1~2초

**지금 HEAD `_run_loop`** — 위에 더해진 것:

1. **매 바퀴** `_sync_holdings_from_db()`
2. 전략 ON/OFF 핫게이트
3. 일일익절 `maybe_trim_open_risk`
4. 매도: `_sell_lock` + (신호 없어도) `REAL_BALANCE_VERIFY` 시 **브로커 잔고 prefetch**
5. `_note_candidate_enters` (중분용)
6. `sync_targets_split` (후보/보유 분리·LS 등)
7. **`manage_pending_orders`**
8. `check_buy_allowed` / 총한도·일일익절 가드
9. `_capture_runtime_overlay`
10. 후보마다: `is_gap_ready` 스킵, `fill_gap`, **호가 주입**, **중분 defer**, `get_trades_by_date` (스캘프 후보마다), 로그마다 `get_merged_env_snapshot()`

**스캘프 `check_buy`**: May도 봉+`get_trades_by_date`+엔진.
지금은 **갭 force / 호가 WS 주입 / 중분 게이트 / 탈락 로그**가 붙음 → 탈락·스킵이 늘면 같은 후보 줄을 **더 오래** 돎.

**중요**: 지금 DB `STRATEGY_LOOP_SLEEP`·`SCAN_REJECT_SLEEP`는 이미 **0**.
그래서 “sleep만 줄이면 된다”는 틀린 진단이고, **한 바퀴 안의 일**이 본체입니다.

```mermaid
flowchart TD
  subgraph fast [Fast_May_to_Jul6am]
    F1[reload]
    F2[sell]
    F3[candidates_sync]
    F4[scan_buy]
    F5[sleep_1to2s]
    F1 --> F2 --> F3 --> F4 --> F5
  end
  subgraph slow [HEAD]
    S1[reload]
    S2[sync_holdings_DB]
    S3[sell_lock_prefetch]
    S4[note_enter_split_ws]
    S5[pending_orders]
    S6[scan_buy_gap_ob_mid]
    S7[overlay]
    S8[sleep_0]
    S1 --> S2 --> S3 --> S4 --> S5 --> S6 --> S7 --> S8
  end
```

---

## 예전에 넣으려던 「틱 우선 매수 큐」— 왜 이해 안 되고, 왜 위험한가

초등생 설명:

- 지금은 “줄 서서 전부 검사”(루프).
- 틱매수 큐는 “가격 띵 울린 애만 새치기 검사”를 **또 하나** 만드는 것.
- **기존 줄(`_scan_and_buy`)은 그대로 두고** 길을 하나 더 파면: 같은 종목 두 번 검사·락 싸움·주문 두 번 위험 → **기존 망가질 수 있음**.
- 게다가 빠른 시절에도 매수는 **틱이 아니었음**. 빨랐던 이유는 틱매수가 아니라 **줄이 짧아서**.

→ 사용자 지적대로 **땜빵**. 이 계획에서는 **폐기**.

---

## 고치는 방향 (근본)

### A) 스캘프 틱매도만 ON (매수와 분리)

- `SCALP_TICK_SELL_ENABLED=true`
- **이미 있는** 틱→매도 길만 켬. 매수 로직/루프 구조 안 건드림.
- 매도 반응만 틱에 맞춤 (MOM/BO/SHORT는 이미 true).

### B) 매수 속도 = 빠른 시절 루프에 가깝게 (군살 제거)

순서 고정:

1. **계측 먼저** (장중, 전략 1개라도): `_run_loop` 한 바퀴를
   `sync_holdings / sell+prefetch / load+ws_sync / pending / scan_and_buy(후보별 check_buy)` **ms로 나눠 로그**
   → “뭐가 수십 초를 먹는지” 숫자로 확정 (추측 패치 금지).
2. 수치 큰 항목만 **May 구조 쪽으로** 되돌리거나 TTL화. 예시(계측 후 확정):
   - `_sync_holdings_from_db` 매바퀴 → N초 TTL (빠른 시절엔 없었음)
   - 매도 신호 **없을 때** broker prefetch 생략
   - `get_merged_env_snapshot` 매수체크 로그 2회 → 루프 1회
   - `get_trades_by_date` 후보마다 → **스캔당 1회**
   - `manage_pending_orders` 매바퀴 비용이 크면 간격 제한
3. **유지**: 호가필터·중분게이트·갭 ready·손절/신호식 (정합·안전).
   “필터 OFF로 속도”는 금지.

틱매수 신규 경로 **없음**. 백테/Optuna 엔진식 변경 **없음**(루프 운영만).

### C) 검증

- 매수체크 간격이 빠른 시절 체감(수 초~십수 초)에 가까워지는지
- 스캘프 보유 시 틱매도 ON 동작
- REST/재큐 폭주 없음
- 교차: 다른 전략 웜업/매수 0건·락 데드락 없는지

---

## 실매 영향

| 항목 | 실매 | 백테 |
|---|---|---|
| SCALP 틱매도 ON | 청산 반응 | 무관 |
| 루프 군살 제거/TTL | 매수 회전·로그 | 무관(실매 루프만) |
| 신호·호가·중분 | 유지 | 유지 |

---

## 하지 않을 것

- 틱매수 큐 / 이중 매수 경로
- 호가·중분·freeze·SKIP_HTS로 속도 맞추기
- sleep=0인데 sleep만 더 만지며 “고쳤다” 하기
- 계측 없이 7월 기능 통째 삭제

## 완료 보고 최소치

- 빠른 커밋(`f61c471`/`d8ba01a`) vs HEAD 대조 요약
- 한 바퀴 ms 표 (어느 단계가 큰지)
- 제거/TTL한 군살 목록과 **안 건드린** 안전장치
- SCALP 틱매도 재조회 값
- 매수체크 간격 전후


지적 맞습니다. 계획을 **고쳤습니다.**

## 틱매수 큐가 뭐였냐 (초등생) + 망가지냐

- **원래**: 줄 서서 후보를 순서대로 “살까?”
- **틱매수 큐**: 가격이 띵 한 애만 **새치기 검사**하는 **길을 하나 더** 파는 것
- 기존 줄은 그대로 두면 → 같은 종목 **두 번 검사 / 주문 꼬임** 가능 → **망가질 수 있음**
- 게다가 빨랐던 때도 매수는 틱이 아니었음. 빨랐던 이유는 **한 바퀴 숙제가 적어서**
→ 그거 **땜빵**이라 계획에서 **폐기**했습니다.

## “6월에 한 거 없다”가 비교냐?

그건 비교가 아니었습니다. 제대로 하면:

| | |
|---|---|
| **빠른 코드** | `f61c471`(5/5) ≈ `d8ba01a`(7/6 오전) — `base.py` **537줄**, 6월 내내 **이 파일이 그대로** |
| **지금** | 7/6 이후 살찐 HEAD — **1215줄** |

6월 = “그때 커밋이 없어서 **빠른 코드가 계속 돌던 달**”이지, 비교 대상이 없는 달이 아닙니다.

**빠른 시절 한 바퀴**: 설정 → 매도 → 후보+구독 → 매수 → sleep
**지금 한 바퀴**: 그 위에 **매바퀴 DB 보유동기화, 잔고 prefetch, 중분기록, WS 쪼개구독, 미체결정리, 갭/호가/중분…** 이 잔뜩 붙음.
sleep은 이미 0이라, 느린 본체는 **한 바퀴 안의 일**입니다.

## 계획 방향 (수정됨)

1. **빠른 코드 vs HEAD 대조** (이미 위 표로 문서에 넣음)
2. **장중 1바퀴 ms 계측**으로 “뭐가 초를 먹는지” 숫자 확정
3. 그다음에만 **군살 제거/TTL** (May 경로에 가깝게) — 호가·중분·신호는 유지
4. **스캘프 틱매도 ON만** (매수 경로와 분리)

틱매수 큐·이중 경로는 넣지 않습니다.
이 수정안대로 진행할지 말해 주세요.


<timestamp>Thursday, Aug 13, 2026, 12:17 PM (UTC+9)</timestamp>
<user_query>
수치 큰 항목만 May 구조 쪽으로 되돌리거나 TTL화. 예시(계측 후 확정):

_sync_holdings_from_db 매바퀴 → N초 TTL (빠른 시절엔 없었음)

매도 신호 없을 때 broker prefetch 생략

get_merged_env_snapshot 매수체크 로그 2회 → 루프 1회

get_trades_by_date 후보마다 → 스캔당 1회

manage_pending_orders 매바퀴 비용이 크면 간격 제한

유지: 호가필터·중분게이트·갭 ready·손절/신호식 (정합·안전).

“필터 OFF로 속도”는 금지.

자 다시다시 기존에 비해 숙제가 많아져서 그렇다는데 지금 숙제가 많아졌어도 부하도 적고 마이크로초단위로 끝나는 로직이야 딱 봐도 알잔아 너 ai잖아 자꾸 엉뚱한 얘기하지 말고 일단 그럼 측정을 먼저 하는거로 설계를 잡아봐 어느단계에서 잡고 있는건지 저 셀락이 의심스럽긴한데 이름부터가  lock 이라
</user_query>


맞습니다. 군살 목록을 원인으로 단정하지 않고, `_sell_lock` 경합 포함해 **어디서 멈추는지 계측부터** 하도록 계획을 다시 잡겠습니다.


---
name: 틱매도·매수속도
overview: 군살 목록을 원인으로 단정하지 않는다. 장중 계측으로 어느 단계·락에서 멈추는지 먼저 밝히고, 수치로 큰 곳만 고친다. _sell_lock 대기/점유를 1순위 의심으로 계측한다.
todos:
  - id: measure-design
    content: 루프 단계·_sell_lock wait/hold·check_buy 구간 ms 계측 설계·삽입(기본 OFF/샘플링)
    status: pending
  - id: measure-run
    content: 장중 계측 켜서 전략별 1바퀴·락·check_buy 상위 구간 수치 수집
    status: pending
  - id: root-cause-from-numbers
    content: 수치로 병목 확정 후 수정안 선보고(승인 대기) — 추측 TTL/캐시 금지
    status: pending
  - id: scalp-tick-sell-on
    content: (계측과 별도·승인 시) SCALP_TICK_SELL_ENABLED=true — 매수 경로 불변
    status: pending
  - id: live-verify
    content: 수정 적용 시에만 매수체크 간격·락·API 스모크
    status: pending
isProject: false
---

# 1단계 = 계측만 (근본원인 확정 전 수정 금지)

## 왜 계획을 다시 잡나

맞음. May 대비 “숙제가 늘었다” ≠ “그게 느린 이유”.
`_sync_holdings` / env 스냅샷 / trades 조회 같은 건 **딱 봐도 보통 µs~수 ms** 급이라, 그걸 원인으로 단정하고 TTL화하는 건 또 땜빵이다.

느린 체감(매수체크 **수십 초~수분**)은 그런 잔챙이가 아니라 **어디선가 초 단위로 막히는 구간**일 가능성이 크다.
이름부터 **`_sell_lock`** 인 락이 1순위 의심.

## `_sell_lock`이 왜 의심되나 (코드만 본 사실)

[`kis_trader/strategies/base.py`](kis_trader/strategies/base.py)

| 경로 | 락 잡는 방식 | 락 안에서 하는 일 |
|---|---|---|
| **메인 루프 매도** | `with self._sell_lock:` → **기다림(blocking)** | `check_sell_signals()` 전체 + (매도신호 있으면) **broker prefetch** + `_submit_sell` |
| **틱매도** | `acquire(blocking=False)` → **못 잡으면 그냥 return** | 동일하게 `check_sell_signals` (+ 필요 시 prefetch/submit) |

초등생 버전:

- 교실에 **매도 전용 열쇠**가 하나 있음 (`_sell_lock`).
- **큰 줄(매수 루프)** 은 매도 검사할 때 이 열쇠를 **받을 때까지 줄 서서 기다림**.
- **틱매도** 는 열쇠 없으면 **이번 틱은 포기**.
- 열쇠를 잡은 놈이 `check_sell`·**잔고 REST**·주문으로 **오래 붙잡으면**, 그 동안 **매수 줄 전체가 멈춤**.

스캘프는 지금 틱매도 OFF라 틱과 싸움은 적지만, **루프가 매도 구간에서 열쇠를 오래 들고 있으면** 그 뒤 매수스캔은 시작도 못 함.
모멘텀/돌파/꼬리는 틱매도 ON → 틱이 열쇠를 자주 건드리며, 루프는 **blocking 대기**할 수 있음.

단, **의심 ≠ 확정**. 숫자로 증명하기 전엔 락을 뜯지 않는다.

## 빠른 시절 비교 (참고만 — 원인 단정용 아님)

- 빠른 코드: `f61c471` / `d8ba01a` — 루프에 `_sell_lock` **없음**, 매도→매수 단순 직렬.
- 지금: 락 + 틱매도 + 매바퀴 부가 호출 많음.
- 비교 의미: “구조가 무거워질 **여지**”만 보여 줌. **어느 줄이 초를 먹는지**는 계측만 답한다.

## 계측 설계 (이번 단계의 본체)

### 원칙

- 기본 **OFF** (`LOOP_PROFILE_ENABLED` 등 `get_env_bool`, 기본 false).
- 켜도 **샘플링**(예: N초마다 1바퀴 상세, 또는 매 M바퀴 1회) — 로그 폭주·저널 마비 금지.
- 단위 **ms** (필요 시 lock wait만 µs).
- 실매 신호식·필터·청산 로직 **변경 없음**. 타이머 + 카운터만.

### 찍을 구간 (한 바퀴)

```mermaid
flowchart TD
  start[loop_start] --> sync[sync_holdings_ms]
  sync --> waitLock[sell_lock_wait_ms]
  waitLock --> holdStart[lock_acquired]
  holdStart --> sellCheck[check_sell_ms]
  sellCheck --> prefetch[prefetch_ms_if_any]
  prefetch --> submit[submit_sell_ms_if_any]
  submit --> unlock[lock_released_hold_ms]
  unlock --> cand[load_candidates_ws_ms]
  cand --> pending[pending_orders_ms]
  pending --> scan[scan_and_buy_ms]
  scan --> endNode[loop_total_ms]
```

`scan_and_buy` 안:

- 후보 수 / `check_buy` 호출 수
- `check_buy` **합계 ms**, **max ms**, (상위 1~3개 code+ms)만
- `gap_not_ready` 스킵 횟수 (대기는 아니지만 “검사 자체를 못 함” 구분)

락 전용:

- `sell_lock_wait_ms` — 루프가 열쇠 받을 때까지 (blocking)
- `sell_lock_hold_ms` — 열쇠 잡고 있던 시간
- 틱매도 ON 전략: `tick_sell_lock_miss` 횟수 (non-blocking 실패) — 경합 간접 지표

### 출력

- 주기 1줄 INFO 예:
  `[LOOP_PROF] SCALP total=… wait_lock=… hold_lock=… sell=… cand=… scan=… buy_n=… buy_sum=… buy_max=…`
- 파일 옵션: `logs/loop_profile_<strategy>.log` (tail -f 가능) — journal과 분리해 운영 로그 안 덮음.

### 수집 방법

- 장중 `LOOP_PROFILE_ENABLED=true` (전략 공통 또는 `{SID}_LOOP_PROFILE_ENABLED`)
- 5~15분 돌리고 OFF
- 표로: total 큰 바퀴에서 **wait_lock vs hold_lock vs scan/buy** 누가 큰지

## 계측 이후에만 (지금은 설계만, 실행·수정 없음)

숫자 나온 뒤 **선보고 → 승인** 후 해당 구간만 수정.

| 계측 결과 | 그때 검토할 수정 (지금 하지 않음) |
|---|---|
| `wait_lock` / `hold_lock` 초 단위 | 락 범위 축소(매도 제출만 락 등) — **매매 구조 변경 → 필수 승인** |
| `check_buy` / `scan` 초 단위 | 후보별 병목(갭 REST·호가·엔진) 개별 추적 |
| 위·아래 다 작음 | WS/유니버스/스레드 기아 등 **루프 밖**으로 범위 확대 |
| sync_holdings·env 스냅샷만 큼 | 그때만 TTL/캐시 (µs급이면 무시) |

**폐기 유지:** 틱매수 큐 땜빵, 필터 OFF로 속도 맞추기, 계측 전 TTL 남발.

## 스캘프 틱매도 ON

- 매수 병목 계측과 **별 이슈**(청산 반응).
- 계측 중/후 **승인 시** DB `SCALP_TICK_SELL_ENABLED=true`만 — 매수 경로 불변.
- 켠 뒤에도 lock miss/hold 계측으로 **부작용 있는지** 같이 본다.

## 완료 조건 (1단계)

- 계측 코드 들어감(기본 OFF) + 장중 샘플 표
- “병목이 락 대기 / 락 점유 안 check_sell / check_buy / 루프 밖” 중 **하나로 분류**
- 수정은 그다음 턴(승인 후)


맞아요. 그 숙제 목록은 **원인 후보로 단정하면 안 됩니다.** 대개 가벼운 일이고, 수십 초 체감이랑 안 맞습니다.

## 다시 잡은 설계 (계측 먼저)

**1단계 = 재지 말고 재기.** 고치기 전에 “어디서 초가 사라지나”만 찍습니다.

특히 **`_sell_lock`**:

- 매수 큰 줄은 매도 검사할 때 이 열쇠를 **받을 때까지 기다림** (blocking)
- 틱매도는 열쇠 없으면 **이번 틱 포기** (non-blocking)
- 열쇠 잡은 놈이 `check_sell` / 잔고 REST / 주문을 오래 하면 → **그 뒤 매수는 시작도 못 함**

이름부터 lock이라 의심할 만하고, 코드상으로도 **매수 앞에 서서 막는 구조**가 맞습니다. 그래도 **숫자 나오기 전엔 락을 안 뜯습니다.**

찍을 것 요약:

- 한 바퀴 총시간
- **락 대기 ms / 락 잡고 있던 ms**
- 그 안에서 sell 검사·prefetch·주문
- 그 다음 후보/스캔/`check_buy` 합·최대
- (틱매도 ON 전략) 락 못 잡아서 스킵한 횟수

계측은 **기본 OFF**, 켤 때만 샘플링. 로그는 가능하면 전용 파일로.

TTL·캐시·틱매수 큐 같은 “일단 빠르게”는 **폐기/보류**. 숫자로 큰 구간만, **보고 후 승인**하고 고칩니다.

스캘프 틱매도 ON은 매수 병목이랑 **별건**이라, 계측과 겹쳐도 되지만 매수 경로랑은 안 섞습니다.

이 계측 우선안대로 실행(코드에 타이머 넣기)할까요?


<timestamp>Thursday, Aug 13, 2026, 12:22 PM (UTC+9)</timestamp>
<user_query>
그래 틱매도 켜고 측정먼저해 매 숙제별로 각각 그리고 총량 계산
</user_query>


스캘프 틱매도 ON과 숙제별·총량 계측을 넣겠습니다.

TodoWrite

SCALP_TICK_SELL_ENABLED=true DB 적용 + 재조회

루프 숙제별·락·check_buy·총량 ms 계측 (기본 OFF)

계측 ON 후 장중 샘플 수집·보고


StrReplace

    "STRATEGY_LOOP_SLEEP_MIN", "STRATEGY_LOOP_SLEEP_MAX",
    # 틱매도 — WS 현재가 갱신 시 매도 검사 (기본 false · 루프 매도 유지 + 틱 보강)
    "TICK_SELL_ENABLED", "TICK_SELL_MIN_INTERVAL_MS",
    "SHORT_TICK_SELL_ENABLED", "SCALP_TICK_SELL_ENABLED",
    "BREAKOUT_TICK_SELL_ENABLED", "MOMENTUM_TICK_SELL_ENABLED",
    "RANGE_BREAK_TICK_SELL_ENABLED", "UPDOW_TICK_SELL_ENABLED",
    "DBBAND_TICK_SELL_ENABLED",

    "STRATEGY_LOOP_SLEEP_MIN", "STRATEGY_LOOP_SLEEP_MAX",
    # 루프 숙제별 ms 계측 (기본 OFF · 병목 확정용 · 신호식 불변)
    "LOOP_PROFILE_ENABLED",
    "LOOP_PROFILE_EVERY_N",
    "LOOP_PROFILE_LOG_PATH",
    # 틱매도 — WS 현재가 갱신 시 매도 검사 (기본 false · 루프 매도 유지 + 틱 보강)
    "TICK_SELL_ENABLED", "TICK_SELL_MIN_INTERVAL_MS",
    "SHORT_TICK_SELL_ENABLED", "SCALP_TICK_SELL_ENABLED",
    "BREAKOUT_TICK_SELL_ENABLED", "MOMENTUM_TICK_SELL_ENABLED",
    "RANGE_BREAK_TICK_SELL_ENABLED", "UPDOW_TICK_SELL_ENABLED",
    "DBBAND_TICK_SELL_ENABLED",


            _f("STRATEGY_LOOP_SLEEP_MIN", "루프 끝 sleep 최소(초)", "float", default=0.1,
               hint="한 바퀴 끝 대기 · WS 구독 캐시 폴링 · 0.1=실매 반응 빠르게"),
            _f("STRATEGY_LOOP_SLEEP_MAX", "루프 끝 sleep 최대(초)", "float", default=0.1),
            _f("LOOP_PROFILE_ENABLED", "루프 숙제 ms 계측", "bool", default=False,
               hint="ON=한 바퀴 단계별·락 wait/hold·check_buy 합/max 로그 · 병목 확정용 · 기본 OFF"),
            _f("LOOP_PROFILE_EVERY_N", "계측 샘플 주기(바퀴)", "int", default=1,
               hint="N바퀴마다 1회 상세 로그 · 1=매바퀴 · 로그 폭주 시 5~10"),
            _f("LOOP_PROFILE_LOG_PATH", "계측 로그 파일", "str", default="logs/loop_profile.log",
               hint="상대경로=레포루트 기준 · 비우면 전략 logger INFO 만"),
            _f("TICK_SELL_ENABLED", "틱매도(공통)", "bool", default=False,
               hint="ON=WS 현재가 갱신마다 매도검사(백테 틱청산에 근접) · OFF=루프(~0.1초)만 · "
                    "전략별 *_TICK_SELL_ENABLED 가 비어있지 않으면 그쪽 우선 · 기본 OFF"),
            _f("TICK_SELL_MIN_INTERVAL_MS", "틱매도 최소간격(ms)", "int", default=50,
               hint="같은 종목 연속 틱 폭주 시 매도검사 하한 · 백테 poll≈50~100ms 정합"),
        ],
    })

            _f("STRATEGY_LOOP_SLEEP_MIN", "루프 끝 sleep 최소(초)", "float", default=0.1,
               hint="한 바퀴 끝 대기 · WS 구독 캐시 폴링 · 0.1=실매 반응 빠르게"),
            _f("STRATEGY_LOOP_SLEEP_MAX", "루프 끝 sleep 최대(초)", "float", default=0.1),
            _f("TICK_SELL_ENABLED", "틱매도(공통)", "bool", default=False,
               hint="ON=WS 현재가 갱신마다 매도검사(백테 틱청산에 근접) · OFF=루프(~0.1초)만 · "
                    "전략별 *_TICK_SELL_ENABLED 가 비어있지 않으면 그쪽 우선 · 기본 OFF"),
            _f("TICK_SELL_MIN_INTERVAL_MS", "틱매도 최소간격(ms)", "int", default=50,
               hint="같은 종목 연속 틱 폭주 시 매도검사 하한 · 백테 poll≈50~100ms 정합"),
        ],
    })


        # 틱매도 — WS 현재가 갱신 시 매도 검사 (기본 OFF · env 로 ON)
        self._sell_lock = threading.Lock()
        self._tick_sell_last_ts: Dict[str, float] = {}
        self._tick_sell_listener_on = False
        # 루프 숙제별 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)

        # 틱매도 — WS 현재가 갱신 시 매도 검사 (기본 OFF · env 로 ON)
        self._sell_lock = threading.Lock()
        self._tick_sell_last_ts: Dict[str, float] = {}
        self._tick_sell_listener_on = False

        self._sync_holdings_from_db(log_restore=True)

        self._tick_sell_last_ts[code] = now
        if not self._sell_lock.acquire(blocking=False):
            self._tick_sell_lock_miss = int(getattr(self, "_tick_sell_lock_miss", 0) or 0) + 1
            return
        try:
            if code not in self.holdings:
                return
            sell_signals = self.check_sell_signals() or []
            if sell_signals and get_env_bool("REAL_BALANCE_VERIFY_BEFORE_SELL", True):
                try:
                    self.order_mgr.prefetch_broker_holdings()
                except Exception:
                    pass
            for sig in sell_signals:
                if (sig.get("code") or "") == code:
                    self._submit_sell(sig)
                    break
        except Exception as ex:
            self.logger.debug("틱매도 예외 %s: %s", code, ex)
        finally:
            self._sell_lock.release()

    def _loop_profile_on(self) -> bool:
        """LOOP_PROFILE_ENABLED — 한 바퀴 숙제별 ms 계측 ON/OFF."""
        try:
            return bool(get_env_bool("LOOP_PROFILE_ENABLED", False))
        except Exception:
            return False

    def _loop_profile_every_n(self) -> int:
        try:
            return max(1, int(get_env_int("LOOP_PROFILE_EVERY_N", 1) or 1))
        except Exception:
            return 1

    def _loop_profile_emit(self, row: Dict[str, Any]) -> None:
        """계측 1줄 — 전략 logger + 선택 파일."""
        parts = [
            f"[LOOP_PROF] {self.strategy_id}",
            f"total={row.get('total_ms', 0):.1f}",
            f"reload={row.get('reload_ms', 0):.1f}",
            f"sync_hold={row.get('sync_hold_ms', 0):.1f}",
            f"halt={row.get('halt_ms', 0):.1f}",
            f"lock_wait={row.get('lock_wait_ms', 0):.1f}",
            f"lock_hold={row.get('lock_hold_ms', 0):.1f}",
            f"sell_chk={row.get('sell_chk_ms', 0):.1f}",
            f"prefetch={row.get('prefetch_ms', 0):.1f}",
            f"submit_sell={row.get('submit_sell_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}",
        ]
        if row.get("buy_max_code"):
            parts.append(f"buy_max_code={row.get('buy_max_code')}")
        line = " ".join(parts)
        try:
            self.logger.info("%s", line)
        except Exception:
            pass
        path = str(get_env_from_db("LOOP_PROFILE_LOG_PATH", "logs/loop_profile.log") or "").strip()
        if not path:
            return
        try:
            import os
            if not os.path.isabs(path):
                root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
                path = os.path.join(root, path)
            os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
            with open(path, "a", encoding="utf-8") as f:
                f.write(dt.now().strftime("%Y-%m-%d %H:%M:%S.%f")[:-3] + " " + line + "\n")
        except Exception as ex:
            try:
                self.logger.debug("LOOP_PROF 파일 기록 실패: %s", ex)
            except Exception:
                pass

    def _run_sell_section_locked(self) -> Dict[str, float]:
        """매도 구간 — _sell_lock blocking. wait/hold/세부 ms 반환."""
        out = {
            "lock_wait_ms": 0.0,
            "lock_hold_ms": 0.0,
            "sell_chk_ms": 0.0,
            "prefetch_ms": 0.0,
            "submit_sell_ms": 0.0,
        }
        t_wait0 = time.perf_counter()
        self._sell_lock.acquire(blocking=True)
        out["lock_wait_ms"] = (time.perf_counter() - t_wait0) * 1000.0
        t_hold0 = time.perf_counter()
        try:
            t0 = time.perf_counter()
            sell_signals = self.check_sell_signals() or []
            out["sell_chk_ms"] = (time.perf_counter() - t0) * 1000.0
            if sell_signals and get_env_bool("REAL_BALANCE_VERIFY_BEFORE_SELL", True):
                t1 = time.perf_counter()
                try:
                    self.order_mgr.prefetch_broker_holdings()
                except Exception:
                    pass
                out["prefetch_ms"] = (time.perf_counter() - t1) * 1000.0
            t2 = time.perf_counter()
            for sig in sell_signals:
                self._submit_sell(sig)
            out["submit_sell_ms"] = (time.perf_counter() - t2) * 1000.0
        finally:
            out["lock_hold_ms"] = (time.perf_counter() - t_hold0) * 1000.0
            self._sell_lock.release()
        return out

    # ------------------------------------------------------------------
    # 스캔 루프 sleep (env 핫리로드 — 재시작 없이 반영)
    # ------------------------------------------------------------------
    # REST 유량과는 별개의 "스캔 회전율" 조절. WS 따라가기 속도를 결정한다.
    # 전략별 클래스 기본값(loop_min_sleep 등)을 폴백으로 두고, env 가 있으면 우선.

        self._tick_sell_last_ts[code] = now
        if not self._sell_lock.acquire(blocking=False):
            return
        try:
            if code not in self.holdings:
                return
            sell_signals = self.check_sell_signals() or []
            if sell_signals and get_env_bool("REAL_BALANCE_VERIFY_BEFORE_SELL", True):
                try:
                    self.order_mgr.prefetch_broker_holdings()
                except Exception:
                    pass
            for sig in sell_signals:
                if (sig.get("code") or "") == code:
                    self._submit_sell(sig)
                    break
        except Exception as ex:
            self.logger.debug("틱매도 예외 %s: %s", code, ex)
        finally:
            self._sell_lock.release()

    # ------------------------------------------------------------------
    # 스캔 루프 sleep (env 핫리로드 — 재시작 없이 반영)
    # ------------------------------------------------------------------
    # REST 유량과는 별개의 "스캔 회전율" 조절. WS 따라가기 속도를 결정한다.
    # 전략별 클래스 기본값(loop_min_sleep 등)을 폴백으로 두고, env 가 있으면 우선.


    def _run_loop(self) -> None:
        last_date = dt.now().strftime("%Y-%m-%d")
        last_closed_log = 0.0
        while self._running:
            try:
                prof = self._loop_profile_on()
                self._loop_prof_i = int(getattr(self, "_loop_prof_i", 0) or 0) + 1
                do_prof = bool(
                    prof
                    and (self._loop_prof_i % self._loop_profile_every_n() == 0)
                )
                row: Dict[str, Any] = {}
                t_loop0 = time.perf_counter() if do_prof else 0.0
                miss0 = int(getattr(self, "_tick_sell_lock_miss", 0) or 0)

                now = dt.now()
                today = now.strftime("%Y-%m-%d")

                # 날짜 변경 처리 (당일 매매불가 리셋 등)
                if today != last_date:
                    last_date = today
                    self.untradable_skip.clear()
                    self.on_new_day()

                # 장 시간 체크 (서브클래스 오버라이드 가능)
                if not self.check_market_status():
                    # 장외 heartbeat: 봇 기동 직후 1회 + 이후 1시간마다 1회.
                    # (봇 살아있음·보유·유니버스 상태만 확인용 — 잦은 로그 노이즈 제거)
                    interval = get_env_int("OFF_HOURS_LOG_INTERVAL_SEC", 3600)
                    if time.time() - last_closed_log >= interval:
                        try:
                            universe = self._load_candidates()
                        except Exception:
                            universe = []
                        self.logger.info(
                            "🌙 [장외] holdings=%d universe=%d recently_sold=%d",
                            len(self.holdings), len(universe), len(self.recently_sold),
                        )
                        last_closed_log = time.time()
                    time.sleep(30)
                    continue

                # 설정 리로드 (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

                # 보유 목록 = 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

                # 전략 ON/OFF 핫게이트 — WS 구독 해제 없음. 보유 청산만 유지.
                if not self._strategy_switch_enabled():
                    if self.holdings:
                        self._run_sell_section_locked()
                    time.sleep(self._scan_sleep("loop"))
                    continue

                # 일일익절 B안 — 매수루프 비어도 hit 후 보유 리스크 정리
                t0 = time.perf_counter() if do_prof else 0.0
                guard = getattr(self, "daily_profit_halt", None)
                if guard is not None:
                    try:
                        guard.maybe_trim_open_risk(self.strategy_id)
                    except Exception as ex:
                        self.logger.debug("일일익절 리스크버짓 예외: %s", ex)
                if do_prof:
                    row["halt_ms"] = (time.perf_counter() - t0) * 1000.0

                # ── [1] 매도 먼저 ────────────────────────────────
                sell_timings = self._run_sell_section_locked()
                if do_prof:
                    row.update(sell_timings)

                # ── [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
                cand_codes = [c.get("code") for c in candidates if c.get("code")]
                hold_codes = list(self.holdings.keys())
                # KIS 최소 구독 모드: 후보=키움 WS, KIS=영구+보유 (WSManager.sync_targets_split)
                # US_MOMENTUM 등은 _sync_ws_for_loop 오버라이드로 해외 WS 만 사용
                t0 = time.perf_counter() if do_prof else 0.0
                self._sync_ws_for_loop(cand_codes, hold_codes)
                if do_prof:
                    row["ws_sync_ms"] = (time.perf_counter() - t0) * 1000.0

                # ── [2b] 미체결 지정가 만료 취소 ───────────────────
                t0 = time.perf_counter() if do_prof else 0.0
                self.manage_pending_orders()
                if do_prof:
                    row["pending_ms"] = (time.perf_counter() - t0) * 1000.0

                # ── [3] 매수 체크 (매수 종료 TIME_END 와 매도 세션 분리) ──
                max_stocks = self._max_stocks()
                active_cnt = len(self.holdings)
                self._loop_prof_scan = None
                t0 = time.perf_counter() if do_prof else 0.0
                if candidates and active_cnt < max_stocks and self.check_buy_allowed():
                    self._scan_and_buy(candidates, max_stocks, active_cnt)
                elif candidates and active_cnt >= max_stocks:
                    # 보유만석이면 _scan_and_buy 미진입 → 매수체크 로그가 안 나와 "멈춘 것"처럼 보임
                    now_m = time.time()
                    last_m = float(getattr(self, "_last_full_skip_log_ts", 0) or 0)
                    if now_m - last_m >= 60.0:
                        self._last_full_skip_log_ts = now_m
                        self.logger.info(
                            "🔍 [매수체크 스킵] 보유만석 %d/%d codes=%s",
                            active_cnt, max_stocks,
                            ",".join(list(self.holdings.keys())[:12]),
                        )
                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)

                # 고점·세션저점 등 런타임 오버레이 저장 (다음 루프 DB sync 시 max merge)
                t0 = time.perf_counter() if do_prof else 0.0
                self._capture_runtime_overlay()
                if do_prof:
                    row["overlay_ms"] = (time.perf_counter() - t0) * 1000.0

                t0 = time.perf_counter() if do_prof else 0.0
                time.sleep(self._scan_sleep("loop"))
                if do_prof:
                    row["sleep_ms"] = (time.perf_counter() - t0) * 1000.0
                    row["total_ms"] = (time.perf_counter() - t_loop0) * 1000.0
                    row["tick_lock_miss"] = int(
                        getattr(self, "_tick_sell_lock_miss", 0) or 0
                    ) - miss0
                    # 숙제 합( sleep 제외 ) — total 과 비교해 미계측 구간 파악
                    part_keys = (
                        "reload_ms", "sync_hold_ms", "halt_ms",
                        "lock_wait_ms", "lock_hold_ms",
                        "cand_ms", "ws_sync_ms", "pending_ms",
                        "scan_ms", "overlay_ms",
                    )
                    # lock_hold 안에 sell_chk/prefetch/submit 포함 → 합산 시 hold만
                    row["sum_parts_ms"] = sum(float(row.get(k, 0) or 0) for k in part_keys)
                    self._loop_profile_emit(row)

            except KeyboardInterrupt:
                self._running = False
                break
            except Exception as e:
                self.logger.error("루프 예외: %s", e)
                time.sleep(5)

    def _run_loop(self) -> None:
        last_date = dt.now().strftime("%Y-%m-%d")
        last_closed_log = 0.0
        while self._running:
            try:
                now = dt.now()
                today = now.strftime("%Y-%m-%d")

                # 날짜 변경 처리 (당일 매매불가 리셋 등)
                if today != last_date:
                    last_date = today
                    self.untradable_skip.clear()
                    self.on_new_day()

                # 장 시간 체크 (서브클래스 오버라이드 가능)
                if not self.check_market_status():
                    # 장외 heartbeat: 봇 기동 직후 1회 + 이후 1시간마다 1회.
                    # (봇 살아있음·보유·유니버스 상태만 확인용 — 잦은 로그 노이즈 제거)
                    interval = get_env_int("OFF_HOURS_LOG_INTERVAL_SEC", 3600)
                    if time.time() - last_closed_log >= interval:
                        try:
                            universe = self._load_candidates()
                        except Exception:
                            universe = []
                        self.logger.info(
                            "🌙 [장외] holdings=%d universe=%d recently_sold=%d",
                            len(self.holdings), len(universe), len(self.recently_sold),
                        )
                        last_closed_log = time.time()
                    time.sleep(30)
                    continue

                # 설정 리로드 (DB env_config 실시간 반영)
                self.reload_config()

                # 보유 목록 = DB 진실 + _runtime 오버레이 (poll 체결·재시작 정합)
                self._sync_holdings_from_db()

                # 전략 ON/OFF 핫게이트 — WS 구독 해제 없음. 보유 청산만 유지.
                if not self._strategy_switch_enabled():
                    if self.holdings:
                        with self._sell_lock:
                            sell_signals = self.check_sell_signals()
                            if sell_signals and get_env_bool(
                                "REAL_BALANCE_VERIFY_BEFORE_SELL", True
                            ):
                                self.order_mgr.prefetch_broker_holdings()
                            for sig in sell_signals:
                                self._submit_sell(sig)
                    time.sleep(self._scan_sleep("loop"))
                    continue

                # 일일익절 B안 — 매수루프 비어도 hit 후 보유 리스크 정리
                guard = getattr(self, "daily_profit_halt", None)
                if guard is not None:
                    try:
                        guard.maybe_trim_open_risk(self.strategy_id)
                    except Exception as ex:
                        self.logger.debug("일일익절 리스크버짓 예외: %s", ex)

                # ── [1] 매도 먼저 ────────────────────────────────
                with self._sell_lock:
                    sell_signals = self.check_sell_signals()
                    if sell_signals and get_env_bool("REAL_BALANCE_VERIFY_BEFORE_SELL", True):
                        self.order_mgr.prefetch_broker_holdings()
                    for sig in sell_signals:
                        self._submit_sell(sig)

                # ── [2] 후보 구독 동기화 (공유 WS) ────────────────
                candidates = self._load_candidates()
                # 중분 편입 시가 애매 가드용 — 후보 ENTER 시각(초) 기록
                self._note_candidate_enters(candidates)
                cand_codes = [c.get("code") for c in candidates if c.get("code")]
                hold_codes = list(self.holdings.keys())
                # KIS 최소 구독 모드: 후보=키움 WS, KIS=영구+보유 (WSManager.sync_targets_split)
                # US_MOMENTUM 등은 _sync_ws_for_loop 오버라이드로 해외 WS 만 사용
                self._sync_ws_for_loop(cand_codes, hold_codes)

                # ── [2b] 미체결 지정가 만료 취소 ───────────────────
                self.manage_pending_orders()

                # ── [3] 매수 체크 (매수 종료 TIME_END 와 매도 세션 분리) ──
                max_stocks = self._max_stocks()
                active_cnt = len(self.holdings)
                if candidates and active_cnt < max_stocks and self.check_buy_allowed():
                    self._scan_and_buy(candidates, max_stocks, active_cnt)
                elif candidates and active_cnt >= max_stocks:
                    # 보유만석이면 _scan_and_buy 미진입 → 매수체크 로그가 안 나와 "멈춘 것"처럼 보임
                    now_m = time.time()
                    last_m = float(getattr(self, "_last_full_skip_log_ts", 0) or 0)
                    if now_m - last_m >= 60.0:
                        self._last_full_skip_log_ts = now_m
                        self.logger.info(
                            "🔍 [매수체크 스킵] 보유만석 %d/%d codes=%s",
                            active_cnt, max_stocks,
                            ",".join(list(self.holdings.keys())[:12]),
                        )

                # 고점·세션저점 등 런타임 오버레이 저장 (다음 루프 DB sync 시 max merge)
                self._capture_runtime_overlay()

                time.sleep(self._scan_sleep("loop"))

            except KeyboardInterrupt:
                self._running = False
                break
            except Exception as e:
                self.logger.error("루프 예외: %s", e)
                time.sleep(5)


        else:
            self.logger.info(
                "🔍 [매수체크/T:%s|O:%s] 후보 %d (보유 %d/%d)",
                self.db.get_merged_env_snapshot().get("LIVE_TICK_PROVIDER", "kiwoom"),
                self.db.get_merged_env_snapshot().get("LIVE_OB_PROVIDER", "kiwoom"),
                len(candidates), active_cnt, max_stocks,
            )
        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,
            }

        else:
            self.logger.info(
                "🔍 [매수체크/T:%s|O:%s] 후보 %d (보유 %d/%d)",
                self.db.get_merged_env_snapshot().get("LIVE_TICK_PROVIDER", "kiwoom"),
                self.db.get_merged_env_snapshot().get("LIVE_OB_PROVIDER", "kiwoom"),
                len(candidates), active_cnt, max_stocks,
            )
        for c in candidates:
            if not self._running:
                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):
                continue

            signal = self.check_buy(code, name)
            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"))
                return  # 1루프당 1매수 (포지션 과집중 방지)
            time.sleep(self._scan_sleep("buy_fail"))


Shell

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

eid = apply_env_patch({
    "SCALP_TICK_SELL_ENABLED": "true",
    "LOOP_PROFILE_ENABLED": "true",
    "LOOP_PROFILE_EVERY_N": "1",
    "LOOP_PROFILE_LOG_PATH": "logs/loop_profile.log",
})
print("env_id", eid)
invalidate_merged_env_cache()
for k in [
    "SCALP_TICK_SELL_ENABLED",
    "LOOP_PROFILE_ENABLED",
    "LOOP_PROFILE_EVERY_N",
    "LOOP_PROFILE_LOG_PATH",
    "MOMENTUM_TICK_SELL_ENABLED",
]:
    print(k, repr(get_env_from_db(k)), "bool" if "ENABLED" in k else "", get_env_bool(k, False) if "ENABLED" in k else "")

# ensure physical/ext
db=TradeDB()
# config_scalp latest
r=db.conn.execute("SELECT SCALP_TICK_SELL_ENABLED, created_at FROM config_scalp ORDER BY id DESC LIMIT 1").fetchone()
print("config_scalp", dict(r) if r else None)
# ext keys
try:
  rows=db.conn.execute(
    "SELECT env_key, env_value FROM env_config_ext WHERE env_key LIKE %s",
    ("LOOP_PROFILE%",),
  ).fetchall()
  print("ext", [dict(x) for x in rows])
except Exception as e:
  print("ext err", e)
# env_config columns?
cols=[r['Field'] for r in db.conn.execute('SHOW COLUMNS FROM env_config').fetchall()]
print("has LOOP_PROFILE_ENABLED col", "LOOP_PROFILE_ENABLED" in cols)
db.close()
PY
mkdir -p /home/hoon/kis_bot/logs
touch /home/hoon/kis_bot/logs/loop_profile.log
sudo systemctl restart kis_trader_main.service
sleep 3
systemctl is-active kis_trader_main.service
journalctl -u kis_trader_main.service --since "30 sec ago" --no-pager 2>/dev/null | rg -i "틱매도|LOOP_PROF|전략 쓰레드 시작|error|Error" | head -40
PY

Apply env, compile, restart live bot


journalctl -u kis_trader_main.service --since "2 min ago" --no-pager 2>/dev/null | rg -i "틱매도|LOOP_PROF|전략 쓰레드 시작|Exception|Traceback|Error" | head -50
echo '===='
sleep 25
wc -l /home/hoon/kis_bot/logs/loop_profile.log 2>/dev/null
tail -n 30 /home/hoon/kis_bot/logs/loop_profile.log 2>/dev/null
echo '==== journal LOOP'
journalctl -u kis_trader_main.service --since "1 min ago" --no-pager 2>/dev/null | rg "LOOP_PROF" | head -20
PY

Verify tick-sell ON and collect profile samples


systemctl is-active kis_trader_main.service; systemctl status kis_trader_main.service --no-pager -l | head -40
journalctl -u kis_trader_main.service -n 80 --no-pager
ls -la /home/hoon/kis_bot/logs/loop_profile.log
ps aux | rg -i "kis_trader|main.py" | rg -v rg | head

Diagnose bot service and process state


systemctl show kis_trader_main.service -p WorkingDirectory -p Environment -p ExecStart --no-pager
journalctl -u kis_trader_main.service --since "12:25:30" --no-pager | rg "틱매도|LOOP_PROF|쓰레드 시작|SCALP" | head -40
# verify get_env from same path as bot
cd /home/hoon/kis_bot && .venv/bin/python - <<'PY'
from kis_trader.utils.env import invalidate_merged_env_cache, get_env_bool, get_env_from_db, get_merged_env_dict
invalidate_merged_env_cache()
m=get_merged_env_dict()
print('LOOP in merge', 'LOOP_PROFILE_ENABLED' in m, m.get('LOOP_PROFILE_ENABLED'))
print('SCALP tick', m.get('SCALP_TICK_SELL_ENABLED'))
print('get_bool', get_env_bool('LOOP_PROFILE_ENABLED', False))
PY
# wait more for a full loop after gap storm
sleep 45
journalctl -u kis_trader_main.service --since "12:26:00" --no-pager | rg "LOOP_PROF" | head -20
wc -l /home/hoon/kis_bot/logs/loop_profile.log
tail -5 /home/hoon/kis_bot/logs/loop_profile.log
# also check if process cwd
ls -l /proc/1323796/cwd
PY

Debug why LOOP_PROF not emitting


journalctl -u kis_trader_main.service --since "12:26:00" --no-pager 2>/dev/null | rg "LOOP_PROF|매수체크" | head -40
ls -la /home/hoon/kis_bot/logs/loop_profile.log /home/hoon/kis_bot/kis_trader/logs/loop_profile.log 2>/dev/null
# find any loop_profile
find /home/hoon/kis_bot -name 'loop_profile.log' 2>/dev/null
# last SCALP strategy lines
journalctl -u kis_trader_main.service --since "12:26:00" --no-pager 2>/dev/null | rg "strategy\.SCALP" | tail -20
PY

Find LOOP_PROF output and SCALP loop progress


계측이 나왔습니다. 샘플을 더 모아 전략별로 집계합니다.

sleep 90
echo '==== file ===='
wc -l /home/hoon/kis_bot/logs/loop_profile.log
cat /home/hoon/kis_bot/logs/loop_profile.log
echo '==== journal all LOOP_PROF ===='
journalctl -u kis_trader_main.service --since "12:26:00" --no-pager 2>/dev/null | rg "LOOP_PROF" | head -80
PY

Collect 90s of LOOP_PROF samples


cd /home/hoon/kis_bot && python3 - <<'PY'
import re
from collections import defaultdict
from pathlib import Path
text=Path('logs/loop_profile.log').read_text()
# parse key=val
pat=re.compile(r'\[LOOP_PROF\] (\w+) (.+)$')
rows=defaultdict(list)
for line in text.splitlines():
  m=pat.search(line)
  if not m: continue
  sid, rest=m.group(1), m.group(2)
  d={}
  for kv in rest.split():
    if '=' not in kv: continue
    k,v=kv.split('=',1)
    try: d[k]=float(v)
    except: d[k]=v
  rows[sid].append(d)

keys=['total','reload','sync_hold','lock_wait','lock_hold','sell_chk','cand','ws_sync','pending','scan','buy_sum','buy_max','overlay','sleep']
print(f"{'SID':10} {'n':>3} {'total':>8} {'scan%':>6} {'buy_sum':>8} {'buy_max':>8} {'lock_w':>7} {'lock_h':>7} {'reload':>7} {'sync':>7}")
for sid in sorted(rows):
  rs=rows[sid]
  def avg(k):
    vs=[float(r.get(k,0) or 0) for r in rs]
    return sum(vs)/len(vs) if vs else 0
  tot=avg('total')
  sc=avg('scan')
  print(f"{sid:10} {len(rs):3d} {tot:8.0f} {100*sc/tot if tot else 0:5.1f}% {avg('buy_sum'):8.0f} {avg('buy_max'):8.0f} {avg('lock_wait'):7.1f} {avg('lock_hold'):7.1f} {avg('reload'):7.0f} {avg('sync_hold'):7.0f}")

print('\n--- worst total per strategy ---')
for sid in sorted(rows):
  worst=max(rows[sid], key=lambda r: float(r.get('total') or 0))
  print(sid, f"total={worst.get('total')} scan={worst.get('scan')} buy_max={worst.get('buy_max')} code={worst.get('buy_max_code')} reload={worst.get('reload')}")
PY
# leave profile on but every 5 to reduce spam? User didn't ask - leave EVERY_N=1 for now
# Confirm SCALP tick still true
.venv/bin/python - <<'PY'
from kis_trader.utils.env import invalidate_merged_env_cache, get_env_from_db
invalidate_merged_env_cache()
print('SCALP_TICK', get_env_from_db('SCALP_TICK_SELL_ENABLED'))
print('LOOP_PROF', get_env_from_db('LOOP_PROFILE_ENABLED'))
PY
PY

Aggregate LOOP_PROF stats by strategy


## 적용 완료

1. **스캘프 틱매도 ON** — `SCALP_TICK_SELL_ENABLED=true` (재조회 true)
   기동 로그: `📡 [틱매도] 리스너 등록 ON (SCALP_TICK_SELL …)`
2. **숙제별 + 총량 계측** — `LOOP_PROFILE_ENABLED=true`
   로그: `logs/loop_profile.log` (`tail -f logs/loop_profile.log`)
3. 실매 봇 **재시작**함 (코드 반영)

---

## 계측 결과 (약 2분, 평균 ms)

| 전략 | 바퀴수 | 총량 | scan 비중 | check_buy합 | 1종 max | lock대기 | lock점유 |
|---|---|---|---|---|---|---|---|
| SCALP | 6 | **32,612** | **93%** | 19,575 | 5,341 | **0.0** | 42 |
| MOMENTUM | 8 | 22,401 | **97%** | 12,495 | 4,475 | **0.0** | 23 |
| BREAKOUT | 3 | 45,327 | **99%** | 35,201 | 6,369 | **0.0** | 0 |
| SHORT | 13 | 15,144 | **84%** | 4,653 | 4,102 | **0.0** | 0 |

최악 1바퀴 예: SCALP **91초**, BREAKOUT **92초**, MOM **71초** — 거의 전부 `scan`/`check_buy`.

---

## 초등생 결론

**`_sell_lock`은 이번 샘플에서 범인 아님.**
`lock_wait`가 전부 **0ms** (열쇠 기다린 적 없음).

느린 본체:

1. **`check_buy` 한 종목에 수 초~십수 초** (max 자주 7~13초)
2. 후보 여러 개면 그게 쌓여 **한 바퀴 수십 초**
3. 가끔 `reload` 3~6초(SHORT), `sync_hold`/`cand` 1초대 — 보조

예전에 의심했던 sync/env 잔챙이는 **총량의 대부분을 못 설명함**.
다음 조사 타깃은 **`check_buy` 안** (갭 REST·봉 로드·호가·DB 등) — 승인 후 그 안을 다시 쪼개 재는 게 맞음.

계측은 지금 **켜 둔 상태**(매바퀴). 로그 많으면 `LOOP_PROFILE_EVERY_N=5` 또는 OFF 말하면 바꿈.


<timestamp>Thursday, Aug 13, 2026, 12:30 PM (UTC+9)</timestamp>
<user_query>
그래 그것도 측정해
</user_query>


`check_buy` 안을 단계별로 쪼개 계측합니다. 각 전략 `check_buy`의 무거운 구간부터 확인합니다.


StrReplace

        except Exception as ex:
            try:
                self.logger.debug("LOOP_PROF 파일 기록 실패: %s", ex)
            except Exception:
                pass

    def _cb_prof_start(self, code: str) -> Optional[Dict[str, Any]]:
        """check_buy 내부 단계 계측 시작 — LOOP_PROFILE_ENABLED 일 때만."""
        if not self._loop_profile_on():
            return None
        now = time.perf_counter()
        return {
            "code": str(code or ""),
            "t0": now,
            "_last": now,
            "stages": {},
        }

    def _cb_prof_mark(self, ctx: Optional[Dict[str, Any]], stage: str) -> None:
        if not ctx:
            return
        now = time.perf_counter()
        last = float(ctx.get("_last") or ctx.get("t0") or now)
        st = ctx.setdefault("stages", {})
        st[stage] = float(st.get(stage, 0) or 0) + (now - last) * 1000.0
        ctx["_last"] = now

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

    def _run_sell_section_locked(self) -> Dict[str, float]:
        """매도 구간 — _sell_lock blocking. wait/hold/세부 ms 반환."""
        out = {
            "lock_wait_ms": 0.0,
            "lock_hold_ms": 0.0,
            "sell_chk_ms": 0.0,
            "prefetch_ms": 0.0,
            "submit_sell_ms": 0.0,
        }
        t_wait0 = time.perf_counter()
        self._sell_lock.acquire(blocking=True)
        out["lock_wait_ms"] = (time.perf_counter() - t_wait0) * 1000.0
        t_hold0 = time.perf_counter()
        try:
            t0 = time.perf_counter()
            sell_signals = self.check_sell_signals() or []
            out["sell_chk_ms"] = (time.perf_counter() - t0) * 1000.0
            if sell_signals and get_env_bool("REAL_BALANCE_VERIFY_BEFORE_SELL", True):
                t1 = time.perf_counter()
                try:
                    self.order_mgr.prefetch_broker_holdings()
                except Exception:
                    pass
                out["prefetch_ms"] = (time.perf_counter() - t1) * 1000.0
            t2 = time.perf_counter()
            for sig in sell_signals:
                self._submit_sell(sig)
            out["submit_sell_ms"] = (time.perf_counter() - t2) * 1000.0
        finally:
            out["lock_hold_ms"] = (time.perf_counter() - t_hold0) * 1000.0
            self._sell_lock.release()
        return out

    # ------------------------------------------------------------------
    # 스캔 루프 sleep (env 핫리로드 — 재시작 없이 반영)
    # ------------------------------------------------------------------
    # REST 유량과는 별개의 "스캔 회전율" 조절. WS 따라가기 속도를 결정한다.
    # 전략별 클래스 기본값(loop_min_sleep 등)을 폴백으로 두고, env 가 있으면 우선.

        except Exception as ex:
            try:
                self.logger.debug("LOOP_PROF 파일 기록 실패: %s", ex)
            except Exception:
                pass

    def _run_sell_section_locked(self) -> Dict[str, float]:
        """매도 구간 — _sell_lock blocking. wait/hold/세부 ms 반환."""
        out = {
            "lock_wait_ms": 0.0,
            "lock_hold_ms": 0.0,
            "sell_chk_ms": 0.0,
            "prefetch_ms": 0.0,
            "submit_sell_ms": 0.0,
        }
        t_wait0 = time.perf_counter()
        self._sell_lock.acquire(blocking=True)
        out["lock_wait_ms"] = (time.perf_counter() - t_wait0) * 1000.0
        t_hold0 = time.perf_counter()
        try:
            t0 = time.perf_counter()
            sell_signals = self.check_sell_signals() or []
            out["sell_chk_ms"] = (time.perf_counter() - t0) * 1000.0
            if sell_signals and get_env_bool("REAL_BALANCE_VERIFY_BEFORE_SELL", True):
                t1 = time.perf_counter()
                try:
                    self.order_mgr.prefetch_broker_holdings()
                except Exception:
                    pass
                out["prefetch_ms"] = (time.perf_counter() - t1) * 1000.0
            t2 = time.perf_counter()
            for sig in sell_signals:
                self._submit_sell(sig)
            out["submit_sell_ms"] = (time.perf_counter() - t2) * 1000.0
        finally:
            out["lock_hold_ms"] = (time.perf_counter() - t_hold0) * 1000.0
            self._sell_lock.release()
        return out

    # ------------------------------------------------------------------
    # 스캔 루프 sleep (env 핫리로드 — 재시작 없이 반영)
    # ------------------------------------------------------------------
    # REST 유량과는 별개의 "스캔 회전율" 조절. WS 따라가기 속도를 결정한다.
    # 전략별 클래스 기본값(loop_min_sleep 등)을 폴백으로 두고, env 가 있으면 우선.


Grep

def _run_sell_section_locked|def _cb_prof_start|def _scan_sleep


`check_buy` 내부 단계 계측을 SCALP/MOM/BREAKOUT/SHORT에 넣고, env 키도 등록한 뒤 재시작해서 샘플을 모으겠습니다.