28분째 캔들 로드 중입니다. 프로세스 스택과 import 수정 여부를 확인합니다.

[REDACTED]

which py-spy 2>/dev/null; timeout 3 py-spy dump --pid 2725624 2>/dev/null | head -40 || (timeout 2 gdb -batch -ex "thread apply all bt 5" -p 2725624 2>/dev/null | head -30)

Sample Python stack of optuna process

cd /home/hoon/kis_bot && .venv/bin/python -c "
from kis_trader.backtest.trigger_snapshot_loader import backtest_needs_trigger_snapshot_load, backtest_use_kiwoom_body_snapshot
from kis_trader.utils.env import get_env_from_db
print('import OK', callable(backtest_needs_trigger_snapshot_load))
"

Verify trigger_snapshot_loader import fix


[REDACTED]

Shell

timeout 5 strace -p 2725624 -e trace=read,write,poll,recvfrom 2>&1 | head -25

Brief strace to see I/O activity

for i in 1 2 3 4 5; do sleep 60; n=$(wc -l < /home/hoon/kis_bot/logs/optuna_web_momentum_20260820_111536.log); echo "=== $i $(date +%H:%M:%S) lines=$n ==="; tail -8 /home/hoon/kis_bot/logs/optuna_web_momentum_20260820_111536.log; ps -p 2725624 -o etime,pcpu --no-headers 2>/dev/null || { echo "PROCESS DEAD"; break; }; done

Poll log every 60s for 5 minutes


프로세스가 종료됐습니다. 로그 전체와 job 상태를 확인합니다.

[REDACTED]


NameError는 해결됐고 잡은 정상 종료됐습니다. 남은 5 trial(pruned로 미완)을 이어서 돌리겠습니다.

[REDACTED]


[REDACTED]


남은 5 trial을 이어서 실행하고, 완료 후 후처리까지 확인합니다.

[REDACTED]

cd /home/hoon/kis_bot && .venv/bin/python -u -c "
from kis_trader.backtest import optuna_web_jobs as owj
meta = owj.continue_optuna_job('opt_20260820_111536_mome')
print('NEW JOB:', meta.get('job_id'), 'pid=', meta.get('pid'), 'trials=', meta.get('trials'), 'study=', meta.get('study_name'))
print('log:', meta.get('log_path'))
"

Continue optuna job for remaining 5 trials


[REDACTED]

LOG=/home/hoon/kis_bot/logs/optuna_web_momentum_20260820_114603.log; PID=2755196; for i in $(seq 1 20); do sleep 30; echo "=== $(date +%H:%M:%S) check $i ==="; tail -3 "$LOG" 2>/dev/null; ps -p $PID -o etime,pcpu --no-headers 2>/dev/null || { echo "DEAD"; tail -20 "$LOG"; break; }; lines=$(wc -l < "$LOG"); if rg -q "Trial [0-9]+ finished|후처리|error|Traceback|NameError" "$LOG" 2>/dev/null; then tail -15 "$LOG"; if rg -q "Traceback|NameError" "$LOG" 2>/dev/null; then break; fi; fi; if [ "$lines" -gt 40 ]; then tail -8 "$LOG"; break; fi; done

Monitor continue job for 10 min max


[REDACTED]

tail -20 /home/hoon/kis_bot/logs/optuna_web_momentum_20260820_114603.log; ps -p 2755196 -o etime,pcpu --no-headers 2>/dev/null || echo "process dead"; date +%H:%M:%S

Check continue job current status


[REDACTED]

LOG=/home/hoon/kis_bot/logs/optuna_web_momentum_20260820_114603.log; PID=2755196; while ps -p $PID >/dev/null 2>&1; do sleep 60; n=$(wc -l < "$LOG"); echo "$(date +%H:%M:%S) lines=$n"; tail -2 "$LOG"; if rg -q "Traceback|NameError" "$LOG" 2>/dev/null; then echo "ERROR"; tail -25 "$LOG"; exit 1; fi; if rg -q "후처리|CLI 종료|Trial.*finished" "$LOG" 2>/dev/null && [ "$n" -gt 35 ]; then echo "LIKELY DONE"; tail -20 "$LOG"; exit 0; fi; done; echo "PROCESS ENDED"; tail -25 "$LOG"

Poll continue job until done or error (up to 20min)


[REDACTED]

tail -5 /home/hoon/kis_bot/logs/optuna_web_momentum_20260820_114603.log; ps -p 2755196 -o etime,pcpu --no-headers 2>/dev/null || echo dead

Quick status of continue job


**`opt_20260820_111536_mome` — 고쳐졌고, 정상 종료됐습니다.**

터미널에 채팅 내용을 붙여넣으면 `command not found`가 납니다. 로그만 보려면 아래처럼 쓰세요.

```bash
tail -f /home/hoon/kis_bot/logs/optuna_web_momentum_20260820_111536.log
```

---

## 1차 잡 결과 (`opt_20260820_111536_mome`)

| 항목 | 내용 |
|------|------|
| status | **done** (exit 0) |
| 소요 | 11:15 → 11:45 (약 30분) |
| 크래시 | **없음** (이전 `get_env_from_db` NameError 해결됨) |
| trial | 10회 시도 → **완료 5 / pruned 5** |
| pruned 이유 | `tp_max < tp` (TPE 샘플링 제약, 정상) |
| best | Trial 8 — PnL **-69,623원**, WR 30.77%, PF 0.59, 52건 |
| 후처리 | **스킵** (완료 trial 5/10 — pruned는 카운트 안 됨) |

로그 흐름: 캔들 571종 → REST 웜업 5종 → 틱 86만건 → trial 10회 → JSON 저장.

결과 JSON: `kis_trader/backtest/results/optuna_momentum_tpe_20260820_114511.json`

---

## 2차 잡 — 남은 5회 이어 돌리기 (방금 시작)

| 항목 | 내용 |
|------|------|
| job_id | **`opt_20260820_114603_mome`** |
| study | 동일 (`momentum_tpe_20260817_20260820_20260820_111536`) |
| trials | **5회** 추가 |
| 현재 | **캔들 로드 중** (~9분 경과, CPU ~63%) |
| 로그 | `logs/optuna_web_momentum_20260820_114603.log` |

1차와 같이 캔들 로드에 **약 25~30분** 걸린 뒤 trial이 시작됩니다.

```bash
tail -f /home/hoon/kis_bot/logs/optuna_web_momentum_20260820_114603.log
```

---

## 웹에서 확인

- 1차 잡: Top5·best 결과 이미 있음 (완료 5건 기준)
- 2차 끝나면 study **완료 10/10** → 후처리 자동 진행
- pruned가 또 많으면 웹 **「이어 돌리기」** 또는 **「확정」**(현재 완료 수로 목표 맞춤 + 후처리)

---

**요약:** 크래시 버그는 해결됐고 1차 잡은 정상 완료. 남은 5 trial은 `opt_20260820_114603_mome`이 백그라운드에서 돌아가는 중입니다.

[REDACTED]


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


[REDACTED]

tail -15 /home/hoon/kis_bot/logs/optuna_web_momentum_20260820_114603.log 2>/dev/null; ps aux | rg '114603|2755196|param_search_optuna.*momentum' | rg -v rg | head -3; test -f /home/hoon/kis_bot/logs/optuna_web_jobs/opt_20260820_114603_mome.json && python3 -c "import json; m=json.load(open('/home/hoon/kis_bot/logs/optuna_web_jobs/opt_20260820_114603_mome.json')); print('status=',m.get('status'),'trials_done=',m.get('progress',{}).get('trials_done'),'n_complete=',m.get('n_complete'))" 2>/dev/null

Check continue job final status


**모니터링 결과 (10분)**

`opt_20260820_114603_mome`(남은 5 trial)은 **정상 실행 중**입니다. 10분 동안 로그는 `📂 캔들 로드 중`에 머물렀고, CPU **~62%**로 실제 작업 중입니다. 1차 잡과 같은 패턴으로 **약 20~30분 후** trial이 시작됩니다.

**1차 잡 `opt_20260820_111536_mome`** — **done**, NameError 없이 완료 (완료 trial 5 / pruned 5).

진행 확인:
```bash
tail -f /home/hoon/kis_bot/logs/optuna_web_momentum_20260820_114603.log
```

[REDACTED]


<timestamp>Thursday, Aug 20, 2026, 11:56 AM (UTC+9)</timestamp>
<user_query>
캔들로드가 이렇게 느린 이유를 찾아줘 쿼리로 해결하거나 쪼개서 for문으로 쿼리를 날리거나 배치로 할 수 있는지 뭐 디비가 이전 데이터랑 안맞는다던지 근본원인을 명명백백히 찾아봐
</user_query>


캔들 로드 경로와 DB 쿼리 패턴을 코드·실DB 기준으로 추적하겠습니다.

[REDACTED]


실DB에서 쿼리 수·인덱스·소요 시간을 측정합니다.

[REDACTED]

cd /home/hoon/kis_bot && .venv/bin/python3 -u <<'PY'
import time
from database import TradeDB
from kis_trader.backtest.bt_candle_source import (
    list_ws_candle_codes, fetch_ws_candles_for_code, resolve_bt_read_pairs,
)
from kis_trader.engine.feed_fallback import candle_garbage_fallback_enabled

start_key = '202608170000'
end_key = '202608202359'
db = TradeDB()

print('=== read pairs ===', resolve_bt_read_pairs())
print('=== candle_garbage_fallback ===', candle_garbage_fallback_enabled())

# indexes
idx = db.conn.execute("SHOW INDEX FROM ws_candles").fetchall()
print('=== ws_candles indexes ===')
for r in idx:
    print(r['Key_name'], r['Column_name'], r['Seq_in_index'])

t0=time.perf_counter()
codes = list_ws_candle_codes(db, 1, start_key, end_key, market='KR')
t1=time.perf_counter()
print(f'list_ws_candle_codes: {len(codes)} codes in {t1-t0:.2f}s')

# sample 10 codes timing
sample = codes[:10]
per_code=[]
for c in sample:
    s=time.perf_counter()
    rows = fetch_ws_candles_for_code(db, c, 1, start_key, end_key, market='KR', confirmed_only=True)
    e=time.perf_counter()
    per_code.append((e-s, len(rows)))
avg = sum(x[0] for x in per_code)/len(per_code)
print(f'fetch_ws_candles_for_code sample10 avg={avg:.3f}s min={min(x[0] for x in per_code):.3f}s max={max(x[0] for x in per_code):.3f}s rows~{sum(x[1] for x in per_code)/len(per_code):.0f}')

# EXPLAIN one code query
pairs = resolve_bt_read_pairs()
parts=[]; params=[]
for src,ch in pairs:
    parts.append('(source=%s AND channel=%s)')
    params.extend([src,ch])
src_sql=' AND ('+' OR '.join(parts)+')'
plan = db.conn.execute(
    'EXPLAIN SELECT candle_time, open, high, low, close, volume, source, channel FROM ws_candles '
    'WHERE timeframe=1 AND code=%s AND market=%s AND candle_time >= %s AND candle_time <= %s '
    'AND is_confirmed=1'+src_sql+' ORDER BY candle_time ASC',
    [1, sample[0], 'KR', start_key, end_key, *params]
).fetchall()
print('=== EXPLAIN fetch one code ===')
for r in plan:
    print(dict(r))

# row counts in period
cnt = db.conn.execute(
    'SELECT COUNT(*) n FROM ws_candles WHERE timeframe=1 AND market=%s AND candle_time >= %s AND candle_time <= %s',
    ('KR', start_key, end_key)
).fetchone()['n']
print(f'ws_candles rows in period (all sources): {cnt:,}')

# sources breakdown
rows = db.conn.execute(
    'SELECT source, channel, COUNT(*) c FROM ws_candles WHERE timeframe=1 AND market=%s AND candle_time >= %s AND candle_time <= %s GROUP BY source, channel ORDER BY c DESC',
    ('KR', start_key, end_key)
).fetchall()
print('source/channel breakdown:', {f"{r['source']}/{r['channel']}": int(r['c']) for r in rows})

# ticks per code cost check
if candle_garbage_fallback_enabled():
    s=time.perf_counter()
    ticks = db.conn.execute(
        'SELECT tick_time, source, tick_time_raw FROM ws_ticks WHERE code=%s AND tick_time >= %s AND tick_time <= %s',
        (sample[0], start_key, end_key)
    ).fetchall()
    print(f'ws_ticks per code query: {len(ticks)} rows in {time.perf_counter()-s:.3f}s')

db.close()
print(f'estimated full loop {len(codes)} codes x {avg:.3f}s = {len(codes)*avg:.0f}s ({len(codes)*avg/60:.1f} min)')
PY

Benchmark candle load queries and EXPLAIN


[REDACTED]

cd /home/hoon/kis_bot && .venv/bin/python3 -u <<'PY'
import time
from database import TradeDB
from kis_trader.backtest.bt_candle_source import list_ws_candle_codes, fetch_ws_candles_for_code, resolve_bt_read_pairs

start_key='202608170000'; end_key='202608202359'
db=TradeDB()
codes=list_ws_candle_codes(db,1,start_key,end_key,market='KR')
print('codes',len(codes))

# ticks-only per code (what _ticks_for_bar_garbage does)
sample=codes[:20]
tick_times=[]
for c in sample:
    s=time.perf_counter()
    rows=db.conn.execute(
        'SELECT tick_time, source, tick_time_raw FROM ws_ticks WHERE code=%s AND tick_time >= %s AND tick_time <= %s',
        (c,start_key,end_key)
    ).fetchall()
    tick_times.append((time.perf_counter()-s,len(rows)))
print('ws_ticks per-code sample20: avg=%.3fs min=%.3fs max=%.3fs rows_avg=%.0f' % (
    sum(t[0] for t in tick_times)/len(tick_times),
    min(t[0] for t in tick_times), max(t[0] for t in tick_times),
    sum(t[1] for t in tick_times)/len(tick_times)))

# bulk candles single query
pairs=resolve_bt_read_pairs(); parts=[]; params=[]
for src,ch in pairs:
    parts.append('(source=%s AND channel=%s)'); params.extend([src,ch])
src_sql=' AND ('+' OR '.join(parts)+')'
s=time.perf_counter()
bulk=db.conn.execute(
    'SELECT code,candle_time,open,high,low,close,volume,source,channel FROM ws_candles '
    'WHERE timeframe=1 AND market=%s AND candle_time >= %s AND candle_time <= %s AND is_confirmed=1'+src_sql+
    ' ORDER BY code, candle_time ASC',
    ['KR',start_key,end_key,*params]
).fetchall()
print('bulk ws_candles all codes: rows=%d time=%.2fs' % (len(bulk), time.perf_counter()-s))

# bulk ticks for all codes in period
s=time.perf_counter()
bulk_ticks=db.conn.execute(
    'SELECT code,tick_time,source FROM ws_ticks WHERE tick_time >= %s AND tick_time <= %s',
    (start_key,end_key)
).fetchall()
print('bulk ws_ticks all codes: rows=%d time=%.2fs' % (len(bulk_ticks), time.perf_counter()-s))

# warmup query per code
warm_times=[]
for c in sample[:10]:
    first=db.conn.execute(
        'SELECT MIN(candle_time) mn FROM ws_candles WHERE timeframe=1 AND code=%s AND market=%s AND candle_time >= %s',
        (c,'KR',start_key)
    ).fetchone()['mn']
    s=time.perf_counter()
    warm=db.conn.execute(
        'SELECT candle_time,open,high,low,close,volume,source,channel FROM ws_candles '
        'WHERE timeframe=1 AND code=%s AND candle_time < %s AND is_confirmed=1'+src_sql+
        ' ORDER BY candle_time DESC LIMIT 200',
        [c,first,*params]
    ).fetchall()
    warm_times.append((time.perf_counter()-s,len(warm)))
print('warmup-before sample10 avg=%.3fs rows=%.0f' % (sum(x[0] for x in warm_times)/len(warm_times), sum(x[1] for x in warm_times)/len(warm_times)))

# table stats
for tbl in ['ws_candles','ws_ticks']:
    st=db.conn.execute(
        'SELECT table_rows, data_length, index_length FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name=%s',
        (tbl,)
    ).fetchone()
    print(tbl,'approx_rows',st['table_rows'],'data_MB',round(st['data_length']/1024/1024,1))

# duplicate source rows per candle_time (schema mismatch indicator)
dup=db.conn.execute('''
SELECT COUNT(*) n FROM (
  SELECT code,candle_time,COUNT(*) c FROM ws_candles
  WHERE timeframe=1 AND market='KR' AND candle_time >= %s AND candle_time <= %s
  GROUP BY code,candle_time HAVING c > 3
) t''', (start_key,end_key)).fetchone()['n']
print('code+time with >3 source rows:', dup)

n=len(codes)
candle_loop=n*0.507
tick_loop=n*(sum(t[0] for t in tick_times)/len(tick_times))
warm_loop=n*(sum(x[0] for x in warm_times)/len(warm_times))
print('=== ESTIMATED TOTAL ===')
print(' per-code candle fetch: %.0fs (%.1f min)' % (candle_loop, candle_loop/60))
print(' per-code ws_ticks garbage: %.0fs (%.1f min)' % (tick_loop, tick_loop/60))
print(' per-code warmup: %.0fs (%.1f min)' % (warm_loop, warm_loop/60))
print(' sum: %.0fs (%.1f min)' % (candle_loop+tick_loop+warm_loop,(candle_loop+tick_loop+warm_loop)/60))
db.close()
PY

Benchmark ticks per-code vs bulk and estimate total time


[REDACTED]

cd /home/hoon/kis_bot && .venv/bin/python3 -u <<'PY'
import time
from kis_trader.backtest.param_search_momentum import _load_candles_for_search
from kis_trader.engine.indicator_cache import materialize_db_on_load

print('materialize_db_on_load=', materialize_db_on_load())

# full load timing
t0=time.perf_counter()
cc=_load_candles_for_search('2026-08-17','2026-08-20', rsi_period=3, market='KR')
t1=time.perf_counter()
print('FULL _load_candles_for_search:', len(cc), 'codes', f'{t1-t0:.1f}s', f'({(t1-t0)/60:.1f} min)')
PY

Time full _load_candles_for_search end-to-end


[REDACTED]

tail -5 /home/hoon/.cursor/projects/home-hoon-kis-bot/terminals/20056.txt; ps -p 2766992 -o etime,pcpu --no-headers 2>/dev/null || echo done

Check if full load benchmark finished


[REDACTED]

while ps -p 2766992 >/dev/null 2>&1; do sleep 15; done; tail -10 /home/hoon/.cursor/projects/home-hoon-kis-bot/terminals/20056.txt

Wait for full load benchmark to finish

cd /home/hoon/kis_bot && .venv/bin/python3 -u <<'PY'
import os, time
from database import TradeDB
from kis_trader.backtest.bt_candle_source import list_ws_candle_codes, fetch_ws_candles_for_code

start_key='202608170000'; end_key='202608202359'
db=TradeDB(); codes=list_ws_candle_codes(db,1,start_key,end_key,market='KR')[:30]

# A: with garbage fallback (default)
t0=time.perf_counter()
for c in codes:
    fetch_ws_candles_for_code(db,c,1,start_key,end_key,market='KR',confirmed_only=True)
print('30 codes WITH garbage fallback:', time.perf_counter()-t0,'s')

# B: disable garbage - monkeypatch by setting env
os.environ['CANDLE_GARBAGE_FALLBACK']='false'
from kis_trader.utils.env import invalidate_merged_env_cache
try: invalidate_merged_env_cache()
except: pass
from importlib import reload
import kis_trader.engine.feed_fallback as ff
reload(ff)
import kis_trader.backtest.bt_candle_source as bcs
reload(bcs)

t0=time.perf_counter()
for c in codes:
    bcs.fetch_ws_candles_for_code(db,c,1,start_key,end_key,market='KR',confirmed_only=True)
print('30 codes WITHOUT garbage fallback:', time.perf_counter()-t0,'s')
db.close()
PY

Compare fetch with vs without tick garbage fallback


[REDACTED]

cd /home/hoon/kis_bot && .venv/bin/python3 -u <<'PY'
import time
from database import TradeDB
from kis_trader.backtest.bt_candle_source import list_ws_candle_codes, fetch_ws_candles_for_code
from kis_trader.engine.indicator_cache import materialize_ws_candles_batch

start_key='202608170000'; end_key='202608202359'
db=TradeDB()
codes=list_ws_candle_codes(db,1,start_key,end_key,market='KR')
print('codes',len(codes))

# load 100 codes into memory only (no materialize yet)
cc={}
t0=time.perf_counter()
for c in codes[:100]:
    rows=fetch_ws_candles_for_code(db,c,1,start_key,end_key,market='KR',confirmed_only=True)
    if len(rows)>=8: cc[c]=rows
print('fetch 100 codes:', time.perf_counter()-t0,'s rows',sum(len(v) for v in cc.values()))

t0=time.perf_counter()
n=materialize_ws_candles_batch(db, cc, 1)
print('materialize 100 codes:', time.perf_counter()-t0,'s updated_rows',n)
db.close()
PY

Time materialize_ws_candles_batch for 100 codes


[REDACTED]

cd /home/hoon/kis_bot && .venv/bin/python3 <<'PY'
from database import TradeDB
db=TradeDB()
for q, params in [
    ('ws_candles per code', 'EXPLAIN SELECT candle_time FROM ws_candles WHERE timeframe=1 AND code=%s AND market=%s AND candle_time >= %s AND candle_time <= %s AND is_confirmed=1', ['005930','KR','202608170000','202608202359']),
    ('ws_ticks per code (current)', 'EXPLAIN SELECT tick_time FROM ws_ticks WHERE code=%s AND tick_time >= %s AND tick_time <= %s', ['005930','202608170000','202608202359']),
    ('ws_ticks with market', 'EXPLAIN SELECT tick_time FROM ws_ticks WHERE market=%s AND code=%s AND tick_time >= %s AND tick_time <= %s', ['KR','005930','202608170000','202608202359']),
]:
    print('===', q, '===')
    for r in db.conn.execute(params[0], params[1]).fetchall():
        print(r.get('select_type'), r.get('type'), r.get('key'), r.get('rows'), r.get('Extra'))
db.close()
PY

EXPLAIN per-code ws_candles and ws_ticks queries

ps aux | rg 'param_search_optuna|2766992' | rg -v rg | head -5

Check competing optuna/benchmark processes


[REDACTED]

Find python child of benchmark shell

cd /home/hoon/kis_bot && .venv/bin/python3 <<'PY'
from database import TradeDB
db=TradeDB()
plans=[
    ('ws_candles','EXPLAIN SELECT candle_time FROM ws_candles WHERE timeframe=1 AND code=%s AND market=%s AND candle_time >= %s AND candle_time <= %s AND is_confirmed=1',['005930','KR','202608170000','202608202359']),
    ('ws_ticks no market','EXPLAIN SELECT tick_time FROM ws_ticks WHERE code=%s AND tick_time >= %s AND tick_time <= %s',['005930','202608170000','202608202359']),
    ('ws_ticks with market','EXPLAIN SELECT tick_time FROM ws_ticks WHERE market=%s AND code=%s AND tick_time >= %s AND tick_time <= %s',['KR','005930','202608170000','202608202359']),
]
for label, sql, params in plans:
    print('===',label,'===')
    for r in db.conn.execute(sql, params).fetchall():
        print('type=',r.get('type'),'key=',r.get('key'),'rows=',r.get('rows'),'extra=',r.get('Extra'))
db.close()
PY

EXPLAIN index usage for per-code queries


[REDACTED]

cd /home/hoon/kis_bot && .venv/bin/python3 <<'PY'
import time
from database import TradeDB
db=TradeDB()
code='005930'; sk='202608170000'; ek='202608202359'
for label, sql, params in [
    ('no market', 'SELECT COUNT(*) n FROM ws_ticks WHERE code=%s AND tick_time >= %s AND tick_time <= %s', (code,sk,ek)),
    ('with market', 'SELECT COUNT(*) n FROM ws_ticks WHERE market=%s AND code=%s AND tick_time >= %s AND tick_time <= %s', ('KR',code,sk,ek)),
]:
    t=time.perf_counter()
    n=db.conn.execute(sql, params).fetchone()['n']
    print(label, 'count=',n,'time=',round(time.perf_counter()-t,3),'s')
db.close()
PY

Compare ws_ticks query speed with/without market filter


[REDACTED]

cd /home/hoon/kis_bot && .venv/bin/python3 <<'PY'
import time
from database import TradeDB
db=TradeDB()
# find code with ticks in period
r=db.conn.execute(
    "SELECT code, COUNT(*) n FROM ws_ticks WHERE market='KR' AND tick_time >= %s AND tick_time <= %s GROUP BY code ORDER BY n DESC LIMIT 1",
    ('202608190900','202608191530')
).fetchone()
code=r['code']; print('sample code',code,'ticks',r['n'])
sk='202608170000'; ek='202608202359'
for label, sql, params in [
    ('no market', 'SELECT tick_time, source FROM ws_ticks WHERE code=%s AND tick_time >= %s AND tick_time <= %s', (code,sk,ek)),
    ('with market', 'SELECT tick_time, source FROM ws_ticks WHERE market=%s AND code=%s AND tick_time >= %s AND tick_time <= %s', ('KR',code,sk,ek)),
]:
    t=time.perf_counter()
    rows=db.conn.execute(sql, params).fetchall()
    print(label, 'rows=',len(rows),'time=',round(time.perf_counter()-t,3),'s')
db.close()
PY

Compare tick query speed for code with many ticks


[REDACTED]

cd /home/hoon/kis_bot && .venv/bin/python3 <<'PY'
from database import TradeDB
db=TradeDB()
r=db.conn.execute('''
SELECT AVG(n) avg_n, MAX(n) max_n, MIN(n) min_n, COUNT(*) codes
FROM (
  SELECT code, COUNT(*) n FROM ws_ticks
  WHERE market='KR' AND tick_time >= %s AND tick_time <= %s
  GROUP BY code
) t''', ('202608170000','202608202359')).fetchone()
print('tick rows per code stats:', dict(r))
r2=db.conn.execute('''
SELECT AVG(n) avg_n FROM (
  SELECT code, COUNT(*) n FROM ws_candles
  WHERE timeframe=1 AND market='KR' AND candle_time >= %s AND candle_time <= %s AND is_confirmed=1
  GROUP BY code
) t''', ('202608170000','202608202359')).fetchone()
print('candle rows per code avg:', r2['avg_n'])
db.close()
PY

Average tick/candle rows per code in period


## 결론: DB 스키마 불일치가 아니라 **종목별 N+1 쿼리 + 틱 조회 설계** 문제

실측 기간 `2026-08-17 ~ 2026-08-20`, 종목 **593개** 기준입니다.

---

### 1. 근본 원인 — 종목 for문마다 SQL 2번 (N+1)

```1837:1845:kis_trader/backtest/param_search_momentum.py
        for code in codes:
            rows = fetch_ws_candles_for_code(
                db, code, 1, start_key, end_key,
                market=mk if mk in ("US", "KR") else None,
                confirmed_only=True,
            )
            if len(rows) < rsi_period + 5:
                continue
            codes_candles[code] = rows
```

| 단계 | 쿼리 수 | 실측 시간 |
|------|---------|-----------|
| `list_ws_candle_codes` (1회) | 1 | **0.77s** |
| `fetch_ws_candles_for_code` × 593 | 593 | **~0.5~1.3s/종목** |
| `_ticks_for_bar_garbage` × 593 (`CANDLE_GARBAGE_FALLBACK=true`) | 593 | **~0.5s/종목** |
| **합계** | **~1,187회** | **약 10~25분** |

원격 DB `192.168.0.141` 왕복 × 1,187회 → 로그가 `📂 캔들 로드 중`에서 오래 멈춰 보이는 이유입니다. CPU 60~70%는 Python dedupe + DB I/O 대기입니다.

**배치로 하면?** 같은 데이터를:

| 방식 | 시간 |
|------|------|
| 종목별 593회 | **~300~770s (5~13분)** |
| **전 종목 bulk 1회** (ws_candles) | **3.17s** |
| **전 종목 bulk 1회** (ws_ticks) | **3.50s** |
| **합계** | **약 7초** |

→ **100배 이상** 줄일 수 있습니다. 인덱스/데이터 문제가 아니라 **로더가 bulk를 안 씀**.

---

### 2. 부가 원인 — `ws_ticks` 쿼리에 `market` 누락

```90:94:kis_trader/backtest/bt_candle_source.py
        rows = db.conn.execute(
            "SELECT tick_time, source, tick_time_raw FROM ws_ticks "
            "WHERE code=%s AND tick_time >= %s AND tick_time <= %s",
            (str(code).strip(), sk, ek),
        ).fetchall()
```

인덱스: `idx_ws_ticks_lookup (market, code, tick_time)`

| 쿼리 | EXPLAIN rows | 실측 |
|------|-------------|------|
| `market` **없음** | **1,554,286** (풀스캔급) | 0건이어도 **0.51s** |
| `market='KR'` **있음** | **1** | **0.005s** |

틱 없는 종목에서 특히 느립니다. 스키마 불일치가 아니라 **WHERE 절이 인덱스 설계와 안 맞음**.

---

### 3. `CANDLE_GARBAGE_FALLBACK=true` (기본 ON)

Optuna prepare에서도 종목마다 `ws_ticks` 전체 기간 조회 → `dedupe_by_read_pairs`에서 WS 봉 쓰레기 판정.

- 30종목 비교: fallback ON **29s** vs OFF **26s** (~10% 차이)
- 593종목 × 틱 평균 **1,889행/종목** → 네트워크·파싱 부담 큼
- **백테 bulk load에는 불필요**할 수 있음 (실매용 garbage 판정)

---

### 4. 멀티소스 OR — 중복 row 전송

read pairs 5개: `kis/ws → kiwoom/ws → ls/ws → kiwoom/rest → kiwoom/rollup`

- 기간 ws_candles raw: **365,335행** (소스 중복 포함)
- dedupe 후 종목당 ~627행
- 종목별 쿼리마다 5소스 row를 가져와 Python에서 dedupe → **DB·CPU 모두 낭비**

스키마 문제 아님 (`code+time`에 source 3개 초과: **0건**).

---

### 5. NOT 원인 (제외됨)

| 항목 | 결과 |
|------|------|
| 인덱스 없음 | `idx_ws_candles_market (market,code,tf,time)` **사용 중** |
| 데이터 불일치 | source/channel 정규화 정상, 중복 이상 없음 |
| `materialize_db_on_load` | ON이지만 100종목 test에서 UPDATE **0건**, 병목 아님 |
| warmup | 종목당 **0.002s**, 무시 가능 |

---

### 6. Optuna가 ~29분 걸린 이유 (실측 타임라인)

```
11:15:37  📂 캔들 로드 시작
11:44:13  ✅ 데이터 로드 571종  ← 약 28분 36초
```

= **593 × (캔들쿼리 + 틱쿼리 + dedupe)** + 당시 **실매봇/다른 Optuna와 DB 경합**.

---

## 해결 방향 (우선순위)

1. **즉시 (작은 패치)**
   `_ticks_for_bar_garbage`에 `market=%s` 추가 → 틱 없는 종목 100배 빨라짐

2. **근본 (권장)**
   `load_momentum_candles_bulk()` 같은 공통 함수:
   - ws_candles **1~N회** (code IN 청크 또는 전체)
   - ws_ticks **1회** bulk
   - 메모리에서 code별 groupby + dedupe
   → **~29분 → ~10~30초** 예상

3. **Optuna 전용**
   prepare 단계에서 `CANDLE_GARBAGE_FALLBACK=false` 또는 bulk 틱 1회만 로드

4. **이어 돌리기**
   같은 study 재실행 시 캔들 캐시(process/RAM) → 2차부터 prepare 스킵

---

**한 줄 요약:** DB가 느린 게 아니라 **593종목 × 2쿼리 = 1,187번 순차 왕복**이 병목이고, 틱 쿼리는 **`market` 누락으로 인덱스를 못 탐**. bulk 2쿼리(~7초)로 바꾸면 해결됩니다.

코드 수정까지 진행할까요? (1번 market 패치만 / 2번 bulk 로더 전체 중 선택)

[REDACTED]


[REDACTED]

tail -8 /home/hoon/kis_bot/logs/optuna_web_momentum_20260820_114603.log 2>/dev/null; test -f /home/hoon/kis_bot/logs/optuna_web_jobs/opt_20260820_114603_mome.json && python3 -c "import json; m=json.load(open('/home/hoon/kis_bot/logs/optuna_web_jobs/opt_20260820_114603_mome.json')); print(m.get('status'), m.get('n_complete'), m.get('leftover_trials'))" 2>/dev/null

Check continue optuna job status


**이어 돌리기 잡 (`opt_20260820_114603_mome`) — 완료**

- 캔들 로드 ~30분 → trial 10~14(추가 5회) 완료
- study **완료 10/10**, 후처리·briefing 저장됨
- best: **Trial 11** (score **-0.717**, 1차 Trial 8 -0.733보다 개선)
- 결과 JSON: `kis_trader/backtest/results/optuna_momentum_tpe_20260820_121708.json`

**garbage fallback 비교 (30종목)**
- ON 29.2s / OFF 26.2s → 약 **10%** 차이. 병목의 주원인은 아님.

**캔들 로드 벤치마크**는 아직 돌아가는 중(26분+) — N+1 쿼리 병목 가설과 일치합니다.

[REDACTED]


**캔들 로드 벤치마크 결과**

`_load_candles_for_search(2026-08-17~20, KR)` 실측:

| 항목 | 값 |
|------|-----|
| 종목 | **574개** |
| 소요 | **1,833s ≈ 30.6분** |
| 종목당 | **~3.2s** |
| `materialize_db_on_load` | **True** |

Optuna 로그(캔들 로드 ~28분)와 거의 같습니다. 원인은 앞서 정리한 **593회×2 SQL N+1** 패턴으로 보면 됩니다. bulk 2쿼리(~7초)로 바꾸면 같은 작업을 크게 줄일 수 있습니다.

[REDACTED]


**벤치마크 완료 확인**

모니터링 스크립트가 `_load_candles_for_search` 종료를 확인했습니다.

- **574종목 · 1,833초(30.6분)** — Optuna 실측(~28분)과 일치
- exit code **0** 정상 종료

앞서 분석한 **종목별 N+1 쿼리** 병목 가설이 수치로 확인됐습니다.

[REDACTED]


<timestamp>Thursday, Aug 20, 2026, 1:12 PM (UTC+9)</timestamp>
<user_query>
반만 이해했는데 이렇게 해서 근본원인이 사라지고 위험성과 정합성에 문제 없는지 확인해서 초딩수준으로 말해줘 그리고 지금 쓰레기데이터가 전체의 몇퍼센트인지 오늘거만
</user_query>


오늘 봉만 쓰레기 비율을 재고, bulk 로드가 실매·백테 정합을 깨는지까지 코드와 DB로 확인하겠습니다.


cd /home/hoon/kis_bot && .venv/bin/python3 -u <<'PY'
"""오늘(20260820) 1분봉: 메인 WS가 쓰레기인 비율 + 그 때문에 pick이 바뀌는 비율."""
from collections import defaultdict
from database import TradeDB
from kis_trader.ws.candle_series import live_read_pairs, CHANNEL_WS, normalize_source_channel
from kis_trader.engine.feed_fallback import bar_is_garbage, live_feed_fallback_max_age_sec

DAY = "20260820"
sk, ek = DAY + "0000", DAY + "2359"
pairs = live_read_pairs()
main = pairs[0]  # (source, channel)
print("read_pairs", pairs)
print("main", main, "fallback_age", live_feed_fallback_max_age_sec())

db = TradeDB()
candles = db.conn.execute(
    "SELECT code, candle_time, source, channel, is_confirmed "
    "FROM ws_candles WHERE timeframe=1 AND market=%s "
    "AND candle_time >= %s AND candle_time <= %s",
    ("KR", sk, ek),
).fetchall()
print("ws_candles rows", len(candles))

ticks = db.conn.execute(
    "SELECT code, tick_time, tick_time_raw, source FROM ws_ticks "
    "WHERE market=%s AND tick_time >= %s AND tick_time <= %s",
    ("KR", sk, ek + "59" if len(ek) == 12 else ek),
).fetchall()
print("ws_ticks rows", len(ticks))

# ticks by (code, source) -> list of dicts (needed for bar_is_garbage)
# also by (code, source, minute) count for 0-tick garbage
tick_by_code_src = defaultdict(list)
for t in ticks:
    code = str(t["code"]).strip()
    src = str(t.get("source") or "").strip().lower()
    tick_by_code_src[(code, src)].append({
        "tick_time": t.get("tick_time"),
        "tick_time_raw": t.get("tick_time_raw") or t.get("tick_time"),
        "source": src,
    })

# group candles: code+ct -> {pair: row}
by = defaultdict(dict)
src_ch_counts = defaultdict(int)
confirmed = 0
for r in candles:
    src, ch = normalize_source_channel(str(r.get("source") or ""), str(r.get("channel") or ""))
    src_ch_counts[(src, ch)] += 1
    if int(r.get("is_confirmed") or 0) != 1:
        continue
    confirmed += 1
    ct = str(r["candle_time"])[:12]
    code = str(r["code"]).strip()
    pair = (src, ch)
    if pair not in by[(code, ct)]:
        by[(code, ct)][pair] = r

print("confirmed candle rows", confirmed)
print("unique code+minute slots", len(by))
print("source/channel rows", dict(src_ch_counts))

# session minutes 0900-1530 if present
def in_sess(ct):
    hm = ct[8:12]
    return "0900" <= hm <= "1530"

slots = list(by.keys())
sess_slots = [k for k in slots if in_sess(k[1])]

def pick(slot_pairs, ticks_list, use_garbage):
    for pair in pairs:
        bar = slot_pairs.get(pair)
        if bar is None:
            continue
        src, ch = pair
        if use_garbage and ticks_list is not None and ch == CHANNEL_WS:
            if bar_is_garbage(ticks_list, candle_time=slot_pairs and list(slot_pairs.values())[0]["candle_time"][:12] if False else None,
                              tf_min=1, source=src, missing_policy="hole"):
                continue
        return pair
    return None

def pick2(code, ct, slot_pairs, use_garbage):
    for pair in pairs:
        bar = slot_pairs.get(pair)
        if bar is None:
            continue
        src, ch = pair
        if use_garbage and ch == CHANNEL_WS:
            tlist = tick_by_code_src.get((code, src), [])
            if bar_is_garbage(tlist, candle_time=ct, tf_min=1, source=src, missing_policy="hole"):
                continue
        return pair
    return None

def stats(keys):
    n = len(keys)
    main_present = 0
    main_garbage = 0
    pick_no = 0
    pick_yes = 0
    pick_changed = 0
    pick_none_yes = 0
    next_src_used = 0
    main_zero_tick = 0
    main_stale_only = 0
    for code, ct in keys:
        slot = by[(code, ct)]
        if main in slot:
            main_present += 1
            tlist = tick_by_code_src.get((code, main[0]), [])
            bad = bar_is_garbage(tlist, candle_time=ct, tf_min=1, source=main[0], missing_policy="hole")
            if bad:
                main_garbage += 1
                # 0-tick vs stale
                from kis_trader.engine.feed_fallback import tick_in_bar_bucket
                bucket = [t for t in tlist if tick_in_bar_bucket(str(t.get("tick_time_raw") or t.get("tick_time") or ""), ct, 1)]
                if not bucket:
                    main_zero_tick += 1
                else:
                    main_stale_only += 1
        a = pick2(code, ct, slot, False)
        b = pick2(code, ct, slot, True)
        if a:
            pick_no += 1
        if b:
            pick_yes += 1
        else:
            pick_none_yes += 1
        if a != b:
            pick_changed += 1
        if b and b != main and main in slot:
            next_src_used += 1
    def pct(x):
        return 0.0 if n == 0 else 100.0 * x / n
    return {
        "slots": n,
        "main_present": main_present,
        "main_present_pct": round(pct(main_present), 2),
        "main_garbage": main_garbage,
        "main_garbage_pct_of_slots": round(pct(main_garbage), 2),
        "main_garbage_pct_of_main_present": round(0 if main_present==0 else 100.0*main_garbage/main_present, 2),
        "zero_tick": main_zero_tick,
        "stale_tick": main_stale_only,
        "pick_changed": pick_changed,
        "pick_changed_pct": round(pct(pick_changed), 2),
        "fell_to_next_while_main_existed": next_src_used,
        "no_bar_after_garbage": pick_none_yes,
        "no_bar_after_garbage_pct": round(pct(pick_none_yes), 2),
    }

print("=== ALL TODAY ===")
print(stats(slots))
print("=== SESSION 0900-1530 ===")
print(stats(sess_slots))

# also: of unique minutes, how many have ANY source vs only garbage
db.close()
PY

Measure today's garbage candle percentage


kill 2839432 2>/dev/null; pkill -f 'unique code\+minute slots' 2>/dev/null; cd /home/hoon/kis_bot && .venv/bin/python3 -u <<'PY'
"""오늘 쓰레기 비율 — 분 단위 인덱스로 빠르게."""
from collections import defaultdict
from datetime import datetime, timedelta
from database import TradeDB
from kis_trader.ws.candle_series import live_read_pairs, CHANNEL_WS, normalize_source_channel
from kis_trader.engine.feed_fallback import live_feed_fallback_max_age_sec, packet_lag_seconds, is_feed_read_stale

DAY = "20260820"
sk, ek = DAY + "0900", DAY + "1312"  # 장중 지금까지 (쿼리 시각 13:12)
# 사용자: 오늘거만 — 장시간 전체 저장된 것도 같이 본다
sk2, ek2 = DAY + "0000", DAY + "2359"
pairs = live_read_pairs()
main_src, main_ch = pairs[0]
age = live_feed_fallback_max_age_sec()
print("main", pairs[0], "age", age)

db = TradeDB()
candles = db.conn.execute(
    "SELECT code, candle_time, source, channel FROM ws_candles "
    "WHERE timeframe=1 AND market=%s AND is_confirmed=1 "
    "AND candle_time >= %s AND candle_time <= %s",
    ("KR", sk2, ek2),
).fetchall()
ticks = db.conn.execute(
    "SELECT code, tick_time, tick_time_raw, source FROM ws_ticks "
    "WHERE market=%s AND tick_time >= %s AND tick_time <= %s",
    ("KR", sk2, ek2 + "59"),
).fetchall()
print("candles", len(candles), "ticks", len(ticks))

# minute key = tick_time[:12]
tick_min = defaultdict(int)  # (code, src, minute) -> count
tick_fresh = defaultdict(int)  # count of ticks with lag<=age vs bar_end = minute+1min

def bar_end(ct12):
    dt = datetime.strptime(ct12, "%Y%m%d%H%M")
    return dt + timedelta(minutes=1)

for t in ticks:
    code = str(t["code"]).strip()
    src = str(t.get("source") or "").strip().lower()
    raw = str(t.get("tick_time_raw") or t.get("tick_time") or "")
    tt = str(t.get("tick_time") or "")
    minute = (tt[:12] if len(tt) >= 12 else raw[:12])
    if len(minute) < 12:
        continue
    tick_min[(code, src, minute)] += 1
    try:
        be = bar_end(minute)
        lag = packet_lag_seconds(raw, now_dt=be)
        if not is_feed_read_stale(lag, age):
            tick_fresh[(code, src, minute)] += 1
    except Exception:
        tick_fresh[(code, src, minute)] += 1  # parse fail → keep (not stale)

by = defaultdict(dict)
src_cnt = defaultdict(int)
for r in candles:
    src, ch = normalize_source_channel(str(r.get("source") or ""), str(r.get("channel") or ""))
    src_cnt[(src, ch)] += 1
    ct = str(r["candle_time"])[:12]
    code = str(r["code"]).strip()
    by[(code, ct)][(src, ch)] = True

def in_sess(ct):
    return "0900" <= ct[8:12] <= "1530"

def classify_ws(code, ct, src):
    n = tick_min.get((code, src, ct), 0)
    if n == 0:
        return "zero"
    if tick_fresh.get((code, src, ct), 0) == 0:
        return "stale"
    return "ok"

def run(keys, label):
    n = len(keys)
    kis_ws = 0
    kis_zero = kis_stale = kis_ok = 0
    pick_same = pick_change_to_next = pick_drop = 0
    first_ws_garbage_then_rest = 0
    no_ws_at_all = 0
    has_rest = 0
    for code, ct in keys:
        slot = by[(code, ct)]
        if ("kiwoom", "rest") in slot:
            has_rest += 1
        # pick without garbage = first pair present
        pick_a = None
        for p in pairs:
            if p in slot:
                pick_a = p
                break
        # pick with garbage
        pick_b = None
        for p in pairs:
            if p not in slot:
                continue
            src, ch = p
            if ch == CHANNEL_WS:
                g = classify_ws(code, ct, src)
                if g != "ok":
                    continue
            pick_b = p
            break
        if ("kis", "ws") in slot:
            kis_ws += 1
            g = classify_ws(code, ct, "kis")
            if g == "ok":
                kis_ok += 1
            elif g == "zero":
                kis_zero += 1
            else:
                kis_stale += 1
        else:
            no_ws_at_all += 1
        if pick_a == pick_b:
            pick_same += 1
        elif pick_b is None:
            pick_drop += 1
        else:
            pick_change_to_next += 1
            if pick_a and pick_a[1] == CHANNEL_WS and pick_b == ("kiwoom", "rest"):
                first_ws_garbage_then_rest += 1
    def pct(x, d=n):
        return 0 if d == 0 else round(100.0 * x / d, 2)
    print(f"\n=== {label} ===")
    print("분봉칸(종목×분)", n)
    print("소스행", dict(src_cnt) if label.endswith("TODAY") else "")
    print("kis/ws 봉 있는 칸", kis_ws, f"{pct(kis_ws)}%")
    print("  그중 틱0건(쓰레기)", kis_zero, f"{pct(kis_zero, kis_ws)}% of kis/ws", f"{pct(kis_zero)}% of all")
    print("  그중 틱전부늙음(쓰레기)", kis_stale, f"{pct(kis_stale, kis_ws)}% of kis/ws")
    print("  그중 정상", kis_ok, f"{pct(kis_ok, kis_ws)}% of kis/ws")
    print("kis/ws 봉 자체 없음", no_ws_at_all, f"{pct(no_ws_at_all)}%")
    print("rest 구멍메움 있는 칸", has_rest, f"{pct(has_rest)}%")
    print("쓰레기검사로 pick 바뀜(다음소스)", pick_change_to_next, f"{pct(pick_change_to_next)}%")
    print("  그중 WS→REST", first_ws_garbage_then_rest)
    print("쓰레기검사로 봉 증발", pick_drop, f"{pct(pick_drop)}%")
    print("pick 동일(쓰레기검사 on/off)", pick_same, f"{pct(pick_same)}%")

run(list(by.keys()), "ALL TODAY")
sess = [k for k in by.keys() if in_sess(k[1])]
run(sess, "SESSION 0900-1530 TODAY")
print("\nsource/channel 행수", dict(src_cnt))
print("행 합", sum(src_cnt.values()), "칸", len(by))
db.close()
PY

Fast minute-indexed today's garbage stats


**느린 이유의 진짜 원인은 “쓰레기 봉”이 아닙니다.**
**종목을 하나씩 DB에 물어보는 방식**이 원인입니다.

---

## 초등 설명

책을 **한 권씩** 꺼내 오면 593번 왔다 갔다 해서 30분 걸립니다.
**한 번에 박스째** 가져오면 같은 책이 **7초**면 됩니다.

DB가 깨지거나, 옛날 데이터랑 안 맞는 게 아닙니다.
물어보는 횟수가 너무 많을 뿐입니다.

| 지금 | 고치면 |
|------|--------|
| 종목 1개 = 질문 2번 × 약 590종목 ≈ **1,200번** | 질문 **2번**(봉 전체 + 틱 전체) |
| 왕복마다 네트워크 대기 | 한 번에 받아서 컴퓨터 안에서 종목별로 나눔 |

틱 질문에서 `market=KR`을 빼면 색인을 못 타서 **빈 종목도 0.5초**씩 잡아먹습니다. 이것도 “데이터 오류”가 아니라 **질문 문장이 인덱스와 안 맞음**입니다.

---

## 이렇게 하면 근본원인이 사라지나?

**사라집니다.** 느린 이유(1,200번 왕복) 자체가 없어집니다.

정합(실매=백테 같은 봉)을 지키려면 **가져오는 방식만** 바꾸고, **고르는 규칙**은 그대로여야 합니다.

1. 봉·틱을 한꺼번에 가져와 **종목별로 나누기** → 숫자 동일, 위험 거의 없음
2. 틱 쿼리에 `market=KR` 넣기 → 행 수는 같고 속도만 빨라짐. 위험 없음
3. **쓰레기 검사를 끄면** → 오늘 기준 **약 5% 칸의 봉이 달라짐**. 이건 정합 깨짐. 끄면 안 됨

실매 엔진·손절·봉 freeze는 안 건드립니다. **백테/Optuna 로더만** 해당입니다.

---

## 오늘(8/20) 쓰레기 비율

지금 메인 시세 = **한투 WS(`kis/ws`)**.
쓰레기 = 그 분·그 소스 **틱이 없거나**, 틱이 있어도 **봉 끝 기준 3초보다 다 늙음**.

| 기준 | 숫자 |
|------|------|
| 오늘 1분칸(종목×분) | **44,369** |
| 한투 WS 봉이 **있는** 칸 | **5,546 (12.5%)** |
| 그중에서 쓰레기(틱 전부 늙음) | **2,201 → 한투 WS의 39.7%** |
| 틱 0건 쓰레기 | **0건 (0%)** |
| **칸 전체 대비 쓰레기(한투 WS)** | **2,201 / 44,369 ≈ 5.0%** |
| 쓰레기 검사로 **다른 소스(키움 REST)로 바뀜** | **1,464칸 (3.3%)** |
| 쓰레기라서 **그 분 봉이 사라짐** | **759칸 (1.7%)** |

오늘 봉 행의 **대부분(약 88%)은 키움 REST 갭보정**입니다. 이건 쓰레기가 아니라 **구멍 메운 정상 봉**입니다.
한투 WS가 원래 없는 칸(87.5%)은 쓰레기 검사가 아니라 **구독이 안 된 종목**입니다.

**한 줄:** 오늘 “쓰레기 때문에 봉이 바뀌는 칸”은 전체의 **약 5%**(3.3% 교체 + 1.7% 삭제)이고, 한투 WS 봉만 보면 그중 **약 40%**가 틱 나이 쓰레기입니다.


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

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


첫 측정은 종목마다 틱을 전부 훑어서 너무 느려 **중단**했습니다.
같은 내용을 **분 단위로 다시 세서** 이미 숫자 나왔습니다.

오늘(8/20) 1분칸 44,369개 기준:

- 한투 WS 봉이 있는 칸 **12.5%**
- 그중 틱이 다 늙은 쓰레기 **39.7%** (틱 0건은 **0%**)
- **칸 전체 대비 쓰레기 ≈ 5%** (봉이 바뀌는 칸 3.3% + 증발 1.7%)


<timestamp>Thursday, Aug 20, 2026, 1:16 PM (UTC+9)</timestamp>
<user_query>
prepare 단계에서 CANDLE_GARBAGE_FALLBACK=false 또는 bulk 틱 1회만 로드  CANDLE_GARBAGE_FALLBACK 를 false 하거나 또는 이건 뭔말이야 실매에서는 쓰레기 온인데 옵투나에서 false면 정합 깨지는거 아닌가 그리고 최근 모멘텀 결과가 마이너스인데 이건 어제 그제 데이터가 삐꾸라서 그런건가 쓰레기각 40프로면 옵투나 의미없지않아?
</user_query>


정합 질문부터 바로잡겠습니다. 쓰레기 40%가 Optuna를 무효화하는지, 어제·그제 데이터가 비었는지도 숫자로 확인합니다.

cd /home/hoon/kis_bot && .venv/bin/python3 -u <<'PY'
from database import TradeDB
from collections import defaultdict

db = TradeDB()
days = ["20260817","20260818","20260819","20260820"]

print("=== SHOW COLUMNS ws_ticks (subset) ===")
cols = [r["Field"] for r in db.conn.execute("SHOW COLUMNS FROM ws_ticks").fetchall()]
print(cols)

print("\n=== day breakdown candles/ticks ===")
for d in days:
    sk, ek = d+"0000", d+"2359"
    c = db.conn.execute(
        "SELECT source, channel, COUNT(*) n FROM ws_candles "
        "WHERE timeframe=1 AND market=%s AND candle_time>=%s AND candle_time<=%s "
        "GROUP BY source, channel",
        ("KR", sk, ek),
    ).fetchall()
    t = db.conn.execute(
        "SELECT source, COUNT(*) n FROM ws_ticks "
        "WHERE market=%s AND tick_time>=%s AND tick_time<=%s GROUP BY source",
        ("KR", sk, ek+"59"),
    ).fetchall()
    cn = db.conn.execute(
        "SELECT COUNT(DISTINCT code) n FROM ws_candles WHERE timeframe=1 AND market=%s AND candle_time>=%s AND candle_time<=%s",
        ("KR", sk, ek),
    ).fetchone()["n"]
    tn = db.conn.execute(
        "SELECT COUNT(DISTINCT code) n FROM ws_ticks WHERE market=%s AND tick_time>=%s AND tick_time<=%s",
        ("KR", sk, ek+"59"),
    ).fetchone()["n"]
    print(d, "candle_codes", cn, "tick_codes", tn)
    print("  candles", {f"{r['source']}/{r['channel']}": int(r["n"]) for r in c})
    print("  ticks", {str(r["source"]): int(r["n"]) for r in t})

# 8/17 weekday? already know from optuna log ticks=0
print("\n=== 8/17 candle time span ===")
r = db.conn.execute(
    "SELECT MIN(candle_time) mn, MAX(candle_time) mx, COUNT(*) n FROM ws_candles "
    "WHERE timeframe=1 AND market=%s AND candle_time>=%s AND candle_time<=%s",
    ("KR","202608170000","202608172359"),
).fetchone()
print(dict(r))

db.close()
PY

Day-by-day candle/tick coverage 8/17-20


**맞습니다. Optuna만 `CANDLE_GARBAGE_FALLBACK=false`로 두면 정합이 깨집니다.** 그 옵션은 쓰면 안 됩니다. 제가 속도 이야기하면서 섞어 말해서 헷갈리게 했습니다.

---

## “false 또는 bulk 틱 1회”가 무슨 말인가

원래 느린 이유는 **종목마다 틱을 따로 물어보기**입니다. 그래서 말한 두 가지는:

| 말 | 뜻 | 정합 |
|----|----|------|
| **false** | 쓰레기 검사를 **끈다** → 틱을 안 읽어서 빨라짐 | **실매 ON / 옵투나 OFF → 봉이 달라짐. 금지** |
| **bulk 틱 1회** | 틱을 **한 번에 다 가져와서** 메모리에서 똑같이 쓰레기 검사 | **실매와 같은 규칙. 이게 정답** |

실매가 쓰레기 ON이면 옵투나도 **ON 유지**가 맞습니다.
고칠 것은 “검사 끄기”가 아니라 “틱을 590번 묻지 말고 1번 묻기”입니다.

---

## 쓰레기 40%면 옵투나 의미 없나?

**전체 봉의 40%가 쓰레기가 아닙니다.**

오늘 숫자:

- 한투 WS 봉이 **있는** 칸만 전체의 **12.5%**
- 그 12.5% 안에서 틱이 늙은 게 **약 40%**
- **칸 전체로 보면 약 5%**만 쓰레기 때문에 봉이 바뀌거나 사라짐
- 오늘 봉 행의 **약 88%는 키움 REST 갭보정** (구멍 메운 정상 봉)

즉 옵투나가 보는 공책의 대부분은 REST로 채워져 있고, 쓰레기 검사는 한투 WS 일부만 걸러 REST로 넘깁니다. **40% ≠ 백테 전체가 가짜**.

---

## 마이너스가 어제·그제 삐꾸 때문인가?

**그제는(8/17) 데이터가 진짜 없습니다.** 어제(8/19)·그제 아닌 월요(8/18)는 있습니다.

| 날짜 | 분봉 | 틱 | 의미 |
|------|------|----|------|
| **8/17 (월)** | **0 / 0** | **0** | 장일인데 공책 비어 있음. 옵투나 기간에 넣어도 **그날은 거래 불가** |
| 8/18 | 봉 많음(REST 위주) + 틱 20만 | 있음 | 공책 있음 |
| 8/19 | 봉+틱 있음 | 있음 | 공책 있음 |
| 8/20 | 장중 일부 | 있음 | 오늘 |

옵투나 결과도 **8/18·19·20 3일만** 거래가 났고, best도 **전부 마이너스**(52건, 승률 31%, PF 0.59).
그건 “쓰레기 40%라서 엔진이 망가졌다”기보다 **그 3일·그 파라미터로는 돈이 안 된 것**에 가깝습니다. 표본이 짧고 8/17은 통째로 빠졌습니다.

8/17이 빈 이유는 스키마 불일치가 아니라 **그날 봉/틱이 DB에 한 줄도 없음**(봇 정지·미적재·정리 등). 어제·그제(18·19)가 통째로 삐꾸는 아닙니다.

---

**정리:** 실매 쓰레기 ON / 옵투나 false는 **정합 깨짐**. 속도는 **bulk 1회 + 검사는 그대로**. 마이너스는 8/17 공백 + 짧은 3일 손실이지, 쓰레기 40%로 옵투나가 무의미한 건 아닙니다.


<timestamp>Thursday, Aug 20, 2026, 1:20 PM (UTC+9)</timestamp>
<user_query>
근데 너무 rest가 많은데 키움틱도아니고 rest면 정합 너무 깨지는데 그리고 이전에 돌렸을땐 플러스가 낫고 옵투나도 실매도 지금 풀백 넣고나서 마이너스면 풀백때문에 그런거아니야?
</user_query>


REST가 많은 게 실매와 다른 공책을 쓰는지, 폴백 넣은 뒤 플러스→마이너스로 바뀐 건지 코드·과거 결과·오늘 봉 소스로 대조하겠습니다.


cd /home/hoon/kis_bot && .venv/bin/python3 -u <<'PY'
import json, glob, os
paths = sorted(glob.glob("kis_trader/backtest/results/optuna_momentum_tpe_202608*.json"))
for p in paths:
    with open(p) as f:
        d = json.load(f)
    start = d.get("start") or d.get("period_start")
    end = d.get("end") or d.get("period_end")
    # try common keys
    top = None
    if isinstance(d.get("results"), list) and d["results"]:
        top = d["results"][0]
    summ = d.get("summary") or d.get("result_summary") or {}
    n_all = d.get("n_all") or summ.get("n_all")
    pnl = None
    trades = None
    wr = None
    if top:
        pnl = top.get("total_pnl") or top.get("pnl")
        trades = top.get("total_trades") or top.get("trades")
        wr = top.get("win_rate")
    if pnl is None:
        t = summ.get("top") or {}
        pnl = t.get("total_pnl"); trades=t.get("total_trades"); wr=t.get("win_rate")
        start = start or d.get("start") or summ.get("start")
        end = end or summ.get("end")
    # fallback keys at root
    start = start or d.get("start_date")
    end = end or d.get("end_date")
    keys = list(d.keys())[:20]
    print(os.path.basename(p), "start", d.get("start"), "end", d.get("end"), "pnl", pnl, "trades", trades, "wr", wr, "n_all", n_all)
PY

Summarize recent momentum Optuna JSON PnL


cd /home/hoon/kis_bot && .venv/bin/python3 -u <<'PY'
from database import TradeDB
db=TradeDB()
for d in ["20260812","20260813","20260814","20260818","20260819","20260820"]:
    sk,ek=d+"0000",d+"2359"
    c=db.conn.execute(
        "SELECT source, channel, COUNT(*) n FROM ws_candles "
        "WHERE timeframe=1 AND market=%s AND candle_time>=%s AND candle_time<=%s "
        "GROUP BY source, channel",
        ("KR",sk,ek)).fetchall()
    t=db.conn.execute(
        "SELECT COUNT(*) n FROM ws_ticks WHERE market=%s AND tick_time>=%s AND tick_time<=%s",
        ("KR",sk,ek+"59")).fetchone()["n"]
    cc=db.conn.execute(
        "SELECT COUNT(DISTINCT code) n FROM ws_candles WHERE timeframe=1 AND market=%s AND candle_time>=%s AND candle_time<=%s",
        ("KR",sk,ek)).fetchone()["n"]
    print(d, "codes", cc, "ticks", t, "candles", {f"{r['source']}/{r['channel']}":int(r["n"]) for r in c})
db.close()
PY

Compare candle source mix old vs new days


**폴백만 넣어서 플러스가 마이너스가 됐다고 단정할 수는 없습니다.**
REST가 많은 것도 **이번 폴백 때문에 생긴 현상이 아닙니다.**

---

## 1. REST가 많으면 정합이 깨지나?

역할을 나누면 됩니다.

| | 실매 | 옵투나 |
|--|------|--------|
| **지금 가격(체결·청산)** | WS **틱** | DB **틱** (`ws_ticks`) |
| **1분봉(RSI·패턴)** | WS 봉 → 구멍만 **키움 REST** | 같은 테이블 `ws_candles` |

REST는 “키움 틱으로 매매한다”가 아니라 **빈 1분봉을 메우는 공책**입니다.
키움 틱이 없어도 REST 봉은 있을 수 있습니다. 그게 갭보정입니다.

그리고 **예전 플러스 구간도 REST가 압도적**이었습니다.

| 날짜 | 한투 WS 봉 | 키움 WS 봉 | **키움 REST** |
|------|------------|------------|----------------|
| 8/12 (예전 +구간) | 3,198 | 4,183 | **61,946** |
| 8/13 | 9,349 | 2,849 | **159,784** |
| 8/14 | 8,854 | 11,798 | **167,647** |
| 8/18 (이번 −구간) | 4,748 | 7,275 | **178,800** |
| 8/19 | 5,524 | 964 | **140,885** |

플러스 나던 8/12~14도 REST가 대부분입니다.
**“REST가 많아서 이번만 정합이 깨졌다”는 설명이 안 됩니다.**

진짜로 다른 점 하나:

- **실매**는 그 분을 소켓이 못 보면 WS 봉을 **유지** (`live_cover=True`)
- **백테**는 틱이 없거나 늙으면 WS 봉을 **버리고 REST로** (`live_cover=False`)

오늘 이걸로 봉이 바뀌는 칸은 **약 5%**입니다. 0은 아니지만, 88% REST의 원인은 이게 아닙니다.
원인은 **한투 WS를 구독한 종목만 한투 봉이 있고, 나머지는 갭보정 REST**입니다.

---

## 2. 예전에 + / 지금 − 가 폴백 때문인가?

숫자로 보면 **기간·횟수가 다릅니다.**

| 언제 | 기간 | trial | best PnL |
|------|------|-------|----------|
| 8/16 옵투나 | **8/12~14** | **250회** | **+113,253** |
| 오늘 옵투나 | **8/17~20** | **10회** | **−25,068 ~ −69,623** |

- **8/17 봉·틱 0건** → 그날은 통째로 없음
- 거래는 **8/18·19·20**만
- 10회 trial은 250회와 비교 불가 (탐색이 거의 안 됨)

같은 8/12~14를 폴백 ON/OFF로 다시 돌린 적이 없습니다.
그래서 **“폴백 넣어서 부호가 뒤집혔다”는 아직 증명 안 됩니다.**

가능한 원인 우선순위:

1. **다른 장 (8/18~20 vs 8/12~14)** + 8/17 공백
2. **trial 10 vs 250**
3. 폴백으로 봉이 바뀌는 **약 5%** (가능은 함, 주범으로 못 박기엔 약함)

실매가 같이 마이너스인 것도, 폴백만이 아니라 **그 구간 시장 + 파라미터**일 수 있습니다.

---

## 3. 한 줄로

- REST 많음 = **예전에도 있던 갭보정**. 키움 틱을 REST로 바꿔 체결하는 게 아님.
- 옵투나 false는 **정합 깨짐**. 검사는 켜 두고 틱만 **한 번에** 읽으면 됨.
- 지금 마이너스 = **기간이 다르고 8/17이 비고, trial이 적음**이 더 큼. 폴백 단독 범인은 **아직 아님**.

폴백이 범인인지 알려면 **같은 8/12~14**를 쓰레기 검사 ON/OFF로 한 번씩만 돌리면 됩니다. 원하시면 그 비교만 설계해서 보고하겠습니다. (지금은 코드 안 바꿈)


<timestamp>Thursday, Aug 20, 2026, 1:23 PM (UTC+9)</timestamp>
<user_query>
아아 이 rest는 캔들이군 틱 말하는건줄 틱은 rest얼마 안되지? 이것도 컬럼 추가해서 넣어야겠는데 어디서 가져왔는지  아 10번 돌려서 그렇구나 답변하고 설계
</user_query>


틱에 REST가 얼마나 있는지, 이미 `source`/`channel`이 있는지부터 확인한 뒤 출처 컬럼 설계를 잡겠습니다.

AskQuestion

출처 컬럼을 어디에 넣을까요?

Prompt: “어디서 가져왔는지” 컬럼을 어디에 넣을지 고르주세요. (ws_candles는 이미 source+channel 있음)

실매 체결/매매 행: 이 거래 봉·특이 어느 소스(kis/ws, kiwoom/rest 등)

옵투나/웹백테 결과 카드·거래행: 공책 봉이 REST엄진지 표시

실매 거래 + 백테/옵투나 둘 다

새 컬럼 잠금 추가 말고, 운영설정/로그에 소스만 보이게


Grep

def insert_ws_tick|def upsert_ws_tick|INTO ws_ticks


CreatePlan

틱 channel 컬럼

틱 REST는 거의 없음(공책은 봉 REST). ws_ticks에는 이미 증권사 source가 있고, 봉과 같이 경로(ws|rest)를 쓰려면 channel만 추가한다. 키움 REST 현재가를 틱으로 넣지 않는다.

# 틱 출처 컬럼 + 답변

## 답변 (설계 전 확정)

**REST 많은 건 1분봉(`ws_candles`)이지 틱이 아니다.**

- 틱: 소켓 체결만 `ws_ticks`에 쌓임. `source`는 이미 있음 (`kis` / `kiwoom`). 오늘 예: 한투 36만 + 키움 15만. **REST 틱 행은 없음.**
- 봉 REST: 키움 `ka10080` 갭보정 → `ws_candles`의 `source=kiwoom, channel=rest`. 이게 행수의 대부분.
- 매도 4차 키움 `ka10007`은 **공책 틱으로 안 넣음**. RAM/직전가만.

**옵투나 마이너스:** 예전 +는 8/12~14 · trial 250. 오늘은 8/17~20 · trial 10 + 8/17 공백. 10번이라 탐색이 거의 안 된 게 맞다.

```mermaid
flowchart LR
  kisWS[KIS_WS] --> ticks[ws_ticks source kis]
  kiwoomWS[Kiwoom_WS] --> ticks2[ws_ticks source kiwoom]
  ka10080[Kiwoom_ka10080] --> candles[ws_candles channel rest]
  kisWS --> candlesWS[ws_candles channel ws]
```

## 이미 있는 것 / 없는 것

| 테이블 | 증권사 | 경로 ws/rest |
|--------|--------|----------------|
| `ws_candles` | `source` | `channel` 있음 |
| `ws_ticks` / `ws_ticks_us` | `source` 있음 | **`channel` 없음** |
| `ls_ws_ticks` | `source` 있음 | channel 없음 |

사용자가 말한 “틱 테이블에 없잖아” = **봉처럼 `channel`이 없다**로 이해한다. `source`를 또 넣으면 중복.

## 설계 (실매 영향: 스키마·INSERT만, 매매 로직 변경 없음)

1. **컬럼**
   [`database.py`](database.py) `ensure_ws_ticks_table` / `ensure_ws_ticks_us_table` / `_migrate_ws_ticks_extra_columns`:
   - `channel VARCHAR(10) NOT NULL DEFAULT 'ws'` COMMENT `ws|rest`
   - 기존 행은 DEFAULT로 전부 `ws` (실제도 WS만 있었음)
   - UNIQUE는 그대로 안 둠 (틱은 이벤트 로그)

2. **INSERT**
   [`_insert_ws_ticks_batch_into`](database.py) + [`tick_recorder.py`](kis_trader/ws/tick_recorder.py): `channel` 기본 `'ws'`. 키움/한투 WS만 기록. **ka10007/inquire_price를 틱 INSERT 하지 않음.**

3. **조회**
   쓰레기 판정·백테 틱 로더는 `source`만으로 충분. `channel`은 공책 확인용. 읽기 WHERE에 `channel='ws'`를 강제하면 REST 틱이 생길 때까지 동작 동일.

4. **문서**
   [`docs/like_mcp.md/db_erd.md`](docs/like_mcp.md/db_erd.md) `ws_ticks` / `ws_ticks_us` COMMENT.

5. **하지 않음**
   - Optuna `CANDLE_GARBAGE_FALLBACK=false`
   - REST 현재가를 `ws_ticks`에 쌓기
   - 봉 bulk 로더 (별 작업: 속도만, 이번 컬럼과 무관)

## 검증

- `SHOW COLUMNS FROM ws_ticks` 에 `channel`
- 신규 INSERT 1행 `source`+`channel=ws`
- 실매 봇은 스키마 마이그레이션만이면 재시작 후 INSERT 실패 없는지 확인
- 주석만 아니면 [`scripts/test_live_execution_validation.py`](scripts/test_live_execution_validation.py) 1회

ws_ticks / ws_ticks_us 에 channel DEFAULT ws 마이그레이션 + INSERT 반영

TickRecorder 항목에 channel=ws 지정 (경로 REST 특 INSERT 금지)

db_erd.md ws_ticks 컬럼 COMMENT 갱신

SHOW COLUMNS + live-execution-validation 1회


<timestamp>Thursday, Aug 20, 2026, 1:42 PM (UTC+9)</timestamp>
<user_query>
틱 출처 컬럼 + 답변

Implement the plan as specified, it is attached for your reference. Do NOT edit the plan file itself.

To-do's from the plan have already been created. Do not create them again. Mark them as in_progress as you work, starting with the first one. Don't stop until you have completed all the to-dos.
</user_query>


틱 `channel` 컬럼 추가부터 진행합니다. 스키마·INSERT 경로를 확인한 뒤 마이그레이션을 넣겠습니다.

TodoWrite

ws_ticks / ws_ticks_us 에 channel DEFAULT ws 마이그레이션 + INSERT 반영

TickRecorder 항목에 channel=ws 지정 (경로 REST 특 INSERT 금지)

db_erd.md ws_ticks 컬럼 COMMENT 갱신

SHOW COLUMNS + live-execution-validation 1회


StrReplace

                    currency VARCHAR(8) NOT NULL DEFAULT 'KRW',
                    source VARCHAR(16) NOT NULL DEFAULT 'kis',
                    recv_ts VARCHAR(30) NOT NULL,
                    KEY idx_ws_ticks_lookup (market, code, tick_time),
                    KEY idx_ws_ticks_recv (recv_ts)
                ) ENGINE=InnoDB CHARACTER SET utf8mb4
            """)

            # 11a. 해외(US) 체결 틱 — 국내 ws_ticks 와 물리 분리 (인덱스·스캔·정리 격리)
            self.conn.execute("""
                CREATE TABLE IF NOT EXISTS ws_ticks_us (
                    id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
                    market VARCHAR(8) NOT NULL DEFAULT 'US',
                    exchange VARCHAR(16) DEFAULT NULL,
                    code VARCHAR(32) NOT NULL,
                    tick_time VARCHAR(14) NOT NULL,
                    price DOUBLE NOT NULL,
                    volume BIGINT NOT NULL DEFAULT 0,
                    tick_seq BIGINT DEFAULT NULL,
                    session VARCHAR(8) DEFAULT NULL,
                    currency VARCHAR(8) NOT NULL DEFAULT 'USD',
                    source VARCHAR(16) NOT NULL DEFAULT 'kis_us',
                    recv_ts VARCHAR(30) NOT NULL,

                    currency VARCHAR(8) NOT NULL DEFAULT 'KRW',
                    source VARCHAR(16) NOT NULL DEFAULT 'kis',
                    channel VARCHAR(10) NOT NULL DEFAULT 'ws',
                    recv_ts VARCHAR(30) NOT NULL,
                    KEY idx_ws_ticks_lookup (market, code, tick_time),
                    KEY idx_ws_ticks_recv (recv_ts)
                ) ENGINE=InnoDB CHARACTER SET utf8mb4
            """)

            # 11a. 해외(US) 체결 틱 — 국내 ws_ticks 와 물리 분리 (인덱스·스캔·정리 격리)
            self.conn.execute("""
                CREATE TABLE IF NOT EXISTS ws_ticks_us (
                    id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
                    market VARCHAR(8) NOT NULL DEFAULT 'US',
                    exchange VARCHAR(16) DEFAULT NULL,
                    code VARCHAR(32) NOT NULL,
                    tick_time VARCHAR(14) NOT NULL,
                    price DOUBLE NOT NULL,
                    volume BIGINT NOT NULL DEFAULT 0,
                    tick_seq BIGINT DEFAULT NULL,
                    session VARCHAR(8) DEFAULT NULL,
                    currency VARCHAR(8) NOT NULL DEFAULT 'USD',
                    source VARCHAR(16) NOT NULL DEFAULT 'kis_us',
                    channel VARCHAR(10) NOT NULL DEFAULT 'ws',
                    recv_ts VARCHAR(30) NOT NULL,

                    currency VARCHAR(8) NOT NULL DEFAULT 'KRW',
                    source VARCHAR(16) NOT NULL DEFAULT 'kis',
                    recv_ts VARCHAR(30) NOT NULL,
                    cntr_str DOUBLE DEFAULT NULL,
                    upper_limit_time VARCHAR(8) DEFAULT NULL,
                    tick_time_raw VARCHAR(64) DEFAULT NULL,
                    KEY idx_ws_ticks_lookup (market, code, tick_time),
                    KEY idx_ws_ticks_recv (recv_ts)
                ) ENGINE=InnoDB CHARACTER SET utf8mb4
            """)
            self._migrate_ws_ticks_extra_columns("ws_ticks")

                    currency VARCHAR(8) NOT NULL DEFAULT 'KRW',
                    source VARCHAR(16) NOT NULL DEFAULT 'kis',
                    channel VARCHAR(10) NOT NULL DEFAULT 'ws',
                    recv_ts VARCHAR(30) NOT NULL,
                    cntr_str DOUBLE DEFAULT NULL,
                    upper_limit_time VARCHAR(8) DEFAULT NULL,
                    tick_time_raw VARCHAR(64) DEFAULT NULL,
                    KEY idx_ws_ticks_lookup (market, code, tick_time),
                    KEY idx_ws_ticks_recv (recv_ts)
                ) ENGINE=InnoDB CHARACTER SET utf8mb4
            """)
            self._migrate_ws_ticks_extra_columns("ws_ticks")

                    currency VARCHAR(8) NOT NULL DEFAULT 'USD',
                    source VARCHAR(16) NOT NULL DEFAULT 'kis_us',
                    recv_ts VARCHAR(30) NOT NULL,
                    cntr_str DOUBLE DEFAULT NULL,
                    upper_limit_time VARCHAR(8) DEFAULT NULL,
                    tick_time_raw VARCHAR(64) DEFAULT NULL,
                    KEY idx_ws_ticks_us_lookup (market, code, tick_time),
                    KEY idx_ws_ticks_us_recv (recv_ts)
                ) ENGINE=InnoDB CHARACTER SET utf8mb4
            """)
            self._migrate_ws_ticks_extra_columns("ws_ticks_us")

                    currency VARCHAR(8) NOT NULL DEFAULT 'USD',
                    source VARCHAR(16) NOT NULL DEFAULT 'kis_us',
                    channel VARCHAR(10) NOT NULL DEFAULT 'ws',
                    recv_ts VARCHAR(30) NOT NULL,
                    cntr_str DOUBLE DEFAULT NULL,
                    upper_limit_time VARCHAR(8) DEFAULT NULL,
                    tick_time_raw VARCHAR(64) DEFAULT NULL,
                    KEY idx_ws_ticks_us_lookup (market, code, tick_time),
                    KEY idx_ws_ticks_us_recv (recv_ts)
                ) ENGINE=InnoDB CHARACTER SET utf8mb4
            """)
            self._migrate_ws_ticks_extra_columns("ws_ticks_us")

        for col, ddl in (
            ("cntr_str", "DOUBLE DEFAULT NULL"),
            ("upper_limit_time", "VARCHAR(8) DEFAULT NULL"),
            ("tick_time_raw", "VARCHAR(64) DEFAULT NULL"),
        ):

        for col, ddl in (
            ("cntr_str", "DOUBLE DEFAULT NULL"),
            ("upper_limit_time", "VARCHAR(8) DEFAULT NULL"),
            ("tick_time_raw", "VARCHAR(64) DEFAULT NULL"),
            # 봉 ws_candles 와 동일: source=증권사, channel=경로(ws|rest). 틱은 WS만 적재 → 기본 ws.
            ("channel", "VARCHAR(10) NOT NULL DEFAULT 'ws' COMMENT 'ws|rest' AFTER `source`"),
        ):

            rows.append((
                (it.get("market") or default_market)[:8],
                (it.get("exchange") or None),
                (it.get("code") or "")[:32],
                (it.get("tick_time") or "")[:14],
                float(it.get("price") or 0),
                int(it.get("volume") or 0),
                it.get("tick_seq"),
                (it.get("session") or None),
                (it.get("currency") or default_currency)[:8],
                (it.get("source") or default_source)[:16],
                it.get("recv_ts") or datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
                float(it["cntr_str"]) if it.get("cntr_str") is not None else None,
                (it.get("upper_limit_time") or None),
                (it.get("tick_time_raw") or None),
            ))
        exec_conn = conn or self.conn
        try:
            ph = "(%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)"
            placeholders = ", ".join([ph] * len(rows))
            flat: List[Any] = []
            for row in rows:
                flat.extend(row)
            exec_conn.execute(
                f"""
                INSERT INTO {table}
                    (market, exchange, code, tick_time, price, volume,
                     tick_seq, session, currency, source, recv_ts,
                     cntr_str, upper_limit_time, tick_time_raw)
                VALUES {placeholders}
                """,
                tuple(flat),
            )

            ch = str(it.get("channel") or "ws").strip().lower() or "ws"
            if ch not in ("ws", "rest"):
                ch = "ws"
            rows.append((
                (it.get("market") or default_market)[:8],
                (it.get("exchange") or None),
                (it.get("code") or "")[:32],
                (it.get("tick_time") or "")[:14],
                float(it.get("price") or 0),
                int(it.get("volume") or 0),
                it.get("tick_seq"),
                (it.get("session") or None),
                (it.get("currency") or default_currency)[:8],
                (it.get("source") or default_source)[:16],
                ch[:10],
                it.get("recv_ts") or datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
                float(it["cntr_str"]) if it.get("cntr_str") is not None else None,
                (it.get("upper_limit_time") or None),
                (it.get("tick_time_raw") or None),
            ))
        exec_conn = conn or self.conn
        try:
            ph = "(%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)"
            placeholders = ", ".join([ph] * len(rows))
            flat: List[Any] = []
            for row in rows:
                flat.extend(row)
            exec_conn.execute(
                f"""
                INSERT INTO {table}
                    (market, exchange, code, tick_time, price, volume,
                     tick_seq, session, currency, source, channel, recv_ts,
                     cntr_str, upper_limit_time, tick_time_raw)
                VALUES {placeholders}
                """,
                tuple(flat),
            )


            "source": (source or "kis")[:16],
            # 봉과 동일: 경로. 체결 틱은 소켓만 적재 (ka10007 REST 현재가 INSERT 금지)
            "channel": "ws",
            "recv_ts": recv_ts,

            "source": (source or "kis")[:16],
            "recv_ts": recv_ts,


| `currency` | `varchar(8)` | NOT NULL | KRW | 국장틱: 통화 (KRW/USD) |
| `source` | `varchar(16)` | NOT NULL | kis | 국장틱: 증권사 (kis/kiwoom/ls). REST 현재가 아님 |
| `channel` | `varchar(10)` | NOT NULL | ws | 국장틱: 경로 (ws\|rest). 체결 틱은 ws만 적재. ka10007 INSERT 금지 |
| `recv_ts` | `varchar(30)` | NOT NULL |  | 국장틱: 수신 시각 |

| `currency` | `varchar(8)` | NOT NULL | KRW | 국장틱: 통화 (KRW/USD) |
| `source` | `varchar(16)` | NOT NULL | kis | 국장틱: 데이터 소스 (kis/kiwoom/ls 등) |
| `recv_ts` | `varchar(30)` | NOT NULL |  | 국장틱: 수신 시각 |

| `currency` | `varchar(8)` | NOT NULL | USD | 해외틱: 통화 (KRW/USD) |
| `source` | `varchar(16)` | NOT NULL | kis_us | 해외틱: 증권사 (kis_us 등). REST 현재가 아님 |
| `channel` | `varchar(10)` | NOT NULL | ws | 해외틱: 경로 (ws\|rest). 체결 틱은 ws만 적재 |
| `recv_ts` | `varchar(30)` | NOT NULL |  | 해외틱: 수신 시각 |

| `currency` | `varchar(8)` | NOT NULL | USD | 해외틱: 통화 (KRW/USD) |
| `source` | `varchar(16)` | NOT NULL | kis_us | 해외틱: 데이터 소스 (kis/kiwoom/ls 등) |
| `recv_ts` | `varchar(30)` | NOT NULL |  | 해외틱: 수신 시각 |

  `currency` varchar(8) NOT NULL DEFAULT 'KRW',
  `source` varchar(16) NOT NULL DEFAULT 'kis',
  `channel` varchar(10) NOT NULL DEFAULT 'ws',
  `recv_ts` varchar(30) NOT NULL,

  `currency` varchar(8) NOT NULL DEFAULT 'KRW',
  `source` varchar(16) NOT NULL DEFAULT 'kis',
  `recv_ts` varchar(30) NOT NULL,


  `currency` varchar(8) NOT NULL DEFAULT 'USD',
  `source` varchar(16) NOT NULL DEFAULT 'kis_us',
  `channel` varchar(10) NOT NULL DEFAULT 'ws',
  `recv_ts` varchar(30) NOT NULL,

  `currency` varchar(8) NOT NULL DEFAULT 'USD',
  `source` varchar(16) NOT NULL DEFAULT 'kis_us',
  `recv_ts` varchar(30) NOT NULL,


- 키움 RAM/리스너/봉 skip = 읽기 나이. `ws_ticks` 적재는 기본 전부(`KIWOOM_TICK_LIVE_MAX_LAG_SEC=0`). 같이 내리지 말 것. `ws_ticks.channel`(기본 ws)은 봉과 같은 경로 라벨. ka10007을 틱 INSERT 하지 말 것.

- 키움 RAM/리스너/봉 skip = 읽기 나이. `ws_ticks` 적재는 기본 전부(`KIWOOM_TICK_LIVE_MAX_LAG_SEC=0`). 같이 내리지 말 것.


Shell

cd /home/hoon/kis_bot && .venv/bin/python3 -u <<'PY'
from database import TradeDB
db = TradeDB()
for t in ("ws_ticks", "ws_ticks_us"):
    cols = db.conn.execute("SHOW COLUMNS FROM " + t).fetchall()
    names = [r["Field"] for r in cols]
    print(t, "channel" in names, [dict(r) for r in cols if r["Field"] in ("source","channel")])
# INSERT 1행 후 확인 후 삭제 (실매 틱 오염 방지: 테스트 코드 TESTCH)
import datetime
now = datetime.datetime.now().strftime("%Y%m%d%H%M%S")
n = db.insert_ws_ticks_batch([{
    "market": "KR",
    "code": "_CHTEST",
    "tick_time": now,
    "price": 1.0,
    "volume": 0,
    "source": "kis",
    "channel": "ws",
    "recv_ts": datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
}])
print("insert_n", n)
row = db.conn.execute(
    "SELECT source, channel FROM ws_ticks WHERE code=%s ORDER BY id DESC LIMIT 1",
    ("_CHTEST",),
).fetchone()
print("row", dict(row) if row else None)
db.conn.execute("DELETE FROM ws_ticks WHERE code=%s", ("_CHTEST",))
print("deleted")
db.close()
PY

Migrate channel column and smoke INSERT/DELETE


StrReplace

                    KEY idx_ws_ticks_us_lookup (market, code, tick_time),
                    KEY idx_ws_ticks_us_recv (recv_ts)
                ) ENGINE=InnoDB CHARACTER SET utf8mb4
            """)
            self._migrate_ws_ticks_extra_columns("ws_ticks")
            self._migrate_ws_ticks_extra_columns("ws_ticks_us")

            # 11b. TRIGGER 호가 스냅샷 (키움 0D — 백테·파람서치 재현)

                    KEY idx_ws_ticks_us_lookup (market, code, tick_time),
                    KEY idx_ws_ticks_us_recv (recv_ts)
                ) ENGINE=InnoDB CHARACTER SET utf8mb4
            """)

            # 11b. TRIGGER 호가 스냅샷 (키움 0D — 백테·파람서치 재현)


Shell

cd /home/hoon/kis_bot && .venv/bin/python3 -u <<'PY'
from database import TradeDB
import datetime
db = TradeDB()
for t in ("ws_ticks", "ws_ticks_us"):
    cols = [dict(r) for r in db.conn.execute("SHOW COLUMNS FROM " + t).fetchall() if r["Field"] in ("source","channel")]
    print(t, cols)
now = datetime.datetime.now().strftime("%Y%m%d%H%M%S")
n = db.insert_ws_ticks_batch([{
    "market": "KR",
    "code": "_CHTEST",
    "tick_time": now,
    "price": 1.0,
    "volume": 0,
    "source": "kis",
    "channel": "ws",
    "recv_ts": datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
}])
print("insert_n", n)
row = db.conn.execute(
    "SELECT source, channel FROM ws_ticks WHERE code=%s ORDER BY id DESC LIMIT 1",
    ("_CHTEST",),
).fetchone()
print("row", dict(row) if row else None)
db.conn.execute("DELETE FROM ws_ticks WHERE code=%s", ("_CHTEST",))
print("deleted")
db.close()
PY

Re-run TradeDB migrate and INSERT smoke


cd /home/hoon/kis_bot && python3 -u scripts/test_live_execution_validation.py

Run live execution validation smoke once


systemctl is-active kis_trader_main.service 2>/dev/null; sudo systemctl restart kis_trader_main.service; sleep 2; systemctl is-active kis_trader_main.service

Restart live trader to pick up tick INSERT


**적용 완료.** 틱 REST는 여전히 안 넣고, 봉과 같이 **어디서 왔는지(`channel`)**만 공책에 적습니다.

## 답변 재확인

- REST가 많은 쪽은 **1분봉**이지 틱이 아님.
- 틱은 소켓만. `source`는 원래 있었음 (`kis`/`kiwoom`).
- 옵투나 마이너스는 **기간이 다르고 trial 10회**인 영향이 큼.

## 한 일

| 항목 | 내용 |
|------|------|
| `ws_ticks` / `ws_ticks_us` | `channel VARCHAR(10) NOT NULL DEFAULT 'ws'` |
| INSERT / TickRecorder | `channel=ws` 고정. ka10007은 틱 INSERT 안 함 |
| 기존 행 | DEFAULT로 전부 `ws` |
| 문서 | `db_erd.md` · 수정 가이드 1줄 |

TradeDB 기동 때 마이그레이션이 안 돌던 구멍을 막아, **봇 시작 시 ALTER** 되게 했습니다.

## 검증

- `SHOW COLUMNS`: 두 테이블 모두 `channel` 있음
- INSERT 1행 `kis`+`ws` 후 테스트 코드 삭제
- `scripts/test_live_execution_validation.py` **최종: 통과** / `👑 [최종 판정] 완결!`
- 로그: `logs/test_live_execution_validation_20260820_134506.log`
- `kis_trader_main` **재시작 active** (새 INSERT 반영)

## 영향

- **실매:** 스키마·틱 저장만. 매매/청산 로직 없음.
- **웹백테·옵투나:** 틱 읽기는 기존처럼 `source`만 사용. 결과 숫자 불변.

`CANDLE_GARBAGE_FALLBACK`는 그대로 ON입니다.


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

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


실매 봇 재시작은 **성공**했습니다. `kis_trader_main`이 **active**이고, 틱 INSERT에 `channel=ws`가 반영되는 상태입니다.


<timestamp>Thursday, Aug 20, 2026, 1:48 PM (UTC+9)</timestamp>
<user_query>
캔들 로드도 고친거야? 아직 되게 오래 걸리는데 그리고
kis_alert_bot
BOT
오후 12:21 이때 이후로 매수매도가 없는데 문제있는거 아냐"? 싹 훑어봐
</user_query>


캔들 로드는 이번 작업에서 고치지 않았습니다. 12:21 이후 매매 0건은 로그·잔고·시세부터 확인하겠습니다.

Grep

12:21|매수|매도|가격없음


Shell

systemctl is-active kis_trader_main.service; journalctl -u kis_trader_main.service --since '2026-08-20 12:15:00' --until '2026-08-20 13:50:00' --no-pager | rg -i '매수|매도|가격없음|ERROR|Traceback|재시작|started|구독|유니버스|후보|HOLD|잔고|skip|차단' | tail -80

Live bot journal around 12:21-now for trades/errors


journalctl -u kis_trader_main.service --since '2026-08-20 12:18:00' --until '2026-08-20 12:35:00' --no-pager | rg -i '매수|매도|체결|주문|가격없음|deactivat|Started|Stopped|ERROR|Traceback|잔고|max_daily|한도|차단' | head -80

Journal around 12:21 last trade window

cd /home/hoon/kis_bot && .venv/bin/python3 -u <<'PY'
from database import TradeDB
db = TradeDB()
# schema first
for t in ("trade_history", "active_trades", "orders"):
    cols = [r["Field"] for r in db.conn.execute("SHOW COLUMNS FROM " + t).fetchall()]
    print(t, cols)

print("\n=== last trades today ===")
# buy_date / sell_date typical
cols_th = [r["Field"] for r in db.conn.execute("SHOW COLUMNS FROM trade_history").fetchall()]
print("use cols", [c for c in cols_th if "date" in c.lower() or "time" in c.lower() or c in ("code","strategy","pnl","buy_price","sell_price")])
PY

SHOW COLUMNS then inspect trade tables


cd /home/hoon/kis_bot && .venv/bin/python3 -u <<'PY'
from database import TradeDB
db = TradeDB()

print("=== active_trades ===")
rows = db.conn.execute("SELECT code, name, strategy, status, current_qty, buy_date, updated_at FROM active_trades").fetchall()
print("n", len(rows))
for r in rows[:30]:
    print(dict(r))

print("\n=== trade_history today buy/sell ===")
# pymysql LIKE need %s
rows = db.conn.execute(
    "SELECT strategy, code, name, buy_date, sell_date, sell_reason, realized_pnl FROM trade_history "
    "WHERE buy_date LIKE %s OR sell_date LIKE %s ORDER BY id DESC LIMIT 40",
    ("2026-08-20%", "2026-08-20%"),
).fetchall()
print("n_sample", len(rows))
for r in rows[:25]:
    print(dict(r))

print("\n=== last buy/sell timestamps ===")
r = db.conn.execute(
    "SELECT MAX(buy_date) mb, MAX(sell_date) ms FROM trade_history WHERE buy_date LIKE %s OR sell_date LIKE %s",
    ("2026-08-20%", "2026-08-20%"),
).fetchone()
print(dict(r))

print("\n=== orders after 12:00 ===")
# SHOW already - ord_date submitted_at
rows = db.conn.execute(
    "SELECT side, code, status, qty, price, submitted_at, filled_at, msg1 FROM orders "
    "WHERE submitted_at LIKE %s OR ord_date LIKE %s ORDER BY id DESC LIMIT 20",
    ("2026-08-20%", "20260820%"),
).fetchall()
for r in rows[:20]:
    print(dict(r))
print("orders n", len(rows))
db.close()
PY

Query today's trades, positions, orders

journalctl -u kis_trader_main.service --since '2026-08-20 12:20:00' --until '2026-08-20 12:26:00' --no-pager | rg -i '체결|주문|매수완료|매도완료|MOMENTUM|탈락|place_order|Filled|잔고' | head -50

Find last fill/order around 12:21

Count buy-reject reasons since 12:21


journalctl -u kis_trader_main.service --since '2026-08-20 12:21:00' --until '2026-08-20 13:50:00' --no-pager | rg 'strategy.MOMENTUM.*매수체크' | tail -5; echo '--- MOMENTUM 탈락 top ---'; journalctl -u kis_trader_main.service --since '2026-08-20 12:21:00' --until '2026-08-20 13:50:00' --no-pager | rg 'strategy.MOMENTUM.*탈락-' | sed 's/.*탈락-/탈락-/' | cut -d: -f1 | sort | uniq -c | sort -nr | head -15; echo '--- 가격없음/매도 ---'; journalctl -u kis_trader_main.service --since '2026-08-20 12:21:00' --until '2026-08-20 13:50:00' --no-pager | rg -c '가격없음|매도-'; journalctl -u kis_trader_main.service --since '2026-08-20 12:21:00' --until '2026-08-20 13:50:00' --no-pager | rg '가격없음|매도신호|SELL|익절|손절' | tail -15

MOMENTUM reject stats and sell/price-missing since 12:21


**캔들 로드는 안 고쳤습니다.** 방금 한 건은 틱 테이블 `channel`뿐입니다. 옵투나 `opt_20260820_134709_mome`도 지금 `📂 캔들 로드 중`에서 막혀 있습니다. 종목마다 SQL 1,200번 치는 그 병목 그대로입니다.

---

## 12:21 이후 매매 없음 — 봇이 죽은 건 아님

12:21 알림은 **마지막 신규 매수 체결**입니다.

| 시각 | 내용 |
|------|------|
| **12:17 주문 / 12:20 체결** | 모멘텀 **핑거(163730)** 매수 29주 — 텔레 12:21과 맞음 |
| 그 다음 신규 매수 | **없음** |
| 그 다음 청산 완료 | 11:53 레이언스 손절이 마지막 **완결 매도** |
| 13:15 | 마크로젠 **매도 주문**은 나감 (23주). 잔고는 아직 46주 보유 |

봇은 12:21 이후에도 **매수체크를 계속** 돌리고 있습니다.
모멘텀은 **보유 4/8**, 후보는 1~2종. 슬롯이 꽉 찬 게 아닙니다.

지금 들고 있는 모멘텀: 고려제강 말고 **008260, 마크로젠, 에어레인, 핑거**.

신규가 안 나가는 이유(로그 기준):

- 모멘텀: **탈락-음봉**이 압도적 (갤럭시아·엔알비·레이언스 등)
- 돌파: **탈락-직전봉약세**
- 스캘프: **탈락-낙폭** (장중 낙폭이 5.3%에 못 미침)
- 꼬리: **탈락-봉등락 / 거래량**

점심~오후 장에서 조건이 안 맞아서 **탈락만 반복**한 겁니다. `매도-가격없음`으로 막힌 흔적은 이 구간에 없습니다.

12:23에 봇이 잠깐 재시작(비활성→복구)됐고, 13:45에도 틱 `channel` 적용 재시작이 있었습니다. 그때마다 루프는 다시 살아났습니다.

키움 `fid20`이 ~32분 늦은 **stale skip**은 있습니다. 지금은 시세 1차가 **한투(kis)**라 매수체크는 `R:kis(1차)`로 돌아가고, 키움은 2차라 그 한 줄만으로 전체가 선 건 아닙니다.

---

**한 줄:** 캔들 로드는 미수정(옵투나 여전히 ~30분). 실매는 안 죽은 상태고, 12:21 이후는 **핑거 매수 이후 조건 미충족 + 모멘텀 4종 보유**입니다.


<timestamp>Thursday, Aug 20, 2026, 1:52 PM (UTC+9)</timestamp>
<user_query>
캔들 로드도 설계 해줘
</user_query>


캔들 로드는 매매 로직이 아니라 공책 읽는 방식이라, 정합을 깨지 않게 설계부터 잡겠습니다.

SwitchMode

Explanation: 캔들 로드 bulk 화는 백테·Optuna 전 전략 공용 경로라 구현 전에 쿼리 설계·쓰레기검사 정합·영향 범위를 먼저 확정해야 합니다.


Grep

def list_ws_candle_codes|def fetch_ws_candles_for_code|def load_momentum_candles|def load_scalp_candles|def load_breakout_candles|def load_tail_candles


**캔들 로드는 아직 안 고쳤습니다.** 아래는 그 병목만 없애는 설계입니다. 매매 규칙·쓰레기 검사는 그대로 둡니다.

---

## 문제 (초등)

책을 **한 권씩** 593번 가져오면 30분.
**박스째 한 번**이면 같은 책이 약 7초.

고칠 대상은 DB가 아니라 **질문 횟수**입니다.

실측 (8/17~20, KR):

| 방식 | 시간 |
|------|------|
| 지금: 종목마다 봉+틱 | **~30분** |
| bulk 봉 1회 + bulk 틱 1회 | **~7초** |

`CANDLE_GARBAGE_FALLBACK=false`는 **실매 ON / 옵투나 OFF → 정합 깨짐. 설계에서 제외.**

---

## 목표

- 읽기 규칙 동일: 메인 WS → 2차 WS → LS → 키움 REST → rollup
- 쓰레기 검사 **ON 유지**, 틱은 **한 번에** 가져와서 메모리에서 같은 `dedupe_by_read_pairs(..., live_cover=False)`
- 실매 엔진 무변경
- 웹백테 = 옵투나 = Grid **같은 헬퍼 1개**

```mermaid
flowchart LR
  old[지금_종목루프] --> q1[봉쿼리_x593]
  old --> q2[틱쿼리_x593]
  q1 --> dedupe[dedupe_plus_garbage]
  q2 --> dedupe
  new[bulk] --> b1[봉_1회]
  new --> b2[틱_1회_market포함]
  b1 --> split[종목별_나누기]
  b2 --> split
  split --> dedupe2[같은_dedupe_garbage]
```

---

## 설계

### 1. 공통 함수 위치

[`kis_trader/backtest/bt_candle_source.py`](kis_trader/backtest/bt_candle_source.py)

추가:

- `fetch_ws_candles_by_code_bulk(...)` → `{code: [봉,...]}`
- 기존 `fetch_ws_candles_for_code`는 **웜업·단종목**용으로 유지 (한 종목 LIMIT 조회)

### 2. SQL (핵심)

**봉 1회** (인덱스 `idx_ws_candles_market` 그대로)

```sql
SELECT code, candle_time, open, high, low, close, volume, source, channel
  [, extra 지표컬럼]
FROM ws_candles
WHERE timeframe=%s AND market=%s
  AND candle_time >= %s AND candle_time <= %s
  AND is_confirmed=1
  AND (source,channel 읽기쌍 OR ...)
ORDER BY code, candle_time
```

RAM이 걱정되면 `code IN (...)` **200종 청크**. 4일 36만행은 한 방도 가능 (실측 3.2초).

**틱 1회** — 쓰레기 검사용. **`market` 필수** (없으면 인덱스 못 탐)

```sql
SELECT code, tick_time, tick_time_raw, source
FROM ws_ticks
WHERE market=%s AND tick_time >= %s AND tick_time <= %s
```

채널은 있어도 필터 안 함(지금은 전부 `ws`). REST 틱 INSERT는 계속 금지.

메모리에서 `(code, source) → 틱리스트` 만든 뒤, 종목별로 지금과 같이 `dedupe_by_read_pairs`.

### 3. 호출부만 for 제거

같은 N+1 패턴인 곳만 bulk로 교체:

| 파일 | 함수 |
|------|------|
| `param_search_momentum.py` | `_load_candles_for_search` (옵투나가 여기를 탐) |
| `momentum_backtest_common.py` | `load_momentum_candles_by_code` |
| `scalping_backtest_common.py` | `load_scalp_candles_by_code` |
| `breakout_backtest_common.py` | `load_breakout_candles_by_code` |
| `tail_backtest_common.py` | `load_tail_candles_by_code` (tf 인자 그대로) |

`history_source=ls` 경로는 **손대지 않음** (`ls_ws_candles` 전용).

웜업 `fetch_ws_candles_warmup_before`는 종목당 0.002초 → **그대로**.

### 4. 로그

로드 시작/끝에 한 줄:

- `📂 캔들 bulk: 봉 N행 · 틱 M행 · 종목 K · Xs`
- 지금처럼 중간 침묵 30분 없애기

### 5. 정합 검증 (구현 후 1회)

- 같은 기간·같은 종목 3개: **구 for vs bulk** OHLC 시계열 완전 일치
- 쓰레기 ON 유지 확인 (false로 맞추지 않음)
- 웹백테 1회 vs 옵투나 prepare 종목수 일치
- 실매 스모크: 로더만이면 `test_live_execution_validation.py` 1회 (엔진 미변경)

### 6. 하지 않음

- 쓰레기 검사 OFF
- 봉 OHLC로 틱 메우기
- `live_cover=True`를 백테에 넣기 (실매와 5% 차이는 **별 이슈**, 이번 속도 작업과 섞지 않음)
- 클래스화

---

## 영향 분류

| | 영향 |
|--|------|
| 실매 | 없음 (공책 읽기만 백테/옵투나) |
| 웹백테 / 옵투나 / Grid | **속도만**. 봉 고르는 규칙 동일하면 PnL 동일해야 함 |
| 틱 `channel` | 무관 (이미 끝난 작업) |

예상: prepare 캔들 **~30분 → 수십 초**. 그다음 REST 웜업 5종·틱 청산 로드는 별 시간.

구현 진행할까요?


<timestamp>Thursday, Aug 20, 2026, 1:55 PM (UTC+9)</timestamp>
<user_query>
진행
</user_query>


캔들 bulk 로드를 공용 헬퍼로 넣고, 옵투나·웹백테 로더만 그 경로로 바꿉니다. 쓰레기 검사는 켜 둡니다.

[REDACTED]


[REDACTED]


[REDACTED]

StrReplace

from __future__ import annotations

from typing import Any, Dict, List, Optional, Sequence, Tuple

from kis_trader.ws.candle_series import (

from __future__ import annotations

import logging
import time
from collections import defaultdict
from typing import Any, Dict, List, Optional, Sequence, Tuple

from kis_trader.ws.candle_series import (

ReadPair = Tuple[str, str]

# 레거시 호환 이름 (실제 필터는 live_read_pairs)
BT_WS_CANDLE_SOURCES: Tuple[str, ...] = ("kiwoom", "kis", "rest", "rollup_1m")

ReadPair = Tuple[str, str]

logger = logging.getLogger("bt_candle_source")

# 레거시 호환 이름 (실제 필터는 live_read_pairs)
BT_WS_CANDLE_SOURCES: Tuple[str, ...] = ("kiwoom", "kis", "rest", "rollup_1m")

def _ticks_for_bar_garbage(db, code: str, start_key: str, end_key: str) -> Optional[List[Dict[str, Any]]]:
    from kis_trader.engine.feed_fallback import candle_garbage_fallback_enabled

    if not candle_garbage_fallback_enabled():
        return None
    sk = str(start_key or "").strip()
    ek = str(end_key or "").strip()
    if len(sk) == 12:
        sk = sk + "00"
    if len(ek) == 12:
        ek = ek + "59"
    try:
        rows = db.conn.execute(
            "SELECT tick_time, source, tick_time_raw FROM ws_ticks "
            "WHERE code=%s AND tick_time >= %s AND tick_time <= %s",
            (str(code).strip(), sk, ek),
        ).fetchall()
        return [dict(r) for r in (rows or [])]
    except Exception:
        return None

def _tick_time_bounds(start_key: str, end_key: str) -> Tuple[str, str]:
    sk = str(start_key or "").strip()
    ek = str(end_key or "").strip()
    if len(sk) == 12:
        sk = sk + "00"
    if len(ek) == 12:
        ek = ek + "59"
    return sk, ek

def _ws_ticks_table(market: Optional[str]) -> str:
    mk = (market or "").strip().upper()
    if mk == "US":
        return "ws_ticks_us"
    return "ws_ticks"

def _ticks_for_bar_garbage(
    db,
    code: str,
    start_key: str,
    end_key: str,
    *,
    market: Optional[str] = None,
) -> Optional[List[Dict[str, Any]]]:
    from kis_trader.engine.feed_fallback import candle_garbage_fallback_enabled

    if not candle_garbage_fallback_enabled():
        return None
    sk, ek = _tick_time_bounds(start_key, end_key)
    mk = (market or "").strip().upper()
    table = _ws_ticks_table(mk if mk in ("US", "KR") else "KR")
    try:
        if mk in ("US", "KR"):
            rows = db.conn.execute(
                f"SELECT tick_time, source, tick_time_raw FROM {table} "
                "WHERE market=%s AND code=%s AND tick_time >= %s AND tick_time <= %s",
                (mk, str(code).strip(), sk, ek),
            ).fetchall()
        else:
            rows = db.conn.execute(
                f"SELECT tick_time, source, tick_time_raw FROM {table} "
                "WHERE market=%s AND code=%s AND tick_time >= %s AND tick_time <= %s",
                ("KR", str(code).strip(), sk, ek),
            ).fetchall()
        return [dict(r) for r in (rows or [])]
    except Exception:
        return None

def _load_ticks_by_code_bulk(
    db,
    start_key: str,
    end_key: str,
    *,
    market: Optional[str] = None,
) -> Optional[Dict[str, List[Dict[str, Any]]]]:
    """쓰레기 검사용 틱 — 기간 1~2쿼리. OFF 면 None (봉만 dedupe)."""
    from kis_trader.engine.feed_fallback import candle_garbage_fallback_enabled

    if not candle_garbage_fallback_enabled():
        return None
    sk, ek = _tick_time_bounds(start_key, end_key)
    mk = (market or "").strip().upper()
    out: Dict[str, List[Dict[str, Any]]] = defaultdict(list)

    def _pull(table: str, mkt: str) -> int:
        rows = db.conn.execute(
            f"SELECT code, tick_time, source, tick_time_raw FROM {table} "
            "WHERE market=%s AND tick_time >= %s AND tick_time <= %s",
            (mkt, sk, ek),
        ).fetchall()
        n = 0
        for r in rows or []:
            code = str(r.get("code") or "").strip()
            if not code:
                continue
            out[code].append(dict(r))
            n += 1
        return n

    try:
        n_kr = n_us = 0
        if mk == "US":
            n_us = _pull("ws_ticks_us", "US")
        elif mk == "KR":
            n_kr = _pull("ws_ticks", "KR")
        else:
            n_kr = _pull("ws_ticks", "KR")
            try:
                n_us = _pull("ws_ticks_us", "US")
            except Exception:
                n_us = 0
        logger.info("📥 틱 bulk 쓰레기검사용: KR=%s US=%s 종목=%s", n_kr, n_us, len(out))
        return dict(out)
    except Exception as exc:
        logger.warning("틱 bulk 스킵(봉만): %s", exc)
        return None


[REDACTED]

    ticks = _ticks_for_bar_garbage(db, str(code), start_key, end_key)
    return dedupe_by_read_pairs(
        [dict(r) for r in rows], pairs, ticks=ticks, tf_min=int(timeframe),
        missing_policy="hole", live_cover=False,
    )

    ticks = _ticks_for_bar_garbage(
        db, str(code), start_key, end_key, market=mk or None,
    )
    return dedupe_by_read_pairs(
        [dict(r) for r in rows], pairs, ticks=ticks, tf_min=int(timeframe),
        missing_policy="hole", live_cover=False,
    )

def fetch_ws_candles_by_code_bulk(
    db,
    timeframe: int,
    start_key: str,
    end_key: str,
    *,
    extra_select: str = "",
    peak_sel: str = "",
    market: Optional[str] = None,
    confirmed_only: bool = True,
    codes_filter: Optional[Sequence[str]] = None,
) -> Dict[str, List[Dict[str, Any]]]:
    """기간 전체 봉 1쿼리 + 틱 1~2쿼리 후 종목별 기존 쓰레기 검사.

    웜업용 ``fetch_ws_candles_for_code`` / ``fetch_ws_candles_warmup_before`` 는 그대로.
    """
    t0 = time.perf_counter()
    pairs = resolve_bt_read_pairs()
    confirmed_sql = " AND is_confirmed=1" if confirmed_only else ""
    mk = (market or "").strip().upper()
    src_sql, src_params = _pairs_in_sql(pairs)
    cols = (
        f"code, candle_time, open, high, low, close, volume, source, channel"
        f"{peak_sel}{extra_select}"
    )
    want: List[str] = []
    if codes_filter:
        want = [
            str(c).strip()
            for c in codes_filter
            if str(c).strip()
        ]
    in_sql = ""
    in_params: List[str] = []
    if want:
        in_sql = " AND code IN (" + ",".join(["%s"] * len(want)) + ")"
        in_params = want

    params: List[Any] = [int(timeframe)]
    mk_sql = ""
    if mk:
        mk_sql = " AND market=%s"
        params.append(mk)
    params.extend([start_key, end_key, *src_params, *in_params])

    rows = db.conn.execute(
        f"SELECT {cols} FROM ws_candles "
        "WHERE timeframe=%s"
        + mk_sql
        + " AND candle_time >= %s AND candle_time <= %s"
        + confirmed_sql
        + src_sql
        + in_sql
        + " ORDER BY code ASC, candle_time ASC",
        params,
    ).fetchall()

    by_code: Dict[str, List[Dict[str, Any]]] = defaultdict(list)
    for r in rows or []:
        code = str(r.get("code") or "").strip()
        if not code:
            continue
        by_code[code].append(dict(r))

    ticks_map = _load_ticks_by_code_bulk(
        db, start_key, end_key, market=mk or None,
    )
    out: Dict[str, List[Dict[str, Any]]] = {}
    for code, raw in by_code.items():
        ticks = None if ticks_map is None else ticks_map.get(code, [])
        out[code] = dedupe_by_read_pairs(
            raw, pairs, ticks=ticks, tf_min=int(timeframe),
            missing_policy="hole", live_cover=False,
        )
    elapsed = time.perf_counter() - t0
    n_tick_rows = 0
    if ticks_map:
        n_tick_rows = sum(len(v) for v in ticks_map.values())
    logger.info(
        "📂 캔들 bulk: 봉 %s행 · 틱 %s행 · 종목 %s · %.2fs (tf=%s market=%s)",
        len(rows or []),
        n_tick_rows,
        len(out),
        elapsed,
        int(timeframe),
        mk or "ALL",
    )
    return out


[REDACTED]

    from kis_trader.backtest.bt_candle_source import (
        fetch_ws_candles_for_code,
        list_ws_candle_codes,
    )

    period_start = str(start_key)[:12]
    mk = (market or "").strip().upper()
    codes = list_ws_candle_codes(db, 1, start_key, end_key, market=mk or None)
    ind_cols = ws_candles_select_indicator_cols(db)
    candles_by_code: Dict[str, List[Dict]] = {}
    total = 0
    for code in codes:
        rows = fetch_ws_candles_for_code(
            db, code, 1, start_key, end_key,
            extra_select=ind_cols,
            market=mk or None,
            confirmed_only=False,
        )
        if len(rows) < 6:
            continue
        candles_by_code[code] = rows
        total += len(rows)

    from kis_trader.backtest.bt_candle_source import fetch_ws_candles_by_code_bulk

    period_start = str(start_key)[:12]
    mk = (market or "").strip().upper()
    ind_cols = ws_candles_select_indicator_cols(db)
    loaded = fetch_ws_candles_by_code_bulk(
        db, 1, start_key, end_key,
        extra_select=ind_cols,
        market=mk or None,
        confirmed_only=False,
    )
    candles_by_code: Dict[str, List[Dict]] = {}
    total = 0
    for code, rows in loaded.items():
        if len(rows) < 6:
            continue
        candles_by_code[code] = rows
        total += len(rows)

    from kis_trader.backtest.bt_candle_source import (
        fetch_ws_candles_for_code,
        list_ws_candle_codes,
    )

    ind_cols = ws_candles_select_indicator_cols(db)
    codes = list_ws_candle_codes(db, 1, start_key, end_key)

    candles_by_code: Dict[str, List[Dict]] = {}
    total_candles = 0

    for code in codes:
        rows = fetch_ws_candles_for_code(
            db, code, 1, start_key, end_key,
            extra_select=ind_cols,
            confirmed_only=True,
        )
        if len(rows) < min_bars:
            continue
        candles_by_code[code] = rows
        total_candles += len(rows)

    from kis_trader.backtest.bt_candle_source import fetch_ws_candles_by_code_bulk

    ind_cols = ws_candles_select_indicator_cols(db)
    loaded = fetch_ws_candles_by_code_bulk(
        db, 1, start_key, end_key,
        extra_select=ind_cols,
        confirmed_only=True,
    )
    candles_by_code: Dict[str, List[Dict]] = {}
    total_candles = 0
    for code, rows in loaded.items():
        if len(rows) < min_bars:
            continue
        candles_by_code[code] = rows
        total_candles += len(rows)

    from kis_trader.backtest.bt_candle_source import (
        fetch_ws_candles_for_code,
        list_ws_candle_codes,
    )

    codes = list_ws_candle_codes(db, 1, start_key, end_key)

    candles_by_code: Dict[str, List[Dict]] = {}
    total_candles = 0

    for code in codes:
        rows = fetch_ws_candles_for_code(
            db, code, 1, start_key, end_key,
            confirmed_only=True,
        )
        if len(rows) < min_bars:
            continue
        candles_by_code[code] = rows
        total_candles += len(rows)

    from kis_trader.backtest.bt_candle_source import fetch_ws_candles_by_code_bulk

    loaded = fetch_ws_candles_by_code_bulk(
        db, 1, start_key, end_key,
        confirmed_only=True,
    )
    candles_by_code: Dict[str, List[Dict]] = {}
    total_candles = 0
    for code, rows in loaded.items():
        if len(rows) < min_bars:
            continue
        candles_by_code[code] = rows
        total_candles += len(rows)

        from kis_trader.backtest.bt_candle_source import (
            fetch_ws_candles_for_code,
            list_ws_candle_codes,
        )

        codes = list_ws_candle_codes(
            db, 1, start_key, end_key,
            market=mk if mk in ("US", "KR") else None,
        )
        if mk == "US":
            try:
                from permanent_subs import codes_by_market as _perm_us
                _us_perm = {str(r.get("code") or "").upper() for r in _perm_us(db, "US")}
                if _us_perm:
                    codes = [c for c in codes if str(c).upper() in _us_perm] or codes
            except Exception:
                pass
        if want:
            codes = [c for c in codes if str(c).upper() in want]

        for code in codes:
            rows = fetch_ws_candles_for_code(
                db, code, 1, start_key, end_key,
                market=mk if mk in ("US", "KR") else None,
                confirmed_only=True,
            )
            if len(rows) < rsi_period + 5:
                continue
            codes_candles[code] = rows

        from kis_trader.backtest.bt_candle_source import fetch_ws_candles_by_code_bulk

        loaded = fetch_ws_candles_by_code_bulk(
            db, 1, start_key, end_key,
            market=mk if mk in ("US", "KR") else None,
            confirmed_only=True,
        )
        codes = list(loaded.keys())
        if mk == "US":
            try:
                from permanent_subs import codes_by_market as _perm_us
                _us_perm = {str(r.get("code") or "").upper() for r in _perm_us(db, "US")}
                if _us_perm:
                    codes = [c for c in codes if str(c).upper() in _us_perm] or codes
            except Exception:
                pass
        if want:
            codes = [c for c in codes if str(c).upper() in want]

        min_bars = int(rsi_period) + 5
        for code in codes:
            rows = loaded.get(code) or []
            if len(rows) < min_bars:
                continue
            codes_candles[code] = rows

    from kis_trader.backtest.bt_candle_source import (
        fetch_ws_candles_for_code,
        list_ws_candle_codes,
    )

    codes = list_ws_candle_codes(db, tail_tf, start_key, end_key)

    # 3분 합성 시 1분만 있는 종목도 후보에 포함
    synth_on = (
        tail_tf == 3
        and get_env_bool("TAIL_BT_SYNTH_3M_FROM_1M", True)
    )
    if synth_on:
        try:
            codes_1m = list_ws_candle_codes(db, 1, start_key, end_key)
            for c in codes_1m:
                if c not in codes:
                    codes.append(c)
            codes = sorted(set(codes))
        except Exception:
            pass

    candles_by_code: Dict[str, List[Dict]] = {}
    total_candles = 0
    min_bars = int(rsi_period) + 5
    synth_filled_total = 0

    for code in codes:
        bars = fetch_ws_candles_for_code(
            db, code, tail_tf, start_key, end_key,
            extra_select=ind_cols,
            peak_sel=peak_sel,
            confirmed_only=True,
        )

        if synth_on:
            bars, n_fill = _synth_fill_3m_holes_from_1m(
                db, code, bars, start_key, end_key, peak_sel=peak_sel,
            )
            synth_filled_total += n_fill

    from kis_trader.backtest.bt_candle_source import fetch_ws_candles_by_code_bulk

    synth_on = (
        tail_tf == 3
        and get_env_bool("TAIL_BT_SYNTH_3M_FROM_1M", True)
    )
    loaded_tf = fetch_ws_candles_by_code_bulk(
        db, tail_tf, start_key, end_key,
        extra_select=ind_cols,
        peak_sel=peak_sel,
        confirmed_only=True,
    )
    loaded_1m: Dict[str, List[Dict]] = {}
    if synth_on:
        loaded_1m = fetch_ws_candles_by_code_bulk(
            db, 1, start_key, end_key,
            extra_select=ind_cols,
            peak_sel=peak_sel,
            confirmed_only=True,
        )

    codes = sorted(set(list(loaded_tf.keys()) + list(loaded_1m.keys())))

    candles_by_code: Dict[str, List[Dict]] = {}
    total_candles = 0
    min_bars = int(rsi_period) + 5
    synth_filled_total = 0

    for code in codes:
        bars = list(loaded_tf.get(code) or [])
        if synth_on:
            bars, n_fill = _synth_fill_3m_holes_from_1m(
                db, code, bars, start_key, end_key, peak_sel=peak_sel,
                rows_1m=loaded_1m.get(code) or [],
            )
            synth_filled_total += n_fill

def _synth_fill_3m_holes_from_1m(
    db,
    code: str,
    bars_3m: List[Dict],
    start_key: str,
    end_key: str,
    *,
    peak_sel: str = "",
) -> Tuple[List[Dict], int]:
    """DB 3분 리스트에 없는 시각만 1분→3분 롤업으로 보강."""
    from kis_trader.engine.candle_rollup import merge_fill_holes, rollup_1m_bars_to_tf
    from kis_trader.backtest.bt_candle_source import fetch_ws_candles_for_code

    try:
        rows_1m = fetch_ws_candles_for_code(
            db, code, 1, start_key, end_key,
            peak_sel=peak_sel,
            confirmed_only=True,
        )
    except Exception:
        return bars_3m, 0
    if not rows_1m:
        return bars_3m, 0
    rolled = rollup_1m_bars_to_tf(rows_1m, 3)
    return merge_fill_holes(bars_3m, rolled)

def _synth_fill_3m_holes_from_1m(
    db,
    code: str,
    bars_3m: List[Dict],
    start_key: str,
    end_key: str,
    *,
    peak_sel: str = "",
    rows_1m: Optional[List[Dict]] = None,
) -> Tuple[List[Dict], int]:
    """DB 3분 리스트에 없는 시각만 1분→3분 롤업으로 보강."""
    from kis_trader.engine.candle_rollup import merge_fill_holes, rollup_1m_bars_to_tf
    from kis_trader.backtest.bt_candle_source import fetch_ws_candles_for_code

    try:
        if rows_1m is None:
            rows_1m = fetch_ws_candles_for_code(
                db, code, 1, start_key, end_key,
                peak_sel=peak_sel,
                confirmed_only=True,
            )
    except Exception:
        return bars_3m, 0
    if not rows_1m:
        return bars_3m, 0
    rolled = rollup_1m_bars_to_tf(rows_1m, 3)
    return merge_fill_holes(bars_3m, rolled)


[REDACTED]

  - tail_backtest_candle_warmup_bars()     ← tail_backtest_common.py
```

  - tail_backtest_candle_warmup_bars()     ← tail_backtest_common.py
기간 봉 로드는 fetch_ws_candles_by_code_bulk (종목 for 금지).
웜업 prepend 만 fetch_ws_candles_warmup_before (종목당).
CANDLE_GARBAGE_FALLBACK 끄지 말 것. history_source=ls 는 별도.
```


[REDACTED]

  - tail_backtest_candle_warmup_bars()     ← tail_backtest_common.py
```

  - tail_backtest_candle_warmup_bars()     ← tail_backtest_common.py
기간 봉 로드는 fetch_ws_candles_by_code_bulk (종목 for 금지).
웜업 prepend 만 fetch_ws_candles_warmup_before (종목당).
CANDLE_GARBAGE_FALLBACK 끄지 말 것. history_source=ls 는 별도.
```


[REDACTED]

        C[momentum_backtest_common.py]
        C2[param_search_momentum.py]
        C3[optuna_momentum.py]
        C4[bt_candle_source.py]
    end

        C[momentum_backtest_common.py]
        C2[param_search_momentum.py]
        C3[optuna_momentum.py]
    end

Shell

python3 -m py_compile kis_trader/backtest/bt_candle_source.py kis_trader/backtest/momentum_backtest_common.py kis_trader/backtest/scalping_backtest_common.py kis_trader/backtest/breakout_backtest_common.py kis_trader/backtest/tail_backtest_common.py kis_trader/backtest/param_search_momentum.py && echo COMPILE_OK

Syntax-check modified backtest loader files


[REDACTED]

Write

#!/usr/bin/env python3
"""단종목 fetch vs bulk OHLC 시계열 일치 (임시 검증)."""
from __future__ import annotations

import logging
import time

logging.basicConfig(level=logging.INFO, format="%(message)s")

from database import TradeDB
from kis_trader.engine.feed_fallback import candle_garbage_fallback_enabled
from kis_trader.backtest.bt_candle_source import (
    fetch_ws_candles_by_code_bulk,
    fetch_ws_candles_for_code,
    list_ws_candle_codes,
)

def _sig(rows):
    out = []
    for r in rows:
        out.append((
            str(r.get("candle_time") or ""),
            float(r.get("open") or 0),
            float(r.get("high") or 0),
            float(r.get("low") or 0),
            float(r.get("close") or 0),
            int(r.get("volume") or 0),
            str(r.get("source") or ""),
            str(r.get("channel") or ""),
        ))
    return out

def main() -> None:
    print("CANDLE_GARBAGE_FALLBACK", candle_garbage_fallback_enabled())
    db = TradeDB()
    try:
        start_key, end_key = "202608170000", "202608202359"
        codes = list_ws_candle_codes(db, 1, start_key, end_key, market="KR")
        sample = codes[:3]
        print("sample", sample, "of", len(codes))
        t0 = time.perf_counter()
        bulk = fetch_ws_candles_by_code_bulk(
            db, 1, start_key, end_key, market="KR", confirmed_only=True,
            codes_filter=sample,
        )
        print("bulk_sec", round(time.perf_counter() - t0, 3), "codes", len(bulk))
        bad = 0
        for code in sample:
            one = fetch_ws_candles_for_code(
                db, code, 1, start_key, end_key, market="KR", confirmed_only=True,
            )
            a, b = _sig(one), _sig(bulk.get(code) or [])
            ok = a == b
            print(code, "n_one", len(a), "n_bulk", len(b), "match", ok)
            if not ok:
                bad += 1
                for i, (x, y) in enumerate(zip(a, b)):
                    if x != y:
                        print(" first_diff_i", i, x, y)
                        break
                if len(a) != len(b):
                    print(" len_diff", len(a), len(b))
        print("FAIL" if bad else "OK")
    finally:
        db.close()

if __name__ == "__main__":
    main()


[REDACTED]

Shell

Compare per-code vs bulk candle OHLC


[REDACTED]

PYTHONPATH=/home/hoon/kis_bot python3 -u scripts/_tmp_parity_candle_bulk.py

Re-run candle bulk parity with PYTHONPATH


[REDACTED]

StrReplace

def _load_ticks_by_code_bulk(
    db,
    start_key: str,
    end_key: str,
    *,
    market: Optional[str] = None,
) -> Optional[Dict[str, List[Dict[str, Any]]]]:
    """쓰레기 검사용 틱 — 기간 1~2쿼리. OFF 면 None (봉만 dedupe)."""
    from kis_trader.engine.feed_fallback import candle_garbage_fallback_enabled

    if not candle_garbage_fallback_enabled():
        return None
    sk, ek = _tick_time_bounds(start_key, end_key)
    mk = (market or "").strip().upper()
    out: Dict[str, List[Dict[str, Any]]] = defaultdict(list)

    def _pull(table: str, mkt: str) -> int:
        rows = db.conn.execute(
            f"SELECT code, tick_time, source, tick_time_raw FROM {table} "
            "WHERE market=%s AND tick_time >= %s AND tick_time <= %s",
            (mkt, sk, ek),
        ).fetchall()

def _load_ticks_by_code_bulk(
    db,
    start_key: str,
    end_key: str,
    *,
    market: Optional[str] = None,
    codes_filter: Optional[Sequence[str]] = None,
) -> Optional[Dict[str, List[Dict[str, Any]]]]:
    """쓰레기 검사용 틱 — 기간 1~2쿼리. OFF 면 None (봉만 dedupe)."""
    from kis_trader.engine.feed_fallback import candle_garbage_fallback_enabled

    if not candle_garbage_fallback_enabled():
        return None
    sk, ek = _tick_time_bounds(start_key, end_key)
    mk = (market or "").strip().upper()
    out: Dict[str, List[Dict[str, Any]]] = defaultdict(list)
    want: List[str] = []
    if codes_filter:
        want = [str(c).strip() for c in codes_filter if str(c).strip()]
    in_sql = ""
    in_params: List[str] = []
    if want:
        in_sql = " AND code IN (" + ",".join(["%s"] * len(want)) + ")"
        in_params = want

    def _pull(table: str, mkt: str) -> int:
        rows = db.conn.execute(
            f"SELECT code, tick_time, source, tick_time_raw FROM {table} "
            "WHERE market=%s AND tick_time >= %s AND tick_time <= %s"
            + in_sql,
            (mkt, sk, ek, *in_params),
        ).fetchall()

    ticks_map = _load_ticks_by_code_bulk(
        db, start_key, end_key, market=mk or None,
    )

    ticks_map = _load_ticks_by_code_bulk(
        db, start_key, end_key, market=mk or None, codes_filter=want or None,
    )


[REDACTED]

def _load_ticks_by_code_bulk(
    db,
    start_key: str,
    end_key: str,
    *,
    market: Optional[str] = None,
) -> Optional[Dict[str, List[Dict[str, Any]]]]:
    """쓰레기 검사용 틱 — 기간 1~2쿼리. OFF 면 None (봉만 dedupe)."""
    from kis_trader.engine.feed_fallback import candle_garbage_fallback_enabled

    if not candle_garbage_fallback_enabled():
        return None
    sk, ek = _tick_time_bounds(start_key, end_key)
    mk = (market or "").strip().upper()
    out: Dict[str, List[Dict[str, Any]]] = defaultdict(list)

    def _pull(table: str, mkt: str) -> int:
        rows = db.conn.execute(
            f"SELECT code, tick_time, source, tick_time_raw FROM {table} "
            "WHERE market=%s AND tick_time >= %s AND tick_time <= %s",
            (mkt, sk, ek),
        ).fetchall()

def _load_ticks_by_code_bulk(
    db,
    start_key: str,
    end_key: str,
    *,
    market: Optional[str] = None,
    codes_filter: Optional[Sequence[str]] = None,
) -> Optional[Dict[str, List[Dict[str, Any]]]]:
    """쓰레기 검사용 틱 — 기간 1~2쿼리. OFF 면 None (봉만 dedupe)."""
    from kis_trader.engine.feed_fallback import candle_garbage_fallback_enabled

    if not candle_garbage_fallback_enabled():
        return None
    sk, ek = _tick_time_bounds(start_key, end_key)
    mk = (market or "").strip().upper()
    out: Dict[str, List[Dict[str, Any]]] = defaultdict(list)
    want: List[str] = []
    if codes_filter:
        want = [str(c).strip() for c in codes_filter if str(c).strip()]
    in_sql = ""
    in_params: List[str] = []
    if want:
        in_sql = " AND code IN (" + ",".join(["%s"] * len(want)) + ")"
        in_params = want

    def _pull(table: str, mkt: str) -> int:
        rows = db.conn.execute(
            f"SELECT code, tick_time, source, tick_time_raw FROM {table} "
            "WHERE market=%s AND tick_time >= %s AND tick_time <= %s"
            + in_sql,
            (mkt, sk, ek, *in_params),
        ).fetchall()

    ticks_map = _load_ticks_by_code_bulk(
        db, start_key, end_key, market=mk or None,
    )

    ticks_map = _load_ticks_by_code_bulk(
        db, start_key, end_key, market=mk or None, codes_filter=want or None,
    )


[REDACTED]

Shell

python3 -m py_compile kis_trader/backtest/bt_candle_source.py && PYTHONPATH=/home/hoon/kis_bot python3 -u scripts/_tmp_parity_candle_bulk.py

Re-verify 3-code OHLC match after tick IN