            data_compat["chetime"] = tick_time_pkt
            data_compat["kiwoom_fid20"] = tick_time_pkt
            data_compat["tick_time"] = (
                tt_raw[:14] if len(tt_raw) >= 14 else (_now_dt.strftime("%Y%m%d") + tick_time_pkt)
            )
            _pkt_dt = None

            data_compat["chetime"] = tick_time_pkt
            data_compat["kiwoom_fid20"] = tick_time_pkt
            _pkt_dt = None

Shell

cd /home/hoon/kis_bot && python3 - <<'PY'
# unit smoke format_trigger_feed_trace
from kis_trader.engine.feed_fallback import (
    extract_tick_trace_fields, extract_ob_trace_fields,
    format_trigger_feed_trace, trigger_feed_detail_log_enabled,
)

assert trigger_feed_detail_log_enabled() is True
tick = extract_tick_trace_fields({
    'stck_prpr': '12340', 'tick_time': '20260821143055', '_age_ms': 200, '_feed_vendor': 'kis',
})
assert tick['price'] == 12340.0 and tick['tick_time'].startswith('20260821')
print('tick', tick)

class Snap:
    def best_bid(self): return 12330
    def best_ask(self): return 12350
    def spread_pct(self): return 0.162
    total_bid_qty = 1000
    total_ask_qty = 800
    snap_time = '20260821143054'
    source = 'kiwoom_0d'
    ts = __import__('time').time() - 0.5

ob = extract_ob_trace_fields(Snap())
print('ob', ob)
s = format_trigger_feed_trace(
    {'vendor':'kis','tier':1,'label':'kis(1차)', **tick},
    {'vendor':'kiwoom','tier':1,'label':'kiwoom(1차)', **ob},
    tick_primary='kis', ob_primary='kiwoom',
)
print(s)
assert '틱1차설정=kis' in s and '호가1차설정=kiwoom' in s
assert '틱실제=kis(1차)' in s and 'px=12340' in s
assert '호가실제=kiwoom(1차)' in s and 'bid=12330' in s
print('OK')
PY
python3 -m py_compile kis_trader/engine/feed_fallback.py kis_trader/network/ws_manager.py kis_trader/strategies/base.py kis_trader/ws/kis_ws.py kis_trader/ws/kiwoom_ws.py

Smoke-test feed trace formatting and compile


cd /home/hoon/kis_bot && python3 -c "
p='docs/like_mcp.md/MODIFICATION_GUIDE.md'
with open(p,encoding='utf-8') as f: s=f.read()
old='- \`LIVE_FEED_FALLBACK_MAX_AGE_SEC\`(기본 3) = **그 틱의 체결시각(FID20/chetime) vs 이 서버 지금**. 1·2·3차 동일. 벤더끼리 시각 비교 금지.\n- 분봉 쓰레기'
new='- \`LIVE_FEED_FALLBACK_MAX_AGE_SEC\`(기본 3) = **그 틱의 체결시각(FID20/chetime) vs 이 서버 지금**. 1·2·3차 동일. 벤더끼리 시각 비교 금지.\n- \`TRIGGER_FEED_DETAIL_LOG\`(기본 true) = 매수체크 \`🔍\` 로그에 틱1차설정/실제(kis|kiwoom|ls)·px·틱타임·호가 bid/ask/or 꼬리.\n- 분봉 쓰레기'
if old not in s:
    print('NOT FOUND');
    i=s.find('LIVE_FEED_FALLBACK_MAX_AGE_SEC')
    print(repr(s[i:i+200]))
else:
    with open(p,'w',encoding='utf-8') as f: f.write(s.replace(old,new,1))
    print('patched')
"
python3 -u scripts/test_live_execution_validation.py > logs/test_live_execution_validation_$(date +%Y%m%d_%H%M%S).log 2>&1 &
echo $!
sleep 1
ls -t logs/test_live_execution_validation_*.log | head -1

Patch guide + start live validation smoke


tail -30 /home/hoon/kis_bot/logs/test_live_execution_validation_20260821_143656.log; rg -n '최종:|완결|실패' /home/hoon/kis_bot/logs/test_live_execution_validation_20260821_143656.log | tail -10; ps -p 4097215 -o pid,etime,cmd 2>/dev/null || echo done

Check smoke test progress/result


트리거(매수체크) 로그에 **틱·호가·벤더(1/2/3차)** 를 붙였습니다.

### 로그 예
```text
🔍 [T:kis|O:kiwoom|R:kis(1차)|탈락-호가수급] … · 틱1차설정=kis(체인 kis→kiwoom→ls) | 호가1차설정=kiwoom(체인 kiwoom→kis→ls) | 틱실제=kis(1차) px=12340 t=20260821143055 lag=0.3s | 호가실제=kiwoom(1차) bid=12330 ask=12350 spr=0.16% or=0.85 snap=… src=kiwoom_0d
```

| 항목 | 내용 |
|------|------|
| 틱1차설정 / 호가1차설정 | DB `LIVE_*_PROVIDER` + 체인(→2차→ls) |
| 틱실제 / 호가실제 | 방금 읽은 벤더 + `kis`/`kiwoom`/`ls` + 몇 차 |
| px / t / lag | 현재가 · 체결시각 · 지연초 |
| bid/ask / spr / or / snap | 호가 · 스프레드 · 잔량비 · 스냅시각 |

### 스위치
- `TRIGGER_FEED_DETAIL_LOG` 기본 **ON** (운영설정에도 추가)
- OFF면 예전처럼 `T:|O:|R:` 접두만

### 반영
- 실매 스모크: `최종: 통과` · `👑 [최종 판정] 완결!`
  로그: `logs/test_live_execution_validation_20260821_143657.log`
- **`kis_trader_main` 재시작** 해야 장중 탈락 로그에 꼬리가 붙습니다.


<timestamp>Friday, Aug 21, 2026, 2:44 PM (UTC+9)</timestamp>
<user_query>
이것도 붙이면 좋은데 난 옵투나 얘기한거긴해 두고 옵투나에도 붙여줘 뭐가 문제인지 파악을 해야겠으니까
</user_query>


실매 로그는 유지하고, Optuna/백테 트리거·틱·호가 경로에도 같은 추적을 붙입니다.


옵투나/백테 로드 시점 벤더 비율 로그와 진입 `entry_source`에 벤더를 붙입니다.

Write

"""
옵투나·백테 피드 추적 로그 — 실매 TRIGGER_FEED_DETAIL 과 같은 축.

무엇을 보나:
  - 틱/호가 DB 로드 후 벤더(kis|kiwoom|ls|…) 건수·비율
  - 설정 1·2·3차 체인 (실매 live_*_primary 와 동일)
  - (옵션) 매수 후보 샘플: px·틱타임·호가 bid/ask·entry_source

실매 `_scan_log` 꼬리와 문구를 맞춰, 옵투나 로그만 봐도 “어느 벤더로 체결·호가판정했는지” 추적 가능.
"""
from __future__ import annotations

from collections import Counter
from typing import Any, Dict, List, Optional

from kis_trader.utils.env import get_env_bool, get_env_int
from kis_trader.utils.logger import get_logger

logger = get_logger("kis_trader.optuna_feed_trace")

# 프로세스당 샘플 상한 (옵투나 trial×종목 폭주 방지)
_sample_logged = 0

def bt_feed_detail_enabled() -> bool:
    """실매와 동일 키 + 백테 전용 키(둘 중 하나 ON)."""
    if get_env_bool("BT_FEED_DETAIL_LOG", True):
        return True
    return get_env_bool("TRIGGER_FEED_DETAIL_LOG", True)

def log_bt_feed_chain_banner(*, context: str = "Optuna/백테") -> None:
    """로드 직전 — 설정상 1·2·3차 체인만 (실매 format_trigger_feed_trace 머리와 동일)."""
    try:
        from kis_trader.engine.feed_fallback import live_ob_primary, live_tick_primary

        tp = live_tick_primary()
        op = live_ob_primary()
        alt_t = "kiwoom" if tp == "kis" else "kis"
        alt_o = "kiwoom" if op == "kis" else "kis"
        tick_src_env = ""
        try:
            import os

            tick_src_env = str(os.environ.get("TICK_SOURCE") or "").strip().lower()
        except Exception:
            pass
        extra = f" | TICK_SOURCE강제={tick_src_env}" if tick_src_env else ""
        logger.info(
            "🔎 [%s 피드체인] 틱1차=%s(체인 %s→%s→ls) | 호가1차=%s(체인 %s→%s→ls)%s",
            context, tp, tp, alt_t, op, op, alt_o, extra,
        )
    except Exception as e:
        logger.debug("피드체인 배너 스킵: %s", e)

def _pct(n: int, total: int) -> float:
    return (100.0 * n / total) if total > 0 else 0.0

def _fmt_counter(ctr: Counter, *, total: Optional[int] = None) -> str:
    tot = int(total if total is not None else sum(ctr.values()))
    if tot <= 0:
        return "(0건)"
    parts = [
        f"{k or '?'}={v:,}({_pct(v, tot):.1f}%)"
        for k, v in ctr.most_common()
    ]
    return f"total={tot:,} | " + " ".join(parts)

def count_tick_sources(
    ticks_by_code: Optional[Dict[str, Any]],
) -> Counter:
    """ticks_by_code {code: {minute: [tick|TickColumnView]}} → source Counter."""
    ctr: Counter = Counter()
    if not ticks_by_code:
        return ctr
    try:
        from kis_trader.backtest.shared_ticks import TickColumnView
    except Exception:
        TickColumnView = ()  # type: ignore

    for _code, minutes in ticks_by_code.items():
        if not isinstance(minutes, dict):
            continue
        for _mk, ticks in minutes.items():
            if TickColumnView and isinstance(ticks, TickColumnView):
                owner = ticks.owner
                src_arr = getattr(owner, "_source", None)
                if src_arr is None:
                    ctr["?"] += len(list(ticks.iter_idx()))
                    continue
                for i in ticks.iter_idx():
                    try:
                        s = src_arr[i].decode("utf-8").strip().lower() or "?"
                    except Exception:
                        s = "?"
                    ctr[s] += 1
                continue
            for t in (ticks or []):
                if not isinstance(t, dict):
                    ctr["?"] += 1
                    continue
                s = str(t.get("source") or "").strip().lower() or "?"
                ctr[s] += 1
    return ctr

def count_orderbook_sources(
    orderbook_by_code: Optional[Dict[str, Any]],
) -> Counter:
    """orderbook_by_code {code: {minute: [OrderbookSnapshot|dict]}}."""
    ctr: Counter = Counter()
    if not orderbook_by_code:
        return ctr
    for _code, minutes in orderbook_by_code.items():
        if not isinstance(minutes, dict):
            continue
        for _mk, snaps in minutes.items():
            for snap in (snaps or []):
                try:
                    s = str(getattr(snap, "source", None) or "").strip().lower()
                    if not s and isinstance(snap, dict):
                        s = str(snap.get("source") or "").strip().lower()
                    ctr[s or "?"] += 1
                except Exception:
                    ctr["?"] += 1
    return ctr

def log_bt_tick_feed_trace(
    ticks_by_code: Optional[Dict[str, Any]],
    *,
    table: str = "ws_ticks",
    main_src: str = "",
    raw_total: Optional[int] = None,
    context: str = "틱로드",
) -> Counter:
    """틱 로드·시간축 폴백 직후 — 벤더 비율 INFO."""
    ctr = count_tick_sources(ticks_by_code)
    kept = sum(ctr.values())
    try:
        from kis_trader.engine.feed_fallback import live_tick_primary

        primary = str(main_src or live_tick_primary()).strip().lower() or "?"
    except Exception:
        primary = str(main_src or "?").strip().lower() or "?"
    raw_bit = ""
    if raw_total is not None and int(raw_total) != kept:
        raw_bit = f" | raw={int(raw_total):,}→kept={kept:,}"
    logger.info(
        "🔎 [%s] table=%s | 1차설정=%s | 벤더 %s%s",
        context, table, primary, _fmt_counter(ctr, total=kept), raw_bit,
    )
    return ctr

def log_bt_orderbook_feed_trace(
    orderbook_by_code: Optional[Dict[str, Any]],
    meta: Optional[Dict[str, Any]] = None,
    *,
    context: str = "호가로드",
) -> Counter:
    """호가 스냅 로드 직후 — source(kiwoom_0d|ls_uh1|log_backfill|…) 비율."""
    ctr = count_orderbook_sources(orderbook_by_code)
    meta = meta or {}
    hist = str(meta.get("orderbook_history_source") or "").strip().lower() or "?"
    rows = int(meta.get("ws_orderbook_rows_loaded") or sum(ctr.values()) or 0)
    try:
        from kis_trader.engine.feed_fallback import live_ob_primary

        primary = live_ob_primary()
    except Exception:
        primary = "?"
    logger.info(
        "🔎 [%s] history_source=%s | 1차설정=%s | rows=%s | 벤더 %s",
        context, hist, primary, f"{rows:,}", _fmt_counter(ctr),
    )
    return ctr

def maybe_log_bt_trigger_sample(
    code: str,
    eval_params: Optional[Dict[str, Any]],
    *,
    entry_price: float = 0.0,
    entry_time: str = "",
    entry_src: str = "",
    reject: str = "",
    context: str = "TRIGGER샘플",
) -> None:
    """매수 평가 직후 샘플 몇 건 — 실매 format_trigger_feed_trace 꼬리와 동일 축."""
    global _sample_logged
    if not bt_feed_detail_enabled():
        return
    max_n = max(0, get_env_int("BT_FEED_DETAIL_LOG_MAX", 40))
    if max_n <= 0 or _sample_logged >= max_n:
        return
    p = eval_params or {}
    try:
        from kis_trader.engine.feed_fallback import (
            format_trigger_feed_trace,
            remember_meta_from_orderbook,
        )

        tick_rec: Dict[str, Any] = {}
        if entry_price and entry_src:
            vendor = ""
            es = str(entry_src or "")
            if ":" in es:
                vendor = es.split(":", 1)[1].strip().lower()
            tick_rec = {
                "vendor": vendor or "ws_ticks",
                "tier": 1,
                "label": es or "ws_ticks",
                "price": float(entry_price),
                "tick_time": str(entry_time or "")[:14],
            }
        ob_snap = p.get("_backtest_orderbook_snapshot")
        ob_rec = remember_meta_from_orderbook(ob_snap) if ob_snap is not None else {}
        tail = format_trigger_feed_trace(tick_rec, ob_rec)
        rej = f" reject={reject}" if reject else ""
        logger.info(
            "🔎 [%s] %s entry_src=%s%s | %s",
            context, code, entry_src or "-", rej, tail,
        )
        _sample_logged += 1
    except Exception as e:
        logger.debug("TRIGGER샘플 스킵: %s", e)

def tick_source_label(tick: Optional[Dict[str, Any]], fallback: str = "ws_ticks") -> str:
    """진입 source 문자열 — ws_ticks:kis / ws_ticks:kiwoom / ohlc_open."""
    if not tick:
        return fallback
    src = str(tick.get("source") or "").strip().lower()
    if src:
        return f"ws_ticks:{src}"
    return "ws_ticks"


StrReplace

    Returns:
        (fill_price, entry_time_key, source) — source: ws_ticks | ohlc_open
    """
    p = params or {}
    fo = float(fallback_open or 0)
    bar_key = str(entry_bar_time or "")[:12]
    if not momentum_backtest_use_tick_entry(p) or not ticks_by_code:
        return fo, bar_key, "ohlc_open"
    ticks = collect_minute_ticks(ticks_by_code, code, bar_key)
    slip_pct = abs(float(get_env_float("MOMENTUM_BACKTEST_BUY_SLIP_PCT", 0.0)))
    min_tt = str(min_tick_time or "").strip()
    # 백테 틱재생: 컬럼 뷰면 dict 재구성 없이 배열 직접 읽기(첫 유효틱 즉시 반환 → 동일).
    try:
        from kis_trader.backtest.shared_ticks import TickColumnView
        _is_view = isinstance(ticks, TickColumnView)
    except Exception:
        _is_view = False
    if _is_view:
        owner = ticks.owner
        _price = owner._price
        _tick_time = owner._tick_time
        for i in ticks.iter_idx():
            tt = _tick_time[i].decode("utf-8")
            if min_tt and len(tt) >= 14 and tt[:14] < min_tt[:14]:
                continue
            px = float(_price[i])
            if px <= 0:
                continue
            fill_px = px * (1.0 + slip_pct / 100.0) if slip_pct > 0 else px
            et = tt[:14] if len(tt) >= 14 else bar_key
            return fill_px, et, "ws_ticks"
        return fo, bar_key, "ohlc_open"
    for tick in ticks:
        tt = str(tick.get("tick_time") or "")
        if min_tt and len(tt) >= 14 and tt[:14] < min_tt[:14]:
            continue
        px = float(tick.get("price") or 0)
        if px <= 0:
            continue
        fill_px = px * (1.0 + slip_pct / 100.0) if slip_pct > 0 else px
        tt = str(tick.get("tick_time") or "")
        et = tt[:14] if len(tt) >= 14 else bar_key
        return fill_px, et, "ws_ticks"
    return fo, bar_key, "ohlc_open"

    Returns:
        (fill_price, entry_time_key, source) — source: ws_ticks:<vendor> | ohlc_open
        (vendor=kis|kiwoom|ls … — 옵투나/백테 피드 추적용)
    """
    p = params or {}
    fo = float(fallback_open or 0)
    bar_key = str(entry_bar_time or "")[:12]
    if not momentum_backtest_use_tick_entry(p) or not ticks_by_code:
        return fo, bar_key, "ohlc_open"
    ticks = collect_minute_ticks(ticks_by_code, code, bar_key)
    slip_pct = abs(float(get_env_float("MOMENTUM_BACKTEST_BUY_SLIP_PCT", 0.0)))
    min_tt = str(min_tick_time or "").strip()
    # 백테 틱재생: 컬럼 뷰면 dict 재구성 없이 배열 직접 읽기(첫 유효틱 즉시 반환 → 동일).
    try:
        from kis_trader.backtest.shared_ticks import TickColumnView
        _is_view = isinstance(ticks, TickColumnView)
    except Exception:
        _is_view = False
    if _is_view:
        owner = ticks.owner
        _price = owner._price
        _tick_time = owner._tick_time
        _source = getattr(owner, "_source", None)
        for i in ticks.iter_idx():
            tt = _tick_time[i].decode("utf-8")
            if min_tt and len(tt) >= 14 and tt[:14] < min_tt[:14]:
                continue
            px = float(_price[i])
            if px <= 0:
                continue
            fill_px = px * (1.0 + slip_pct / 100.0) if slip_pct > 0 else px
            et = tt[:14] if len(tt) >= 14 else bar_key
            vendor = ""
            if _source is not None:
                try:
                    vendor = _source[i].decode("utf-8").strip().lower()
                except Exception:
                    vendor = ""
            return fill_px, et, (f"ws_ticks:{vendor}" if vendor else "ws_ticks")
        return fo, bar_key, "ohlc_open"
    for tick in ticks:
        tt = str(tick.get("tick_time") or "")
        if min_tt and len(tt) >= 14 and tt[:14] < min_tt[:14]:
            continue
        px = float(tick.get("price") or 0)
        if px <= 0:
            continue
        fill_px = px * (1.0 + slip_pct / 100.0) if slip_pct > 0 else px
        tt = str(tick.get("tick_time") or "")
        et = tt[:14] if len(tt) >= 14 else bar_key
        try:
            from kis_trader.backtest.optuna_feed_trace import tick_source_label

            return fill_px, et, tick_source_label(tick)
        except Exception:
            return fill_px, et, "ws_ticks"
    return fo, bar_key, "ohlc_open"


    if kept != total:
        logger.info(
            "📌 틱 시간축 폴백: raw=%d → kept=%d (같은 초 메인 우선, 메인 lag>2s면 보조)",
            total, kept,
        )
    total = kept

    return dict(out), total

    if kept != total:
        logger.info(
            "📌 틱 시간축 폴백: raw=%d → kept=%d (같은 초 메인 우선, 메인 lag>2s면 보조)",
            total, kept,
        )
    raw_before_merge = total
    total = kept

    # 옵투나/백테: 틱 벤더(kis|kiwoom|ls) 비율 — 실매 트리거 피드 추적과 동일 축
    try:
        from kis_trader.backtest.optuna_feed_trace import log_bt_tick_feed_trace

        log_bt_tick_feed_trace(
            dict(out),
            table=table,
            main_src=main_src,
            raw_total=raw_before_merge,
            context="틱로드",
        )
    except Exception as e:
        logger.debug("틱 피드추적 로그 스킵: %s", e)

    return dict(out), total

    meta["log_verdict_by_code"] = log_verdict
    meta["log_verdict_rows"] = sum(
        len(lst) for cm in log_verdict.values() for lst in cm.values()
    )
    return ob_by_code, pg_by_code, meta

    meta["log_verdict_by_code"] = log_verdict
    meta["log_verdict_rows"] = sum(
        len(lst) for cm in log_verdict.values() for lst in cm.values()
    )
    # 옵투나/백테: 호가 스냅 벤더 비율 (kiwoom_0d / ls_uh1 / log_backfill …)
    try:
        from kis_trader.backtest.optuna_feed_trace import log_bt_orderbook_feed_trace

        log_bt_orderbook_feed_trace(ob_by_code, meta, context="호가로드")
    except Exception as e:
        logger.debug("호가 피드추적 로그 스킵: %s", e)
    return ob_by_code, pg_by_code, meta

        entry_price, entry_time_key, entry_src = align_momentum_entry_from_ticks(
            ticks_by_code, code, entry_bar_time, entry_open, params,
            min_tick_time=min_tick_time,
        )
        pe_data: Dict[str, Any] = {
            "entry_time": entry_time_key,
            "entry_price": entry_price,
            "entry_source": entry_src,
            "entry_bar_key": str(entry_bar_time or "")[:12],
            "stop": entry_price * (1 - sl_pct),
            "target": entry_price * (1 + tp_pct),
            "rsi": sig.get("rsi"),
        }

        entry_price, entry_time_key, entry_src = align_momentum_entry_from_ticks(
            ticks_by_code, code, entry_bar_time, entry_open, params,
            min_tick_time=min_tick_time,
        )
        # 옵투나 원인파악: 통과 후보 샘플에 틱벤더·호가·틱타임 꼬리 (상한 BT_FEED_DETAIL_LOG_MAX)
        try:
            from kis_trader.backtest.optuna_feed_trace import maybe_log_bt_trigger_sample

            maybe_log_bt_trigger_sample(
                code, eval_params,
                entry_price=float(entry_price or 0),
                entry_time=str(entry_time_key or ""),
                entry_src=str(entry_src or ""),
                context="MOMENTUM-TRIGGER",
            )
        except Exception:
            pass
        pe_data: Dict[str, Any] = {
            "entry_time": entry_time_key,
            "entry_price": entry_price,
            "entry_source": entry_src,
            "entry_bar_key": str(entry_bar_time or "")[:12],
            "stop": entry_price * (1 - sl_pct),
            "target": entry_price * (1 + tp_pct),
            "rsi": sig.get("rsi"),
        }

    logger.info("📂 캔들 로드 중 %s~%s (이 동안 Top5는 비어 있음 · trial 0)", start, end)
    codes_candles = _load_candles_for_search(

    # 실매과 동일 1·2·3차 체인 — 옵투나 로그에서도 벤더 추적 가능
    try:
        from kis_trader.backtest.optuna_feed_trace import log_bt_feed_chain_banner

        log_bt_feed_chain_banner(context="Optuna-MOMENTUM")
    except Exception:
        pass
    logger.info("📂 캔들 로드 중 %s~%s (이 동안 Top5는 비어 있음 · trial 0)", start, end)
    codes_candles = _load_candles_for_search(


def align_entry_price_from_ticks(
    ticks: List[Dict[str, Any]],
    fallback_open: float,
) -> Tuple[float, str]:
    """신호 직후(진입봉) 첫 체결 틱 가격 (없으면 시가).

    Returns source: ``ws_ticks:<vendor>`` | ``ohlc_open`` (옵투나 피드 추적용).
    """
    fo = float(fallback_open or 0)
    if not ticks:
        return fo, "ohlc_open"
    # 백테 틱재생: 컬럼 뷰면 price 배열 직접 읽기(첫 유효틱 즉시 반환 → 동일).
    try:
        from kis_trader.backtest.shared_ticks import TickColumnView
        if isinstance(ticks, TickColumnView):
            owner = ticks.owner
            _price = owner._price
            _source = getattr(owner, "_source", None)
            for i in ticks.iter_idx():
                price = float(_price[i])
                if price > 0:
                    vendor = ""
                    if _source is not None:
                        try:
                            vendor = _source[i].decode("utf-8").strip().lower()
                        except Exception:
                            vendor = ""
                    return price, (f"ws_ticks:{vendor}" if vendor else "ws_ticks")
            return fo, "ohlc_open"
    except Exception:
        pass
    for tick in ticks:
        price = float(tick.get("price") or 0)
        if price > 0:
            try:
                from kis_trader.backtest.optuna_feed_trace import tick_source_label

                return price, tick_source_label(tick)
            except Exception:
                return price, "ws_ticks"
    return fo, "ohlc_open"

def align_entry_price_from_ticks(
    ticks: List[Dict[str, Any]],
    fallback_open: float,
) -> Tuple[float, str]:
    """신호 직후(진입봉) 첫 체결 틱 가격 (없으면 시가)."""
    fo = float(fallback_open or 0)
    if not ticks:
        return fo, "ohlc_open"
    # 백테 틱재생: 컬럼 뷰면 price 배열 직접 읽기(첫 유효틱 즉시 반환 → 동일).
    try:
        from kis_trader.backtest.shared_ticks import TickColumnView
        if isinstance(ticks, TickColumnView):
            _price = ticks.owner._price
            for i in ticks.iter_idx():
                price = float(_price[i])
                if price > 0:
                    return price, "ws_ticks"
            return fo, "ohlc_open"
    except Exception:
        pass
    for tick in ticks:
        price = float(tick.get("price") or 0)
        if price > 0:
            return price, "ws_ticks"
    return fo, "ohlc_open"

def _momentum_source_label(kind: str, src: str) -> str:
    s = str(src or "").strip().lower()
    # ws_ticks:kis / ws_ticks:kiwoom — 옵투나·백테 벤더 추적
    vendor = ""
    if s.startswith("ws_ticks:"):
        vendor = s.split(":", 1)[1].strip()
        s = "ws_ticks"
    if kind == "entry":
        if s in ("ws_ticks", "tick"):
            return f"틱진입({vendor})" if vendor else "틱진입"
        if s in ("ohlc_open", "open"):
            return "시가"
        if s in ("ohlc_low", "ohlc_fallback", "ohlc_high", "ohlc_intrabar"):
            return "진입OHLC"
        return s or "-"
    if s in ("ws_ticks", "tick"):
        return f"틱청산({vendor})" if vendor else "틱청산"
    if s in ("ohlc_bar", "ohlc_fallback"):
        return "청산OHLC"
    if s in ("eod_flat", "wallclock_eod", "wallclock_last"):
        return "틱공백EOD"
    return s or "-"

def _momentum_source_label(kind: str, src: str) -> str:
    s = str(src or "").strip().lower()
    if kind == "entry":
        if s in ("ws_ticks", "tick"):
            return "틱진입"
        if s in ("ohlc_open", "open"):
            return "시가"
        if s in ("ohlc_low", "ohlc_fallback", "ohlc_high", "ohlc_intrabar"):
            return "진입OHLC"
        return s or "-"
    if s in ("ws_ticks", "tick"):
        return "틱청산"
    if s in ("ohlc_bar", "ohlc_fallback"):
        return "청산OHLC"
    if s in ("eod_flat", "wallclock_eod", "wallclock_last"):
        return "틱공백EOD"
    return s or "-"

    "LIVE_FEED_FALLBACK_MAX_AGE_SEC",
    "TRIGGER_FEED_DETAIL_LOG",
    # 옵투나/백테 피드 추적 (틱·호가 벤더 비율 + TRIGGER 샘플 상한)
    "BT_FEED_DETAIL_LOG",
    "BT_FEED_DETAIL_LOG_MAX",
    "CANDLE_GARBAGE_FALLBACK",

    "LIVE_FEED_FALLBACK_MAX_AGE_SEC",
    "TRIGGER_FEED_DETAIL_LOG",
    "CANDLE_GARBAGE_FALLBACK",

                _f("TRIGGER_FEED_DETAIL_LOG", "📝 트리거 로그에 틱·호가·벤더 상세", "bool", default=True,
                   hint=(
                       "ON=매수체크(🔍) 로그 끝에 틱1차설정/실제(kis|kiwoom|ls)·현재가·틱타임·"
                       "호가 bid/ask/잔량비·스냅시각을 붙임. OFF=접두 T/O/R 만. 재시작 없이 반영(수 초)"
                   )),
                _f("BT_FEED_DETAIL_LOG", "📝 옵투나/백테에도 틱·호가 벤더 추적", "bool", default=True,
                   hint=(
                       "ON=옵투나·백테 로그에 틱/호가 로드 벤더 비율 + TRIGGER 샘플 "
                       "(실매 TRIGGER_FEED_DETAIL_LOG 과 같은 축). OFF=샘플만 끔(비율 INFO는 유지)"
                   )),
                _f("BT_FEED_DETAIL_LOG_MAX", "옵투나 TRIGGER 샘플 상한(건)", "int", default=40,
                   hint="프로세스당 매수 후보 상세 샘플 최대. 0=샘플 없음. 기본 40"),
                _f("CANDLE_GARBAGE_FALLBACK", "🕯 분봉 쓰레기→2차·3차 봉 통째", "bool", default=True,
                   hint=(
                       "ON(기본)=그 분 메인 틱이 0건이거나 전부 봉끝 대비 읽기나이 초과면 "

                _f("TRIGGER_FEED_DETAIL_LOG", "📝 트리거 로그에 틱·호가·벤더 상세", "bool", default=True,
                   hint=(
                       "ON=매수체크(🔍) 로그 끝에 틱1차설정/실제(kis|kiwoom|ls)·현재가·틱타임·"
                       "호가 bid/ask/잔량비·스냅시각을 붙임. OFF=접두 T/O/R 만. 재시작 없이 반영(수 초)"
                   )),
                _f("CANDLE_GARBAGE_FALLBACK", "🕯 분봉 쓰레기→2차·3차 봉 통째", "bool", default=True,
                   hint=(
                       "ON(기본)=그 분 메인 틱이 0건이거나 전부 봉끝 대비 읽기나이 초과면 "


    _hs = resolve_backtest_universe_history_source(history_source)
    base_fixed["_universe_history_source"] = _hs

    try:
        from kis_trader.backtest.optuna_feed_trace import log_bt_feed_chain_banner

        log_bt_feed_chain_banner(context="Optuna-BREAKOUT")
    except Exception:
        pass
    codes_candles = _load_candles_for_search(

    _hs = resolve_backtest_universe_history_source(history_source)
    base_fixed["_universe_history_source"] = _hs

    codes_candles = _load_candles_for_search(

    _hs = resolve_backtest_universe_history_source(history_source)
    base_fixed["_universe_history_source"] = _hs

    try:
        from kis_trader.backtest.optuna_feed_trace import log_bt_feed_chain_banner

        log_bt_feed_chain_banner(context="Optuna-SCALP")
    except Exception:
        pass
    codes_candles = _load_candles_for_search(

    _hs = resolve_backtest_universe_history_source(history_source)
    base_fixed["_universe_history_source"] = _hs

    codes_candles = _load_candles_for_search(

- `TRIGGER_FEED_DETAIL_LOG`(기본 true) = 매수체크 `🔍` 로그에 틱1차설정/실제(kis|kiwoom|ls)·px·틱타임·호가 bid/ask/or 꼬리.
- `BT_FEED_DETAIL_LOG`(기본 true) / `BT_FEED_DETAIL_LOG_MAX`(기본 40) = 옵투나·백테에도 동일 축: 틱/호가 로드 벤더 비율 INFO + TRIGGER 샘플. `entry_source=ws_ticks:kis` 등.
- 분봉 쓰레기=`CANDLE_GARBAGE_FALLBACK`(기본 true). 그 분 그 소스 틱 0건이거나 전부 **봉 끝시각** 대비 읽기나이 초과면 그 WS 봉을 구멍 → 다음 소스 **봉 통째**. 한 봉 안 키움+한투 혼합 금지. 실매 링이 그 소스·그 분을 커버 못하면 유지.

- `TRIGGER_FEED_DETAIL_LOG`(기본 true) = 매수체크 `🔍` 로그에 틱1차설정/실제(kis|kiwoom|ls)·px·틱타임·호가 bid/ask/or 꼬리.
- 분봉 쓰레기=`CANDLE_GARBAGE_FALLBACK`(기본 true). 그 분 그 소스 틱 0건이거나 전부 **봉 끝시각** 대비 읽기나이 초과면 그 WS 봉을 구멍 → 다음 소스 **봉 통째**. 한 봉 안 키움+한투 혼합 금지. 실매 링이 그 소스·그 분을 커버 못하면 유지.


        logger.info(
            f"📅 데이터 로드: {start} ~ {end} | TF={tail_tf} | "
            f"유니버스={universe_source} | 매수시간 "
            f"{base_params.get('time_start_hm', 930):04d}-{base_params.get('time_end_hm', 1500):04d}"
        )
        try:
            from kis_trader.backtest.optuna_feed_trace import log_bt_feed_chain_banner

            log_bt_feed_chain_banner(context="Optuna-TAIL")
        except Exception:
            pass

        rsi_period = int(base_params.get("rsi_period", 14))
        candles_by_code, total_candles, has_holding_peak = tbc.load_tail_candles_by_code(

        logger.info(
            f"📅 데이터 로드: {start} ~ {end} | TF={tail_tf} | "
            f"유니버스={universe_source} | 매수시간 "
            f"{base_params.get('time_start_hm', 930):04d}-{base_params.get('time_end_hm', 1500):04d}"
        )

        rsi_period = int(base_params.get("rsi_period", 14))
        candles_by_code, total_candles, has_holding_peak = tbc.load_tail_candles_by_code(


def bt_feed_detail_enabled() -> bool:
    """옵투나 TRIGGER 샘플 ON/OFF (벤더 비율 INFO는 항상)."""
    return get_env_bool("BT_FEED_DETAIL_LOG", True)

def bt_feed_detail_enabled() -> bool:
    """실매와 동일 키 + 백테 전용 키(둘 중 하나 ON)."""
    if get_env_bool("BT_FEED_DETAIL_LOG", True):
        return True
    return get_env_bool("TRIGGER_FEED_DETAIL_LOG", True)


def maybe_log_bt_trigger_sample(
    code: str,
    eval_params: Optional[Dict[str, Any]],
    *,
    entry_price: float = 0.0,
    entry_time: str = "",
    entry_src: str = "",
    reject: str = "",
    context: str = "TRIGGER샘플",
) -> None:
    """매수 평가 직후 샘플 몇 건 — 실매 format_trigger_feed_trace 꼬리와 동일 축."""
    global _sample_logged
    if not bt_feed_detail_enabled():
        return
    max_n = max(0, get_env_int("BT_FEED_DETAIL_LOG_MAX", 40))
    if max_n <= 0 or _sample_logged >= max_n:
        return
    p = eval_params or {}
    try:
        from kis_trader.engine.feed_fallback import (
            extract_ob_trace_fields,
            format_trigger_feed_trace,
            tick_feed_tier,
        )

        tick_rec: Dict[str, Any] = {}
        if entry_price and entry_src:
            vendor = ""
            es = str(entry_src or "")
            if ":" in es:
                vendor = es.split(":", 1)[1].strip().lower()
            tick_rec = {
                "vendor": vendor or "ws_ticks",
                "tier": 1,
                "label": es or "ws_ticks",
                "price": float(entry_price),
                "tick_time": str(entry_time or "")[:14],
            }
        ob_snap = p.get("_backtest_orderbook_snapshot")
        ob_rec: Dict[str, Any] = {}
        if ob_snap is not None:
            ob_rec = extract_ob_trace_fields(ob_snap)
            raw_src = str(
                ob_rec.get("ob_source") or getattr(ob_snap, "source", "") or ""
            ).strip().lower()
            # kiwoom_0d / ls_uh1 / kis → 체인 벤더 라벨
            vendor = "ls" if raw_src.startswith("ls") else (
                "kiwoom" if "kiwoom" in raw_src or raw_src == "log_backfill" else (
                    "kis" if "kis" in raw_src else (raw_src or "ob")
                )
            )
            ob_rec["vendor"] = vendor
            ob_rec["tier"] = tick_feed_tier(vendor, "")
            ob_rec["label"] = raw_src or vendor
        tail = format_trigger_feed_trace(tick_rec, ob_rec)
        rej = f" reject={reject}" if reject else ""
        logger.info(
            "🔎 [%s] %s entry_src=%s%s | %s",
            context, code, entry_src or "-", rej, tail,
        )
        _sample_logged += 1
    except Exception as e:
        logger.debug("TRIGGER샘플 스킵: %s", e)

def maybe_log_bt_trigger_sample(
    code: str,
    eval_params: Optional[Dict[str, Any]],
    *,
    entry_price: float = 0.0,
    entry_time: str = "",
    entry_src: str = "",
    reject: str = "",
    context: str = "TRIGGER샘플",
) -> None:
    """매수 평가 직후 샘플 몇 건 — 실매 format_trigger_feed_trace 꼬리와 동일 축."""
    global _sample_logged
    if not bt_feed_detail_enabled():
        return
    max_n = max(0, get_env_int("BT_FEED_DETAIL_LOG_MAX", 40))
    if max_n <= 0 or _sample_logged >= max_n:
        return
    p = eval_params or {}
    try:
        from kis_trader.engine.feed_fallback import (
            format_trigger_feed_trace,
            remember_meta_from_orderbook,
        )

        tick_rec: Dict[str, Any] = {}
        if entry_price and entry_src:
            vendor = ""
            es = str(entry_src or "")
            if ":" in es:
                vendor = es.split(":", 1)[1].strip().lower()
            tick_rec = {
                "vendor": vendor or "ws_ticks",
                "tier": 1,
                "label": es or "ws_ticks",
                "price": float(entry_price),
                "tick_time": str(entry_time or "")[:14],
            }
        ob_snap = p.get("_backtest_orderbook_snapshot")
        ob_rec = remember_meta_from_orderbook(ob_snap) if ob_snap is not None else {}
        tail = format_trigger_feed_trace(tick_rec, ob_rec)
        rej = f" reject={reject}" if reject else ""
        logger.info(
            "🔎 [%s] %s entry_src=%s%s | %s",
            context, code, entry_src or "-", rej, tail,
        )
        _sample_logged += 1
    except Exception as e:
        logger.debug("TRIGGER샘플 스킵: %s", e)


Shell

cd /home/hoon/kis_bot && python3 -m py_compile kis_trader/backtest/optuna_feed_trace.py kis_trader/backtest/breakout_tick_loader.py kis_trader/backtest/trigger_snapshot_loader.py kis_trader/engine/momentum_tick_replay.py kis_trader/engine/tail_tick_replay.py kis_trader/backtest/momentum_portfolio_backtest.py kis_trader/backtest/optuna_momentum.py kis_trader/backtest/optuna_breakout.py kis_trader/backtest/optuna_scalping.py kis_trader/backtest/param_search_optuna.py && python3 -u scripts/test_live_execution_validation.py 2>&1 | tee logs/test_live_execution_validation_$(date +%Y%m%d_%H%M%S).log | tail -n 40

Compile changed modules and run live smoke


cd /home/hoon/kis_bot && sudo systemctl restart kis_backtest_web.service && sleep 2 && systemctl is-active kis_backtest_web.service && curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:5050/

Restart backtest web and healthcheck


ls -1 /home/hoon/kis_bot/logs/test_live_execution_validation_20260821_144*.log 2>/dev/null | tail -3; python3 - <<'PY'
from kis_trader.backtest.optuna_feed_trace import log_bt_feed_chain_banner, tick_source_label
log_bt_feed_chain_banner(context="smoke")
print(tick_source_label({"source": "kis"}))
print(tick_source_label({"source": "kiwoom"}))
PY

Verify feed trace helpers and log path


CallMcpTool

Open backtest web for smoke check


옵투나에도 실매와 같은 축으로 **틱·호가·벤더 추적**을 붙였습니다. 실매 `TRIGGER_FEED_DETAIL_LOG`는 그대로 둡니다.

### 무엇이 붙었나
1. **로드 시 INFO (항상)**
   - 틱: `kis` / `kiwoom` / `ls` 건수·비율 + 1차 설정
   - 호가: `kiwoom_0d` / `ls_uh1` / `log_backfill` 등 비율 + `history_source`
   - 시작 배너: `틱1차=…(체인 …→…→ls) | 호가1차=…`

2. **TRIGGER 샘플 (기본 ON, 상한 40건)**
   - `entry_src` · px · 틱타임 · 호가 bid/ask/or · 스냅 src
   - env: `BT_FEED_DETAIL_LOG` / `BT_FEED_DETAIL_LOG_MAX`

3. **거래 `entry_source`**
   - `ws_ticks` → `ws_ticks:kis` / `ws_ticks:kiwoom` …
   - 웹 라벨: `틱진입(kis)` 등

### 로그에서 볼 문구
```
🔎 [Optuna-MOMENTUM 피드체인] 틱1차=kis(체인 kis→kiwoom→ls) | …
🔎 [틱로드] table=ws_ticks | 1차설정=kis | 벤더 total=… | kis=… kiwoom=…
🔎 [호가로드] history_source=… | 벤더 …
🔎 [MOMENTUM-TRIGGER] 005930 entry_src=ws_ticks:kis | 틱1차설정=… | 틱실제=… | 호가실제=…
```

### 영향 범위
| 구분 | 영향 |
|------|------|
| 실매 매매 로직 | 없음 (상세 로그 유지) |
| 옵투나/백테 | 로그·`entry_source` 표기 |
| 웹 | 진입 소스 라벨에 벤더 |

### 검증
- 스모크: `최종: 통과` + `👑 [최종 판정] 완결!`
  로그: `logs/test_live_execution_validation_20260821_144857.log`
- 웹 재시작 `active` + `http://192.168.0.149:5050/#optuna` 로드 OK

다음 옵투나 잡부터 해당 로그가 나옵니다. 샘플을 끄려면 `BT_FEED_DETAIL_LOG=false`, 더 보려면 `BT_FEED_DETAIL_LOG_MAX`만 올리면 됩니다.


<timestamp>Friday, Aug 21, 2026, 3:03 PM (UTC+9)</timestamp>
<user_query>
방금 옵투나 돌렸는데 로그 나오냐? 내가 볼뗀 안나오는거 같은데
</user_query>


방금 돌린 옵투나 로그를 확인해서 피드 추적 줄이 있는지, 없으면 왜 빠졌는지 짚겠습니다.

Shell

ls -lt /home/hoon/kis_bot/logs/optuna* 2>/dev/null | head -20; ls -lt /home/hoon/kis_bot/logs/*optuna* 2>/dev/null | head -20; ls -lt /tmp/*optuna* 2>/dev/null | head -10; ls -lt /home/hoon/kis_bot/logs/ | head -30

Find newest Optuna log files


wc -l /home/hoon/kis_bot/logs/optuna_web_momentum_20260821_145331.log; rg -c 'MOMENTUM-TRIGGER|틱로드|호가로드|피드체인' /home/hoon/kis_bot/logs/optuna_web_momentum_20260821_145331.log; tail -n 15 /home/hoon/kis_bot/logs/optuna_web_momentum_20260821_145331.log; head -n 10 /home/hoon/kis_bot/logs/optuna_web_jobs 2>/dev/null; ls /home/hoon/kis_bot/logs/optuna_web_jobs/ | tail -5

Count feed lines and show log tail


**나옵니다.** 방금 잡 로그에 이미 찍혀 있습니다. 끝부분만 보시면 Trial 줄만 보여서 안 보이는 겁니다.

파일: `logs/optuna_web_momentum_20260821_145331.log`

```bash
rg '피드체인|틱로드|MOMENTUM-TRIGGER|봉 pick' logs/optuna_web_momentum_20260821_145331.log | head
```

요약:
| 시각 | 내용 |
|------|------|
| 14:53:32 | `피드체인` 틱1차=kis / 호가1차=kiwoom |
| 14:53:54 | `봉 pick` — pick이 거의 `kiwoom/rest`(98%), `kis/ws` 2% |
| 14:54:48 | `틱로드` — kept 100만 · **kis 98.6% / kiwoom 1.4%** |
| 14:55:11~ | `MOMENTUM-TRIGGER` 샘플(~40건) · `entry_src=ws_ticks:kis` 또는 `ohlc_open` |

참고: 이번 잡은 **호가필터 OFF**라서 `[호가로드]` 줄은 없고, 샘플도 전부 `호가실제=없음`입니다. 호가 벤더까지 보려면 옵투나에서 호가방(후처리) 또는 필터 ON으로 돌려야 합니다.


<timestamp>Friday, Aug 21, 2026, 3:07 PM (UTC+9)</timestamp>
<user_query>
뭔 개소리야 자꾸 호가는 후처리라는데 왜 자꾸 호가온으로 돌리래 후처리 로그는 당연히 나와야지
</user_query>


맞습니다. 코어 TPE는 호가 OFF가 정상이고, 호가 추적 로그는 후처리에서 나와야 합니다. 후처리 경로에 로그가 빠졌는지 확인합니다.


tail -n 5 /home/hoon/kis_bot/logs/optuna_web_momentum_20260821_145331.log; rg -n '후처리|postprocess|TopN|호가|pp_' /home/hoon/kis_bot/logs/optuna_web_momentum_20260821_145331.log | head -40; wc -l /home/hoon/kis_bot/logs/optuna_web_momentum_20260821_145331.log; cat /home/hoon/kis_bot/logs/optuna_web_latest_job.txt 2>/dev/null; ls -lt /home/hoon/kis_bot/logs/optuna_web_jobs/opt_20260821_145331* 2>/dev/null

Check if postprocess started in latest job


지적하신 게 맞습니다. 코어는 호가 OFF가 맞고, 호가 로그는 후처리 `recommend_orderbook` 경로에 붙여야 합니다. 지금 그 경로에는 추적 로그가 빠져 있습니다.


StrReplace

# 프로세스당 샘플 상한 (옵투나 trial×종목 폭주 방지)
_sample_logged = 0

def bt_feed_detail_enabled() -> bool:
    """옵투나 TRIGGER 샘플 ON/OFF (벤더 비율 INFO는 항상)."""
    return get_env_bool("BT_FEED_DETAIL_LOG", True)

# 프로세스당 샘플 상한 (옵투나 trial×종목 폭주 방지)
_sample_logged = 0
_pp_sample_logged = 0

def bt_feed_detail_enabled() -> bool:
    """옵투나 TRIGGER 샘플 ON/OFF (벤더 비율 INFO는 항상)."""
    return get_env_bool("BT_FEED_DETAIL_LOG", True)

def reset_bt_feed_sample_counter(*, postprocess: bool = False) -> None:
    """후처리 시작 시 샘플 카운터 리셋 — 코어 TPE에서 소진돼도 호가방 로그가 나오게."""
    global _sample_logged, _pp_sample_logged
    if postprocess:
        _pp_sample_logged = 0
    else:
        _sample_logged = 0

def log_bt_postprocess_ob_db_scope(
    db: Any,
    *,
    table: str,
    cols: Optional[List[str]] = None,
    source_filter: Optional[tuple] = None,
    date_from: str = "",
    date_to: str = "",
    context: str = "호가후처리",
) -> None:
    """후처리 호가 추천 직전 — 테이블·기간·source 건수 (코어 TPE 호가OFF 와 무관)."""
    try:
        from kis_trader.engine.feed_fallback import live_ob_primary

        primary = live_ob_primary()
    except Exception:
        primary = "?"
    sf = tuple(source_filter or ())
    logger.info(
        "🔎 [%s] table=%s | 1차설정=%s | source_filter=%s | 기간=%s~%s",
        context, table, primary, sf or "(all)",
        (date_from or "?")[:10], (date_to or "?")[:10],
    )
    colset = set(cols or [])
    if not db or "source" not in colset:
        if db and "source" not in colset:
            logger.info("🔎 [%s] source 컬럼 없음 — 벤더 비율 집계 스킵", context)
        return
    try:
        where: List[str] = []
        params: List[Any] = []
        df = str(date_from or "").replace("-", "").strip()[:8]
        dt = str(date_to or "").replace("-", "").strip()[:8]
        if len(df) == 8:
            where.append("snap_time >= %s")
            params.append(df + "000000")
        if len(dt) == 8:
            where.append("snap_time <= %s")
            params.append(dt + "235959")
        if sf:
            ph = ",".join(["%s"] * len(sf))
            where.append(f"source IN ({ph})")
            params.extend(sf)
        wh = (" WHERE " + " AND ".join(where)) if where else ""
        sql = f"SELECT source, COUNT(*) AS n FROM {table}{wh} GROUP BY source ORDER BY n DESC"
        rows = db.conn.execute(sql, tuple(params)).fetchall()
        ctr: Counter = Counter()
        for r in rows or []:
            k = str((r.get("source") if isinstance(r, dict) else r[0]) or "?").strip().lower() or "?"
            n = int((r.get("n") if isinstance(r, dict) else r[1]) or 0)
            ctr[k] += n
        logger.info(
            "🔎 [%s DB벤더] %s",
            context, _fmt_counter(ctr) if ctr else "(0건 — 기간/필터 미스)",
        )
    except Exception as e:
        logger.warning("⚠ [%s] DB 벤더 집계 실패: %s", context, e)

def maybe_log_bt_ob_postprocess_sample(
    *,
    code: str,
    buy_time: str,
    buy_price: float,
    snap_t: str = "",
    best_bid: int = 0,
    best_ask: int = 0,
    spread_pct: float = 0.0,
    bid_ask_ratio: float = 0.0,
    snap_source: str = "",
    hit: bool = True,
    context: str = "호가후처리샘플",
) -> None:
    """후처리: 체결↔호가 스냅 매칭 샘플 (상한 BT_FEED_DETAIL_LOG_MAX)."""
    global _pp_sample_logged
    if not bt_feed_detail_enabled():
        return
    max_n = max(0, get_env_int("BT_FEED_DETAIL_LOG_MAX", 40))
    if max_n <= 0 or _pp_sample_logged >= max_n:
        return
    try:
        from kis_trader.engine.feed_fallback import live_ob_primary

        op = live_ob_primary()
        alt = "kiwoom" if op == "kis" else "kis"
        src = str(snap_source or "").strip().lower() or ("없음" if not hit else "?")
        if hit:
            logger.info(
                "🔎 [%s] %s buy=%s px=%s | 호가1차설정=%s(체인 %s→%s→ls) | "
                "호가실제 src=%s snap=%s bid=%s ask=%s spr=%.3f%% or=%.3f",
                context, code, buy_time, int(buy_price or 0),
                op, op, alt,
                src, snap_t or "-",
                int(best_bid or 0), int(best_ask or 0),
                float(spread_pct or 0), float(bid_ask_ratio or 0),
            )
        else:
            logger.info(
                "🔎 [%s] %s buy=%s px=%s | 호가1차설정=%s | 호가실제=없음(스냅 미스)",
                context, code, buy_time, int(buy_price or 0), op,
            )
        _pp_sample_logged += 1
    except Exception as e:
        logger.debug("호가후처리샘플 스킵: %s", e)


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

from typing import Any, Dict, List, Optional

@dataclass
class Snap:
    t: datetime
    total_bid: int
    total_ask: int
    best_bid: int
    best_ask: int
    bid_qty_l3: int = 0
    ask_qty_l3: int = 0
    source: str = ""  # kiwoom_0d / ls_uh1 … 후처리 피드 추적용

@dataclass
class TradeInfo:

@dataclass
class Snap:
    t: datetime
    total_bid: int
    total_ask: int
    best_bid: int
    best_ask: int
    bid_qty_l3: int = 0
    ask_qty_l3: int = 0

@dataclass
class TradeInfo:

    sel = ["snap_time", "total_bid_qty", "total_ask_qty", "best_bid", "best_ask"]
    if "bid_qty_l3" in cols:
        sel.append("bid_qty_l3")
    if "ask_qty_l3" in cols:
        sel.append("ask_qty_l3")
    if "source" in cols:
        sel.append("source")
    snap_sql = (
        f"SELECT {', '.join(sel)} FROM {table} "
        "WHERE code=%s AND snap_time >= %s AND snap_time < %s"
    )
    snap_params: List[Any] = [
        code,
        (buy_dt - lookback).strftime("%Y%m%d%H%M%S"),
        (buy_dt + horizon).strftime("%Y%m%d%H%M%S"),
    ]
    if source_filter and "source" in cols:
        ph = ",".join(["%s"] * len(source_filter))
        snap_sql += f" AND source IN ({ph})"
        snap_params.extend(source_filter)
    snap_sql += " ORDER BY snap_time ASC"
    s_rows = db.conn.execute(snap_sql, tuple(snap_params)).fetchall()
    snaps: List[Snap] = []
    for sr in s_rows:
        s_dt = _snap_to_dt(sr["snap_time"])
        if not s_dt:
            continue
        snaps.append(
            Snap(
                t=s_dt,
                total_bid=int(sr["total_bid_qty"] or 0),
                total_ask=int(sr["total_ask_qty"] or 0),
                best_bid=int(sr["best_bid"] or 0),
                best_ask=int(sr["best_ask"] or 0),
                bid_qty_l3=int(sr["bid_qty_l3"] or 0) if "bid_qty_l3" in sr else 0,
                ask_qty_l3=int(sr["ask_qty_l3"] or 0) if "ask_qty_l3" in sr else 0,
                source=str(sr.get("source") or "").strip().lower() if "source" in sr else "",
            )
        )
    return snaps

    sel = ["snap_time", "total_bid_qty", "total_ask_qty", "best_bid", "best_ask"]
    if "bid_qty_l3" in cols:
        sel.append("bid_qty_l3")
    if "ask_qty_l3" in cols:
        sel.append("ask_qty_l3")
    snap_sql = (
        f"SELECT {', '.join(sel)} FROM {table} "
        "WHERE code=%s AND snap_time >= %s AND snap_time < %s"
    )
    snap_params: List[Any] = [
        code,
        (buy_dt - lookback).strftime("%Y%m%d%H%M%S"),
        (buy_dt + horizon).strftime("%Y%m%d%H%M%S"),
    ]
    if source_filter and "source" in cols:
        ph = ",".join(["%s"] * len(source_filter))
        snap_sql += f" AND source IN ({ph})"
        snap_params.extend(source_filter)
    snap_sql += " ORDER BY snap_time ASC"
    s_rows = db.conn.execute(snap_sql, tuple(snap_params)).fetchall()
    snaps: List[Snap] = []
    for sr in s_rows:
        s_dt = _snap_to_dt(sr["snap_time"])
        if not s_dt:
            continue
        snaps.append(
            Snap(
                t=s_dt,
                total_bid=int(sr["total_bid_qty"] or 0),
                total_ask=int(sr["total_ask_qty"] or 0),
                best_bid=int(sr["best_bid"] or 0),
                best_ask=int(sr["best_ask"] or 0),
                bid_qty_l3=int(sr["bid_qty_l3"] or 0) if "bid_qty_l3" in sr else 0,
                ask_qty_l3=int(sr["ask_qty_l3"] or 0) if "ask_qty_l3" in sr else 0,
            )
        )
    return snaps

    """백테 체결 dict(buy_time/buy_price/qty/pnl) → 호가 TradeInfo."""
    lookback, horizon = _ob_lookback_horizon()
    out: List[TradeInfo] = []
    n_miss = 0
    for b in raw_fills or []:
        if not isinstance(b, dict):
            continue
        code = str(b.get("code") or "").strip()
        raw_dt = b.get("buy_date") or b.get("buy_time") or b.get("entry_time")
        try:
            buy_dt = _parse_dt(raw_dt)
        except Exception:
            continue
        buy_price = float(b.get("buy_price") or b.get("entry") or 0)
        qty = int(b.get("qty") or 0)
        actual_pnl = float(b.get("actual_pnl") if b.get("actual_pnl") is not None else (b.get("pnl") or b.get("realized_pnl") or 0))
        actual_profit_rate = float(b.get("profit_rate") or b.get("actual_profit_rate") or 0)
        sell_price = float(b.get("sell_price") or b.get("exit") or 0)
        snaps = _fetch_ob_snaps(db, table, cols, source_filter, code, buy_dt, lookback, horizon)
        ti = _tradeinfo_from_fill(
            code=code,
            name=str(b.get("name") or code),
            buy_dt=buy_dt,
            buy_price=buy_price,
            sell_price=sell_price,
            qty=qty,
            actual_pnl=actual_pnl,
            actual_profit_rate=actual_profit_rate,
            snaps=snaps,
        )
        # 후처리 피드 추적: 스냅 hit/miss + 벤더·bid/ask
        try:
            from kis_trader.backtest.optuna_feed_trace import maybe_log_bt_ob_postprocess_sample

            if ti is not None:
                es = ti.entry_snaps[-1] if ti.entry_snaps else None
                maybe_log_bt_ob_postprocess_sample(
                    code=code,
                    buy_time=buy_dt.strftime("%Y%m%d%H%M%S"),
                    buy_price=buy_price,
                    snap_t=es.t.strftime("%Y%m%d%H%M%S") if es else "",
                    best_bid=int(es.best_bid) if es else 0,
                    best_ask=int(es.best_ask) if es else 0,
                    spread_pct=float(ti.orig_spread_pct),
                    bid_ask_ratio=float(ti.orig_bid_ask_ratio),
                    snap_source=str(es.source or "") if es else "",
                    hit=True,
                )
            else:
                n_miss += 1
                maybe_log_bt_ob_postprocess_sample(
                    code=code,
                    buy_time=buy_dt.strftime("%Y%m%d%H%M%S"),
                    buy_price=buy_price,
                    hit=False,
                )
        except Exception:
            if ti is None:
                n_miss += 1
        if ti:
            out.append(ti)
    if n_miss > 0:
        logger.info(
            "🔎 [호가후처리] 체결→스냅 미스 %s/%s건 (필터·기간·종목 공백)",
            n_miss, len(raw_fills or []),
        )
    return out

    """백테 체결 dict(buy_time/buy_price/qty/pnl) → 호가 TradeInfo."""
    lookback, horizon = _ob_lookback_horizon()
    out: List[TradeInfo] = []
    for b in raw_fills or []:
        if not isinstance(b, dict):
            continue
        code = str(b.get("code") or "").strip()
        raw_dt = b.get("buy_date") or b.get("buy_time") or b.get("entry_time")
        try:
            buy_dt = _parse_dt(raw_dt)
        except Exception:
            continue
        buy_price = float(b.get("buy_price") or b.get("entry") or 0)
        qty = int(b.get("qty") or 0)
        actual_pnl = float(b.get("actual_pnl") if b.get("actual_pnl") is not None else (b.get("pnl") or b.get("realized_pnl") or 0))
        actual_profit_rate = float(b.get("profit_rate") or b.get("actual_profit_rate") or 0)
        sell_price = float(b.get("sell_price") or b.get("exit") or 0)
        snaps = _fetch_ob_snaps(db, table, cols, source_filter, code, buy_dt, lookback, horizon)
        ti = _tradeinfo_from_fill(
            code=code,
            name=str(b.get("name") or code),
            buy_dt=buy_dt,
            buy_price=buy_price,
            sell_price=sell_price,
            qty=qty,
            actual_pnl=actual_pnl,
            actual_profit_rate=actual_profit_rate,
            snaps=snaps,
        )
        if ti:
            out.append(ti)
    return out

    lg.info(
        "📌 [호가 후처리] strategy=%s table=%s source_filter=%s",
        strat_upper, table, source_filter or "(all)",
    )

    db = TradeDB()
    # 1. 테이블 존재 여부 및 컬럼 검사
    try:
        cols = [r["Field"] for r in db.conn.execute(f"SHOW COLUMNS FROM {table}").fetchall()]
        need = {"code", "snap_time", "total_bid_qty", "total_ask_qty", "best_bid", "best_ask"}
        if need - set(cols):
            lg.warning("⚠ [%s] 호가 테이블 필수 컬럼 부족. 추천 생략.", table)
            return {"ok": False, "reason": "insufficient_columns", "table": table}
    except Exception as exc:
        lg.warning("⚠ [%s] 테이블 조회 실패: %s. 추천 생략.", table, exc)
        return {"ok": False, "reason": "table_not_found", "table": table}

    # 코어 TPE 호가OFF와 무관 — 후처리에서 DB 호가 벤더·기간을 추적 로그
    try:
        from kis_trader.backtest.optuna_feed_trace import (
            log_bt_feed_chain_banner,
            log_bt_postprocess_ob_db_scope,
            reset_bt_feed_sample_counter,
        )

        reset_bt_feed_sample_counter(postprocess=True)
        log_bt_feed_chain_banner(context="호가후처리")
        log_bt_postprocess_ob_db_scope(
            db,
            table=table,
            cols=cols,
            source_filter=source_filter,
            date_from=str(date_from or ""),
            date_to=str(date_to or ""),
            context="호가후처리",
        )
    except Exception as e:
        lg.debug("호가후처리 피드추적 스킵: %s", e)

    date_sql = f"SELECT DISTINCT SUBSTR(snap_time, 1, 8) as dt FROM {table}"

    lg.info(
        "📌 [호가 후처리] strategy=%s table=%s source_filter=%s",
        strat_upper, table, source_filter or "(all)",
    )

    db = TradeDB()
    # 1. 테이블 존재 여부 및 컬럼 검사
    try:
        cols = [r["Field"] for r in db.conn.execute(f"SHOW COLUMNS FROM {table}").fetchall()]
        need = {"code", "snap_time", "total_bid_qty", "total_ask_qty", "best_bid", "best_ask"}
        if need - set(cols):
            lg.warning("⚠ [%s] 호가 테이블 필수 컬럼 부족. 추천 생략.", table)
            return {"ok": False, "reason": "insufficient_columns", "table": table}
    except Exception as exc:
        lg.warning("⚠ [%s] 테이블 조회 실패: %s. 추천 생략.", table, exc)
        return {"ok": False, "reason": "table_not_found", "table": table}

    date_sql = f"SELECT DISTINCT SUBSTR(snap_time, 1, 8) as dt FROM {table}"

    if do_ob:
        opp.begin_job(lg, max(1, n_units))
        try:
            from kis_trader.backtest.optuna_feed_trace import (
                log_bt_feed_chain_banner,
                reset_bt_feed_sample_counter,
            )

            reset_bt_feed_sample_counter(postprocess=True)
            log_bt_feed_chain_banner(context="Optuna후처리")
            lg.info(
                "🔎 [Optuna후처리] 호가 추천은 코어 TPE(필터OFF)와 별도 — "
                "아래 [호가후처리]/[호가후처리샘플] 줄을 보면 됨"
            )
        except Exception:
            pass

    for i, row in enumerate(gated, start=1):

    if do_ob:
        opp.begin_job(lg, max(1, n_units))

    for i, row in enumerate(gated, start=1):


    if len(trades) < 3:
        n_raw = len(raw_fills) if raw_fills else 0
        lg.warning(
            "⚠ [%s] 호가 연제 가능한 실제 매수 건수(%s건, 체결원본=%s)가 부족하여 최적화 생략.",
            strat_upper, len(trades), n_raw,
        )
        return {
            "ok": False,
            "reason": "not_enough_trades",
            "trade_count": len(trades),
            "fill_count": n_raw,
        }

    # 연동된 진입 스냅 벤더 요약 (후처리 원인파악)
    try:
        from collections import Counter as _Ctr

        _src_ctr = _Ctr()
        for _ti in trades:
            _es = _ti.entry_snaps[-1] if _ti.entry_snaps else None
            _src_ctr[str((_es.source if _es else "") or "?").strip().lower() or "?"] += 1
        lg.info(
            "🔎 [호가후처리] 연동체결=%d건 | 진입스냅벤더 %s",
            len(trades),
            " ".join(f"{k}={v}" for k, v in _src_ctr.most_common()),
        )
    except Exception:
        pass

    orig_stats = _ob_orig_stats(trades)

    if len(trades) < 3:
        n_raw = len(raw_fills) if raw_fills else 0
        lg.warning(
            "⚠ [%s] 호가 연제 가능한 실제 매수 건수(%s건, 체결원본=%s)가 부족하여 최적화 생략.",
            strat_upper, len(trades), n_raw,
        )
        return {
            "ok": False,
            "reason": "not_enough_trades",
            "trade_count": len(trades),
            "fill_count": n_raw,
        }

    orig_stats = _ob_orig_stats(trades)

- `BT_FEED_DETAIL_LOG`(기본 true) / `BT_FEED_DETAIL_LOG_MAX`(기본 40) = 옵투나·백테에도 동일 축: 틱/호가 로드 벤더 비율 INFO + TRIGGER 샘플. `entry_source=ws_ticks:kis` 등.
- 호가 후처리(`recommend_orderbook_parameters`): 코어 TPE 호가OFF와 무관. `[호가후처리]` DB벤더·`[호가후처리샘플]` bid/ask/src 가 **후처리 구간**에 나옴.

- `BT_FEED_DETAIL_LOG`(기본 true) / `BT_FEED_DETAIL_LOG_MAX`(기본 40) = 옵투나·백테에도 동일 축: 틱/호가 로드 벤더 비율 INFO + TRIGGER 샘플. `entry_source=ws_ticks:kis` 등.

Shell

cd /home/hoon/kis_bot && python3 -m py_compile kis_trader/backtest/optuna_feed_trace.py kis_trader/backtest/optuna_orderbook_recommend.py kis_trader/backtest/optuna_postprocess_topn.py && python3 - <<'PY'
from database import TradeDB
from kis_trader.backtest.optuna_feed_trace import log_bt_postprocess_ob_db_scope, reset_bt_feed_sample_counter
db = TradeDB()
cols = [r["Field"] for r in db.conn.execute("SHOW COLUMNS FROM ws_orderbook").fetchall()]
reset_bt_feed_sample_counter(postprocess=True)
log_bt_postprocess_ob_db_scope(
    db, table="ws_orderbook", cols=cols, source_filter=("kiwoom_0d",),
    date_from="2026-08-18", date_to="2026-08-20", context="호가후처리-스모크",
)
db.close()
print("ok")
PY

Compile and smoke postprocess OB scope log


맞습니다. 제가 잘못 말했습니다. **코어 TPE는 호가 OFF가 정상**이고, 호가 로그는 **후처리**에서 나와야 합니다. “호가 ON으로 돌리라”는 말은 무시하세요.

### 원인
호가 후처리는 `load_trigger_snapshots`(우리가 로그 붙인 경로)를 안 타고, `recommend_orderbook_parameters` → `_fetch_ob_snaps`로 **따로 SELECT**합니다. 그래서 후처리 구간에 벤더/호가 샘플이 안 찍혔습니다.

### 수정
후처리 진입 시 아래가 나오도록 붙였습니다.
- `[Optuna후처리]` / `[호가후처리]` — table·source_filter·기간·DB 벤더 비율
- `[호가후처리샘플]` — 체결↔스냅 hit/miss · bid/ask · src
- 연동체결 건수 + 진입스냅 벤더 요약

지금 돌리는 TPE가 끝난 뒤 후처리에 들어가면 같은 로그 파일 끝쪽에 보입니다.

```bash
rg '호가후처리|Optuna후처리|호가 후처리' logs/optuna_web_momentum_20260821_145331.log
```

(이미 시작된 잡은 옛 코드일 수 있어, **이번 수정 이후 새로 돌아가는 후처리**부터 확실합니다.)


<timestamp>Friday, Aug 21, 2026, 3:13 PM (UTC+9)</timestamp>
<user_query>
📈 KIS Quant Dashboard
모멘텀 완료 200/200
100% · best#143 WR 45.3% · PnL -29,365원 · PF 0.79 · MDD 71,233 · 53건
모멘텀 백테 완료 2026-08-12~2026-08-18
100%
업데이트: 2026. 8. 21. 오후 2:52:57
매매
모의 · active
시세:KIS(실) | 호가:키움(실) | DB:후보
📊 실거래 분석
📈 오늘 운영
⚙ 운영 설정
💼 보유·매도
📉 스캘핑 백테스트
🎣 꼬리잡기 백테스트
🚀 돌파매매 백테스트
🌊 모멘텀 백테스트
🇺🇸 해외 모멘텀
🔬 Optuna
📈 홀딩 전략
📦 박스엔진
📡 영구구독 관리
📢 DART 수주
🔬 Optuna TPE 탐색
서버 subprocess · apply-best 없음 · 해외모멘텀=전역만(종목 cfg는 🇺🇸탭)
전략 (여러 개면 순차 · 해외는 따로 체크)
모멘텀
해외모멘텀(전역)
꼬리 진입
align 다음봉시가
limit_atr 지정가
돌파 손절
fixed 고정%
atr ATR배수
스캘핑
시작
2026-08-18
종료
2026-08-20
trials
200
스터디 총 횟수
비움=이번
mode
tpe
이력소스
키움 (target_candidates)
캔들소스
기본
틱소스
기본
호가소스
기본

탐색 게이트 WR/PF=0 · 사후=results_gated · 안정=results_stable · DB 자동적용 안 함 · 2개 이상=순차(병렬 아님) · 이력소스=저장 후보 테이블 · 꼬리: align/limit_atr 중 켠 것만 · 돌파: fixed/atr 중 켠 것만(둘 다=스터디 2개 순차). 별도 「꼬리」「돌파」체크는 없음
진행
학습 trial
후처리 (호가·휩쏘 · 끝나면 「상세」)
후처리 끝 · 「상세」 가능
상태 done
잡 import_optuna_momentum_tpe_20260821_151059
study momentum_tpe_20260818_20260820_20260821_145331
trial 200 / 200 (100%) · best#143 WR 45.3% · PnL -29,365원 · PF 0.79 · MDD 71,233 · 53건
로그 —
결과 JSON /home/hoon/kis_bot/kis_trader/backtest/results/optuna_momentum_tpe_20260821_151059.json
상위(learn): trial #143 · 거래 53 · WR 45.3% · PF 0.79 · PnL -29,365원 · gated 0/185 · stable 0
 —  —  — 완료 후에만 · TIME_* 미변경 · 위 버튼=타점만 · 트레일/호가는 아래 표 · confirm 필수 · mode_combo=TopN 최빈값 조합(표에서 뺌)
스터디 과적합 요약 (휴리스틱 · 교차검증 아님 · 적용은 아래 각 행 과적합% 보세요)
과적합 28% · 적용가능 72% · 상대적으로낮음
휴리스틱상 위험 상대적 낮음 — 그래도 다일 확인 권장
요인	가점	설명
표본 장일	+16.0	거래일≈3일 — 다일 재검증 권장(≥5일)
거래 표본	+0.0	상위 후보 거래 53건 — 상대적 양호
승률·PF 이상치	+0.0	이상치 없음
게이트 커버	+12.0	사후합격 0건 (학습 185) — DB 적용 비권장
PnL 고원	+0.0	고원 비율 1% (1건)
임계값 분포 (참고 · 적용 판단용 아님)
과적합%는 교차검증 점수가 아니라 표본·이상치·고원 휴리스틱입니다. 적용 가능도%=100−과적합위험%. DB 적용 버튼 활성 조건(gated PnL>0)과는 별개입니다.
다단트레일 추천 (탐색 축 아님 · DB 적용 시에만 반영)
추천 없음 — 양수 PnL 앵커 없음 — 다단트레일 추천 생략
학습 Top5 results · Optuna 목적함수 1위 풀 · 진행바 best=여기 1위
#	trial	거래	승률	PF	PnL	손실일	최악일	안정점수 ↑	과적합% ↓
1	#143	53	45.3%	0.79	-29,365	2	-41,072	-39742.69	16% · 상대적으로 낮음
2	#97	43	53.5%	0.75	-31,910	2	-53,346	-50092.56	16% · 상대적으로 낮음
3	#121	49	53.1%	0.75	-39,678	2	-44,446	-55498.95	16% · 상대적으로 낮음
4	#98	55	43.6%	0.74	-38,415	2	-66,027	-54097.25	16% · 상대적으로 낮음
5	#198	47	42.5%	0.78	-27,042	2	-36,563	-32560.65	16% · 상대적으로 낮음
사후합격 Top5 results_gated · 「상세」=아래 후처리 표(8방 호가)
#	trial	거래	승률	PF	PnL	손실일	최악일	안정점수 ↑	과적합% ↓
gated 후보 없음 / 미완료
안정 Top5 results_stable · 「상세」=아래 후처리 표(8방 호가) · 손실일/최악일 게이트
안정점수 = 일평균PnL − λ×일표준편차 (원). 높을수록 좋음 · 만점 없음. 게이트: 손실일≤1 · 최악일≥-30,000 · 활성일≥2 · λ=1
#	trial	거래	승률	PF	PnL	손실일	최악일	안정점수 ↑	과적합% ↓
stable 후보 없음 (구 JSON은 재실행 필요 / 게이트 미충족)
후처리 비교표 (사후합격·안정 Top5 「상세」 · 열까지 적용=뒤쪽 축은 OFF · live 참고만)
  후처리 끝 · 「상세」 가능
합의(gated+mode, 실매 제외)
호가 방: 110 진입+익절 · 1앵커 · PnL 764 · WR 50.0% · 휩쏘: 있음 · 트레일: 없음
합의=Top5(+mode) 8방 중 median PnL 최고 방 + median 파라미터. 실매 참고행 제외.
이 순위 후처리 앵커 없음. Top5 「상세」를 누르세요.
선택 후보 상세 momentum · learn #2 · trial #97
거래 43 · 승률 53.5% · PF 0.75 · PnL -31,910 · 안정 -50092.56 (높을수록 좋음·만점없음 · 손실일 2 · 최악 -53,346) · 과적합 16.0% (높을수록 위험·0~100 · 상대적으로 낮음) · 파라미터 68개 · 기간 2026-08-18~2026-08-20
일별 PnL: 2026-08-18:-53,346 · 2026-08-19:-20,380 · 2026-08-20:41,816

{
  "_orderbook_filter_enabled": false,
  "_ratchet_ascending_ok": true,
  "_universe_history_source": "kiwoom",
  "backtest_tick_fallback_ohlc": false,
  "backtest_use_tick_entry": true,
  "backtest_use_tick_exit": true,
  "chase_lookback_min": 15,
  "cooldown_min": 3,
  "e_min_chg_pct": 1.2000000000000002,
  "ema_fast_period": 14,
  "ema_slow_period": 32,
  "eod_enabled": true,
  "eod_hm": "15:20",
  "fee_rate": 0.015,
  "high_chase_thr": 0.96,
  "max_daily": 70,
  "max_daily_chg": 23,
  "max_hold_bars": 230,
  "max_loss_krw": 200000,
  "max_stocks": 8,
  "min_margin": 0.1,
  "min_price": 1000,
  "mom_max_from_open_pct": 45,
  "mom_min_from_open_pct": -999,
  "mom_rsi_max": 77,
  "mom_rsi_min": 51,
  "mom_time_end_hm": 1530,
  "mom_vol_mult": 7.6000000000000005,
  "mom_vol_win": 4,
  "pattern_breakout": true,
  "pattern_pullback": true,
  "pullback_lookback_min": 8,
  "pullback_max_pct": 5.2,
  "pullback_min_pct": 0.2,
  "ratchet_cut_1": 0.5,
  "ratchet_cut_2": 1.7000000000000002,
  "ratchet_cut_3": 1.2000000000000002,
  "ratchet_gain_1": 2,
  "ratchet_gain_2": 5.5,
  "ratchet_gain_3": 9,
  "ratchet_n": 2,
  "ratchet_on": false,
  "ratchet_tiers": "",
  "rsi_period": 3,
  "scan_interval_min": 1,
  "sell_tax": 0.18,
  "setup_bear_bars_min": 2,
  "setup_vol_max_mult": 2.1,
  "shoulder_cut_pct": 0.25,
  "shoulder_min_high": 3.1,
  "skip_hts_scan_dupes": false,
  "sl_pct": 2.5,
  "slot_money": 300000,
  "time_end_hm": 1530,
  "time_start_hm": 900,
  "total_budget_krw": 1200000,
  "tp_max_pct": 7,
  "tp_pct": 1.5,
  "trail_arm_pct": 4.3,
  "trail_pct": 0.6000000000000001,
  "trigger_e_confirm": true,
  "trigger_require_bull_bar": true,
  "use_daily_range_filter": false,
  "use_defense_filters": true,
  "use_ema_filter": false,
  "use_high_chase_filter": false,
  "use_rsi_max_filter": false,
  "vol_mult": 0
}
브리핑 미리보기
# Optuna 브리핑 — 모멘텀 (tpe)
기간: 2026-08-18 ~ 2026-08-20 (거래일≈3일) · trials 200/200
탐색 게이트: WR≥0% PF≥0 · min_trades≥1 | 후보 게이트: WR≥40% PF≥1 · min_trades≥1
- min_trades=1: **합격 인정 최소 거래 건수**(표본 부족·우연 승률 방지). 이보다 적은 조합은 학습/후보 집계에서 제외됩니다.
학습용 통과 185건 · 사후합격(results_gated) 0건

## 과적합·적용 가능도 (휴리스틱)
- **과적합 위험 28.0%** · **적용 가능도 72.0%** · 판정: **상대적으로낮음** — 휴리스틱상 위험 상대적 낮음 — 그래도 다일 확인 권장
  - 표본 장일: +16.0p — 거래일≈3일 — 다일 재검증 권장(≥5일)
  - 거래 표본: +0.0p — 상위 후보 거래 53건 — 상대적 양호
  - 승률·PF 이상치: +0.0p — 이상치 없음
  - 게이트 커버: +12.0p — 사후합격 0건 (학습 185) — DB 적용 비권장
  - PnL 고원: +0.0p — 고원 비율 1% (1건)
- 임계값 분포 표본: results(learning) 상위 30건
| 파라미터 | median | p25~p75 | mode(비율) |
|---|---:|---|---|
| `use_ema_filter` | 1.0 | 1.0 ~ 1.0 | True (83%) |
| `ema_fast_period` | 14.0 | 12.0 ~ 15.0 | 15 (37%) |
| `ema_slow_period` | 40.0 | 37.25 ~ 42.0 | 41 (17%) |
| `mom_vol_mult` | 6.9 | 6.3 ~ 7.3 | 7.7 (13%) |
| `mom_vol_win` | 3.0 | 3.0 ~ 4.0 | 3 (70%) |
| `e_min_chg_pct` | 0.9 | 0.7 ~ 1.2 | 1.2 (17%) |
| `tp_pct` | 1.5 | 1.5 ~ 1.6 | 1.5 (57%) |
| `tp_max_pct` | 7.0 | 4.5 ~ 9.375 | 3.5 (13%) |
| `sl_pct` | 2.85 | 2.125 ~ 3.875 | 1.7 (7%) |
| `shoulder_min_high` | 3.2 | 3.0 ~ 3.7 | 3.2 (17%) |
| `shoulder_cut_pct` | 0.3 | 0.25 ~ 0.3375 | 0.3 (40%) |
| `trail_pct` | 0.95 | 0.625 ~ 1.175 | 1.1 (17%) |
- _참고: 과적합%는 교차검증 점수가 아니라 표본·이상치·고원 휴리스틱입니다. 적용 가능도%=100−과적합위험%. DB 적용 버튼 활성 조건(gated PnL>0)과는 별개입니다._

## 이전 장에서는
- 실매 동기간 청산 53건 · 승률 32% · 합산 PnL 104,435원 (백테 Optuna와 숫자 달라도 정상 — 고스트·유니버스·틱 경로 차이).
- 학습 1위(사후게이트 미달 가능): trial #143 · 거래 53건 · 승률 45.3% · PF 0.79 · PnL -29,365원
- 플러스/학습 후보는 있으나 승률·PF 사후 기준은 못 넘김 → **당장 DB 적용은 비권장.**

## 앞으로 장에서는
- **1~2일 Optuna best를 바로 실매에 넣지 마세요.** 다일(≥5거래일) 재검증·웹백테 1회 후 검토.
- 적용 시에는 `results_gated` 후보만. 탐색용 best(objective)와 다를 수 있습니다.
- 장이 좁아지거나 변동성이 커지면: 손절·트레일·시간창이 먼저 깨집니다. 승률 자랑보다 **손절이 살아 있는지**를 보세요.
- 현재 사후합격 0건 → 파라미터 유지(현행 DB) + 유니버스/데이터 점검이 우선입니다.

_생성: 2026-08-21 15:12:24 (rule)_

## AI 코멘트

_(미생성: api_call_failed. 구글 키는 GEMINI_API_KEY 를 DB/환경에 넣으세요 — kis_trader/backtest/optuna_briefing.py 모듈 docstring 참고)_
최근 잡 (8개)
정렬

시작↓
job_id	전략	기간	상태	trial	시작	종료
import_optuna_momentum_tpe_20260821_151059	모멘텀	2026-08-18~2026-08-20	done ·목표 도달 (시도 200 / 목표 200 · COMPLETE 185)	—/200 ·목표 200/200	2026-08-21T15:11:01	2026-08-21T15:11:01
opt_20260821_145331_mome	모멘텀	2026-08-18~2026-08-20	done ·목표 도달 (시도 200 / 목표 200 · COMPLETE 185)	200/200 ·목표 200/200	2026-08-21T14:53:31	2026-08-21T15:12:27
import_optuna_scalp_tpe_20260821_122053	스캘핑	2026-08-18~2026-08-20	done ·목표 도달 (시도 200 / 목표 200 · COMPLETE 180)	200/200 ·목표 200/200	2026-08-21T12:21:45	2026-08-21T12:21:45
import_optuna_breakout_tpe_20260821_120015	돌파(atr)	2026-08-18~2026-08-20	done ·목표 도달 (시도 200 / 목표 200 · COMPLETE 200)	—/200 ·목표 200/200	2026-08-21T12:00:45	2026-08-21T12:00:45
import_optuna_breakout_tpe_20260821_114315	돌파(fixed)	2026-08-18~2026-08-20	done ·목표 도달 (시도 200 / 목표 200 · COMPLETE 200)	200/200 ·목표 200/200	2026-08-21T11:43:45	2026-08-21T11:43:45
import_optuna_tail_tpe_20260821_113102	꼬리(limit_atr)	2026-08-18~2026-08-20	done ·목표 도달 (시도 200 / 목표 200 · COMPLETE 180)	—/200 ·목표 200/200	2026-08-21T11:31:44	2026-08-21T11:31:44
import_optuna_tail_tpe_20260821_111355	꼬리(align)	2026-08-18~2026-08-20	done ·목표 도달 (시도 200 / 목표 200 · COMPLETE 183)	—/200 ·목표 200/200	2026-08-21T11:14:45	2026-08-21T11:14:45
import_optuna_momentum_tpe_20260821_104243	모멘텀	2026-08-18~2026-08-20	done ·목표 도달 (시도 200 / 목표 200 · COMPLETE 188)	200/200 ·목표 200/200	2026-08-21T10:42:44	2026-08-21T10:42:44

이거 후처리 왜 안나와 최근에 돌린거
</user_query>


후처리가 ‘끝’으로 뜨는데 앵커/상세가 비는 원인을 최근 잡 JSON·로그에서 확인합니다.

ls -lt /home/hoon/kis_bot/kis_trader/backtest/results/optuna_momentum*20260821* 2>/dev/null | head -15; ls -lt /home/hoon/kis_bot/logs/optuna_web_momentum_20260821* 2>/dev/null | head -10; rg -n '후처리|호가후처리|postprocess|앵커|not_enough|gated|attach_topn' /home/hoon/kis_bot/logs/optuna_web_momentum_20260821_145331.log | tail -60

Find latest momentum results and postprocess log lines

python3 - <<'PY'
import json, os
paths = [
 "/home/hoon/kis_bot/kis_trader/backtest/results/optuna_momentum_tpe_20260821_151059.json",
]
# also find 145331
base="/home/hoon/kis_bot/kis_trader/backtest/results"
for f in sorted(os.listdir(base)):
    if "20260821_145331" in f or "20260821_151059" in f:
        paths.append(os.path.join(base,f))
# job json
jdir="/home/hoon/kis_bot/logs/optuna_web_jobs"
for f in ["opt_20260821_145331_mome.json","import_optuna_momentum_tpe_20260821_151059.json"]:
    p=os.path.join(jdir,f)
    if os.path.isfile(p): paths.append(p)

seen=set()
for p in paths:
    if p in seen or not os.path.isfile(p):
        continue
    seen.add(p)
    with open(p) as fp:
        d=json.load(fp)
    # job wrapper?
    payload = d.get("payload") or d.get("result") or d
    if "postprocess_topn" not in payload and "results_all" not in payload:
        # nested
        for k in ("payload_json","data","out"):
            if isinstance(d.get(k), dict):
                payload=d[k]
                break
            if isinstance(d.get(k), str):
                try: payload=json.loads(d[k])
                except: pass
    topn = payload.get("postprocess_topn") if isinstance(payload, dict) else None
    print("="*60)
    print(p)
    print("keys", sorted(list(payload.keys())[:40]) if isinstance(payload, dict) else type(payload))
    if not isinstance(payload, dict):
        continue
    print("results_gated", len(payload.get("results_gated") or []))
    print("results_stable", len(payload.get("results_stable") or []))
    print("results_all/learn", len(payload.get("results_all") or payload.get("results") or []))
    if isinstance(topn, dict):
        anchors = topn.get("postprocess_by_anchor") or []
        print("postprocess_topn keys", list(topn.keys())[:20])
        print("anchors", len(anchors), [a.get("id") for a in anchors[:8]])
        cons = topn.get("consensus") or {}
        print("consensus keys", list(cons.keys())[:15] if isinstance(cons, dict) else cons)
        if anchors:
            a0=anchors[0]
            print("anchor0", a0.get("id"), "ob", (a0.get("orderbook") or {}).get("ok"), (a0.get("orderbook") or {}).get("reason"), "ws", (a0.get("whipsaw") or a0.get("ws") or {}).get("ok") if isinstance(a0.get("whipsaw") or a0.get("ws"), dict) else None)
    else:
        print("postprocess_topn", topn)
    # job status fields
    for k in ("status","pp_done","pp_needed","message","note","state"):
        if k in d: print(f"job.{k}=", d.get(k))
PY

Inspect postprocess anchors in result JSONs


후처리는 돌아갔고 앵커는 `mode`/`live` 2개뿐입니다. gated=0이라 학습 Top5「상세」에 안 붙는 UI 구조를 확인합니다.

Grep

postprocess_by_anchor|후처리 앵커|learn#|gated#|이 순위 후처리

Shell

python3 - <<'PY'
import json
p="/home/hoon/kis_bot/kis_trader/backtest/results/optuna_momentum_tpe_20260821_151059.json"
d=json.load(open(p))
topn=d["postprocess_topn"]
for a in topn.get("postprocess_by_anchor") or []:
    ob=a.get("orderbook") or {}
    print(a.get("id"), "ob.ok", ob.get("ok"), "reason", ob.get("reason"), "trade_count", ob.get("trade_count"), "combos", list((ob.get("combos") or {}).keys())[:8])
    cons=topn.get("postprocess_consensus") or a.get("consensus")
print("consensus", json.dumps(topn.get("postprocess_consensus"), ensure_ascii=False)[:500] if topn.get("postprocess_consensus") else None)
print("n_gated", d.get("n_results_gated"), "gates", d.get("report_gates"))
PY

Dump anchor orderbook status from result JSON


원인: 후처리는 끝났고 `mode`/`live` 앵커는 있는데, 사후합격(gated)=0이라 표가 기본으로 `gated#1`을 찾아 ‘앵커 없음’만 뜹니다. gated 없을 때 `mode`/`live`로 보이게 고칩니다.

StrReplace

const sel = window._optunaPostSel || { source: 'gated', rank: 1 };
  let src = sel.source || 'gated';
  let rk = sel.rank || 1;
  let a = null;
  if (src === 'mode') {
    a = anchors.find((x) => x.role === 'mode') || null;
  } else if (src === 'stable') {
    a = anchors.find((x) => x.role === 'stable' && Number(x.rank) === Number(rk)) || null;
  } else if (src === 'learn') {
    // 학습 Top5는 후처리 앵커를 안 만듦(사후합격만). mode 로 폴백.
    a = anchors.find((x) => x.role === 'mode') || null;
    if (a) src = 'mode';
  } else {
    a = anchors.find((x) => x.role === 'gated' && Number(x.rank) === Number(rk)) || null;
  }
  // 사후합격 0건이면 gated# 앵커가 없음 → mode(+live) 자동 표시 (후처리 안 된 것처럼 오해 방지)
  let fallbackNote = '';
  if (!a) {
    const modeA = anchors.find((x) => x.role === 'mode') || null;
    if (modeA) {
      a = modeA;
      src = 'mode';
      rk = 1;
      fallbackNote =
        '
'
        + '사후합격(gated)·안정(stable) Top5가 비어 후처리 표는 mode/live만 표시합니다. '
        + '합의(위)는 이미 계산됨. 학습 Top5「보기」는 파라미터만 — 「상세」는 사후/안정 행에만 있습니다.'
        + '
';
      window._optunaPostSel = { source: 'mode', rank: 1 };
    }
  }
  const live = anchors.find((x) => x.role === 'live') || null;
  const ofKey = src === 'stable' ? 'top5_stable' : (src === 'learn' ? 'top5_learn' : 'top5_gated');
  const ofRow = ((sum && sum[ofKey]) || []).find((x) => Number(x.rank) === Number(rk)) || null;
  const ofPct = (ofRow && ofRow.overfit_risk_pct != null)
    ? ofRow.overfit_risk_pct
    : ((topn && topn.apply_overfit_pct != null) ? topn.apply_overfit_pct : sum.apply_overfit_pct);
  const verd = (ofRow && (ofRow.overfit_verdict_ui || ofRow.overfit_verdict))
    || (topn && (topn.apply_overfit_verdict_ui || topn.apply_overfit_verdict)) || '';
  if (!a) {
    const gatedN = ((sum && sum.top5_gated) || []).length;
    const stableN = ((sum && sum.top5_stable) || []).length;
    cards.innerHTML =
      '
이 순위 후처리 앵커 없음.
'
      + `<div class="text-muted" style="font-size:11px">gated=${gatedN} · stable=${stableN} · `
      + `앵커=${anchors.map((x) => x.id || x.role).join(',') || '없음'}. `
      + '사후합격·안정 Top5「상세」또는 mode 행을 선택하세요.
';
    finish();
    return;
  }
  if (fallbackNote) {
    // renderOne 전에 안내를 붙이기 위해 cards 초기화는 아래에서 함
    cards.dataset.fallbackNote = '1';
  } else {
    delete cards.dataset.fallbackNote;
  }

const sel = window._optunaPostSel || { source: 'gated', rank: 1 };
  const src = sel.source || 'gated';
  const rk = sel.rank || 1;
  let a = null;
  if (src === 'mode') {
    a = anchors.find((x) => x.role === 'mode') || null;
  } else if (src === 'stable') {
    a = anchors.find((x) => x.role === 'stable' && Number(x.rank) === Number(rk)) || null;
  } else {
    a = anchors.find((x) => x.role === 'gated' && Number(x.rank) === Number(rk)) || null;
  }
  const live = anchors.find((x) => x.role === 'live') || null;
  const ofKey = src === 'stable' ? 'top5_stable' : (src === 'learn' ? 'top5_learn' : 'top5_gated');
  const ofRow = ((sum && sum[ofKey]) || []).find((x) => Number(x.rank) === Number(rk)) || null;
  const ofPct = (ofRow && ofRow.overfit_risk_pct != null)
    ? ofRow.overfit_risk_pct
    : ((topn && topn.apply_overfit_pct != null) ? topn.apply_overfit_pct : sum.apply_overfit_pct);
  const verd = (ofRow && (ofRow.overfit_verdict_ui || ofRow.overfit_verdict))
    || (topn && (topn.apply_overfit_verdict_ui || topn.apply_overfit_verdict)) || '';
  if (!a) {
    cards.innerHTML = '
이 순위 후처리 앵커 없음. Top5 「상세」를 누르세요.
';
    finish();
    return;
  }


  cards.innerHTML =
    (fallbackNote || '')
    + renderOne(a, { live: false })
    + (live ? renderOne(live, { live: true }) : '');
  document.querySelectorAll('#opt_top5_tbody tr').forEach((tr, i) => {
    tr.style.outline = (src === 'gated' && i + 1 === Number(rk)) ? '1px solid var(--accent)' : '';
  });
  document.querySelectorAll('#opt_top5_stable_tbody tr').forEach((tr, i) => {
    tr.style.outline = (src === 'stable' && i + 1 === Number(rk)) ? '1px solid var(--accent)' : '';
  });
  finish();
}

  cards.innerHTML = renderOne(a, { live: false }) + (live ? renderOne(live, { live: true }) : '');
  document.querySelectorAll('#opt_top5_tbody tr').forEach((tr, i) => {
    tr.style.outline = (src === 'gated' && i + 1 === Number(rk)) ? '1px solid var(--accent)' : '';
  });
  document.querySelectorAll('#opt_top5_stable_tbody tr').forEach((tr, i) => {
    tr.style.outline = (src === 'stable' && i + 1 === Number(rk)) ? '1px solid var(--accent)' : '';
  });
  finish();
}


후처리 비교표
          (사후합격·안정 Top5「상세」· gated 없으면 mode/live 자동 · live 참고만)

후처리 비교표
          (사후합격·안정 Top5 「상세」 · 열까지 적용=뒤쪽 축은 OFF · live 참고만)

<script src="{{ url_for('static', filename='js/backtest.js') }}?v=20260821ppfb"></script>

<script src="{{ url_for('static', filename='js/backtest.js') }}?v=20260821tab"></script>

Shell

sudo systemctl restart kis_backtest_web.service && sleep 2 && systemctl is-active kis_backtest_web.service && curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:5050/

Restart backtest web for JS cache bump


CallMcpTool

Open Optuna tab for UI verify


Grep

앵커 없음|mode ·|사후합격\(gated\)|110 진입|호가 방|mode 상세|후처리 비교

CallMcpTool

Lock browser for interaction


(() => {
  const c = document.getElementById('opt_postprocess_cards');
  const cons = document.getElementById('opt_postprocess_consensus');
  const btn = document.getElementById('opt_btn_mode_detail');
  return {
    cards: (c && c.innerText || '').slice(0, 800),
    cons: (cons && cons.innerText || '').slice(0, 400),
    hasModeBtn: !!btn,
    js: document.querySelector('script[src*="backtest.js"]')?.src || '',
    job: (window._optunaLastJob && window._optunaLastJob.job_id) || null,
    anchors: (((window._optunaLastSummary||{}).postprocess_topn||{}).postprocess_by_anchor||[]).map(a=>a.id),
  };
})()

Check postprocess cards state via CDP


Hard reload Optuna with new JS version


(() => {
  const js = document.querySelector('script[src*="backtest.js"]')?.src || '';
  // find job table buttons
  const rows = [...document.querySelectorAll('table tr')].map(tr => tr.innerText.slice(0,120));
  const jobBtns = [...document.querySelectorAll('button')].filter(b => /보기|상세/.test(b.textContent||'')).map(b => b.textContent.trim());
  return { js, sampleRows: rows.filter(r => /145331|151059|opt_20260821/.test(r)).slice(0,5), btnN: jobBtns.length, btns: jobBtns.slice(0,15) };
})()

Find recent job rows and JS version


(() => {
  const tr = [...document.querySelectorAll('table tr')].find(t => (t.innerText||'').includes('opt_20260821_145331_mome'));
  if (!tr) return {err:'no row'};
  const btn = [...tr.querySelectorAll('button')].find(b => (b.textContent||'').trim()==='보기');
  if (!btn) return {err:'no btn', text: tr.innerText.slice(0,200)};
  btn.click();
  return {ok:true};
})()

Click 보기 on opt_20260821_145331 job


new Promise(r => setTimeout(() => {
  const c = document.getElementById('opt_postprocess_cards');
  const cons = document.getElementById('opt_postprocess_consensus');
  const anchors = (((window._optunaLastSummary||{}).postprocess_topn||{}).postprocess_by_anchor||[]).map(a=>a.id);
  // trigger re-render if needed
  if (typeof optunaRenderPostprocess === 'function' && window._optunaLastSummary) {
    window._optunaPostSel = {source:'gated', rank:1};
    optunaRenderPostprocess(window._optunaLastSummary);
  }
  r({
    job: (window._optunaLastJob && window._optunaLastJob.job_id) || null,
    anchors,
    cards: (c && c.innerText || '').slice(0,900),
    cons: (cons && cons.innerText || '').slice(0,350),
  });
}, 1500))

Wait and verify mode fallback postprocess UI


(() => {
  if (typeof optunaRenderPostprocess === 'function' && window._optunaLastSummary) {
    window._optunaPostSel = {source:'gated', rank:1};
    optunaRenderPostprocess(window._optunaLastSummary);
  }
  const c = document.getElementById('opt_postprocess_cards');
  const cons = document.getElementById('opt_postprocess_consensus');
  const anchors = (((window._optunaLastSummary||{}).postprocess_topn||{}).postprocess_by_anchor||[]).map(a=>a.id);
  return {
    job: (window._optunaLastJob && window._optunaLastJob.job_id) || null,
    hasSum: !!window._optunaLastSummary,
    anchors,
    cards: (c && c.innerText || '').slice(0,1000),
    cons: (cons && cons.innerText || '').slice(0,400),
  };
})()

Re-render postprocess and read cards


Unlock browser


**후처리는 안 돈 게 아닙니다.** 이미 끝났고, JSON에도 `mode`/`live` 앵커가 있습니다.

### 왜 표가 비어 보였나
1. **사후합격(gated) = 0건** (학습 185건 전부 PF≥1·WR≥40% 미달 → best도 PF 0.79)
2. 후처리 표는 원래 **gated/stable「상세」** 기준인데, gated가 없으니 `gated#1`을 찾다 **「앵커 없음」**만 뜸
3. 위쪽 **합의(110 진입+익절 · PnL 764)** 는 이미 `mode`로 계산된 값 — 후처리 자체는 성공

학습 Top5「보기」는 **파라미터만**이고, 「상세」(8방 표)는 사후/안정 행에만 있습니다.

### 지금 보면 되는 곳
- 잡: **`opt_20260821_145331_mome`** (import_… 말고)
- 후처리 비교표에서 **「mode 상세」** 버튼
- 또는 하드 새로고침 후: gated 없으면 **mode/live 자동 표시**되게 UI 고침 (`?v=20260821ppfb`)

하드새로고침(`192.168.0.149:5050`) 하면 안내 문구 + 8방 표가 바로 뜹니다.


<timestamp>Friday, Aug 21, 2026, 3:19 PM (UTC+9)</timestamp>
<user_query>
예전에도 기본에 승률이랑 pnl 조건 걸어놔서 탐색이 안됐었어 이건 다 돌고나서 찾는거야 아니면 애초에 걸려있어서 -부터 안돈거야 좁혀 들어와야되는데 아예 제외하고 돌릴면 학습이 안되잖아
</user_query>


탐색 중 게이트인지, 끝난 뒤 사후 필터인지부터 코드에서 확인하겠습니다.


**다 돌고 나서 찾는 쪽입니다.** 마이너스라고 처음부터 빼서 안 도는 구조가 아닙니다.

### 두 겹으로 나뉘어 있음

| 구분 | 기본값 | 언제 | 효과 |
|------|--------|------|------|
| **탐색 게이트** | WR=0 · PF=0 · 거래≥1 | trial **돌리는 중** | 거의 안 막음. −PnL도 백테 돌고 objective로 TPE에 들어감 |
| **사후 게이트** (`results_gated`) | WR≥40 · PF≥1 | **200회 끝난 뒤** 목록 나누기 | 적용 후보만 거름. 학습은 이미 끝난 상태 |

지금 UI에 나온 `탐색 게이트 WR/PF=0 · 사후=results_gated` 가 그 뜻입니다.

### 이번 잡으로 보면
- trial **200/200 전부 돌림** (COMPLETE 185)
- 학습 Top5에 **−PnL** 이 올라온 것 = 탐색에 포함됐다는 증거
- gated 0건 = **끝난 뒤** PF≥1·WR≥40에 못 걸린 것뿐 (탐색이 막힌 게 아님)

### 예전에 “안 돌았다”고 느낀 경우
탐색 게이트에 WR/PF를 올려 두면, 미달 trial은 `None` → 실패 objective로 처리되어 **TPE가 −/+ 차이를 제대로 못 배웁니다.** 그래서 지금 기본은 탐색=0, 사후만 40/1입니다.

**PnL≥0** 조건은 탐색 게이트에 없고, `--apply-best` 할 때만 gated 후보가 적자면 적용 스킵입니다.

정리: 좁혀 들어오는 건 TPE가 **마이너스 포함 전체 PnL**로 하고, WR/PF는 **후보 고를 때**만 씁니다.


<timestamp>Friday, Aug 21, 2026, 6:15 PM (UTC+9)</timestamp>
<user_query>
그래서 그건 고친걸 알아 내가 지금 말한건 후처리
</user_query>


후처리 쪽 말씀이군요. gated(WR/PF) 때문에 학습 Top이 후처리에서 빠지는지 코드로 확인합니다.


Grep

role === 'gated'|role == \"learn\"|consensus


맞습니다. 후처리는 사후게이트(WR/PF) 통과분(`results_gated`)만 호가 8방 앵커로 써서, gated=0이면 학습 Top(−PnL)이 후처리에서 빠집니다. gated 비면 학습 TopN에도 후처리를 돌리도록 고칩니다.


StrReplace

def _consensus_from_anchors(anchors: List[Dict[str, Any]], strategy: str) -> Dict[str, Any]:
    """gated(+learn 폴백)+mode. live·stable 제외. 호가=8방 median PnL 최고 방."""
    # 사후합격 없으면 학습 Top 후처리(learn)를 합의에 넣음 — WR/PF 때문에 호가방 학습이 비지 않게
    roles = ("gated", "mode")
    if not any(str(a.get("role") or "") == "gated" for a in (anchors or [])):
        roles = ("gated", "learn", "mode")
    pool = [a for a in anchors if str(a.get("role") or "") in roles]
    by_combo: Dict[str, List[Dict[str, Any]]] = {cid: [] for cid in _COMBO_IDS}

def _consensus_from_anchors(anchors: List[Dict[str, Any]], strategy: str) -> Dict[str, Any]:
    """gated+mode. live·stable 제외. 호가=8방 중 median PnL 최고 방 + median 파라미터."""
    pool = [a for a in anchors if str(a.get("role") or "") in ("gated", "mode")]
    by_combo: Dict[str, List[Dict[str, Any]]] = {cid: [] for cid in _COMBO_IDS}

    note = (
        "합의=Top5(+mode) 8방 중 median PnL 최고 방 + median 파라미터. 실매 참고행 제외."
        if any(str(a.get("role") or "") == "gated" for a in (anchors or []))
        else "합의=학습Top(+mode) — 사후합격(gated) 0건이라 WR/PF 미달 후보로 호가방 학습. 실매 참고행 제외."
    )

note = "합의=Top5(+mode) 8방 중 median PnL 최고 방 + median 파라미터. 실매 참고행 제외."

def append_learn_postprocess_anchors(
    data: Dict[str, Any],
    anchors: List[Dict[str, Any]],
    *,
    strat: str,
    strat_u: str,
    top_n: int,
    do_ob: bool,
    evaluate_fn: Optional[EvalFn],
    lg: logging.Logger,
    ob_n_trials: int = 0,
) -> None:
    """
    사후합격(gated)이 비었을 때 학습 TopN(results)에도 호가/휩쏘 후처리.

    탐색은 WR/PF=0 이라 −PnL도 TPE에 들어가는데, 후처리만 gated(WR40/PF1)에
    묶이면 gated=0 일 때 호가 8방이 mode/live만 남고 학습 후보가 통째 제외됨.
    → 후처리도 ‘끝난 뒤 후보 고르기’이지 ‘처음부터 제외’가 아니게 learn 폴백.
    """
    if any(str(a.get("role") or "") == "gated" for a in anchors):
        return
    if any(str(a.get("role") or "") == "learn" for a in anchors):
        return
    learn = list(
        (data or {}).get("results")
        or (data or {}).get("results_all")
        or []
    )[: max(1, int(top_n or 5))]
    if not learn:
        return
    lg.info(
        "📌 [후처리] 사후합격 0건 → 학습 Top%d 에 호가/휩쏘 후처리 (WR/PF 사후게이트와 분리)",
        len(learn),
    )
    for i, row in enumerate(learn, start=1):
        if not isinstance(row, dict):
            continue
        combo = _combo_from_row(row)
        fills: List[Dict[str, Any]] = []
        if do_ob:
            from kis_trader.backtest import optuna_post_progress as opp
            opp.next_unit(f"learn#{i}", "학습Top 호가")
        if do_ob and evaluate_fn is not None:
            lg.info("📌 [후처리] learn#%d 백테 재실행 (호가/휩쏘 체결)", i)
            fills = _replay_fills(evaluate_fn, combo, lg)
        pnl = row.get("total_pnl")
        trail = _slim_trail(_trail_for_pnl(strat, pnl))
        ob = {"ok": False, "reason": "light_skip"}
        ws = {"ok": False, "reason": "light_skip"}
        if do_ob:
            if fills:
                ob = _ob_for_anchor(
                    strategy=strat_u, out_data=data, fills=fills, live=False,
                    lg=lg, n_trials=ob_n_trials,
                )
                ws = _ws_for_anchor(
                    strategy=strat_u, out_data=data, fills=fills, live=False, lg=lg,
                )
            elif evaluate_fn is None:
                ob = {"ok": False, "reason": "no_replay_fills"}
                ws = {"ok": False, "reason": "no_replay_fills"}
            else:
                ob = {"ok": False, "reason": "not_enough_trades", "trade_count": 0}
                ws = {"ok": False, "reason": "not_enough_trades", "trade_count": 0}
        anchors.append({
            "id": f"learn#{i}",
            "role": "learn",
            "rank": i,
            "optuna_trial_number": row.get("optuna_trial_number") or row.get("_trial_number"),
            "total_pnl": _krw_int(pnl),
            "total_trades": int(row.get("total_trades") or 0),
            "win_rate": row.get("win_rate"),
            "pf": row.get("pf"),
            "orderbook": ob,
            "whipsaw": ws,
            "trail": trail,
            "note": "학습 Top(사후게이트 미달 폴백) — 호가방 학습용 · DB적용은 gated 우선",
        })

def append_stable_postprocess_anchors(

    gated = list(data.get("results_gated") or [])[:top_n]
    anchors: List[Dict[str, Any]] = []

    from kis_trader.backtest import optuna_post_progress as opp

    stable_preview = list((data or {}).get("results_stable") or [])[: max(1, int(top_n or 5))] if _include_stable() else []
    learn_preview: List[Any] = []
    if not gated:
        learn_preview = list(
            (data or {}).get("results") or (data or {}).get("results_all") or []
        )[: max(1, int(top_n or 5))]
    n_units = len(gated) + len(learn_preview) + (len(stable_preview) if _include_stable() else 0)
    if _include_mode():
        n_units += 1
    if _include_live():
        n_units += 1
    if do_ob:
        opp.begin_job(lg, max(1, n_units))
        try:
            from kis_trader.backtest.optuna_feed_trace import (
                log_bt_feed_chain_banner,
                reset_bt_feed_sample_counter,
            )

            reset_bt_feed_sample_counter(postprocess=True)
            log_bt_feed_chain_banner(context="Optuna후처리")
            lg.info(
                "🔎 [Optuna후처리] 호가 추천은 코어 TPE(필터OFF)와 별도 — "
                "아래 [호가후처리]/[호가후처리샘플] 줄을 보면 됨"
            )
        except Exception:
            pass

    for i, row in enumerate(gated, start=1):
        combo = _combo_from_row(row)
        fills: List[Dict[str, Any]] = []
        if do_ob:
            opp.next_unit(f"gated#{i}", "백테재실행·호가")
        if do_ob and evaluate_fn is not None:
            lg.info("📌 [후처리] gated#%d 백테 재실행 (호가/휩쏘 체결)", i)
            fills = _replay_fills(evaluate_fn, combo, lg)
        pnl = row.get("total_pnl")
        trail = _slim_trail(_trail_for_pnl(strat, pnl))
        ob = {"ok": False, "reason": "light_skip"}
        ws = {"ok": False, "reason": "light_skip"}
        if do_ob:
            if fills:
                ob = _ob_for_anchor(strategy=strat_u, out_data=data, fills=fills, live=False, lg=lg, n_trials=ob_n_trials)
                ws = _ws_for_anchor(strategy=strat_u, out_data=data, fills=fills, live=False, lg=lg)
            elif evaluate_fn is None:
                ob = {"ok": False, "reason": "no_replay_fills"}
                ws = {"ok": False, "reason": "no_replay_fills"}
            else:
                ob = {"ok": False, "reason": "not_enough_trades", "trade_count": 0}
                ws = {"ok": False, "reason": "not_enough_trades", "trade_count": 0}
        anchors.append({
            "id": f"gated#{i}",
            "role": "gated",
            "rank": i,
            "optuna_trial_number": row.get("optuna_trial_number") or row.get("_trial_number"),
            "total_pnl": _krw_int(pnl),
            "total_trades": int(row.get("total_trades") or 0),
            "win_rate": row.get("win_rate"),
            "pf": row.get("pf"),
            "orderbook": ob,
            "whipsaw": ws,
            "trail": trail,
            "note": "사후합격 후보",
        })

    # gated 비면 학습 Top 에도 호가 후처리 (탐색 WR/PF=0 과 같은 정신)
    append_learn_postprocess_anchors(
        data, anchors,
        strat=strat, strat_u=strat_u, top_n=top_n,
        do_ob=do_ob, evaluate_fn=evaluate_fn, lg=lg, ob_n_trials=ob_n_trials,
    )

    append_stable_postprocess_anchors(

    gated = list(data.get("results_gated") or [])[:top_n]
    anchors: List[Dict[str, Any]] = []

    from kis_trader.backtest import optuna_post_progress as opp

    stable_preview = list((data or {}).get("results_stable") or [])[: max(1, int(top_n or 5))] if _include_stable() else []
    n_units = len(gated) + (len(stable_preview) if _include_stable() else 0)
    if _include_mode():
        n_units += 1
    if _include_live():
        n_units += 1
    if do_ob:
        opp.begin_job(lg, max(1, n_units))
        try:
            from kis_trader.backtest.optuna_feed_trace import (
                log_bt_feed_chain_banner,
                reset_bt_feed_sample_counter,
            )

            reset_bt_feed_sample_counter(postprocess=True)
            log_bt_feed_chain_banner(context="Optuna후처리")
            lg.info(
                "🔎 [Optuna후처리] 호가 추천은 코어 TPE(필터OFF)와 별도 — "
                "아래 [호가후처리]/[호가후처리샘플] 줄을 보면 됨"
            )
        except Exception:
            pass

    for i, row in enumerate(gated, start=1):
        combo = _combo_from_row(row)
        fills: List[Dict[str, Any]] = []
        if do_ob:
            opp.next_unit(f"gated#{i}", "백테재실행·호가")
        if do_ob and evaluate_fn is not None:
            lg.info("📌 [후처리] gated#%d 백테 재실행 (호가/휩쏘 체결)", i)
            fills = _replay_fills(evaluate_fn, combo, lg)
        pnl = row.get("total_pnl")
        trail = _slim_trail(_trail_for_pnl(strat, pnl))
        ob = {"ok": False, "reason": "light_skip"}
        ws = {"ok": False, "reason": "light_skip"}
        if do_ob:
            if fills:
                ob = _ob_for_anchor(strategy=strat_u, out_data=data, fills=fills, live=False, lg=lg, n_trials=ob_n_trials)
                ws = _ws_for_anchor(strategy=strat_u, out_data=data, fills=fills, live=False, lg=lg)
            elif evaluate_fn is None:
                ob = {"ok": False, "reason": "no_replay_fills"}
                ws = {"ok": False, "reason": "no_replay_fills"}
            else:
                ob = {"ok": False, "reason": "not_enough_trades", "trade_count": 0}
                ws = {"ok": False, "reason": "not_enough_trades", "trade_count": 0}
        anchors.append({
            "id": f"gated#{i}",
            "role": "gated",
            "rank": i,
            "optuna_trial_number": row.get("optuna_trial_number") or row.get("_trial_number"),
            "total_pnl": _krw_int(pnl),
            "total_trades": int(row.get("total_trades") or 0),
            "win_rate": row.get("win_rate"),
            "pf": row.get("pf"),
            "orderbook": ob,
            "whipsaw": ws,
            "trail": trail,
            "note": "사후합격 후보",
        })

    append_stable_postprocess_anchors(


function optunaRankTableRow(r, source) {
  const rank = r.rank || 1;
  const src = source;
  const busy = optunaPostBusy(window._optunaLastJob);
  const anchors = (((window._optunaLastSummary || {}).postprocess_topn || {}).postprocess_by_anchor) || [];
  const hasLearnAnchor = anchors.some((a) => a && a.role === 'learn');
  // 학습 Top「상세」: learn 후처리 앵커가 있을 때만 (gated 비면 learn 폴백)
  const detailBtn = (src === 'learn' && !hasLearnAnchor)
    ? ''
    : (busy
      ? '<button class="btn btn-sm btn-outline-secondary py-0" disabled title="후처리 끝나야 표가 채워집니다">상세</button>'
      : `<button class="btn btn-sm btn-outline-primary py-0" onclick="optunaSelectPostprocess('${src}',${rank})">상세</button>`);
  const applyCls = src === 'stable' ? 'btn-outline-info' : 'btn-outline-success';
  return `

    ${rank}
    #${r.optuna_trial_number ?? '—'}
    ${optunaFmtNum(r.total_trades, 0)}
    ${optunaFmtNum(r.win_rate, 1)}%
    ${optunaFmtNum(r.pf, 2)}
    = 0 ? 'text-pnl-pos' : 'text-pnl-neg'}">${optunaFmtNum(r.total_pnl)}
    ${r.n_losing_days != null ? optunaFmtNum(r.n_losing_days, 0) : '—'}
    = 0 ? 'text-pnl-pos' : 'text-pnl-neg'}">${r.worst_day_pnl != null ? optunaFmtNum(r.worst_day_pnl) : '—'}
    ${optunaStableScoreCell(r)}
    ${optunaOverfitCell(r)}

      <button class="btn btn-sm btn-outline-secondary py-0" onclick="optunaShowCandidate('${src}',${rank})">보기</button>
      ${detailBtn}
      <button class="btn btn-sm ${applyCls} py-0" onclick="optunaApply('${src}',${rank})">적용</button>

`;
}

function optunaRankTableRow(r, source) {
  const rank = r.rank || 1;
  const src = source;
  const busy = optunaPostBusy(window._optunaLastJob);
  const detailBtn = (src === 'learn')
    ? ''
    : (busy
      ? '<button class="btn btn-sm btn-outline-secondary py-0" disabled title="후처리 끝나야 표가 채워집니다">상세</button>'
      : `<button class="btn btn-sm btn-outline-primary py-0" onclick="optunaSelectPostprocess('${src}',${rank})">상세</button>`);
  const applyCls = src === 'stable' ? 'btn-outline-info' : 'btn-outline-success';
  return `

    ${rank}
    #${r.optuna_trial_number ?? '—'}
    ${optunaFmtNum(r.total_trades, 0)}
    ${optunaFmtNum(r.win_rate, 1)}%
    ${optunaFmtNum(r.pf, 2)}
    = 0 ? 'text-pnl-pos' : 'text-pnl-neg'}">${optunaFmtNum(r.total_pnl)}
    ${r.n_losing_days != null ? optunaFmtNum(r.n_losing_days, 0) : '—'}
    = 0 ? 'text-pnl-pos' : 'text-pnl-neg'}">${r.worst_day_pnl != null ? optunaFmtNum(r.worst_day_pnl) : '—'}
    ${optunaStableScoreCell(r)}
    ${optunaOverfitCell(r)}

      <button class="btn btn-sm btn-outline-secondary py-0" onclick="optunaShowCandidate('${src}',${rank})">보기</button>
      ${detailBtn}
      <button class="btn btn-sm ${applyCls} py-0" onclick="optunaApply('${src}',${rank})">적용</button>

`;
}

const sel = window._optunaPostSel || { source: 'gated', rank: 1 };
  let src = sel.source || 'gated';
  let rk = sel.rank || 1;
  let a = null;
  if (src === 'mode') {
    a = anchors.find((x) => x.role === 'mode') || null;
  } else if (src === 'stable') {
    a = anchors.find((x) => x.role === 'stable' && Number(x.rank) === Number(rk)) || null;
  } else if (src === 'learn') {
    a = anchors.find((x) => x.role === 'learn' && Number(x.rank) === Number(rk)) || null;
  } else {
    a = anchors.find((x) => x.role === 'gated' && Number(x.rank) === Number(rk)) || null;
  }
  // gated 비면 learn# → mode 순으로 폴백 (호가방이 학습 후보에서 보이게)
  let fallbackNote = '';
  if (!a) {
    const learnA = anchors.find((x) => x.role === 'learn' && Number(x.rank) === 1)
      || anchors.find((x) => x.role === 'learn') || null;
    const modeA = anchors.find((x) => x.role === 'mode') || null;
    if (learnA) {
      a = learnA;
      src = 'learn';
      rk = Number(learnA.rank) || 1;
      fallbackNote =
        '
'
        + '사후합격(gated) 0건 → 학습 Top 후처리(learn#) 표시. '
        + 'WR/PF 사후게이트와 무관하게 호가 8방을 돌린 결과입니다. DB적용은 여전히 gated 우선.'
        + '
';
      window._optunaPostSel = { source: 'learn', rank: rk };
    } else if (modeA) {
      a = modeA;
      src = 'mode';
      rk = 1;
      fallbackNote =
        '
'
        + '사후합격·학습 앵커 없음 → mode/live만 표시.'
        + '
';
      window._optunaPostSel = { source: 'mode', rank: 1 };
    }
  }
  const live = anchors.find((x) => x.role === 'live') || null;
  const ofKey = src === 'stable' ? 'top5_stable' : (src === 'learn' ? 'top5_learn' : 'top5_gated');
  const ofRow = ((sum && sum[ofKey]) || []).find((x) => Number(x.rank) === Number(rk)) || null;
  const ofPct = (ofRow && ofRow.overfit_risk_pct != null)
    ? ofRow.overfit_risk_pct
    : ((topn && topn.apply_overfit_pct != null) ? topn.apply_overfit_pct : sum.apply_overfit_pct);
  const verd = (ofRow && (ofRow.overfit_verdict_ui || ofRow.overfit_verdict))
    || (topn && (topn.apply_overfit_verdict_ui || topn.apply_overfit_verdict)) || '';
  if (!a) {
    const gatedN = ((sum && sum.top5_gated) || []).length;
    const stableN = ((sum && sum.top5_stable) || []).length;
    cards.innerHTML =
      '
이 순위 후처리 앵커 없음.
'
      + `<div class="text-muted" style="font-size:11px">gated=${gatedN} · stable=${stableN} · `
      + `앵커=${anchors.map((x) => x.id || x.role).join(',') || '없음'}. `
      + '사후합격·학습·안정 Top5「상세」또는 mode 를 선택하세요.
';
    finish();
    return;
  }
  if (fallbackNote) {
    cards.dataset.fallbackNote = '1';
  } else {
    delete cards.dataset.fallbackNote;
  }

const sel = window._optunaPostSel || { source: 'gated', rank: 1 };
  let src = sel.source || 'gated';
  let rk = sel.rank || 1;
  let a = null;
  if (src === 'mode') {
    a = anchors.find((x) => x.role === 'mode') || null;
  } else if (src === 'stable') {
    a = anchors.find((x) => x.role === 'stable' && Number(x.rank) === Number(rk)) || null;
  } else if (src === 'learn') {
    // 학습 Top5는 후처리 앵커를 안 만듦(사후합격만). mode 로 폴백.
    a = anchors.find((x) => x.role === 'mode') || null;
    if (a) src = 'mode';
  } else {
    a = anchors.find((x) => x.role === 'gated' && Number(x.rank) === Number(rk)) || null;
  }
  // 사후합격 0건이면 gated# 앵커가 없음 → mode(+live) 자동 표시 (후처리 안 된 것처럼 오해 방지)
  let fallbackNote = '';
  if (!a) {
    const modeA = anchors.find((x) => x.role === 'mode') || null;
    if (modeA) {
      a = modeA;
      src = 'mode';
      rk = 1;
      fallbackNote =
        '
'
        + '사후합격(gated)·안정(stable) Top5가 비어 후처리 표는 mode/live만 표시합니다. '
        + '합의(위)는 이미 계산됨. 학습 Top5「보기」는 파라미터만 — 「상세」는 사후/안정 행에만 있습니다.'
        + '
';
      window._optunaPostSel = { source: 'mode', rank: 1 };
    }
  }
  const live = anchors.find((x) => x.role === 'live') || null;
  const ofKey = src === 'stable' ? 'top5_stable' : (src === 'learn' ? 'top5_learn' : 'top5_gated');
  const ofRow = ((sum && sum[ofKey]) || []).find((x) => Number(x.rank) === Number(rk)) || null;
  const ofPct = (ofRow && ofRow.overfit_risk_pct != null)
    ? ofRow.overfit_risk_pct
    : ((topn && topn.apply_overfit_pct != null) ? topn.apply_overfit_pct : sum.apply_overfit_pct);
  const verd = (ofRow && (ofRow.overfit_verdict_ui || ofRow.overfit_verdict))
    || (topn && (topn.apply_overfit_verdict_ui || topn.apply_overfit_verdict)) || '';
  if (!a) {
    const gatedN = ((sum && sum.top5_gated) || []).length;
    const stableN = ((sum && sum.top5_stable) || []).length;
    cards.innerHTML =
      '
이 순위 후처리 앵커 없음.
'
      + `<div class="text-muted" style="font-size:11px">gated=${gatedN} · stable=${stableN} · `
      + `앵커=${anchors.map((x) => x.id || x.role).join(',') || '없음'}. `
      + '사후합격·안정 Top5「상세」또는 mode 행을 선택하세요.
';
    finish();
    return;
  }
  if (fallbackNote) {
    // renderOne 전에 안내를 붙이기 위해 cards 초기화는 아래에서 함
    cards.dataset.fallbackNote = '1';
  } else {
    delete cards.dataset.fallbackNote;
  }

const rowSrc = isLive ? '' : (row.role === 'mode' ? 'mode' : (row.role === 'stable' ? 'stable' : (row.role === 'learn' ? 'learn' : 'gated')));

const rowSrc = isLive ? '' : (row.role === 'mode' ? 'mode' : (row.role === 'stable' ? 'stable' : 'gated'));

function optunaPool8wayKind(anchors) {
  const pool = (anchors || []).filter((a) => a && (a.role === 'gated' || a.role === 'learn' || a.role === 'mode'));
  if (!pool.length) return 'legacy';
  let anyOk = false;
  let anyFailed = false;
  pool.forEach((a) => {

function optunaPool8wayKind(anchors) {
  const pool = (anchors || []).filter((a) => a && (a.role === 'gated' || a.role === 'mode'));
  if (!pool.length) return 'legacy';
  let anyOk = false;
  let anyFailed = false;
  pool.forEach((a) => {

  document.querySelectorAll('#opt_top5_tbody tr').forEach((tr, i) => {
    tr.style.outline = (src === 'gated' && i + 1 === Number(rk)) ? '1px solid var(--accent)' : '';
  });
  document.querySelectorAll('#opt_top5_learn_tbody tr').forEach((tr, i) => {
    tr.style.outline = (src === 'learn' && i + 1 === Number(rk)) ? '1px solid var(--accent)' : '';
  });
  document.querySelectorAll('#opt_top5_stable_tbody tr').forEach((tr, i) => {
    tr.style.outline = (src === 'stable' && i + 1 === Number(rk)) ? '1px solid var(--accent)' : '';
  });
  finish();
}

  document.querySelectorAll('#opt_top5_tbody tr').forEach((tr, i) => {
    tr.style.outline = (src === 'gated' && i + 1 === Number(rk)) ? '1px solid var(--accent)' : '';
  });
  document.querySelectorAll('#opt_top5_stable_tbody tr').forEach((tr, i) => {
    tr.style.outline = (src === 'stable' && i + 1 === Number(rk)) ? '1px solid var(--accent)' : '';
  });
  finish();
}

후처리 비교표
          (사후합격「상세」· gated 없으면 학습Top learn# 폴백 · mode/live)

후처리 비교표
          (사후합격·안정 Top5「상세」· gated 없으면 mode/live 자동 · live 참고만)

<script src="{{ url_for('static', filename='js/backtest.js') }}?v=20260821learnp"></script>

<script src="{{ url_for('static', filename='js/backtest.js') }}?v=20260821ppfb"></script>


Shell

cd /home/hoon/kis_bot && python3 -m py_compile kis_trader/backtest/optuna_postprocess_topn.py && sudo systemctl restart kis_backtest_web.service && sleep 2 && systemctl is-active kis_backtest_web.service && curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:5050/

Compile postprocess and restart web


맞아요. **탐색 게이트는 이미 0**이고, 문제는 **후처리가 사후합격(gated=WR40/PF1)만 앵커로 잡던 것**이었습니다.

### 후처리에서 뭐가 잘못이었나
- 코어 TPE: −PnL도 다 돌림 → 학습됨
- 후처리 호가 8방: **`results_gated`만** 재실행
- gated=0이면 → 학습 Top(−PnL)은 **후처리에서 통째 제외**, mode/live만 남음

그래서 “끝난 뒤 고르는 게 아니라, 후처리 단계에서 아예 빼 버리는” 느낌이 난 겁니다.

### 고친 내용
gated 비면 **학습 TopN에도** `learn#`로 호가/휩쏘 후처리 돌림. 합의도 learn+mode 포함.

이미 끝난 잡(`opt_20260821_145331_mome`)은 JSON에 learn 앵커가 없으니, Optuna 탭에서 **「이 잡 후처리 재실행」** 한 번 누르면 반영됩니다.


<timestamp>Friday, Aug 21, 2026, 6:19 PM (UTC+9)</timestamp>
<user_query>
사후게이트(WR/PF) 통과분(results_gated)만 호가 8방 앵커로 써서, 이말이 뭐지 기본 통과분만 쓰는건 맞는데 gated 비면 학습 TopN에도 후처리를 돌리도록 고칩니다. 뭘 고친다는거지? 한글로좀 설명해줘라 죄 다 영어야 게이트드가 비면이 뭔 의미인지 단어뜻을 알아도 못알아듣겠다 야 뭘 포함한거지 도대체
</user_query>


쉽게만 말할게요.

### 옵투나가 끝나면 후보가 두 무더기로 나뉨

1. **학습 결과**
   200번 돌린 전부. 손익이 마이너스여도 여기 들어감.
   화면의 「학습 Top5」.

2. **사후합격**
   그중에서도 **승률·PF 기준을 통과한 것만**.
   (지금 기본: 승률 40% 이상, PF 1.0 이상)
   화면의 「사후합격 Top5」.
   코드에서 이걸 `results_gated` / gated라고 부름.

「gated가 비다」= **사후합격 후보가 한 건도 없다**는 뜻.
이번처럼 학습은 185건 있는데, 승률·PF를 못 넘겨서 사후합격 표가 텅 빈 상태.

---

### 후처리(호가 8방)가 예전에 뭘 했냐

호가 추천은 **사후합격 후보만** 골라서 다시 돌려봤음.
→ 사후합격이 0건이면, **학습 Top에 있는 종목/파라미터는 호가 후처리에 안 넣음.**
→ 화면에 호가 표가 비거나 mode만 남는 것처럼 보임.

탐색(TPE)은 마이너스도 쓰는데, **호가 후처리만 “합격한 놈만” 쓰니** 합격이 없으면 호가 쪽은 배울 재료가 없었던 거임.

---

### 그래서 뭘 고쳤냐 (한 줄)

**사후합격이 한 건도 없을 때는, 학습 Top5에도 호가 후처리를 돌리게 바꿈.**

- 사후합격 있으면 → 예전처럼 **합격분만** 호가 후처리 (이게 기본·맞음)
- 사후합격 없으면 → **학습 Top5라도** 호가 후처리 (마이너스여도 호가 숫자 뽑음)

DB에 바로 넣자는 뜻이 아님.
「합격 표가 비었을 때 호가 표까지 비지 않게」만 고친 거임.

이미 끝난 잡은 **「이 잡 후처리 재실행」** 눌러야 이 로직이 다시 적용됨.


<timestamp>Friday, Aug 21, 2026, 6:21 PM (UTC+9)</timestamp>
<user_query>
이야 완전 기회박탈이었네 근데 기본 돌린거중에 마이너스인거 싹 다 돌리나 이제 그럼? 100000개 나와도? 전부?
</user_query>