[REDACTED]

PYTHONPATH=/home/hoon/kis_bot python3 -u -c "
import logging, time
logging.basicConfig(level=logging.INFO, format='%(message)s')
from database import TradeDB
from kis_trader.backtest.bt_candle_source import fetch_ws_candles_by_code_bulk
db = TradeDB()
t0 = time.perf_counter()
m = fetch_ws_candles_by_code_bulk(db, 1, '202608170000', '202608202359', market='KR', confirmed_only=True)
print('full_codes', len(m), 'bars', sum(len(v) for v in m.values()), 'sec', round(time.perf_counter()-t0, 2))
db.close()
"

Time full KR universe candle bulk load


[REDACTED]


[REDACTED]

Shell

ps -p 2886103 -o pid,etime,pcpu,pmem,stat,cmd 2>/dev/null; tail -5 /home/hoon/.cursor/projects/home-hoon-kis-bot/terminals/230833.txt

Check if bulk load process is still CPU-busy


[REDACTED]

pgrep -P 2886103 -a; pgrep -af 'fetch_ws_candles_by_code_bulk|PYTHONPATH=/home/hoon/kis_bot python3 -u -c' | head

Find python child of hung bulk load


[REDACTED]

ps -p 2886117 -o pid,etime,pcpu,pmem,stat

CPU usage of bulk-load Python process


[REDACTED]

ps -p 2886117 -o pid,etime,pcpu,pmem,stat

Recheck bulk Python CPU after 11 minutes


[REDACTED]

Stop hung full-universe garbage CPU loop

StrReplace

    tf = max(1, int(tf_min or 1))
    tick_idx = None
    if ticks is not None:
        tick_idx = _index_ticks_by_minute(ticks)
    out: List[Dict[str, Any]] = []
    for ct in sorted(by_ct.keys()):
        picked: Optional[Dict[str, Any]] = None
        for pair in order:
            bar = by_ct[ct].get(pair)
            if bar is None:
                continue
            src, ch = pair
            if ticks is not None and ch == CHANNEL_WS:
                scoped = _ticks_in_bar_from_index(tick_idx, src, ct, tf)
                if live_cover:
                    bad = live_bar_is_garbage(
                        ticks, candle_time=ct, tf_min=tf, source=src,
                    )
                else:
                    bad = bar_is_garbage(
                        scoped, candle_time=ct, tf_min=tf, source=src,
                        missing_policy=missing_policy,
                    )
                if bad:
                    continue

    tf = max(1, int(tf_min or 1))
    out: List[Dict[str, Any]] = []
    for ct in sorted(by_ct.keys()):
        picked: Optional[Dict[str, Any]] = None
        for pair in order:
            bar = by_ct[ct].get(pair)
            if bar is None:
                continue
            src, ch = pair
            if ticks is not None and ch == CHANNEL_WS:
                if live_cover:
                    bad = live_bar_is_garbage(
                        ticks, candle_time=ct, tf_min=tf, source=src,
                    )
                else:
                    bad = bar_is_garbage(
                        ticks, candle_time=ct, tf_min=tf, source=src,
                        missing_policy=missing_policy,
                    )
                if bad:
                    continue


[REDACTED]

    return f"LIVE({main})+{aux}+ls+kiwoom_rest"

def _index_ticks_by_minute(ticks: Sequence[Dict[str, Any]]) -> Dict[Tuple[str, str], List[Dict[str, Any]]]:
    """(source, YYYYMMDDHHMM) → 틱. source 빈 틱은 ('*', 분) — 모든 증권사 쓰레기검사에 포함."""
    idx: Dict[Tuple[str, str], List[Dict[str, Any]]] = {}
    for t in ticks or []:
        if not isinstance(t, dict):
            continue
        raw = str(t.get("tick_time_raw") or t.get("tick_time") or "").strip()
        digits = raw.replace(":", "").replace("-", "").replace(" ", "")
        tmin = digits[:12] if len(digits) >= 12 else ""
        if len(tmin) < 12 or not tmin.isdigit():
            continue
        tsrc = str(t.get("source") or "").strip().lower()
        key = (tsrc if tsrc else "*", tmin)
        idx.setdefault(key, []).append(t)
    return idx

def _ticks_in_bar_from_index(
    idx: Optional[Dict[Tuple[str, str], List[Dict[str, Any]]]],
    source: str,
    candle_time: str,
    tf_min: int,
) -> List[Dict[str, Any]]:
    """tick_in_bar_bucket 과 같은 분 구간만 — bar_is_garbage 전체 스캔 방지."""
    from kis_trader.engine.candle_rollup import add_candle_minutes

    if not idx:
        return []
    ct = str(candle_time or "").strip()[:12]
    if len(ct) < 12:
        return []
    end = add_candle_minutes(ct, max(1, int(tf_min or 1)))
    if not end:
        return []
    end12 = end[:12]
    src = str(source or "").strip().lower()
    out: List[Dict[str, Any]] = []
    cur = ct
    while cur and cur < end12:
        if src:
            out.extend(idx.get((src, cur), ()))
        out.extend(idx.get(("*", cur), ()))
        nxt = add_candle_minutes(cur, 1)
        if not nxt or nxt[:12] <= cur:
            break
        cur = nxt[:12]
    return out

def dedupe_by_read_pairs(

    return f"LIVE({main})+{aux}+ls+kiwoom_rest"

def dedupe_by_read_pairs(


[REDACTED]

Shell

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

Compile and re-run 3-code OHLC parity


[REDACTED]

PYTHONPATH=/home/hoon/kis_bot python3 -u -c "
import logging, time
logging.basicConfig(level=logging.INFO, format='%(message)s')
from database import TradeDB
from kis_trader.backtest.bt_candle_source import fetch_ws_candles_by_code_bulk
db = TradeDB()
t0 = time.perf_counter()
m = fetch_ws_candles_by_code_bulk(db, 1, '202608170000', '202608202359', market='KR', confirmed_only=True)
print('full_codes', len(m), 'bars', sum(len(v) for v in m.values()), 'sec', round(time.perf_counter()-t0, 2))
db.close()
"

Time full KR bulk load after tick index


[REDACTED]

Run live execution validation smoke once


캔들 로드 bulk를 넣었습니다. **실매 매매 로직은 그대로**이고, **백테·옵투나 로더 속도만** 바뀝니다.

**한 줄:** 종목마다 SQL 두 번 치던 것을, 기간 봉 1번 + 틱 1번(쓰레기 검사용)으로 바꿨습니다. 쓰레기 검사는 **ON 유지**. OHLC로 틱 메우기 없음. LS 경로는 손대지 않았습니다.

### 무엇이 바뀌었나
- 공용: `fetch_ws_candles_by_code_bulk` (`kis_trader/backtest/bt_candle_source.py`)
- 호출: 모멘텀 옵투나·웹, 스캘프, 돌파, 꼬리 로더
- 웜업은 예전처럼 종목당 `fetch_ws_candles_warmup_before`
- 틱 조회에 **`market` + 인덱스** (`idx_ws_ticks_lookup`) — 예전처럼 market 없이 전 테이블 스캔하지 않음
- 쓰레기 판정은 같음. 분 단위로 틱을 나눠 보는 것만 해서, 전체 틱×전체 봉 이중 루프를 없앰 (결과 동일, CPU만 줄어듦)

### 검증
| 항목 | 결과 |
|------|------|
| 쓰레기 | `CANDLE_GARBAGE_FALLBACK=True` |
| 종목 3개 OHLC | `000080` / `000100` / `000120` **일치** |
| 8/17~20 KR 전체 | **600종목 · 약 20초** (이전 `_load_candles` **약 30.6분**) |
| 실매 스모크 | **최종: 통과** · `👑 [최종 판정] 완결!` |

로그: `logs/test_live_execution_validation_20260820_141419.log`

실매 `get_candles`는 `live_cover=True`라 이번 인덱스 경로를 안 탑니다. **봇 재시작은 하지 않았습니다.** 다음 옵투나부터 캔들 로드가 20초 근처로 나와야 합니다.

[REDACTED]


<timestamp>Thursday, Aug 20, 2026, 2: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>


아까 돌려 두었던 **전체 종목 캔들 bulk 시간 측정**은 제가 중간에 끊었습니다(종료코드 143). 쓰레기 검사가 틱×봉으로 너무 오래 걸려서입니다.

그 뒤에 분 단위 인덱스를 넣었고, 같은 기간 KR 전체는 **약 20초**에 끝났습니다. 지금 쓸 숫자는 그 결과입니다.

[REDACTED]


<timestamp>Thursday, Aug 20, 2026, 5:32 PM (UTC+9)</timestamp>
<user_query>
어떻게 거래가 이렇게 없을수가 있냐 로그 디비 뒤져봐 이거 뭔가 잘못된거같아
</user_query>


지금 바로 실매 로그 + DB를 같이 확인해서, **왜 진입이 0건인지**를 원인별로 분해해 보겠습니다. 먼저 테이블 스키마부터 확인하고 최근 몇 시간 후보/필터/주문 흐름을 뽑겠습니다.

[REDACTED]

rg

CREATE TABLE IF NOT EXISTS\s+([a-zA-Z0-9_]+)

매수|매도|후보|탈락|가격없음|order|체결


DB에서 최근 몇 시간 실제 흐름(후보→매수시도→체결)을 바로 집계해 원인 구간을 찍겠습니다. 컬럼 확인 후 조회합니다.

[REDACTED]

Shell

PYTHONPATH=/home/hoon/kis_bot python3 - <<'PY'
from database import TradeDB

TABLES = [
    'target_candidates_history',
    'buy_execution_log',
    'order_execution_history',
    'trade_history',
    'active_trades',
]

db = TradeDB()
try:
    print('=== COLUMNS ===')
    for t in TABLES:
        cols = db.conn.execute(f"SHOW COLUMNS FROM {t}").fetchall()
        names = [c['Field'] for c in cols]
        print(t, names)

    print('\n=== RECENT COUNTS (last 6h) ===')
    q1 = db.conn.execute(
        "SELECT COUNT(*) n FROM target_candidates_history WHERE scan_time >= DATE_SUB(NOW(), INTERVAL 6 HOUR)"
    ).fetchone()
    print('target_candidates_history', q1['n'])

    q2 = db.conn.execute(
        "SELECT COUNT(*) n FROM buy_execution_log WHERE created_at >= DATE_SUB(NOW(), INTERVAL 6 HOUR)"
    ).fetchone()
    print('buy_execution_log', q2['n'])

    q3 = db.conn.execute(
        "SELECT COUNT(*) n FROM order_execution_history WHERE created_at >= DATE_SUB(NOW(), INTERVAL 6 HOUR)"
    ).fetchone()
    print('order_execution_history', q3['n'])

    q4 = db.conn.execute(
        "SELECT COUNT(*) n FROM trade_history WHERE exit_time >= DATE_SUB(NOW(), INTERVAL 6 HOUR)"
    ).fetchone()
    print('trade_history(exit)', q4['n'])

    print('\n=== ACTIVE TRADES ===')
    rows = db.conn.execute(
        "SELECT strategy, code, qty, entry_price, entry_time FROM active_trades ORDER BY entry_time DESC LIMIT 20"
    ).fetchall()
    for r in rows:
        print(dict(r))

    print('\n=== LATEST buy_execution_log 30 ===')
    rows = db.conn.execute(
        "SELECT created_at, strategy, code, status, reason FROM buy_execution_log ORDER BY id DESC LIMIT 30"
    ).fetchall()
    for r in rows:
        print(dict(r))

    print('\n=== LATEST order_execution_history 30 ===')
    rows = db.conn.execute(
        "SELECT created_at, strategy, code, side, status, reason FROM order_execution_history ORDER BY id DESC LIMIT 30"
    ).fetchall()
    for r in rows:
        print(dict(r))

    print('\n=== CANDIDATE counts by strategy last 6h ===')
    rows = db.conn.execute(
        "SELECT strategy_id, COUNT(*) n FROM target_candidates_history WHERE scan_time >= DATE_SUB(NOW(), INTERVAL 6 HOUR) GROUP BY strategy_id ORDER BY n DESC"
    ).fetchall()
    for r in rows:
        print(dict(r))
finally:
    db.close()
PY

Inspect recent trading pipeline counts and latest rows


컬럼명이 달라서 1차 조회가 실패했고(원인 확인 완료), 실제 컬럼 기준으로 즉시 재조회합니다. 동시에 실매 서비스 로그도 최근 구간 확인하겠습니다.

[REDACTED]

PYTHONPATH=/home/hoon/kis_bot python3 - <<'PY'
from database import TradeDB

db = TradeDB()
try:
    print('=== RECENT COUNTS (last 6h) ===')
    c1 = db.conn.execute("SELECT COUNT(*) n FROM target_candidates_history WHERE scan_time >= DATE_SUB(NOW(), INTERVAL 6 HOUR)").fetchone()['n']
    c2 = db.conn.execute("SELECT COUNT(*) n FROM buy_execution_log WHERE executed_at >= DATE_SUB(NOW(), INTERVAL 6 HOUR)").fetchone()['n']
    c3 = db.conn.execute("SELECT COUNT(*) n FROM order_execution_history WHERE fetched_at >= DATE_SUB(NOW(), INTERVAL 6 HOUR)").fetchone()['n']
    c4 = db.conn.execute("SELECT COUNT(*) n FROM trade_history WHERE sell_date >= DATE_SUB(NOW(), INTERVAL 6 HOUR)").fetchone()['n']
    print('target_candidates_history', c1)
    print('buy_execution_log', c2)
    print('order_execution_history', c3)
    print('trade_history(sell)', c4)

    print('\n=== ACTIVE TRADES ===')
    rows = db.conn.execute(
        "SELECT strategy, code, current_qty, avg_buy_price, buy_date FROM active_trades ORDER BY buy_date DESC LIMIT 20"
    ).fetchall()
    for r in rows:
        print(dict(r))

    print('\n=== buy_execution_log latest 40 ===')
    rows = db.conn.execute(
        "SELECT executed_at, strategy, code, qty, amount FROM buy_execution_log ORDER BY id DESC LIMIT 40"
    ).fetchall()
    for r in rows:
        print(dict(r))

    print('\n=== order_execution_history latest 60 ===')
    rows = db.conn.execute(
        "SELECT fetched_at, stk_cd, stk_nm, trde_tp, ord_qty, cntr_qty, ord_dt, ord_tm, cnfm_tm FROM order_execution_history ORDER BY id DESC LIMIT 60"
    ).fetchall()
    for r in rows:
        print(dict(r))

    print('\n=== candidate strategy counts last 6h ===')
    rows = db.conn.execute(
        "SELECT strategy_id, COUNT(*) n FROM target_candidates_history WHERE scan_time >= DATE_SUB(NOW(), INTERVAL 6 HOUR) GROUP BY strategy_id ORDER BY n DESC"
    ).fetchall()
    for r in rows:
        print(dict(r))

    print('\n=== candidate codes last 30min by strategy ===')
    rows = db.conn.execute(
        "SELECT strategy_id, COUNT(DISTINCT code) codes FROM target_candidates_history WHERE scan_time >= DATE_SUB(NOW(), INTERVAL 30 MINUTE) GROUP BY strategy_id ORDER BY codes DESC"
    ).fetchall()
    for r in rows:
        print(dict(r))
finally:
    db.close()
PY

Query recent candidates, buys, orders, holdings from DB

journalctl -u kis_trader_main.service --since 'today 12:00' --no-pager

Read live bot logs since noon


[REDACTED]

journalctl -u kis_trader_main.service --since '2026-08-20 12:00:00' --no-pager

Fetch live bot journal from noon

journalctl -u kis_trader_main.service --since '2026-08-20 12:00:00' --no-pager | rg '탈락|매수|후보|가격없음|호가없음|스킵|slot|포트|max_stocks|보유'

Filter noon journal for entry-blocking reasons


지금 DB상으론 후보는 많이 들어오는데 매수 실행 로그가 0건이라, **실제 차단 원인(슬롯/최대보유/필터/시간창)**을 설정값+체결이력에서 좁혀보겠습니다.

[REDACTED]

rg

탈락-|호가없음|가격없음|max_stocks|max stocks|보유|매수 스킵|진입 스킵|슬롯|후보 0|후보 없음

Shell

PYTHONPATH=/home/hoon/kis_bot python3 - <<'PY'
from database import TradeDB
keys = [
    'MOMENTUM_MAX_STOCKS','MOMENTUM_SLOT_MONEY','MOMENTUM_TOTAL_BUDGET_KRW',
    'BREAKOUT_MAX_STOCKS','BREAKOUT_SLOT_MONEY','BREAKOUT_TOTAL_BUDGET_KRW',
    'SCALP_MAX_STOCKS','SCALP_SLOT_MONEY','SCALP_TOTAL_BUDGET_KRW',
    'TAIL_MAX_STOCKS','TAIL_SLOT_MONEY','TAIL_TOTAL_BUDGET_KRW',
    'MOMENTUM_ORDERBOOK_FILTER_ENABLED','SCALP_ORDERBOOK_FILTER_ENABLED','TAIL_ORDERBOOK_FILTER_ENABLED',
    'LIVE_BUY_ENABLED','LIVE_TICK_PROVIDER','CANDLE_SOURCE'
]
db=TradeDB()
try:
    print('=== env_config values ===')
    for k in keys:
        r = db.conn.execute('SELECT value FROM env_config WHERE `key`=%s', (k,)).fetchone()
        if not r:
            r = db.conn.execute('SELECT value FROM env_config_ext WHERE `key`=%s', (k,)).fetchone()
        print(k, None if not r else r['value'])

    print('\n=== active by strategy ===')
    rows=db.conn.execute('SELECT strategy, COUNT(*) n, SUM(current_qty*avg_buy_price) invested FROM active_trades GROUP BY strategy').fetchall()
    for r in rows:
        print(dict(r))

    print('\n=== recent sells 6h ===')
    rows=db.conn.execute("SELECT sell_date, strategy, code, sell_reason, realized_pnl, hold_minutes FROM trade_history WHERE sell_date >= DATE_SUB(NOW(), INTERVAL 6 HOUR) ORDER BY sell_date DESC LIMIT 50").fetchall()
    for r in rows:
        print(dict(r))

    print('\n=== recent buys 24h from trade_history ===')
    rows=db.conn.execute("SELECT buy_date, strategy, code, buy_price, qty FROM trade_history WHERE buy_date >= DATE_SUB(NOW(), INTERVAL 24 HOUR) ORDER BY buy_date DESC LIMIT 50").fetchall()
    for r in rows:
        print(dict(r))
finally:
    db.close()
PY

Check strategy limits, active slots, and recent trade history


[REDACTED]

PYTHONPATH=/home/hoon/kis_bot python3 - <<'PY'
from database import TradeDB
from kis_trader.utils.env import get_env_from_db

db=TradeDB()
try:
    for t in ('env_config','env_config_ext','trade_history'):
        cols=db.conn.execute(f'SHOW COLUMNS FROM {t}').fetchall()
        print(t, [c['Field'] for c in cols])

    keys=[
        'MOMENTUM_MAX_STOCKS','BREAKOUT_MAX_STOCKS','SCALP_MAX_STOCKS','TAIL_MAX_STOCKS',
        'SCALP_MIN_DROP_RATE','BREAKOUT_PREV_BAR_MIN_PCT','MOMENTUM_MIN_VOL_RATIO'
    ]
    print('\n=== env via get_env_from_db ===')
    for k in keys:
        print(k, get_env_from_db(k, None))

    print('\n=== recent buys 24h (trade_history) ===')
    rows=db.conn.execute("SELECT buy_date, strategy, code, buy_price, qty FROM trade_history WHERE buy_date >= DATE_SUB(NOW(), INTERVAL 24 HOUR) ORDER BY buy_date DESC LIMIT 50").fetchall()
    for r in rows:
        print(dict(r))

    print('\n=== recent sells 6h ===')
    rows=db.conn.execute("SELECT sell_date, strategy, code, sell_reason, realized_pnl FROM trade_history WHERE sell_date >= DATE_SUB(NOW(), INTERVAL 6 HOUR) ORDER BY sell_date DESC LIMIT 50").fetchall()
    for r in rows:
        print(dict(r))
finally:
    db.close()
PY

Inspect env schema and fetch key strategy thresholds


[REDACTED]

python3 - <<'PY'
import re
from collections import Counter
p='/home/hoon/.cursor/projects/home-hoon-kis-bot/agent-tools/2280c12a-d528-4a73-964a-eb206a618046.txt'
reason=Counter(); strat=Counter(); cand=Counter(); buyok=0
re_reason=re.compile(r'탈락-([^\]]+)')
re_strat=re.compile(r'\[kis_trader\.strategy\.([A-Z_]+)\]')
re_cand=re.compile(r'후보\s+(\d+)\s+\(보유\s+(\d+)/(\d+)\)')
with open(p,'r',encoding='utf-8',errors='ignore') as f:
    for line in f:
        m=re_reason.search(line)
        if m: reason[m.group(1)] += 1
        s=re_strat.search(line)
        if s: strat[s.group(1)] += 1
        c=re_cand.search(line)
        if c:
            cand[(s.group(1) if s else '?', c.group(1), c.group(2), c.group(3))] += 1
        if '✅ 매수 성공' in line or '매수 체결' in line:
            buyok += 1
print('top reasons')
for k,v in reason.most_common(12):
    print(k,v)
print('\nstrategies log lines')
for k,v in strat.most_common():
    print(k,v)
print('\ncommon candidate snapshots')
for k,v in cand.most_common(12):
    print(k,v)
print('\nbuy_success_mentions',buyok)
PY

Summarize rejection reasons from noon journal extract


맞습니다. 방금 **로그+DB를 직접 뒤져서** 원인 확인했습니다.
결론부터 말하면, 지금은 **엔진이 멈춘 게 아니라 “후보는 들어오는데 트리거에서 거의 전부 탈락”** 상태입니다.

- 최근 6시간 DB:
  - `target_candidates_history`: **53,069건** (후보 유입 정상)
  - `buy_execution_log`: **0건**
  - `order_execution_history`: **0건**
  - `trade_history` 매도: **13건** (청산은 계속 발생)
- 현재 보유:
  - 모멘텀 2종목 + HOLDING 4종목 (슬롯 막힘 아님, 모멘텀도 2/8)
- 최근 24시간 매수 이력(`trade_history`)은 8/20 오전~정오까지 다수 존재 (즉, 매수 기능 자체 고장은 아님)

로그(12:00 이후)에서 탈락 사유 집계:
- `탈락-직전봉약세`: **86,908**
- `탈락-낙폭`: **65,722**
- `탈락-봉등락`: **22,399**
- `탈락-음봉`: **13,930**
- `탈락-거래량`: **5,072**
- `탈락-호가없음`: **254**
- `매수 성공/매수 체결` 문구: **0회**

즉 지금 0건의 핵심은 **인프라 장애보다는 장중 조건 미충족(필터 과엄격 + 장세 미스매치)** 쪽입니다.
추가로 눈에 띄는 건 `ghost_purge:broker_zero` 매도가 오늘 여러 번 있어, **보유 동기화 경로도 별도 점검 필요**합니다 (매수 0건의 직접 원인은 아니지만 손익/포지션 신뢰도에 영향).

원하면 다음으로 바로:
1) 전략별 “어느 필터가 가장 많이 막는지” 시간대별로 더 쪼개서,
2) 오늘 장 기준으로 최소 조정 후보(예: 스캘프 낙폭, 돌파 직전봉 임계, 모멘텀 거래량배수)만 수치 제안드리겠습니다.

[REDACTED]


<timestamp>Thursday, Aug 20, 2026, 5:36 PM (UTC+9)</timestamp>
<user_query>
말도안돼 어제는 훨씬 많이 거래됐는데 오늘 괸거때문에 뭔가 꼬인거같은데
</user_query>


어제와 오늘을 나란히 비교해서, **재시작(13:45) 이후에 뭔가 꼬였는지**부터 확인하겠습니다.

[REDACTED]

PYTHONPATH=/home/hoon/kis_bot python3 - <<'PY'
from database import TradeDB
from collections import defaultdict

db = TradeDB()
try:
    print('=== TRADES BY DAY (trade_history buys) ===')
    rows = db.conn.execute("""
        SELECT DATE(buy_date) d, strategy, COUNT(*) n, SUM(qty) qty
        FROM trade_history
        WHERE buy_date >= DATE_SUB(CURDATE(), INTERVAL 3 DAY)
        GROUP BY DATE(buy_date), strategy
        ORDER BY d DESC, strategy
    """).fetchall()
    for r in rows:
        print(dict(r))

    print('\n=== TRADES BY HOUR TODAY (buy) ===')
    rows = db.conn.execute("""
        SELECT DATE_FORMAT(buy_date, '%H') h, strategy, COUNT(*) n
        FROM trade_history
        WHERE DATE(buy_date)=CURDATE()
        GROUP BY DATE_FORMAT(buy_date, '%H'), strategy
        ORDER BY h, strategy
    """).fetchall()
    for r in rows:
        print(dict(r))

    print('\n=== SELLS TODAY by reason ===')
    rows = db.conn.execute("""
        SELECT sell_reason, COUNT(*) n
        FROM trade_history
        WHERE DATE(sell_date)=CURDATE()
        GROUP BY sell_reason ORDER BY n DESC
    """).fetchall()
    for r in rows:
        print(dict(r))

    print('\n=== ACTIVE vs ghost timeline today ===')
    rows = db.conn.execute("""
        SELECT sell_date, strategy, code, sell_reason, buy_date
        FROM trade_history
        WHERE DATE(sell_date)=CURDATE() AND sell_reason LIKE 'ghost%'
        ORDER BY sell_date
    """).fetchall()
    for r in rows:
        print(dict(r))

    print('\n=== CANDIDATES per hour today ===')
    rows = db.conn.execute("""
        SELECT DATE_FORMAT(scan_time, '%H') h, strategy_id, COUNT(*) n, COUNT(DISTINCT code) codes
        FROM target_candidates_history
        WHERE DATE(scan_time)=CURDATE()
        GROUP BY DATE_FORMAT(scan_time, '%H'), strategy_id
        ORDER BY h, strategy_id
    """).fetchall()
    cur_h = None
    for r in rows:
        if cur_h != r['h']:
            cur_h = r['h']; print(f"-- {cur_h}:00 --")
        print(dict(r))

    print('\n=== CANDIDATES per hour yesterday ===')
    rows = db.conn.execute("""
        SELECT DATE_FORMAT(scan_time, '%H') h, strategy_id, COUNT(*) n, COUNT(DISTINCT code) codes
        FROM target_candidates_history
        WHERE DATE(scan_time)=DATE_SUB(CURDATE(), INTERVAL 1 DAY)
        GROUP BY DATE_FORMAT(scan_time, '%H'), strategy_id
        ORDER BY h, strategy_id
    """).fetchall()
    cur_h = None
    for r in rows:
        if cur_h != r['h']:
            cur_h = r['h']; print(f"-- {cur_h}:00 --")
        print(dict(r))
finally:
    db.close()
PY

Compare yesterday vs today trades and candidates by hour

journalctl -u kis_trader_main.service --since '2026-08-20 13:40:00' --until '2026-08-20 14:10:00' --no-pager | rg 'Started|Stopped|재시작|ERROR|Exception|Traceback|ghost|매수|탈락|후보|보유'

Inspect restart window logs around 13:45

journalctl -u kis_trader_main.service --since '2026-08-19 09:00:00' --until '2026-08-19 16:00:00' --no-pager | rg '✅ 매수|매수 성공|매수 체결|매수주문|BUY|진입' | head -40

Sample yesterday buy success log lines


[REDACTED]

PYTHONPATH=/home/hoon/kis_bot python3 - <<'PY'
from database import TradeDB
db=TradeDB()
try:
    print('=== TRADES BY HOUR TODAY (buy) ===')
    rows=db.conn.execute("""
        SELECT HOUR(buy_date) h, strategy, COUNT(*) n
        FROM trade_history
        WHERE DATE(buy_date)=CURDATE()
        GROUP BY HOUR(buy_date), strategy
        ORDER BY h, strategy
    """).fetchall()
    for r in rows: print(dict(r))

    print('\n=== TRADES BY HOUR YESTERDAY (buy) ===')
    rows=db.conn.execute("""
        SELECT HOUR(buy_date) h, strategy, COUNT(*) n
        FROM trade_history
        WHERE DATE(buy_date)=DATE_SUB(CURDATE(), INTERVAL 1 DAY)
        GROUP BY HOUR(buy_date), strategy
        ORDER BY h, strategy
    """).fetchall()
    for r in rows: print(dict(r))

    print('\n=== TODAY buys detail ===')
    rows=db.conn.execute("""
        SELECT buy_date, strategy, code, qty, buy_price
        FROM trade_history WHERE DATE(buy_date)=CURDATE() ORDER BY buy_date
    """).fetchall()
    for r in rows: print(dict(r))
finally: db.close()
PY

Compare hourly buy counts today vs yesterday

journalctl -u kis_trader_main.service --since '2026-08-20 12:00:00' --until '2026-08-20 12:30:00' --no-pager | rg '매수|주문|체결|진입|BUY|buy' | head -30

Find buy-related logs around last buy time 12:17


[REDACTED]

journalctl -u kis_trader_main.service --since '2026-08-20 12:10:00' --until '2026-08-20 12:25:00' --no-pager | rg '163730|매수|주문|체결|탈락|보유|ghost|ERROR' | head -60

Logs around last buy 163730 at 12:17

PYTHONPATH=/home/hoon/kis_bot python3 - <<'PY'
from kis_trader.utils.env import get_env_from_db
keys=['MOMENTUM_TIME_START','MOMENTUM_TIME_END','BREAKOUT_TIME_START','BREAKOUT_TIME_END','SCALP_TIME_START','SCALP_TIME_END','TAIL_TIME_START','TAIL_TIME_END','TIME_START','TIME_END','DAILY_PROFIT_HALT_NEW_BUYS','MARKET_GUARD_ENABLED','FORCE_BUY_TEST']
for k in keys:
    print(k, get_env_from_db(k, None))
PY

Check time window and buy halt env keys


[REDACTED]

journalctl -u kis_trader_main.service --since '2026-08-20 12:17:00' --until '2026-08-20 15:30:00' --no-pager | rg '매수주문|매수 성공|매수체결|진입완료|buy_order|BUY OK|체결완료|주문접수|탈락-호가|탈락-예산|탈락-슬롯|탈락-중복|이미보유|시간외|TIME_END|1530|신규매수금지' | head -80

Search for buy attempts or blocks 12:17-15:30 today

python3 - <<'PY'
import re
from collections import Counter

def summarize(path, label):
    reason=Counter(); passed=Counter()
    re_pass=re.compile(r'(통과|매수주문|매수 성공|진입|✅)')
    re_reason=re.compile(r'탈락-([^\]]+)')
    re_strat=re.compile(r'strategy\.([A-Z_]+)')
    with open(path,'r',encoding='utf-8',errors='ignore') as f:
        for line in f:
            s=re_strat.search(line)
            st=s.group(1) if s else '?'
            m=re_reason.search(line)
            if m: reason[(st,m.group(1))]+=1
            if re_pass.search(line) and '매수체크' not in line:
                passed[st]+=1
    print(f'=== {label} top rejections ===')
    for (st,r),v in reason.most_common(10):
        print(st,r,v)
    print('pass hints', dict(passed))

# today noon-1530 from journal directly
import subprocess
p=subprocess.run([
    'journalctl','-u','kis_trader_main.service','--since','2026-08-20 12:00:00','--until','2026-08-20 15:30:00','--no-pager'
],capture_output=True,text=True)
lines=p.stdout.splitlines()
reason=Counter(); buycheck=Counter(); hold=Counter()
for line in lines:
    s=re.search(r'strategy\.([A-Z_]+)', line)
    st=s.group(1) if s else '?'
    if '매수체크' in line:
        buycheck[st]+=1
        m=re.search(r'보유\s+(\d+)/(\d+)', line)
        if m: hold[(st,m.group(1),m.group(2))]+=1
    m=re.search(r'탈락-([^\]]+)', line)
    if m: reason[(st,m.group(1))]+=1
print('=== TODAY 12:00-15:30 ===')
print('buycheck', dict(buycheck))
print('hold top', hold.most_common(6))
print('reject top', reason.most_common(12))

p2=subprocess.run([
    'journalctl','-u','kis_trader_main.service','--since','2026-08-19 12:00:00','--until','2026-08-19 15:30:00','--no-pager'
],capture_output=True,text=True)
lines=p2.stdout.splitlines(); reason=Counter(); buycheck=Counter(); hold=Counter()
for line in lines:
    s=re.search(r'strategy\.([A-Z_]+)', line)
    st=s.group(1) if s else '?'
    if '매수체크' in line:
        buycheck[st]+=1
        m=re.search(r'보유\s+(\d+)/(\d+)', line)
        if m: hold[(st,m.group(1),m.group(2))]+=1
    m=re.search(r'탈락-([^\]]+)', line)
    if m: reason[(st,m.group(1))]+=1
print('\n=== YESTERDAY 12:00-15:30 ===')
print('buycheck', dict(buycheck))
print('hold top', hold.most_common(6))
print('reject top', reason.most_common(12))
PY

Compare rejection patterns today vs yesterday afternoon


[REDACTED]

journalctl -u kis_trader_main.service --since '2026-08-20 12:20:00' --until '2026-08-20 15:30:00' --no-pager | rg '매수체결|매수주문' | wc -l; journalctl -u kis_trader_main.service --since '2026-08-19 12:20:00' --until '2026-08-19 15:30:00' --no-pager | rg '매수체결|매수주문' | wc -l

Count buy fills afternoon today vs yesterday

journalctl -u kis_trader_main.service --since '2026-08-19 12:20:00' --until '2026-08-19 15:30:00' --no-pager | rg '매수체결|매수주문'

List yesterday afternoon buy fills

journalctl -u kis_trader_main.service --since '2026-08-20 13:45:00' --until '2026-08-20 15:30:00' --no-pager | rg '매수체결|매수주문|ERROR|Exception|ghost_purge|탈락-호가수급|탈락-호가없음' | head -40

Post-restart buy attempts and errors


[REDACTED]

journalctl -u kis_trader_main.service --since '2026-08-20 12:20:00' --until '2026-08-20 15:30:00' --no-pager | rg '매수주문|진입시도|주문접수|BUY|매수체결'
journalctl -u kis_trader_main.service --since '2026-08-20 12:20:00' --until '2026-08-20 15:30:00' --no-pager | rg '탈락-(?!호가|음봉|거래량|직전|낙폭|봉등락|피뢰|회복|저항|최소|E미)' | head -30

Find late-stage buy attempts or unusual rejections

PYTHONPATH=/home/hoon/kis_bot python3 - <<'PY'
from database import TradeDB
from kis_trader.utils.env import get_env_from_db
print('MOMENTUM_ORDERBOOK_MIN_BID_ASK_RATIO', get_env_from_db('MOMENTUM_ORDERBOOK_MIN_BID_ASK_RATIO', None))
print('MOMENTUM_ORDERBOOK_MAX_SPREAD_PCT', get_env_from_db('MOMENTUM_ORDERBOOK_MAX_SPREAD_PCT', None))
print('MOMENTUM_MIN_VOL_RATIO or similar')
for k in ['MOMENTUM_VOL_RATIO_MIN','MOMENTUM_VOLUME_RATIO','MOMENTUM_MIN_VOLUME_RATIO','MOMENTUM_VOL_MULT']:
    v=get_env_from_db(k, None)
    if v is not None: print(k,v)
db=TradeDB()
try:
    cols=db.conn.execute('SHOW COLUMNS FROM config_momentum').fetchall()
    names=[c['Field'] for c in cols if 'VOL' in c['Field'].upper() or 'ORDERBOOK' in c['Field'].upper() or 'BID' in c['Field'].upper()]
    print('config_momentum cols sample', names[:20])
    row=db.conn.execute('SELECT * FROM config_momentum ORDER BY id DESC LIMIT 1').fetchone()
    if row:
        for k in names:
            if row.get(k) not in (None,''):
                print(k, row[k])
finally: db.close()
PY

Check momentum orderbook and volume filter DB values


**재시작(13:45) 때문은 아닙니다.** 로그·DB 기준으로 보면, 오후 거래 급감은 **12:17 이후 필터에서 막힌 것**이고, 재시작보다 **1시간 28분 먼저** 매수가 끊겼습니다.

## 핵심 결론

| 구분 | 어제(8/19) | 오늘(8/20) |
|------|-----------|-----------|
| 하루 매수 건수 | **20건** (모멘 17 + 돌파 3) | **11건** (모멘 7 + 돌파 4) |
| 12:20~15:30 매수 | **6건+** (13~15시에도 체결) | **0건** (12:17 핑거 163730 이후 신규 없음) |
| 후보 유입(6h) | 정상 | **53,069건** (스캔·유니버스 정상) |
| 봇 동작 | - | 매수체크 루프 **계속 돌아감** |

오늘도 **아침엔 11건** 나왔습니다. “완전 0”이 아니라 **오후만 비었고**, 어제 대비 **전체는 약 절반** 수준입니다.

---

## 왜 12:17 이후 신규 매수가 없었나

**1) 재시작과 무관 (시간 증거)**

- 마지막 신규 매수: **12:17:38** (핑거 163730)
- 봇 재시작: **13:45:53**
- → 재시작 **전에** 이미 매수가 멈춘 상태

**2) 오후엔 “봉 필터”를 넘긴 종목이 호가 필터에서 막힘**

12:20~15:30 모멘텀 탈락 상위:

- `탈락-호가수급`: **518회** (매수/매도잔량비 **0.86 미만**)
- `탈락-호가없음`: **221회**

실제 로그 예:

- **124500 아이티센글로벌**: 0.70~0.80 → **0.86** 못 넘김 (수십 번 재시도)
- **228850 레이언스**: 0.61~0.71 → 동일
- **053260 금강철강**: `호가없음` 반복 (재시작 후에도)

DB 설정: `MOMENTUM_ORDERBOOK_MIN_BID_ASK_RATIO = 0.86` (변경 흔적 없음)

→ **엔진 고장이 아니라, 오후 장세 + 호가 필터가 딱 걸린 케이스**입니다. 어제도 같은 필터로 1245회 막혔는데, **그때는 0.86을 넘긴 종목이 있었고 오늘 오후엔 없었음**.

**3) 돌파/스캘프는 종일 0건 (오늘도 어제 패턴과 동일)**

- 돌파: `탈락-직전봉약세` 대량 (보유 0/8, 슬롯 문제 아님)
- 스캘프: `탈락-낙폭` 5.3% 미달 (보유 0/8)

**4) 슬롯/시간 제한 아님**

- 모멘텀 보유: **2~4/8** (여유 있음)
- `TIME_END = 1530` → 지금(17:36)은 장 마감 후라 추가 매수 없음이 정상

---

## “꼬인 것 같다”고 느낄 만한 **진짜 이슈** (별도)

`ghost_purge:broker_zero` 가 오늘 여러 번 발생:

- 10:14, 10:58, 11:03, **14:40(038290 마크로젠)**, 15:30(163730) 등
- 브로커 잔고 0인데 로컬엔 보유 → **로컬 포지션 강제 정리**

이건 **매수 0건의 직접 원인은 아니지만**, 보유·손익 표시를 어지럽혀 “봇이 이상하다”고 느끼게 만드는 **별도 버그 후보**입니다. (브로커 조회 타이밍/유예 vs 실제 체결 불일치)

---

## 한 줄 요약

- **재시작 때문에 매수가 막힌 건 아님** (12:17에 이미 끊김)
- **오후는 모멘텀 후보가 호가수급 0.86에서 0.01~0.15 부족**하게 반복 탈락
- **스캔·루프·후보는 정상**, “고장”보다 **장세 + 필터 조합**에 가깝
- 다만 **`ghost_purge`는 따로 조사 필요** (보유 동기화 신뢰도)

원하시면 다음으로 **124500·228850처럼 0.86에 근접했던 종목**만 골라 “봉 조건 통과 → 호가에서 막힌” 타임라인 표로 뽑아드리거나, **`ghost_purge` 원인(브로커 조회 grace·체결 지연)**부터 파고들겠습니다.

[REDACTED]


<timestamp>Thursday, Aug 20, 2026, 5:41 PM (UTC+9)</timestamp>
<user_query>
그니까 지금 틱을 키스로 바꾸고 보조가 키움 호가는 키움이 메인이라그런건 아니야? 풀백이랑 어차피 같은시간에 체크할거인데 옵투나는 같은걸로 돌려도되지만 실매는 같은걸로 돌려야한다던가 그런건 아닌가? 그리고 키움메인으로 찾은 옵투나값이란말이지 이 최적값은 그리고 ghost_purge:broker_zero 이건 언제어제 나오는건데 재시작 몇번 했는데 오늘 그거때문은 아닌닌가
</user_query>


틱/호가 메인·폴백 설정과 `ghost_purge` 발생 시점을 코드·DB·로그에서 같이 대조해 보겠습니다.

[REDACTED]

PYTHONPATH=/home/hoon/kis_bot python3 - <<'PY'
from kis_trader.utils.env import get_env_from_db
keys=[
 'LIVE_TICK_PROVIDER','LIVE_OB_PROVIDER','CANDLE_SOURCE',
 'MOMENTUM_ORDERBOOK_FILTER_ENABLED','WS_ORDERBOOK_FILTER_MAX_AGE_SEC',
 'WS_ORDERBOOK_TICK_MAX_AGE_SEC','LIVE_FEED_FALLBACK_MAX_AGE_SEC',
 'BROKER_HOLDINGS_CACHE_TTL_SEC','GHOST_POSITION_COOLDOWN_SEC',
 'INQUIRE_PSBL_RVSECNCL_BEFORE_GHOST','KIS_BROKER_HOLDINGS_GRACE_SEC',
]
for k in keys:
    print(k, get_env_from_db(k, None))
PY

Read current tick/orderbook provider env from DB

journalctl -u kis_trader_main.service --since '2026-08-17' --no-pager | rg 'ghost_purge|유령잔고' | head -30
journalctl -u kis_trader_main.service --since '2026-08-17' --no-pager | rg 'ghost_purge|유령잔고' | tail -30
journalctl -u kis_trader_main.service --since '2026-08-17' --no-pager | rg 'ghost_purge|유령잔고' | wc -l

ghost_purge history since Aug 17


[REDACTED]

PYTHONPATH=/home/hoon/kis_bot python3 - <<'PY'
from database import TradeDB
db=TradeDB()
try:
    rows=db.conn.execute("""
      SELECT sell_date, strategy, code, sell_reason
      FROM trade_history
      WHERE sell_reason LIKE 'ghost%'
      ORDER BY sell_date DESC LIMIT 30
    """).fetchall()
    print('=== ghost in trade_history ===')
    for r in rows: print(dict(r))
    c=db.conn.execute("SELECT COUNT(*) n FROM trade_history WHERE sell_reason LIKE 'ghost%' AND sell_date >= '2026-08-17'").fetchone()['n']
    print('since 8/17', c)
finally: db.close()
PY
journalctl -u kis_trader_main.service --since '2026-08-20 09:00:00' --until '2026-08-20 11:10:00' --no-pager | rg 'Started kis_trader|Stopped kis_trader|유령|ghost|매수체결' | head -25

ghost history in DB and restart correlation today morning


[REDACTED]

python3 - <<'PY'
import subprocess,re
from collections import Counter
for label, since, until in [
 ('pre-1345','2026-08-20 12:20:00','2026-08-20 13:45:00'),
 ('post-1345','2026-08-20 13:45:00','2026-08-20 15:30:00'),
]:
    p=subprocess.run(['journalctl','-u','kis_trader_main.service','--since',since,'--until',until,'--no-pager'],capture_output=True,text=True)
    c=Counter()
    for line in p.stdout.splitlines():
        if 'MOMENTUM' not in line: continue
        m=re.search(r'탈락-([^\]]+)', line)
        if m: c[m.group(1)]+=1
    print('===',label,'===')
    for k,v in c.most_common(8): print(k,v)
PY

Compare momentum rejections before vs after 13:45 restart


**틱 KIS + 호가 키움 조합 때문은 아닙니다.** 그건 원래 설계된 분리입니다. 오후 매수 0건의 직접 원인도 아닙니다.

현재 DB 설정:

| 키 | 값 | 역할 |
|---|---|---|
| `LIVE_TICK_PROVIDER` | **kis** | 시세·틱·봉 읽기 (매수체크 가격) |
| `LIVE_OB_PROVIDER` | **kiwoom** | 호가 스냅 (호가필터) |

로그의 `T:kis|O:kiwoom`이 바로 이 조합입니다.

---

## 1) “틱 KIS + 호가 키움” 때문에 막히는 건가?

**아닙니다.** 애초에 **틱과 호가는 분리**돼 있습니다.

- **봉/가격 TRIGGER** → `get_price()` → `LIVE_TICK_PROVIDER`(KIS) 체인
- **호가필터(0.86 잔량비)** → `get_orderbook_snapshot()` → `LIVE_OB_PROVIDER`(키움) 체인

같은 순간에 검사하지만, **읽는 RAM이 다릅니다.** 버그라기보다 **의도된 구조**입니다.

오늘 오후 탈락도:

- `탈락-호가수급` → **키움 호가**로 0.70~0.80 < **0.86** (정상 필터 동작)
- `탈락-호가없음` → 해당 종목 **키움 호가 스냅 없음**

KIS 틱으로 호가를 잘못 읽어서 막힌 패턴은 아닙니다.

**재시작(13:45) 전후 비교:**

| 구간 | 호가수급 | 호가없음 |
|------|---------|---------|
| 12:20~13:45 (재시작 전) | 319 | 148 |
| 13:45~15:30 (재시작 후) | 183 | **32** |

재시작 후 호가 쪽이 **더 나빠진 게 아니라** 오히려 `호가없음`은 줄었습니다.

---

## 2) “옵투나는 키움, 실매는 같아야 한다” — 맞는 말인가?

**부분적으로만** 맞습니다.

### 봉/트리거 (틱·캔들)
- 백테/옵투나는 `CANDLE_SOURCE` 빈값이면 **`LIVE_TICK_PROVIDER` 기준**으로 실매와 같은 `(source,channel)` 읽기 쌍을 씁니다.
- 지금 DB가 `kis`면 **옵투나도 KIS 메인 봉 경로**로 돌아갑니다. “실매만 KIS, 옵투나는 키움” 불일치는 **아닙니다.**

### 호가필터 (0.86 등) — **여기가 갭**
- 실매: `MOMENTUM_ORDERBOOK_FILTER_ENABLED = true`, 잔량비 **0.86**
- 옵투나 기본: `--orderbook-filter off` → **1단계 코어(봉·거래량 등)만** 탐색, **호가 0.86은 trial에 안 들어감**

즉 “키움 메인으로 찾은 옵투나 최적값”이라기보다:

> **봉/거래량 축은 옵투나로 찾았고, 호가 0.86은 DB/후처리 값인데 실매에서 매일 걸리는 구조**

입니다. 오늘 124500·228850처럼 **봉은 통과 → 호가 0.86에서 0.01~0.15 부족**한 케이스가 전형적입니다.

옵투나 `--orderbook-filter off`와 실매 호가 ON이 **완전 동일 엔진은 아닙니다.** 다만 오늘 오후 0건의 직접 원인은 “틱/호가 벤더 불일치”가 아니라 **실매 호가 필터 + 장세** 쪽에 가깝습니다.

---

## 3) `ghost_purge:broker_zero` — 언제부터? 재시작 때문?

**오늘부터 생긴 게 아닙니다.** 재시작 때문도 **아닙니다.**

| 날짜 | ghost_purge 예 |
|------|----------------|
| 8/18 | 덕성, 우리기술투자 등 (장마감) |
| 8/19 | 에스넷, 골프존홀딩스 등 — **그날도 매수 20건** |
| 8/20 | 09:00 노랑풍선 → **09:03 돌파 매수** / 10:58 레이언스 purge → **12:17 핑거 매수** |

**매수 직전·직후에도** ghost가 나옵니다. 재시작 몇 번과 **상관없이** 브로커 잔고 조회가 0으로 오면 로컬 포지션을 지우는 **잔고 동기화 로직**입니다.

오늘 재시작 타임라인:

- **10:30** 재시작 → 10:30 **레이언스 매수 성공**
- **11:02** 재시작 → **12:17** 핑거 매수 (마지막)
- **13:45** 재시작 (틱 channel) → **이미 1시간 28분 전부터** 매수 없음

→ **“꺼서 꼬였다”기보다 12:17 이후 TRIGGER+호가에서 전부 탈락**이 맞습니다.

ghost는 **별도 문제**입니다:
- 보유 표시·손익을 헷갈리게 할 수 있음 (038290 14:40 purge 등)
- **신규 매수를 막지는 않음** (슬롯 2~4/8 여유 있었음)

---

## 한 줄 정리

| 의심 | 판정 |
|------|------|
| 틱 KIS + 호가 키움 분리 | **설계상 정상**, 오후 0건 원인 아님 |
| 실매↔옵투나 동일 provider 필수 | **봉은 동일**, **호가필터는 옵투나 off vs 실매 on 갭 있음** |
| ghost_purge = 재시작 버그 | **아님** (8/18부터, 매수 있는 날에도 발생) |
| 오후 거래 없음 | **12:17 이후 호가 0.86·음봉·거래량 필터** (재시작보다 먼저 끊김) |

원하시면 다음으로:
1. **옵투나 `--orderbook-filter on` + 실매 DB 0.86 포함** 재검증 (정합 갭 확인)
2. **`ghost_purge` 오판** (228850·163730 등 매수 직후 purge) 원인만 따로 파기

어느 쪽부터 할지 알려주세요.

[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>


`ghost_purge` 로그 조회(8/17~) 결과입니다.

- **8/17 이후 journalctl 기준 20건** (대부분 `ghost_purge:broker_zero`)
- **8/18부터** 이미 있었고, **8/19에도 8건+** (그날 매수 20건과 **동시** 발생)
- **8/20**: 09:00 노랑풍선 → 10:14 씨에스윈드 → 10:58 레이언스 → 11:03 핑거(orphan) → 14:40 마크로젠 → 15:30 핑거

**재시작 때문에 새로 생긴 현상은 아닙니다.** 다만 228850·163730처럼 **매수 직후/보유 중 브로커 0주로 오판**하는 케이스는 따로 원인 조사가 필요합니다.

[REDACTED]


<timestamp>Thursday, Aug 20, 2026, 5:51 PM (UTC+9)</timestamp>
<user_query>
오케이 둘 다 해봐
</user_query>


두 가지 모두 진행합니다. `ghost_purge` 오판 원인부터 로그·코드로 추적하고, 이어서 옵투나 호가필터 ON 정합 검증을 돌리겠습니다.

[REDACTED]

TodoWrite

ghost_purge 228850·163730 오판 원인 조사 (로그+코드)

옵투나 orderbook-filter on + 실매 0.86 정합 검증

Shell

journalctl -u kis_trader_main.service --since '2026-08-20 10:25:00' --until '2026-08-20 11:10:00' --no-pager | rg '228850|163730|유령|ghost|매수|매도|잔고|broker|고아|orphan|매도가능' | head -80
journalctl -u kis_trader_main.service --since '2026-08-20 12:10:00' --until '2026-08-20 15:35:00' --no-pager | rg '163730|038290|유령|ghost|매수|매도|잔고|broker|매도가능' | head -80

Trace ghost_purge timeline for 228850 and 163730


[REDACTED]

journalctl -u kis_trader_main.service --since '2026-08-20 10:29:00' --until '2026-08-20 11:05:00' --no-pager | rg '228850|163730|유령|ghost|매수체결|매도|손절|호가컷|잔고|broker|고아|orphan|매도가능|purge|active_trade'
journalctl -u kis_trader_main.service --since '2026-08-20 14:35:00' --until '2026-08-20 15:35:00' --no-pager | rg '163730|038290|유령|ghost|매수|매도|잔고|purge|active'

Focused ghost timeline 228850 and 163730


[REDACTED]

Grep

228850.*(유령|ghost|매수|매도|손절|호가|체결|purge|잔고|매도가능)

163730.*(유령|ghost|매수|매도|손절|호가|체결|purge|잔고|매도가능)


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


[REDACTED]

228850.*(유령|ghost|손절|호가컷|매도체결|매도주문|purge)


[REDACTED]

228850.*(틱매도|매도주문|매도신호|손절|익절|trail|래칫|place)


[REDACTED]

Shell

cd /home/hoon/kis_bot && python3 - <<'PY'
from database import TradeDB
db = TradeDB()
cols = [r["Field"] for r in db.conn.execute("SHOW COLUMNS FROM trade_history").fetchall()]
print("cols:", cols[:20], "...")
rows = db.conn.execute(
    """SELECT code, name, strategy_id, side, qty, price, sell_reason, executed_at
       FROM trade_history
       WHERE code IN ('228850','163730','038290') AND executed_at >= '2026-08-20'
       ORDER BY executed_at""",
).fetchall()
for r in rows:
    print(dict(r))
PY

Query trade_history for ghost case stocks


[REDACTED]

cd /home/hoon/kis_bot && python3 - <<'PY'
from database import TradeDB
db = TradeDB()
rows = db.conn.execute(
    """SELECT code, name, strategy, qty, buy_price, sell_price, buy_date, sell_date, sell_reason, realized_pnl
       FROM trade_history
       WHERE code IN ('228850','163730','038290') AND sell_date >= '2026-08-20 00:00:00'
       ORDER BY sell_date""",
).fetchall()
for r in rows:
    print(dict(r))
# also buys today
rows2 = db.conn.execute(
    """SELECT code, name, strategy, qty, buy_price, sell_price, buy_date, sell_date, sell_reason
       FROM trade_history
       WHERE code IN ('228850','163730','038290') AND buy_date >= '2026-08-20 00:00:00'
       ORDER BY buy_date""",
).fetchall()
print('--- buys ---')
for r in rows2:
    print(dict(r))
PY

Query trade_history with correct columns

Grep

228850.*(장마감|손절|익절|호가컷|매도주문|매도체결|틱)


[REDACTED]

Shell

cd /home/hoon/kis_bot && python3 - <<'PY'
from kis_trader.backtest.param_search_momentum import _momentum_grids, _mom_fixed_defaults
live = _mom_fixed_defaults(market='KR')
gr = _momentum_grids()['fast']
ratio = gr.get('min_bid_ask_ratio')
spread = gr.get('max_spread_pct')
print('live min_bid_ask_ratio', live.get('min_bid_ask_ratio') or live.get('orderbook_min_bid_ask_ratio'))
print('grid min_bid_ask_ratio', ratio)
print('grid max_spread_pct', spread)
print('0.86 in grid', 0.86 in [float(x) for x in (ratio or [])])
PY

Check momentum grid includes live 0.86 ratio


[REDACTED]

cd /home/hoon/kis_bot && python3 - <<'PY'
from database import TradeDB
from utils.env_config import get_env_from_db
db = TradeDB()
print('MOMENTUM_ORDERBOOK_MIN_BID_ASK_RATIO', get_env_from_db('MOMENTUM_ORDERBOOK_MIN_BID_ASK_RATIO'))
rows = db.conn.execute(
    """SELECT ord_no, code, side, qty, filled_qty, status, created_at, updated_at
       FROM orders WHERE code='228850' AND DATE(created_at)='2026-08-20' ORDER BY created_at""",
).fetchall()
print('orders 228850:')
for r in rows:
    print(dict(r))
rows2 = db.conn.execute(
    """SELECT id, code, qty, buy_price, buy_date, sell_date, status
       FROM active_trades WHERE code IN ('228850','163730') ORDER BY buy_date""",
).fetchall()
print('active_trades now:', [dict(r) for r in rows2])
PY

Query orders and active_trades for ghost cases


[REDACTED]

cd /home/hoon/kis_bot && python3 - <<'PY'
from database import TradeDB, get_env_from_db
db = TradeDB()
print('MOMENTUM_ORDERBOOK_MIN_BID_ASK_RATIO', get_env_from_db('MOMENTUM_ORDERBOOK_MIN_BID_ASK_RATIO'))
cols = [r['Field'] for r in db.conn.execute('SHOW COLUMNS FROM orders').fetchall()]
print('orders cols sample', cols[:15])
rows = db.conn.execute(
    """SELECT * FROM orders WHERE code='228850' AND created_at >= '2026-08-20' ORDER BY created_at LIMIT 20""",
).fetchall()
for r in rows:
    d = dict(r)
    print({k: d[k] for k in list(d)[:12]})
PY

Query orders and env for ghost/optuna


[REDACTED]

cd /home/hoon/kis_bot && python3 - <<'PY'
from database import TradeDB
db = TradeDB()
r = db.conn.execute("SELECT config_key, config_value FROM env_config WHERE config_key LIKE 'MOMENTUM_ORDERBOOK%%'").fetchall()
for x in r: print(dict(x))
PY

Read momentum orderbook env from DB


[REDACTED]

Grep

레이언스.*(손절|익절|장마감|호가|매도|틱|SELL|청산)


[REDACTED]

Shell

cd /home/hoon/kis_bot && nohup python3 -u - <<'PY' > logs/compare_momentum_ob_20260820.log 2>&1 &
import copy, json, time
from kis_trader.backtest.optuna_momentum import prepare_momentum_search_context
from kis_trader.backtest.param_search_momentum import evaluate_momentum_param_combo

START, END = "2026-08-17", "2026-08-20"
print("prepare ON context...", flush=True)
ctx_on = prepare_momentum_search_context(START, END, "tpe", orderbook_filter="on")
if not ctx_on:
    raise SystemExit("ctx_on failed")
print("prepare done, eval OFF...", flush=True)
bf_off = copy.deepcopy(ctx_on.base_fixed)
bf_off["_orderbook_filter_enabled"] = False
r_off = evaluate_momentum_param_combo(
    {}, base_fixed=bf_off, grid_keys=ctx_on.grid_keys,
    codes_candles=ctx_on.codes_candles, min_trades=0, min_win_rate=0, min_pf=0,
    universe_by_slot=ctx_on.universe_by_slot, slot_money=ctx_on.slot_money,
    max_stocks=ctx_on.max_stocks, total_budget_krw=ctx_on.total_budget_krw,
    fee_rate=ctx_on.fee_rate, sell_tax=ctx_on.sell_tax, period_days=ctx_on.period_days,
    ticks_by_code=ctx_on.ticks_by_code, orderbook_by_code=ctx_on.orderbook_by_code,
    program_by_code=ctx_on.program_by_code, log_verdict_by_code=ctx_on.log_verdict_by_code,
    start_key=ctx_on.start_key, end_key=ctx_on.end_key,
)
print("eval ON...", flush=True)
bf_on = copy.deepcopy(ctx_on.base_fixed)
bf_on["_orderbook_filter_enabled"] = True
r_on = evaluate_momentum_param_combo(
    {}, base_fixed=bf_on, grid_keys=ctx_on.grid_keys,
    codes_candles=ctx_on.codes_candles, min_trades=0, min_win_rate=0, min_pf=0,
    universe_by_slot=ctx_on.universe_by_slot, slot_money=ctx_on.slot_money,
    max_stocks=ctx_on.max_stocks, total_budget_krw=ctx_on.total_budget_krw,
    fee_rate=ctx_on.fee_rate, sell_tax=ctx_on.sell_tax, period_days=ctx_on.period_days,
    ticks_by_code=ctx_on.ticks_by_code, orderbook_by_code=ctx_on.orderbook_by_code,
    program_by_code=ctx_on.program_by_code, log_verdict_by_code=ctx_on.log_verdict_by_code,
    start_key=ctx_on.start_key, end_key=ctx_on.end_key,
)
out = {
    "period": [START, END],
    "off": {k: r_off.get(k) for k in ("total_trades","total_pnl","win_rate","pf","mdd")} if r_off else None,
    "on": {k: r_on.get(k) for k in ("total_trades","total_pnl","win_rate","pf","mdd")} if r_on else None,
    "base_ob_ratio": bf_on.get("min_bid_ask_ratio"),
    "base_ob_enabled_on": bf_on.get("_orderbook_filter_enabled"),
}
print("RESULT", json.dumps(out, ensure_ascii=False), flush=True)
PY
echo "started pid=$!"

Background compare momentum backtest OB off vs on


[REDACTED]

sleep 90 && tail -30 /home/hoon/kis_bot/logs/compare_momentum_ob_20260820.log

Wait and tail OB comparison log


[REDACTED]

sleep 30 && tail -5 /home/hoon/kis_bot/logs/compare_momentum_ob_20260820.log

Wait for comparison RESULT line


[REDACTED]

cd /home/hoon/kis_bot && python3 - <<'PY'
from kis_trader.utils.env import get_strategy_env_dict
from kis_trader.engine.orderbook_env import build_orderbook_cfg_for_strategy
env = get_strategy_env_dict('MOMENTUM')
print('ORDERBOOK_FILTER', env.get('MOMENTUM_ORDERBOOK_FILTER_ENABLED'))
print('MIN_RATIO', env.get('MOMENTUM_ORDERBOOK_MIN_BID_ASK_RATIO'))
cfg = build_orderbook_cfg_for_strategy('MOMENTUM', {})
print('cfg', cfg)
PY

Read live momentum orderbook config from DB


[REDACTED]

cd /home/hoon/kis_bot && python3 - <<'PY'
from kis_trader.utils.env import get_strategy_env_dict
from kis_trader.engine.orderbook_env import orderbook_filter_enabled, get_orderbook_cfg
env = get_strategy_env_dict('MOMENTUM')
print('FILTER', orderbook_filter_enabled('MOMENTUM'))
print('MIN_RATIO env', env.get('MOMENTUM_ORDERBOOK_MIN_BID_ASK_RATIO'))
print('cfg', get_orderbook_cfg('MOMENTUM'))
PY

Read momentum orderbook live config

TodoWrite

ghost_purge 228850·163730 오판 원인 조사 (로그+코드)

옵투나 orderbook-filter on + 실매 0.86 정합 검증


[REDACTED]

Shell

cd /home/hoon/kis_bot && python3 - <<'PY'
from kis_trader.utils.env import get_strategy_env_dict
from kis_trader.engine.orderbook_env import orderbook_filter_enabled, resolve_orderbook_cfg
env = get_strategy_env_dict('MOMENTUM')
print('FILTER', orderbook_filter_enabled('MOMENTUM'))
print('MIN_RATIO env', env.get('MOMENTUM_ORDERBOOK_MIN_BID_ASK_RATIO'))
print('cfg', resolve_orderbook_cfg('MOMENTUM'))
PY

Read live orderbook cfg via resolve_orderbook_cfg


[REDACTED]

cd /home/hoon/kis_bot && python3 - <<'PY'
from kis_trader.utils.env import get_strategy_env_dict
from kis_trader.engine.orderbook_env import orderbook_filter_enabled, load_orderbook_threshold_cfg
env = get_strategy_env_dict('MOMENTUM')
print('FILTER', orderbook_filter_enabled('MOMENTUM'))
print('MIN_RATIO env', env.get('MOMENTUM_ORDERBOOK_MIN_BID_ASK_RATIO'))
print('cfg', load_orderbook_threshold_cfg('MOMENTUM'))
PY

Load live orderbook threshold config


## 1) ghost_purge 오판 조사 결과

**결론 먼저:** ghost_purge는 **매수를 막지 않습니다.** 로컬 `active_trades`만 지우는 동기화 정리이고, 오늘 오후 0건의 직접 원인은 아닙니다. 다만 **보유·체결 동기화 신뢰도** 이슈는 있습니다.

### 케이스별 타임라인

| 종목 | 시각 | 경로 | 브로커 vs 로컬 | 판정 |
|------|------|------|----------------|------|
| **228850** | 10:58 | `order_mgr` 매도 시도 → `broker_zero` | 10:30 매수 45주 체결·10:31 orphan 동기화 OK → **매도 로그 없이** 10:58 ghost | **오판 의심** |
| **163730** | 11:03 | `orphan_reconcile` 재시작 | 10:15 매수 28주 로컬만 남음, 브로커 0 | **stale 로컬 정리** (12:17 신규 매수와 무관) |
| **163730** | 15:30 | `order_mgr` EOD 매도 루프 | 15:29 보유 29·매도가능 0·**금일매도 28** → 15:30 ghost | **체결 후 로컬 미반영** |
| **038290** | 14:40 | 동일 패턴 | ghost_purge, PnL=0 | EOD/재시작 계열 |

### 228850 (가장 의심스러운 오판)

```
10:29:56  매수 부분체결 3/45주
10:30:16  재조회 → 45주 @ 6576 확정
10:31:36  orphan 계좌동기화 (재시작 직후)
10:58:08  ⚠ ghost_purge:broker_zero  ← 매도체결/매도주문 로그 없음
```

- `_place_sell()` → `REAL_BALANCE_VERIFY_BEFORE_SELL` → 잔고 0 → `_purge_ghost_position()`.
- `trade_history`: qty **91** (실매 45주와 불일치), `realized_pnl=0` — `active_trades.current_qty` 이중 누적 가능성.
- **가설:** 모의계좌 잔고 API 일시 0 / TTL 캐시(5초) / 틱매도가 매도 시도했으나 체결 로그 없이 ghost.

### 163730 (15:30)

```
15:29:39  보유=29, 매도가능=0, 금일매도=28  (EOD 매도 잠금)
15:30:51  ghost_purge:broker_zero
```

- 장마감 매도가 **부분 체결(28주)** 된 뒤 브로커는 0에 가깝고, 로컬은 29주 유지 → ghost.
- **근본:** EOD 매도 체결 → `active_trades` 반영 지연/누락.

### 코드상 동작 (정상 설계, 타이밍 이슈)

```2329:2363:kis_trader/execution/order_manager.py
            if get_env_bool("REAL_BALANCE_VERIFY_BEFORE_SELL", True):
                real_map = self.get_broker_holdings(force=False)
                ...
                if not broker_row_still_held(real_row):
                    ...
                    return self._purge_ghost_position(req, "broker_zero")
```

- `매도가능=0` + 미체결 매도 있으면 ghost **스킵** (`_ghost_purge_block_reason`).
- 브로커 **완전 0**이면 ghost **실행** — EOD·재시작 직후 타이밍에 오판 여지.

**수정 제안(선보고, 미적용):** ghost 전 `force=True` 재조회 + 1회 재시도, 재시작 후 grace, EOD 매도 체결 poll 후 ghost. qty 91 이중누적 원인 별도 추적.

---

## 2) Optuna `--orderbook-filter on` 정합 검증

**실매 DB:** `MOMENTUM_ORDERBOOK_FILTER_ENABLED=True`, `MIN_BID_ASK_RATIO=0.86`

동일 기간·동일 DB 파라미터로 백테 1회 비교 (8/17~8/20):

| 구분 | 거래수 | PnL | 승률 | PF |
|------|--------|-----|------|-----|
| **OB OFF** (Optuna 기본) | 32 | -140,639원 | 31.3% | 0.42 |
| **OB ON** (실매 동일) | 24 | -32,798원 | 41.7% | 0.75 |
| **Δ** | **-8 (-25%)** | +107,841 | +10.4%p | +0.33 |

로그: `logs/compare_momentum_ob_20260820.log`

**해석**

1. Optuna 기본 `--orderbook-filter off`는 **진입을 ~25% 과대 추정** → 실매(0.86 ON)보다 거래 많음.
2. 오늘 오후 live 탈락 **호가수급 518건** (`0.xx < 0.86`)과 방향 일치 — OB ON이면 백테도 비슷하게 줄어듦.
3. 오늘 17:33 Optuna TPE 200 trial은 **OB OFF** (`logs/optuna_web_momentum_20260820_173315.log`) → best trial도 호가 미반영.
4. `fast` 그리드에 `min_bid_ask_ratio` 축 **없음** — OB ON은 `--orderbook-filter on` 또는 `stage2-core-lock` 호가 3축 필요.

**권장 운영**

- 파람서치: `--orderbook-filter on` (실매 0.86 포함).
- Optuna TPE는 호가 OFF로 돌린 177 trial → **실매 정합 비교용으로는 재해석 필요**.
- DB 미적용(기본).

---

## 영향 분류

| 항목 | 실매 | 백테/Optuna |
|------|------|-------------|
| ghost_purge 조사 | 보유 동기화 리스크 (매수 차단 아님) | 해당 없음 |
| OB ON/OFF 비교 | 이미 ON(0.86) | Optuna 기본 OFF → 갭 확인 |

코드 수정은 하지 않았습니다. ghost 쪽 **grace·재조회 패치** 또는 Optuna **OB ON 재실행** 중 어디부터 진행할지 알려주시면 됩니다.

[REDACTED]


**ghost_purge 조사**와 **Optuna 호가필터 ON/OFF 비교**까지 마쳤습니다.

### ghost_purge
- **매수 차단 원인 아님** — 로컬 `active_trades` 정리용.
- **228850 (10:58)**: 매도 로그 없이 `broker_zero` ghost → **오판 의심** (모의 잔고 API/수량 91주 이중 누적).
- **163730 (11:03)**: 재시작 시 stale 로컬(10:15분) 정리 — 12:17 매수와 무관.
- **163730 (15:30)**: EOD 매도(금일매도 28) 후 로컬 29주 남음 → ghost — **체결 동기화 지연**.

### Optuna vs 실매 (8/17~8/20, 현재 DB 파라미터)
| | 거래 | PnL |
|--|------|-----|
| OB **OFF** (Optuna 기본) | 32 | -140,639원 |
| OB **ON** (실매 0.86) | 24 | -32,798원 |

→ Optuna OFF는 진입 **~25% 과대**; 오후 live `호가수급 < 0.86` 탈락과 방향 일치.

로그: `logs/compare_momentum_ob_20260820.log`

---

다음 단계 후보:
1. **ghost** — purge 전 잔고 `force` 재조회 + 재시작 grace
2. **Optuna** — `--orderbook-filter on`으로 재탐색

어느 쪽부터 진행할지 알려주세요.

[REDACTED]


<timestamp>Friday, Aug 21, 2026, 9:03 AM (UTC+9)</timestamp>
<user_query>
fast 그리드에 min_bid_ask_ratio 축 없음 — OB ON은 --orderbook-filter on 또는 stage2-core-lock 호가 3축 필요.이건 뭐야 호가필터 on일때만 필요한 축인가? off 로 돌릴땐 필요없나? 그리고 on은 어디서 필터를 거는거지? 지금 진입 호가 익절 손절 휩쏘까지 하는데 또 어디가 on인거지 그리고 위에 가설이 맞는지 근본원인 다시 조사해줘봐 더 심층적으로 그리고 초딩수준으로 다시 알려줘
</user_query>


호가필터 ON/OFF가 어디에 걸리는지, fast 그리드 축 의미, ghost 가설을 코드·로그로 다시 깊게 확인합니다.

TodoWrite

호가필터 ON/OFF·min_bid_ask_ratio 축 의미 정리

진입/익절/손절/휩쏘 vs Optuna OB ON 적용 지점 확인

ghost_purge 가설 근본원인 심층 재조사


호가 스위치 분리와 ghost 수량 이상(91주)을 DB·로그로 더 깊게 파겠습니다.

Shell

cd /home/hoon/kis_bot && python3 - <<'PY'
from database import TradeDB
from kis_trader.utils.env import get_strategy_env_dict, get_env_bool, get_env_from_db

db = TradeDB()
# schema first
for t in ('orders','active_trades','trade_history'):
    cols = [r['Field'] for r in db.conn.execute(f'SHOW COLUMNS FROM {t}').fetchall()]
    print(t, cols[:25])

env = get_strategy_env_dict('MOMENTUM')
keys = [
 'MOMENTUM_ORDERBOOK_FILTER_ENABLED','MOMENTUM_ORDERBOOK_MIN_BID_ASK_RATIO',
 'MOMENTUM_EXIT_OB_ENABLED','MOMENTUM_STOP_OB_ENABLED',
 'MOMENTUM_WHIPSAW_FILTER_ENABLED','SELL_USE_ORDERBOOK_ON_PROFIT',
]
for k in keys:
    print(k, '=', env.get(k) if k.startswith('MOMENTUM') or k.startswith('SELL') else get_env_from_db(k))
print('SELL_USE', get_env_from_db('SELL_USE_ORDERBOOK_ON_PROFIT'))
print('WHIPSAW global', get_env_from_db('WS_WHIPSAW_FILTER_ENABLED'))
print('MOM whipsaw', env.get('MOMENTUM_WHIPSAW_FILTER_ENABLED'))
PY

Schema + live OB/EXIT/STOP/whipsaw switches


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

# orders for ghost cases
for code in ('228850','163730','038290'):
    rows = db.conn.execute(
        """SELECT ord_no, side, qty, filled_qty, filled_avg_price, status, ord_date, submitted_at, filled_at
           FROM orders WHERE code=%s AND submitted_at >= '2026-08-20 00:00:00'
           ORDER BY submitted_at""",
        (code,),
    ).fetchall()
    print(f'\n=== orders {code} n={len(rows)} ===')
    for r in rows:
        print(dict(r))

print('\n=== trade_history ghost ===')
rows = db.conn.execute(
    """SELECT id, code, qty, buy_price, sell_price, buy_date, sell_date, sell_reason, realized_pnl
       FROM trade_history
       WHERE sell_date >= '2026-08-20' AND sell_reason LIKE %s
       ORDER BY sell_date""",
    ('ghost%%',),
).fetchall()
for r in rows:
    print(dict(r))
PY

Query orders and ghost trade_history for Aug20


핵심 발견: 228850은 10:57에 **실제 매도 91주 체결**이 있었습니다. ghost가 ‘잔고 API 오판’인지, **체결 반영 누락**인지 코드를 더 추적합니다.


cd /home/hoon/kis_bot && python3 - <<'PY'
from database import TradeDB
db = TradeDB()
# look at raw_json for the sell that ghosted
rows = db.conn.execute(
    """SELECT ord_no, side, qty, filled_qty, status, submitted_at, filled_at, raw_json
       FROM orders WHERE ord_no IN ('0000020055','0000019275','0000035401','0000032092','0000027859')
       ORDER BY submitted_at"""
).fetchall()
for r in rows:
    d=dict(r)
    raw=d.pop('raw_json',None)
    print(d)
    if raw:
        print('  raw:', str(raw)[:300])
PY

Inspect sell order raw_json for ghost cases


TodoWrite

호가필터 ON/OFF·min_bid_ask_ratio 축 의미 정리

진입/익절/손절/휩쏘 vs Optuna OB ON 적용 지점 확인

ghost_purge 가설 근본원인 심층 재조사


## 1. `min_bid_ask_ratio` 축은 뭐야?

**한 줄:** Optuna `--orderbook-filter on/off` 는 “**살 때** 호가 검사 켜냐/끄냐”이고, `min_bid_ask_ratio` 축은 “그 검사의 **숫자(0.86 등)를 여러 개 돌려보냐**”입니다.

| 돌리는 방식 | 진입 호가필터 | `min_bid_ask_ratio` 그리드 축 |
|------------|---------------|------------------------------|
| `--orderbook-filter off` (기본) | **안 씀** | **필요 없음** (꺼져 있어서 숫자가 의미 없음) |
| `--orderbook-filter on` | **씀** → DB값(지금 **0.86**) 고정 적용 | 축 **없어도 됨**. 실매와 같은 숫자로 한 번 검사 |
| `stage2-core-lock` | 강제 ON | 코어 고정 + 호가 3축(스프·잔량비·매도벽) **여러 값 탐색**할 때 필요 |

즉 **ON일 때만 “검사”가 돌아가고**, 축은 “ON인데 **숫자를 바꿔가며 찾을 때**”만 필요합니다.
OFF로 돌리면 축이 있어도 검사 자체를 안 하니 소용없습니다.

---

## 2. “ON”은 어디에 걸리는 거야? (헷갈리기 쉬운 부분)

호가 관련 스위치가 **여러 개**입니다. 이름이 비슷해도 **서로 다른 문**입니다.

```
[사기 직전]  진입 호가필터  ← Optuna --orderbook-filter on/off 가 여기만 건드림
              MOMENTUM_ORDERBOOK_FILTER_ENABLED
              (탈락-호가수급 0.71 < 0.86 같은 로그)

[이미 들고 있을 때]
              휩쏘     = 급락→급반등이면 안 삼 (지금 모멘텀 DB: OFF)
              EXIT_OB  = 이익 난 뒤 호가 분위기 깨지면 팔기 「호가컷」(지금 ON)
              STOP_OB  = 손해 난 뒤 큰 손절 전에 먼저 팔기 「손절호가」(지금 ON)

[팔 주문 낼 때]
              SELL_USE_ORDERBOOK_ON_PROFIT = 익절을 지정가로 내기 (주문 방식)
```

초등 비유:
- **진입 호가필터** = 가게 앞에서 “줄이 너무 길면 **안 들어감**”
- **EXIT/STOP 호가** = 이미 들어가서 **나갈 타이밍**을 호가판으로 봄
- **휩쏘** = “방금 출렁인 장난감이면 안 삼”
- Optuna `--orderbook-filter` = **가게 앞 줄 검사만** on/off

지금 실매 DB:
- 진입 호가필터 **ON** (0.86)
- EXIT_OB / STOP_OB **ON**
- 모멘텀 휩쏘 **OFF**

그래서 “이미 진입·익절·손절 호가 하는데 또 ON이 어디?” → **또 하나가 아니라, Optuna 기본이 진입 검사만 OFF**라서 백테가 실매보다 많이 사는 겁니다.

---

## 3. ghost 가설 — 다시 판 결과 (이전 가설 수정)

**이전 가설(잔고 API가 잠깐 0으로 속였다)은 틀렸습니다.**

### 진짜 일어난 일 (228850 레이언스)

| 시각 | 사실 |
|------|------|
| 10:27 | 매수 **46주** |
| 10:29 | 매수 **45주** → 합 **91주** (qty 91은 버그 숫자 아님) |
| **10:57** | **호가컷**으로 매도 91주 **실제 체결** @ 6700 (orders에 있음) |
| 10:57:16 | 로그: `매도체결 이미 반영 스킵 filled=91 <= db=91` |
| 10:58 | ghost_purge (브로커 0은 **맞음** — 이미 팔렸으니까) · PnL **0원**으로만 기록 |

같은 패턴:
- 163730 10:47 **손절호가** 체결 → 스킵 → 11:03 ghost
- 163730 15:29 **장마감청산** 체결 → 스킵 → 15:30 ghost
- 038290 14:39 **호가컷** 체결 → 스킵 → 14:40 ghost

### 근본원인 (초등)

1. 증권사에는 **진짜로 팔림**
2. 봇은 `orders` 장부에 “체결 91주”만 **먼저** 찍음
3. 그다음 “장부 정리(`close_trade`)” 할 때
   “이미 찍혀 있네 → **스킵**” 해버림
4. 로컬 `active_trades`만 남음
5. 다음에 “팔자” → 증권사 0주 → **유령정리** → 손익 0원으로 기록

코드 구멍: 매도 경로에서 `update_order_fill`을 **먼저** 하고, `_finalize_sell_fill`이 `filled <= db`면 `close_trade`를 **안 함**.

```2515:2523:kis_trader/execution/order_manager.py
            else:
                # 레거시 모의: 참고가로 가정 체결
                ...
                self.db.update_order_fill(  # ← 여기서 먼저 filled_qty 기록
                    ...
                    filled_qty=filled_qty, ... status="SUBMITTED",
                )
```

그 직후 `_finalize_sell_fill` → `이미 반영 스킵` → **청산 미반영**.

지정가(익절 호가) 경로도 같은 식의 “먼저 update 후 finalize”가 있습니다.

---

### 정리

| 질문 | 답 |
|------|-----|
| 축은 ON일 때만? | **검사**는 ON일 때만. 축은 “숫자 탐색”할 때만 |
| OFF면 축 필요? | **없음** |
| Optuna ON은 어디? | **진입 호가필터만** (EXIT/STOP/휩쏘와 별개) |
| ghost 원인? | 잔고 API 오판 ❌ → **매도는 됐는데 close_trade 스킵** ✅ |

---

**수정은 핵심 매매 경로**라서, 승인 전에는 코드를 안 바꿉니다.
고치려면: `update_order_fill`을 finalize **안으로만** 두고, “이미 반영”이어도 `active_trades`가 남아 있으면 `close_trade`를 보완하는 쪽이 근본입니다.

이 패치로 진행할까요?


<timestamp>Friday, Aug 21, 2026, 9:38 AM (UTC+9)</timestamp>
<user_query>
진입 호가필터 ON (0.86) 지금 후처리에서 돌아서 뽑는 수치는 뭔데 그럼 진입필터 돌잖아  tpe     충분히 범위가 넓은가 지금 어차피 후처리라 off 로 돌리고 후처리때 찾는게 맞으니 축을 넣을 필요는 없는거 같은데 맞아? 확인해봐 고스트는 그럼 가설이 확정인거야? 그리고 3차 rest 일때 마지막 램으로 하기로 했는데 rest가 30초 텀이면 30초전 가격이 30초간 유지되는건가 아니면 루프를 돌아서 다시 1차 2차에 가격이 잡히면 그게 마지막 가격이 되는건가 30초 안에
</user_query>


후처리가 뽑는 진입호가 수치·TPE OFF+후처리 구조·ghost 확정 여부·3차 REST/마지막 RAM 가격 갱신 규칙을 코드와 문서로 확인합니다.


Shell

cd /home/hoon/kis_bot && python3 - <<'PY'
import json, os, glob
# latest momentum tpe json
paths = sorted(glob.glob('kis_trader/backtest/results/optuna_momentum_tpe_*.json'), key=os.path.getmtime, reverse=True)[:5]
for p in paths:
    print('FILE', p, 'mtime', os.path.getmtime(p))
    with open(p) as f:
        d = json.load(f)
    ob = d.get('orderbook_recommend') or {}
    print('  keys', list(ob.keys())[:20] if isinstance(ob, dict) else type(ob))
    # try common shapes
    if isinstance(ob, dict):
        for k in ('entry','recommend','best','params','MOMENTUM','momentum'):
            if k in ob:
                print(' ', k, ob[k] if not isinstance(ob[k], dict) else {kk:ob[k].get(kk) for kk in list(ob[k])[:15]})
        # nested
        for k,v in list(ob.items())[:8]:
            if isinstance(v, dict):
                interesting = {kk:v.get(kk) for kk in v if any(x in kk.lower() for x in ('ratio','spread','ask','filter','enabled','min_bid'))}
                if interesting:
                    print(' ', k, interesting)
    pt = d.get('postprocess_topn') or {}
    if isinstance(pt, dict):
        print('  postprocess_topn keys', list(pt.keys())[:15])
        pba = pt.get('postprocess_by_anchor') or {}
        if isinstance(pba, dict):
            for ank, av in list(pba.items())[:3]:
                if isinstance(av, dict):
                    print('  anchor', ank, {kk:av.get(kk) for kk in list(av)[:12]})
    print()
PY

Read latest Optuna OB postprocess recommend numbers


cd /home/hoon/kis_bot && python3 - <<'PY'
import json
p='kis_trader/backtest/results/optuna_momentum_tpe_20260820_121708.json'
with open(p) as f: d=json.load(f)
pt=d['postprocess_topn']
print('run_ob_whipsaw', pt.get('run_ob_whipsaw'))
cons=pt.get('postprocess_consensus') or {}
print('consensus keys', list(cons.keys())[:30])
print(json.dumps(cons, ensure_ascii=False, indent=2)[:2500])
# also check by_anchor for entry
pba=pt.get('postprocess_by_anchor') or {}
print('anchors', list(pba.keys())[:20])
for ank in list(pba.keys())[:5]:
    av=pba[ank]
    if not isinstance(av, dict):
        continue
    # look for entry params
    for kk in ('entry','best','params','combo','orderbook','e','exs','111'):
        if kk in av:
            print(ank, kk, av[kk] if not isinstance(av[kk], dict) else {x:av[kk].get(x) for x in list(av[kk])[:20]})
    # shallow interesting
    interesting={k:av[k] for k in av if any(x in str(k).lower() for x in ('ratio','spread','ask','entry','ob'))}
    if interesting:
        print(ank, 'interesting', interesting)
PY

Extract postprocess consensus OB numbers

cd /home/hoon/kis_bot && python3 - <<'PY'
from kis_trader.utils.env import get_env_float, get_env_from_db
keys=[
 'OPTUNA_OB_ENTRY_SPREAD_MIN','OPTUNA_OB_ENTRY_SPREAD_MAX',
 'OPTUNA_OB_ENTRY_RATIO_MIN','OPTUNA_OB_ENTRY_RATIO_MAX',
 'OPTUNA_OB_ENTRY_ASK_MULT_MIN','OPTUNA_OB_ENTRY_ASK_MULT_MAX',
 'LIVE_FEED_FALLBACK_MAX_AGE_SEC','SELL_WS_STALE_REST_COOLDOWN_SEC',
 'SELL_WS_LAST_RAM_MAX_AGE_SEC','WS_ORDERBOOK_FILTER_MAX_AGE_SEC',
 'WS_ORDERBOOK_TICK_MAX_AGE_SEC',
]
for k in keys:
    print(k, '=', get_env_from_db(k) or f'(code default via get) {get_env_float(k, -1) if "AGE" in k or "SEC" in k or "MIN" in k or "MAX" in k else ""}')
# live momentum OB actual
from kis_trader.engine.orderbook_env import load_orderbook_threshold_cfg, orderbook_filter_enabled
print('filter_enabled', orderbook_filter_enabled('MOMENTUM'))
print('cfg', load_orderbook_threshold_cfg('MOMENTUM'))
PY

Read Optuna OB ranges and live price age envs


## 1. TPE OFF + 후처리 — 맞아요

설계가 이미 그렇게 되어 있습니다 (`docs/옵투나.md`).

| 단계 | 하는 일 | 진입 호가필터 |
|------|---------|---------------|
| **TPE (Stage1)** | RSI·손절·트레일 등 **차트**만 | **OFF** (축 안 넣음) |
| **후처리 (Stage2)** | 이미 난 체결에 호가 스냅 **다시 씌워** 숫자 찾기 | **ON 고정**으로 스프·잔량비·매도벽 탐색 |

그래서 **fast/TPE 그리드에 `min_bid_ask_ratio` 축 넣을 필요 없습니다.**
후처리가 그 축을 따로 돌립니다.

후처리 탐색 범위 (DB/코드 기본, **충분히 넓음** · 실매 0.86 포함):

| 축 | 범위 |
|----|------|
| 스프레드 | 0.1 ~ 8.0 |
| **잔량비** | **0.05 ~ 1.5** (step 0.05) ← 0.86 들어감 |
| 매도벽배수 | 1.0 ~ 80.0 |

**후처리가 최근에 뽑은 예** (8/16 완료 JSON `orderbook_recommend`):

- 잔량비 **0.855** ≈ 지금 실매 **0.86**
- 스프레드 **4.09** (실매 5.44)
- 매도벽 **41.4** (실매 41.2)
- EXIT/STOP도 같이 추천

8/20 일부 TPE는 스터디 미달·호가스냅 부족으로 후처리가 비었고 (`no_replay_fills`), 그건 “축이 없어서”가 아니라 **후처리 데이터가 부족**한 경우입니다.

---

## 2. ghost — 가설 → **그 케이스들은 확정**

잔고 API가 잠깐 속였다(❌)가 아니라:

1. **호가컷/손절호가/장마감**으로 **실제 매도 체결** (orders에 ODNO·수량·가격 있음)
2. `filled_qty`만 먼저 찍히고 → `이미 반영 스킵` → **`close_trade` 안 함**
3. 증권사는 0주(맞음) · 로컬만 남음 → ghost · PnL 0원

228850·163730·038290 타임라인이 orders+로그와 **일치**하므로, 그 날 그 건들에 대해는 **근본원인 확정**입니다.
(고치는 패치는 아직 승인 대기.)

---

## 3. 3차 / REST 30초 — “30초 동안 그 가격만 고정”이 **아님**

매도 시세는 **매 루프마다 처음부터** 다시 봅니다.

```
① 1차 → ② 2차 → ③ 3차   (신선도 ≈ LIVE_FEED_FALLBACK 3초)
        ↓ 전부 실패
④ last-RAM (최대 ~30초 된 마지막 소켓가)
        ↓ 그것도 안 되면
⑤ 직전 성공가 캐시 (REST/WS 성공 직후 기억, 쿨다운 동안)
        ↓
⑥ 키움 REST(ka10007) 1회  ← 종목당 최소 간격 ~30초 (유량 보호)
```

초등 비유:
- **30초** = “REST 전화는 **너무 자주 걸지 마**” 쿨다운
- 그 사이에 1·2·3차 소켓에 **새 가격이 잡히면 → 그게 바로 최신가** (REST 옛값 버림)
- REST로 받은 값은 `_remember_last_good`에 들어가고, 소켓이 계속 죽을 때만 “직전가 유지”로 잠깐 씀
- **30초 내내 REST 한 장만 박아 두는 구조가 아님**

호가필터 쪽도 비슷하게 분리:
- 판정: `FILTER_MAX_AGE=0` → 마지막 RAM으로 검사하되, 벤더가 **3초** 넘으면 그 사진만 버리고 2·3차
- 저장 TTL 30초는 **DB에 쌓는 용**이라 판정과 다름

---

**요약:** TPE는 OFF로 두고 후처리로 진입호가 숫자 찾는 구조가 맞고, 그리드 축 추가 불필요. ghost는 해당 건 확정. REST 30초는 고정가 유지가 아니라 **재호출 간격**이고, 그 안에도 1·2·3이 살아나면 그게 최신입니다.


<timestamp>Friday, Aug 21, 2026, 9:52 AM (UTC+9)</timestamp>
<user_query>
일단 이거 패치하고 레스트 실패로 30초 락 걸리면 1차2차 3차 다시 타는게 맞아? 근데 이것도 30초간 못받아오면? 유동 적은건 못받잖아 그럼 어차피 rest로 받은게 마지막 값이겟구나
</user_query>


고스트 패치를 진행하고, REST 30초 락과 1·2·3차 재시도 관계를 코드로 다시 확인합니다.


TodoWrite

매도 finalize 선반영→close_trade 스킵 버그 패치

실매 스모크 test_live_execution_validation 1회


매도 `update_order_fill` 선반영으로 `close_trade`가 스킵되는 구멍을 고칩니다.

StrReplace

        with self._lock_for(req.code):
            existing = self.db.get_order_by_odno(
                ord_no, strategy_id=req.strategy_id, code=req.code,
            )
            prev_filled = int((existing or {}).get("filled_qty") or 0)
            if filled_qty <= prev_filled:
                logger.info(
                    "%s⏭ [매도체결] 이미 반영 스킵 ODNO=%s filled=%d <= db=%d%s",
                    LOG_CYAN, ord_no, filled_qty, prev_filled, LOG_RESET,
                )
                prev_px = float(
                    (existing or {}).get("filled_avg_price") or sell_price or 0
                )
                return OrderResult(
                    True,
                    ord_no=ord_no,
                    filled_qty=prev_filled,
                    filled_avg_price=prev_px,
                    reason="sell_fill_already_applied",
                    request=req,
                )
            delta_qty = filled_qty - prev_filled

            status = "FILLED" if filled_qty >= order_qty else "PARTIAL"
            self.db.update_order_fill(
                ord_no=ord_no,
                strategy_id=req.strategy_id,
                code=req.code,
                filled_qty=filled_qty,
                filled_avg_price=sell_price,
                status=status,
            )

            # 시장가 부분체결: IOC면 잔량 이미 취소, 아니면 잔량 취소 후 pending 해제
            if 0 < filled_qty < order_qty:
                miss = order_qty - filled_qty
                if not getattr(self.client, "uses_market_sell_ioc", lambda: False)():
                    try:
                        self.client.cancel_order(ord_no, qty=miss)
                    except Exception as e:
                        logger.debug(
                            "매도 잔량 취소 스킵/실패 %s ODNO=%s: %s",
                            req.code, ord_no, e,
                        )
                self._seal_partial_sell_order(ord_no, req, filled_qty)

            fee_rate = float(get_env_from_db("FEE_RATE_PCT", "0.015")) / 100.0
            tax_rate = float(get_env_from_db("SELL_TAX_RATE_PCT", "0.18")) / 100.0
            buy_price = req.buy_price or 0
            if buy_price > 0:
                fees = (
                    buy_price * delta_qty * fee_rate
                    + sell_price * delta_qty * (fee_rate + tax_rate)
                )
                realized_pnl = (sell_price - buy_price) * delta_qty - fees
            else:
                realized_pnl = None

        with self._lock_for(req.code):
            existing = self.db.get_order_by_odno(
                ord_no, strategy_id=req.strategy_id, code=req.code,
            )
            prev_filled = int((existing or {}).get("filled_qty") or 0)
            # orders.filled 만 먼저 찍히고 close_trade 가 빠진 경우
            # (모의 가정체결·지정가 merge 선반영) → active 남으면 장부 보완.
            # 진짜 중복 poll 은 active 도 0 이라 여기서 스킵.
            repair_only = False
            if filled_qty <= prev_filled:
                remain = self._active_qty(req.strategy_id, req.code)
                if remain <= 0:
                    logger.info(
                        "%s⏭ [매도체결] 이미 반영 스킵 ODNO=%s filled=%d <= db=%d%s",
                        LOG_CYAN, ord_no, filled_qty, prev_filled, LOG_RESET,
                    )
                    prev_px = float(
                        (existing or {}).get("filled_avg_price") or sell_price or 0
                    )
                    return OrderResult(
                        True,
                        ord_no=ord_no,
                        filled_qty=prev_filled,
                        filled_avg_price=prev_px,
                        reason="sell_fill_already_applied",
                        request=req,
                    )
                repair_only = True
                delta_qty = remain
                if sell_price <= 0:
                    sell_price = float(
                        (existing or {}).get("filled_avg_price") or 0
                    )
                logger.warning(
                    "%s⚠ [매도체결·장부보완] [%s] %s %s ODNO=%s "
                    "orders.filled=%d 이미 있으나 active=%d → close_trade%s",
                    LOG_YELLOW, req.strategy_id, req.name, req.code, ord_no,
                    prev_filled, remain, LOG_RESET,
                )
            else:
                delta_qty = filled_qty - prev_filled

            if not repair_only:
                status = "FILLED" if filled_qty >= order_qty else "PARTIAL"
                self.db.update_order_fill(
                    ord_no=ord_no,
                    strategy_id=req.strategy_id,
                    code=req.code,
                    filled_qty=filled_qty,
                    filled_avg_price=sell_price,
                    status=status,
                )

                # 시장가 부분체결: IOC면 잔량 이미 취소, 아니면 잔량 취소 후 pending 해제
                if 0 < filled_qty < order_qty:
                    miss = order_qty - filled_qty
                    if not getattr(self.client, "uses_market_sell_ioc", lambda: False)():
                        try:
                            self.client.cancel_order(ord_no, qty=miss)
                        except Exception as e:
                            logger.debug(
                                "매도 잔량 취소 스킵/실패 %s ODNO=%s: %s",
                                req.code, ord_no, e,
                            )
                    self._seal_partial_sell_order(ord_no, req, filled_qty)

            fee_rate = float(get_env_from_db("FEE_RATE_PCT", "0.015")) / 100.0
            tax_rate = float(get_env_from_db("SELL_TAX_RATE_PCT", "0.18")) / 100.0
            buy_price = req.buy_price or 0
            if buy_price > 0:
                fees = (
                    buy_price * delta_qty * fee_rate
                    + sell_price * delta_qty * (fee_rate + tax_rate)
                )
                realized_pnl = (sell_price - buy_price) * delta_qty - fees
            else:
                realized_pnl = None

            else:
                # 레거시 모의: 참고가로 가정 체결
                sell_price = req.price_ref or req.buy_price
                filled_qty = sell_qty
                self.db.update_order_fill(
                    ord_no=ord_no, strategy_id=req.strategy_id, code=req.code,
                    filled_qty=filled_qty,
                    filled_avg_price=sell_price, status="SUBMITTED",
                )

            # 익절 지정가 미확인·부분체결 → 취소 후 재조회, 필요할 때만 시장가
            # (지정가 체결 중 시장가 재시도 → 타전략 몫까지 이중매도 방지)
            if use_limit and (
                not fill
                or int(fill.get("filled_qty", 0) or 0) < sell_qty
            ):
                filled_so_far = int((fill or {}).get("filled_qty", 0) or 0)
                merged, _mkt_no, pending = self._after_limit_sell_need_market(
                    req=req,
                    ord_no=ord_no,
                    sell_qty=sell_qty,
                    filled_so_far=filled_so_far,
                    wait_sec=wait_sec,
                    limit_price=float(limit_price or 0),
                )
                if merged and int(merged.get("filled_qty", 0) or 0) > 0:
                    filled_qty = int(merged["filled_qty"])
                    sell_price = float(merged["avg_price"])
                    fill = {"filled_qty": filled_qty, "avg_price": sell_price}
                    self.db.update_order_fill(
                        ord_no=ord_no,
                        strategy_id=req.strategy_id,
                        code=req.code,
                        filled_qty=filled_qty,
                        filled_avg_price=sell_price,
                        status="FILLED" if filled_qty >= sell_qty else "PARTIAL",
                    )
                if pending or filled_qty <= 0 or sell_price <= 0:

            else:
                # 레거시 모의: 참고가로 가정 체결.
                # orders.filled 는 _finalize_sell_fill 에서만 기록
                # (여기서 선반영하면 filled<=db 스킵 → close_trade 누락 → ghost).
                sell_price = req.price_ref or req.buy_price
                filled_qty = sell_qty

            # 익절 지정가 미확인·부분체결 → 취소 후 재조회, 필요할 때만 시장가
            # (지정가 체결 중 시장가 재시도 → 타전략 몫까지 이중매도 방지)
            if use_limit and (
                not fill
                or int(fill.get("filled_qty", 0) or 0) < sell_qty
            ):
                filled_so_far = int((fill or {}).get("filled_qty", 0) or 0)
                merged, _mkt_no, pending = self._after_limit_sell_need_market(
                    req=req,
                    ord_no=ord_no,
                    sell_qty=sell_qty,
                    filled_so_far=filled_so_far,
                    wait_sec=wait_sec,
                    limit_price=float(limit_price or 0),
                )
                if merged and int(merged.get("filled_qty", 0) or 0) > 0:
                    filled_qty = int(merged["filled_qty"])
                    sell_price = float(merged["avg_price"])
                    fill = {"filled_qty": filled_qty, "avg_price": sell_price}
                    # filled 선반영 금지 — finalize 가 update+close 원자적으로 처리
                if pending or filled_qty <= 0 or sell_price <= 0:


Shell

cd /home/hoon/kis_bot && python3 -m py_compile kis_trader/execution/order_manager.py && nohup python3 -u scripts/test_live_execution_validation.py > logs/test_live_execution_validation_$(date +%Y%m%d_%H%M%S).log 2>&1 & echo "pid=$!"; sleep 1; ls -t logs/test_live_execution_validation_*.log | head -1

Compile order_manager and start live smoke test


tail -30 /home/hoon/kis_bot/logs/test_live_execution_validation_20260821_095311.log; ps -p 3865687 -o pid,etime,cmd 2>/dev/null || echo done

Tail smoke log and check process


TodoWrite

매도 finalize 선반영→close_trade 스킵 버그 패치

실매 스모크 test_live_execution_validation 1회


## REST 30초 — 맞아요

**맞습니다.** 30초 락은 **REST 전화만** 잠급니다. 1·2·3차는 **매 루프마다 다시** 탑니다.

```
루프마다:
  1차 → 2차 → 3차  (항상 시도, 락 없음)
  ↓ 다 실패
  last-RAM / 직전가 캐시
  ↓
  REST — 여기만 종목당 ~30초 쿨다운
```

- 그 30초 안에 소켓이 다시 오면 → **그게 최신가** (REST 옛값 버림)
- 유동 적어서 30초 내내 1·2·3이 계속 죽으면 → REST로 받은 값이 `_last_good`에 남아서 **「직전가 유지」**로 쓰임
  → 말씀하신 대로, **그때는 REST가 사실상 마지막 가격**이 맞습니다.

---

## ghost 패치 완료

**원인:** `orders.filled`만 먼저 찍고 → `이미 반영 스킵` → `close_trade` 안 함 → ghost 0원.

**고친 것 (`order_manager.py`):**
1. 모의 가정체결·지정가 merge에서 **filled 선반영 제거** → finalize에서만 기록
2. 이미 filled여도 **`active_trades`가 남아 있으면 `close_trade` 보완** (`매도체결·장부보완`)

**영향:** 실매 매도 장부 동기화 (백테/Optuna 무관)

**스모크:** `logs/test_live_execution_validation_20260821_095311.log`
`최종: 통과` · `👑 [최종 판정] 완결!`

실매 봇(`kis_trader_main`)은 웹과 무관해서 자동 재시작은 안 했습니다. 장중에 반영하려면 재시작이 필요합니다.


<timestamp>Friday, Aug 21, 2026, 9:59 AM (UTC+9)</timestamp>
<user_query>
어제 옵투나 돌린거 후처리 자동으로 안돈거같은데 이거 원인 찾아봐 지금 내가 수동을로 돌렷어
</user_query>


어제 Optuna 로그·JSON을 보고 후처리가 자동으로 스킵된 원인을 찾겠습니다.

Grep

후처리 스킵|스터디 총 횟수 미달|attach_topn_postprocess|run_ob_whipsaw|postprocess


후처리|스터디 총 횟수|미달|완료

스터디 총 횟수 미달|후처리 스킵|kis_study_trials|finalize_optuna


Shell

cd /home/hoon/kis_bot && python3 - <<'PY'
# count trial states from the 173315 log if possible, or from study
import re
from pathlib import Path
text = Path('logs/optuna_web_momentum_20260820_173315.log').read_text(errors='replace')
finished = re.findall(r'Trial (\d+) finished', text)
pruned = re.findall(r'Trial (\d+) pruned', text)
print('finished lines', len(finished), 'max', max(map(int,finished)) if finished else None)
print('pruned lines', len(pruned))
# unique trial numbers that finished
print('unique finished', len(set(finished)))
# look for why 177 not 200
print('last lines:')
print('\n'.join(text.strip().splitlines()[-8:]))
PY

Count finished vs pruned trials in Optuna log


<timestamp>Friday, Aug 21, 2026, 10:01 AM (UTC+9)</timestamp>
<user_query>
에라이 문제출제가 잘못된거 빼고 돌리는 숫자로 해야지 끝났다는 기준은
</user_query>


끝난 기준을 COMPLETE만 세지 말고, pruned(문제 잘못된 것)를 빼고·시도한 횟수 기준으로 맞추겠습니다. 코드부터 확인합니다.


끝난 기준을 COMPLETE만이 아니라 **시도 수(COMPLETE+PRUNED+FAIL)** 로 바꿉니다.

StrReplace

def count_study_states(study: Any) -> Tuple[int, int]:
    n_complete = 0
    n_running = 0
    try:
        import optuna
        complete = optuna.trial.TrialState.COMPLETE
        running = optuna.trial.TrialState.RUNNING
    except Exception:
        complete = running = None
    for t in list(getattr(study, "trials", None) or []):
        st = getattr(t, "state", None)
        if complete is not None and st == complete:
            n_complete += 1
        elif running is not None and st == running:
            n_running += 1
    return n_complete, n_running

def count_study_states(study: Any) -> Tuple[int, int, int]:
    """(COMPLETE 수, RUNNING 수, 끝난 시도 수).

    끝난 시도 = COMPLETE + PRUNED + FAIL.
    후처리·남은횟수 게이트는 「돌린 횟수」(끝난 시도) 기준.
    문제출제 오류(pruned: tp_max<tp 등)는 목표에서 빼지 않고 시도로 친다.
    """
    n_complete = 0
    n_running = 0
    n_finished = 0
    try:
        import optuna
        complete = optuna.trial.TrialState.COMPLETE
        running = optuna.trial.TrialState.RUNNING
        pruned = optuna.trial.TrialState.PRUNED
        fail = optuna.trial.TrialState.FAIL
    except Exception:
        complete = running = pruned = fail = None
    for t in list(getattr(study, "trials", None) or []):
        st = getattr(t, "state", None)
        if complete is not None and st == complete:
            n_complete += 1
            n_finished += 1
        elif running is not None and st == running:
            n_running += 1
        elif pruned is not None and st == pruned:
            n_finished += 1
        elif fail is not None and st == fail:
            n_finished += 1
    return n_complete, n_running, n_finished

def leftover_trials(study_trials: int, n_complete: int) -> int:
    t = int(study_trials or 0)
    c = int(n_complete or 0)
    if t <= 0:
        return 0
    return max(0, t - c)

def leftover_note(study_trials: int, n_complete: int) -> str:
    left = leftover_trials(study_trials, n_complete)
    t = int(study_trials or 0)
    c = int(n_complete or 0)
    if t <= 0:
        return ""
    if left > 0:
        return "남은 %s회 (완료 %s / 목표 %s)" % (left, c, t)
    return "목표 도달 (완료 %s / 목표 %s)" % (c, t)

def leftover_trials(study_trials: int, n_finished: int) -> int:
    """남은 횟수 = 목표 − 끝난 시도(COMPLETE+PRUNED+FAIL)."""
    t = int(study_trials or 0)
    c = int(n_finished or 0)
    if t <= 0:
        return 0
    return max(0, t - c)

def leftover_note(
    study_trials: int,
    n_finished: int,
    *,
    n_complete: Optional[int] = None,
) -> str:
    left = leftover_trials(study_trials, n_finished)
    t = int(study_trials or 0)
    done = int(n_finished or 0)
    ok = int(n_complete) if n_complete is not None else done
    if t <= 0:
        return ""
    if left > 0:
        return "남은 %s회 (시도 %s / 목표 %s · COMPLETE %s)" % (left, done, t, ok)
    return "목표 도달 (시도 %s / 목표 %s · COMPLETE %s)" % (done, t, ok)

def should_run_postprocess(n_complete: int, study_trials: int) -> bool:
    t = int(study_trials or 0)
    if t <= 0:
        return True
    return int(n_complete or 0) >= t

def finalize_optuna_export(
    study: Any,
    *,
    out_data: Dict[str, Any],
    out_path: str,
    strategy: str,
    mode: str,
    enrich_fn: Callable[[], None],
    log: Optional[logging.Logger] = None,
    symbol: str = "",
) -> bool:
    """optimize 이후. 목표 미달이면 후처리 스킵, 도달이면 락 후 기존 enrich."""
    lg = log or logger
    name = str(getattr(study, "study_name", "") or out_data.get("optuna_study_name") or "")
    n_c, n_r = count_study_states(study)
    try:
        target = parse_study_trials_value((study.user_attrs or {}).get(KIS_STUDY_TRIALS_ATTR))
    except Exception:
        target = 0
    if target <= 0:
        target = max(1, int(out_data.get("optuna_n_trials_requested") or 0) or n_c)
    start = str(out_data.get("start") or "")
    end = str(out_data.get("end") or "")
    upsert_counts(
        study_name=name,
        strategy=str(out_data.get("strategy") or strategy or ""),
        mode=str(out_data.get("mode") or mode or ""),
        start=start,
        end=end,
        symbol=str(out_data.get("symbol") or symbol or ""),
        study_trials=target,
        n_complete=n_c,
        n_running=n_r,
    )
    out_data["optuna_study_trials"] = target
    out_data["optuna_n_complete"] = n_c
    row = load_row(name) or {}
    if str(row.get("pp_status") or "") == "done" and row.get("payload_json") and int(row.get("pp_for_n") or 0) >= target:
        lg.info("📌 후처리 이미 완료 (pp_for_n=%s, 목표=%s) — 스킵", row.get("pp_for_n"), target)
        return False
    if not should_run_postprocess(n_c, target):
        save_payload(
            study_name=name,
            out_data=out_data,
            n_complete=n_c,
            pp_status="idle",
        )
        lg.info(
            "📌 스터디 총 횟수 미달 — 후처리 스킵 (완료 %s / 목표 %s). 이어 돌리기 또는 확정.",
            n_c, target,
        )
        return False
    if not try_lock_postprocess(name):
        lg.info("📌 다른 워커가 후처리 중/완료 — 이 프로세스는 후처리 생략")
        return False
    try:
        enrich_fn()
        save_payload(
            study_name=name,
            out_data=out_data,
            n_complete=n_c,
            pp_status="done",
        )
        lg.info("📌 optuna_study_result payload 저장 study=%s n_complete=%s", name, n_c)
        return True
    except Exception as exc:
        save_payload(
            study_name=name,
            out_data=out_data,
            n_complete=n_c,
            pp_status="error",
            pp_error=str(exc),
        )
        raise

def flags_for_web(study_name: str) -> Dict[str, Any]:
    row = load_row(study_name) or {}
    t = int(row.get("study_trials") or 0)
    c = int(row.get("n_complete") or 0)
    left = leftover_trials(t, c)
    pp = str(row.get("pp_status") or "idle")
    has_payload = bool(row.get("payload_json"))
    return {
        "study_name": study_name,
        "study_trials": t,
        "n_complete": c,
        "n_running": int(row.get("n_running") or 0),
        "leftover_trials": left,
        "leftover_note": row.get("leftover_note") or leftover_note(t, c),
        "pp_status": pp,
        "pp_needed": int(row.get("pp_needed") or 0),
        "has_payload": has_payload,
        "can_continue": left > 0,
        "can_confirm": left > 0,
    }

def should_run_postprocess(n_finished: int, study_trials: int) -> bool:
    """목표 도달 = 끝난 시도(COMPLETE+PRUNED+FAIL) ≥ kis_study_trials."""
    t = int(study_trials or 0)
    if t <= 0:
        return True
    return int(n_finished or 0) >= t

def finalize_optuna_export(
    study: Any,
    *,
    out_data: Dict[str, Any],
    out_path: str,
    strategy: str,
    mode: str,
    enrich_fn: Callable[[], None],
    log: Optional[logging.Logger] = None,
    symbol: str = "",
) -> bool:
    """optimize 이후. 시도 목표 미달이면 후처리 스킵, 도달이면 락 후 기존 enrich."""
    lg = log or logger
    name = str(getattr(study, "study_name", "") or out_data.get("optuna_study_name") or "")
    n_c, n_r, n_f = count_study_states(study)
    try:
        target = parse_study_trials_value((study.user_attrs or {}).get(KIS_STUDY_TRIALS_ATTR))
    except Exception:
        target = 0
    if target <= 0:
        target = max(1, int(out_data.get("optuna_n_trials_requested") or 0) or n_f or n_c)
    start = str(out_data.get("start") or "")
    end = str(out_data.get("end") or "")
    # DB n_complete 컬럼 = 끝난 시도(게이트·남은횟수). COMPLETE 실수는 out_data 에.
    upsert_counts(
        study_name=name,
        strategy=str(out_data.get("strategy") or strategy or ""),
        mode=str(out_data.get("mode") or mode or ""),
        start=start,
        end=end,
        symbol=str(out_data.get("symbol") or symbol or ""),
        study_trials=target,
        n_complete=n_f,
        n_running=n_r,
        leftover_n_complete=n_c,
    )
    out_data["optuna_study_trials"] = target
    out_data["optuna_n_complete"] = n_c
    out_data["optuna_n_finished"] = n_f
    row = load_row(name) or {}
    if str(row.get("pp_status") or "") == "done" and row.get("payload_json") and int(row.get("pp_for_n") or 0) >= target:
        lg.info("📌 후처리 이미 완료 (pp_for_n=%s, 목표=%s) — 스킵", row.get("pp_for_n"), target)
        return False
    if not should_run_postprocess(n_f, target):
        save_payload(
            study_name=name,
            out_data=out_data,
            n_complete=n_f,
            pp_status="idle",
        )
        lg.info(
            "📌 스터디 총 횟수 미달 — 후처리 스킵 "
            "(시도 %s / 목표 %s · COMPLETE %s). 이어 돌리기 또는 확정.",
            n_f, target, n_c,
        )
        return False
    if not try_lock_postprocess(name):
        lg.info("📌 다른 워커가 후처리 중/완료 — 이 프로세스는 후처리 생략")
        return False
    try:
        enrich_fn()
        save_payload(
            study_name=name,
            out_data=out_data,
            n_complete=n_f,
            pp_status="done",
        )
        lg.info(
            "📌 optuna_study_result payload 저장 study=%s 시도=%s COMPLETE=%s",
            name, n_f, n_c,
        )
        return True
    except Exception as exc:
        save_payload(
            study_name=name,
            out_data=out_data,
            n_complete=n_f,
            pp_status="error",
            pp_error=str(exc),
        )
        raise

def flags_for_web(study_name: str) -> Dict[str, Any]:
    row = load_row(study_name) or {}
    t = int(row.get("study_trials") or 0)
    # n_complete 컬럼 = 끝난 시도(게이트용). COMPLETE 실수는 leftover_note 참고.
    done = int(row.get("n_complete") or 0)
    left = leftover_trials(t, done)
    pp = str(row.get("pp_status") or "idle")
    has_payload = bool(row.get("payload_json"))
    note = row.get("leftover_note") or leftover_note(t, done)
    return {
        "study_name": study_name,
        "study_trials": t,
        "n_complete": done,
        "n_running": int(row.get("n_running") or 0),
        "leftover_trials": left,
        "leftover_note": note,
        "pp_status": pp,
        "pp_needed": int(row.get("pp_needed") or 0),
        "has_payload": has_payload,
        "can_continue": left > 0,
        "can_confirm": left > 0,
    }


def upsert_counts(
    *,
    study_name: str,
    strategy: str = "",
    mode: str = "",
    start: str = "",
    end: str = "",
    job_id: str = "",
    symbol: str = "",
    study_trials: int = 0,
    n_complete: int = 0,
    n_running: int = 0,
    leftover_n_complete: Optional[int] = None,
) -> None:
    """n_complete 인자 = 끝난 시도(게이트). leftover_n_complete = COMPLETE 실수(안내 문구)."""
    name = str(study_name or "").strip()
    if not name:
        return
    note = leftover_note(
        study_trials,
        n_complete,
        n_complete=leftover_n_complete if leftover_n_complete is not None else None,
    )
    needed = 1 if leftover_trials(study_trials, n_complete) > 0 else 0
    jid = (job_id or os.environ.get("OPTUNA_WEB_JOB_ID") or "")[:64] or None
    sym = (symbol or "")[:16] or None
    try:
        ensure_optuna_study_result_table()
        _db().conn.execute(
            """
            INSERT INTO optuna_study_result (
              study_name, strategy, mode, start_date, end_date, job_id, symbol,
              study_trials, n_complete, n_running, leftover_note, pp_needed
            ) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
            ON DUPLICATE KEY UPDATE
              strategy=IF(VALUES(strategy)='', strategy, VALUES(strategy)),
              mode=IF(VALUES(mode)='', mode, VALUES(mode)),
              start_date=COALESCE(VALUES(start_date), start_date),
              end_date=COALESCE(VALUES(end_date), end_date),
              job_id=COALESCE(VALUES(job_id), job_id),
              symbol=COALESCE(VALUES(symbol), symbol),
              study_trials=IF(VALUES(study_trials)>0, VALUES(study_trials), study_trials),
              n_complete=VALUES(n_complete),
              n_running=VALUES(n_running),
              leftover_note=VALUES(leftover_note),
              pp_needed=VALUES(pp_needed)
            """,
            (
                name,
                (strategy or "")[:32],
                (mode or "")[:32],
                (start or None),
                (end or None),
                jid,
                sym,
                int(study_trials or 0),
                int(n_complete or 0),
                int(n_running or 0),
                note[:200] if note else "",
                int(needed),
            ),
        )
    except Exception as exc:
        logger.warning("⚠ optuna_study_result upsert_counts 실패: %s", exc)

def upsert_counts(
    *,
    study_name: str,
    strategy: str = "",
    mode: str = "",
    start: str = "",
    end: str = "",
    job_id: str = "",
    symbol: str = "",
    study_trials: int = 0,
    n_complete: int = 0,
    n_running: int = 0,
) -> None:
    name = str(study_name or "").strip()
    if not name:
        return
    note = leftover_note(study_trials, n_complete)
    needed = 1 if leftover_trials(study_trials, n_complete) > 0 else 0
    jid = (job_id or os.environ.get("OPTUNA_WEB_JOB_ID") or "")[:64] or None
    sym = (symbol or "")[:16] or None
    try:
        ensure_optuna_study_result_table()
        _db().conn.execute(
            """
            INSERT INTO optuna_study_result (
              study_name, strategy, mode, start_date, end_date, job_id, symbol,
              study_trials, n_complete, n_running, leftover_note, pp_needed
            ) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
            ON DUPLICATE KEY UPDATE
              strategy=IF(VALUES(strategy)='', strategy, VALUES(strategy)),
              mode=IF(VALUES(mode)='', mode, VALUES(mode)),
              start_date=COALESCE(VALUES(start_date), start_date),
              end_date=COALESCE(VALUES(end_date), end_date),
              job_id=COALESCE(VALUES(job_id), job_id),
              symbol=COALESCE(VALUES(symbol), symbol),
              study_trials=IF(VALUES(study_trials)>0, VALUES(study_trials), study_trials),
              n_complete=VALUES(n_complete),
              n_running=VALUES(n_running),
              leftover_note=VALUES(leftover_note),
              pp_needed=VALUES(pp_needed)
            """,
            (
                name,
                (strategy or "")[:32],
                (mode or "")[:32],
                (start or None),
                (end or None),
                jid,
                sym,
                int(study_trials or 0),
                int(n_complete or 0),
                int(n_running or 0),
                note[:200] if note else "",
                int(needed),
            ),
        )
    except Exception as exc:
        logger.warning("⚠ optuna_study_result upsert_counts 실패: %s", exc)


def upsert_counts(
    *,
    study_name: str,
    strategy: str = "",
    mode: str = "",
    start: str = "",
    end: str = "",
    job_id: str = "",
    symbol: str = "",
    study_trials: int = 0,
    n_complete: int = 0,
    n_running: int = 0,
    leftover_n_complete: Optional[int] = None,
) -> None:
    """n_complete = 끝난 시도(게이트). leftover_n_complete = COMPLETE 실수(안내)."""
    name = str(study_name or "").strip()
    if not name:
        return
    note = leftover_note(
        study_trials,
        n_complete,
        n_complete=leftover_n_complete if leftover_n_complete is not None else None,
    )
    needed = 1 if leftover_trials(study_trials, n_complete) > 0 else 0
    jid = (job_id or os.environ.get("OPTUNA_WEB_JOB_ID") or "")[:64] or None
    sym = (symbol or "")[:16] or None
    try:
        ensure_optuna_study_result_table()
        _db().conn.execute(
            """
            INSERT INTO optuna_study_result (
              study_name, strategy, mode, start_date, end_date, job_id, symbol,
              study_trials, n_complete, n_running, leftover_note, pp_needed
            ) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
            ON DUPLICATE KEY UPDATE
              strategy=IF(VALUES(strategy)='', strategy, VALUES(strategy)),
              mode=IF(VALUES(mode)='', mode, VALUES(mode)),
              start_date=COALESCE(VALUES(start_date), start_date),
              end_date=COALESCE(VALUES(end_date), end_date),
              job_id=COALESCE(VALUES(job_id), job_id),
              symbol=COALESCE(VALUES(symbol), symbol),
              study_trials=IF(VALUES(study_trials)>0, VALUES(study_trials), study_trials),
              n_complete=VALUES(n_complete),
              n_running=VALUES(n_running),
              leftover_note=VALUES(leftover_note),
              pp_needed=VALUES(pp_needed)
            """,
            (
                name,
                str(strategy or "")[:32],
                str(mode or "")[:32],
                (start or "")[:10] or None,
                (end or "")[:10] or None,
                jid,
                sym,
                int(study_trials or 0),
                int(n_complete or 0),
                int(n_running or 0),
                note[:200] if note else None,
                int(needed),
            ),
        )
    except Exception as exc:
        logger.warning("⚠ optuna_study_result upsert 실패: %s", exc)

def upsert_counts(
    *,
    study_name: str,
    strategy: str = "",
    mode: str = "",
    start: str = "",
    end: str = "",
    job_id: str = "",
    symbol: str = "",
    study_trials: int = 0,
    n_complete: int = 0,
    n_running: int = 0,
) -> None:
    name = str(study_name or "").strip()
    if not name:
        return
    note = leftover_note(study_trials, n_complete)
    needed = 1 if leftover_trials(study_trials, n_complete) > 0 else 0
    jid = (job_id or os.environ.get("OPTUNA_WEB_JOB_ID") or "")[:64] or None
    sym = (symbol or "")[:16] or None
    try:
        ensure_optuna_study_result_table()
        _db().conn.execute(
            """
            INSERT INTO optuna_study_result (
              study_name, strategy, mode, start_date, end_date, job_id, symbol,
              study_trials, n_complete, n_running, leftover_note, pp_needed
            ) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
            ON DUPLICATE KEY UPDATE
              strategy=IF(VALUES(strategy)='', strategy, VALUES(strategy)),
              mode=IF(VALUES(mode)='', mode, VALUES(mode)),
              start_date=COALESCE(VALUES(start_date), start_date),
              end_date=COALESCE(VALUES(end_date), end_date),
              job_id=COALESCE(VALUES(job_id), job_id),
              symbol=COALESCE(VALUES(symbol), symbol),
              study_trials=IF(VALUES(study_trials)>0, VALUES(study_trials), study_trials),
              n_complete=VALUES(n_complete),
              n_running=VALUES(n_running),
              leftover_note=VALUES(leftover_note),
              pp_needed=VALUES(pp_needed)
            """,
            (
                name,
                str(strategy or "")[:32],
                str(mode or "")[:32],
                (start or "")[:10] or None,
                (end or "")[:10] or None,
                jid,
                sym,
                int(study_trials or 0),
                int(n_complete or 0),
                int(n_running or 0),
                note[:200] if note else None,
                int(needed),
            ),
        )
    except Exception as exc:
        logger.warning("⚠ optuna_study_result upsert 실패: %s", exc)


help="이 study 시도 목표(COMPLETE+PRUNED+FAIL). 미지정/0=이번 --trials. 시도 < 목표면 후처리 스킵",

help="이 study 완료 목표. 미지정/0=이번 --trials. 완료 수 < 목표면 후처리 스킵",

  study_trials        INT          NOT NULL DEFAULT 0 COMMENT '스터디 총 시도 목표(COMPLETE+PRUNED+FAIL). 0이면 미설정',
  n_complete          INT          NOT NULL DEFAULT 0 COMMENT '끝난 시도 수 캐시(COMPLETE+PRUNED+FAIL, 후처리 게이트)',

  study_trials        INT          NOT NULL DEFAULT 0 COMMENT '스터디 총 완료 목표. 0이면 미설정',
  n_complete          INT          NOT NULL DEFAULT 0 COMMENT 'kis_optuna 완료 trial 수 캐시',


                    n_c, _nr, n_f = count_study_states(st)
                except Exception:
                    n_f = n_c
                save_payload(
                    study_name=sname,
                    out_data=data,
                    n_complete=n_f if n_f else n_c,
                    pp_status="done",
                )

                    n_c, _nr, _nf = count_study_states(st)
                except Exception:
                    pass
                save_payload(
                    study_name=sname,
                    out_data=data,
                    n_complete=n_c,
                    pp_status="done",
                )

Shell

cd /home/hoon/kis_bot && python3 - <<'PY'
from kis_trader.backtest.optuna_study_store import should_run_postprocess, leftover_trials, leftover_note

# yesterday case: 177 COMPLETE + 23 pruned = 200 finished
assert should_run_postprocess(200, 200) is True
assert should_run_postprocess(177, 200) is False  # old gate would use this
assert leftover_trials(200, 200) == 0
assert leftover_trials(200, 177) == 23
note = leftover_note(200, 200, n_complete=177)
assert '목표 도달' in note and 'COMPLETE 177' in note
print('OK', note)

# under target
assert should_run_postprocess(50, 200) is False
assert leftover_trials(200, 50) == 150
print('OK under')
PY
python3 -m py_compile kis_trader/backtest/optuna_study_store.py kis_trader/backtest/optuna_rerun_postprocess.py kis_trader/backtest/param_search_optuna.py
# docs/옵투나.md one line update if mentions COMPLETE only
grep -n '후처리\|COMPLETE\|kis_study' docs/옵투나.md | head -20

Unit-check gate logic and compile


StrReplace

웹 Optuna 버튼도 완료 시 후처리를 붙인다 (`optuna_postprocess_topn`).
`orderbook_recommend` 블록 + `postprocess_topn` 이 JSON에 실림.

**후처리 자동 실행 게이트** (`kis_study_trials` / `--study-trials`):
끝난 기준 = **시도 수** `COMPLETE + PRUNED + FAIL` ≥ 목표.
`tp_max < tp` 같은 pruned(문제 출제 오류)도 시도로 친다. COMPLETE만 세서 스킵하지 않음 (2026-08-21).

---

## 2. 진입 후처리 격자 (env_config_ext)

웹 Optuna 버튼도 완료 시 후처리를 붙인다 (`optuna_postprocess_topn`).
`orderbook_recommend` 블록 + `postprocess_topn` 이 JSON에 실림.

---

## 2. 진입 후처리 격자 (env_config_ext)


맞습니다. 그래서 **끝난 기준을 바꿨습니다.**

### 변경
| | 이전 | 지금 |
|--|------|------|
| 후처리 조건 | COMPLETE ≥ 목표 | **시도**(COMPLETE+PRUNED+FAIL) ≥ 목표 |
| 어제 200회 예 | 177 COMPLETE → 스킵 | 시도 200 → **후처리 돌림** |

pruned(`tp_max < tp` 같은 **문제 출제 오류**)도 “돌린 횟수”에 포함합니다.

로그 예: `목표 도달 (시도 200 / 목표 200 · COMPLETE 177)`

### 파일
- `kis_trader/backtest/optuna_study_store.py`
- `param_search_optuna.py` help 문구
- `docs/옵투나.md`

**영향:** 백테/Optuna 후처리 게이트만. 실매 무관.
다음 Optuna부터 자동 적용됩니다. (어제 건은 이미 수동 후처리 하셨으면 OK)


<timestamp>Friday, Aug 21, 2026, 10:12 AM (UTC+9)</timestamp>
<user_query>
끝나고 새로고침 되면 편하겠는데  뭐든지 러닝인것도 새로고침해야 나오고 근데 또 새로고침하면 다른 탭이 활성화 되더만
</user_query>


Optuna/백테 웹에서 완료·러닝 상태 자동 갱신과, 새로고침 시 탭이 바뀌는 문제를 확인합니다.


TodoWrite

탭 localStorage 저장·복원 (새로고침 시 같은 탭)

러닝/완료 시 잡 목록·결과 자동 갱신

웹 재시작 + 192.168.0.149:5050 브라우저 검증