테스트가 BREAKOUT/SCALP에 `ls_condition`을 하드코딩해서 `hist=ls`로 나온 겁니다. 실매 DB는 전부 `kiwoom_condition`이라 테스트를 실DB 기준으로 고칩니다.

StrReplace

    print("    [5-B] 유니버스 ALIGN + history 소스 (실매 DB UNIVERSE_SOURCE 기준):")
    print(
        "        ※ hist=kiwoom → target_candidates_history | "
        "hist=ls → ls_candidates_history (ALIGN 교집합용, 시세 LS WS 와 무관)"
    )
    for strat in strategies:
        align_on = live_universe_slot_align_enabled(strat)
        # 실매와 동일: 인자 없이 DB {SID}_UNIVERSE_SOURCE 로 해석
        hist_src = resolve_live_universe_history_source(strat)
        univ = (
            get_env_from_db(f"{strat}_UNIVERSE_SOURCE", "") or ""
        ).strip() or "(default)"
        flag = "ON" if align_on else "OFF"
        print(
            f"        👉 [{strat:8s}] ALIGN={flag} univ={univ} hist={hist_src}"
        )

    print("    [5-B] 유니버스 ALIGN:")
    for strat in strategies:
        align_on = live_universe_slot_align_enabled(strat)
        hist_src = resolve_live_universe_history_source(
            strat,
            universe_source=(
                "kiwoom_condition"
                if strat in ["MOMENTUM", "TAIL"]
                else "ls_condition"
            ),
        )
        flag = "ON" if align_on else "OFF"
        print(f"        👉 [{strat:8s}] ALIGN={flag} hist={hist_src}")

Shell

cd /home/hoon/kis_bot && python3 -u scripts/test_live_execution_validation.py 2>&1 | rg -A8 '5-B|최종 판정|실패'

Re-run test; show fixed ALIGN/hist lines


**hist**는 시세(LS WS)가 아닙니다.

ALIGN이 ON일 때, 실시간 후보와 **교집합할 이력 테이블**을 가리킵니다.

| hist | 읽는 테이블 |
|------|-------------|
| `kiwoom` | `target_candidates_history` |
| `ls` | `ls_candidates_history` |

방금 로그에 BREAKOUT/SCALP가 `hist=ls`로 나온 건 **실매 설정이 아니라 테스트 버그**였습니다. 테스트가 BREAKOUT/SCALP에 `universe_source=ls_condition`을 하드코딩해서 그렇게 찍힌 거예요.

실매 DB는 전부 `kiwoom_condition`이라, 고친 뒤엔 이렇게 나옵니다:

`BREAKOUT/SCALP/... hist=kiwoom`


<timestamp>Thursday, Aug 13, 2026, 3:55 AM (UTC+9)</timestamp>
<user_query>
@test_live_execution_validation_rerun.log (31-37) 이건 또 뭐야 지금 진입은 호가 휩쏘 다 껐는데 이거 뭐 어쩔 다시 보강해 디비에서 가져온는값들로 하고 좀 더 명확히 한글도 좀 넣어주고 다 뭐 변수명이니 가독성이 너무 떨어지네
</user_query>


DB의 호가·휩쏘 실제값을 확인한 뒤, 테스트가 그 값을 따르고 한글 설명이 분명하도록 고칩니다.

cd /home/hoon/kis_bot && python3 <<'PY'
from kis_trader.utils.env import get_env_from_db, get_env_bool, invalidate_merged_env_cache
from kis_trader.engine.orderbook_filter import orderbook_filter_enabled
invalidate_merged_env_cache()
for sid in ('MOMENTUM','BREAKOUT','SCALP','TAIL','SHORT'):
    keys=[
        f'{sid}_ORDERBOOK_FILTER_ENABLED',
        f'{sid}_WHIPSAW_FILTER_ENABLED',
        f'{sid}_EXIT_OB_ENABLED',
        f'{sid}_STOP_OB_ENABLED',
    ]
    # also common
    print('===', sid, '===')
    print('  orderbook_filter_enabled()', orderbook_filter_enabled(sid))
    for k in keys:
        print(f'  {k}={get_env_from_db(k,\"\")!r}')
# global-ish
for k in ('ORDERBOOK_FILTER_ENABLED','WHIPSAW_FILTER_ENABLED','LIVE_UNIVERSE_SLOT_ALIGN'):
    print(k, get_env_from_db(k,''))
PY

Read DB orderbook/whipsaw flags per strategy


cd /home/hoon/kis_bot && python3 <<'PY'
from kis_trader.utils.env import get_env_from_db, invalidate_merged_env_cache
from kis_trader.engine.orderbook_filter import orderbook_filter_enabled
from kis_trader.engine.whipsaw_filter import whipsaw_filter_enabled
invalidate_merged_env_cache()
for sid in ('MOMENTUM','BREAKOUT','SCALP','TAIL','SHORT'):
    print('===', sid, '===')
    print('  호가필터함수', orderbook_filter_enabled(sid))
    print('  휩쏘필터함수', whipsaw_filter_enabled(sid))
    for suf in ('ORDERBOOK_FILTER_ENABLED','WHIPSAW_FILTER_ENABLED','EXIT_OB_ENABLED','STOP_OB_ENABLED'):
        k=f'{sid}_{suf}'
        print(f'  {k}={get_env_from_db(k, "")!r}')
print('전역 ORDERBOOK', get_env_from_db('ORDERBOOK_FILTER_ENABLED',''))
print('전역 WHIPSAW', get_env_from_db('WHIPSAW_FILTER_ENABLED',''))
PY

Read DB filter flags with correct quoting


Write

#!/usr/bin/env python3
"""
scripts/test_live_execution_validation.py
=========================================
실매 코어 스모크 (주문·REST 연타 없음).

반드시 **DB에서 읽은 실제 ON/OFF** 를 먼저 찍고, 그 값에 맞춰 기대 동작을 검사한다.
가상 호가 주입은 「DB에서 필터 ON인 전략」에만 실매와 동일하게 적용한다.
(OFF 전략에 가짜 탈락을 찍지 않음 — 헷갈림 방지)

실행:
  python3 -u scripts/test_live_execution_validation.py
  # 로그 예: logs/test_live_execution_validation_*.log
"""
from __future__ import annotations

import logging
import sys
import threading
from pathlib import Path
from typing import Dict, List
from unittest.mock import MagicMock, patch

ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
    sys.path.insert(0, str(ROOT))

logging.basicConfig(level=logging.WARNING, format="[%(levelname)s] %(message)s")

전략목록 = ["MOMENTUM", "BREAKOUT", "SCALP", "TAIL"]
전략한글 = {
    "MOMENTUM": "모멘텀",
    "BREAKOUT": "돌파",
    "SCALP": "스캘핑",
    "TAIL": "꼬리(SHORT=TAIL키)",
}

def _성공(msg: str) -> None:
    print(f"    ✅ {msg}")

def _주의(msg: str) -> None:
    print(f"    ⚠ {msg}")

def _실패(msg: str) -> None:
    print(f"    ❌ {msg}")

def _전략표시(sid: str) -> str:
    return f"{전략한글.get(sid, sid)}[{sid}]"

def _db원문(키: str) -> str:
    from kis_trader.utils.env import get_env_from_db

    raw = get_env_from_db(키, "")
    if raw in (None, "", "None"):
        return "(비어있음→코드기본값)"
    return repr(str(raw))

def run_validation() -> bool:
    print("=" * 78)
    print("실매 엔진 검증 (DB 실제값 기준 · 호가/휩쏘/WS spill)")
    print("=" * 78)
    실패목록: List[str] = []

    # ── 1) 모듈 로드 ───────────────────────────────────────────────
    print("\n[1단계] 핵심 모듈 import")
    try:
        from database import TradeDB, ENV_CONFIG_KEYS
        from kis_trader.strategies.momentum import MomentumStrategy
        from kis_trader.strategies.breakout import BreakoutStrategy
        from kis_trader.engine.orderbook_filter import orderbook_reject_for_entry
        from kis_trader.engine.orderbook_env import (
            load_orderbook_threshold_cfg,
            orderbook_filter_enabled,
        )
        from kis_trader.engine.whipsaw_filter import whipsaw_filter_enabled
        from kis_trader.ws.orderbook_cache import OrderbookSnapshot, OrderbookLevel
        from kis_trader.engine.momentum_hts_logic import need_ob_or_history
        from kis_trader.network.ws_manager import WSManager
        from kis_trader.ws.kis_ws import KISWebSocketPriceCache
        from kis_trader.execution.order_manager import OrderManager
        from kis_trader.utils.env import get_env_bool, get_env_from_db, invalidate_merged_env_cache
        from kis_trader.utils.live_portfolio_common import (
            live_universe_slot_align_enabled,
            resolve_live_universe_history_source,
        )
        from kis_trader.web.live_config_schema import build_live_config_groups
        import kis_trader.engine.trigger_eval_collect as 트리거수집
        import inspect

        invalidate_merged_env_cache()
        _성공("전략/호가/휩쏘/WSManager/체결매니저 로드 OK")
    except Exception as exc:
        _실패(f"import 실패: {exc}")
        return False

    db = TradeDB()

    # ── 2) DB 진입필터 ON/OFF 표 (핵심) ─────────────────────────────
    print("\n[2단계] DB에 저장된 「진입」필터 스위치 (이게 실매 기준)")
    print("        · 진입호가필터 = {전략}_ORDERBOOK_FILTER_ENABLED")
    print("        · 진입휩쏘필터 = {전략}_WHIPSAW_FILTER_ENABLED")
    print("        · 청산호가(EXIT/STOP)는 「진입」과 별개 — 아래 3단계에서 표기")
    print("-" * 78)

    진입호가_켜진전략: List[str] = []
    for sid in 전략목록:
        호가키 = f"{sid}_ORDERBOOK_FILTER_ENABLED"
        휩쏘키 = f"{sid}_WHIPSAW_FILTER_ENABLED"
        호가_db = _db원문(호가키)
        휩쏘_db = _db원문(휩쏘키)
        호가_실매적용 = orderbook_filter_enabled(sid)
        휩쏘_실매적용 = whipsaw_filter_enabled(sid)
        임계 = load_orderbook_threshold_cfg(sid, {})

        if 호가_실매적용:
            진입호가_켜진전략.append(sid)

        print(f"  {_전략표시(sid)}")
        print(
            f"    진입 호가필터: {'켜짐(ON)' if 호가_실매적용 else '꺼짐(OFF)'} "
            f"| DB {호가키}={호가_db}"
        )
        print(
            f"    진입 휩쏘필터: {'켜짐(ON)' if 휩쏘_실매적용 else '꺼짐(OFF)'} "
            f"| DB {휩쏘키}={휩쏘_db}"
        )
        if 호가_실매적용:
            print(
                f"    호가 임계(DB): 스프레드상한={임계.get('max_spread_pct')}% "
                f"| 잔량비하한={임계.get('min_bid_ask_ratio')}"
            )
        else:
            print("    → 실매 진입 시 호가 탈락검사 안 함 (스냅이 있어도 통과)")

    print("-" * 78)
    print(
        f"  요약: 진입호가필터 ON 전략 = "
        f"{', '.join(_전략표시(s) for s in 진입호가_켜진전략) or '(없음 — 전부 OFF)'}"
    )
    print(
        f"  참고 전역키(전략키 없을 때 안 씀/호가는 전략키만): "
        f"ORDERBOOK_FILTER_ENABLED={_db원문('ORDERBOOK_FILTER_ENABLED')} "
        f"| WHIPSAW_FILTER_ENABLED={_db원문('WHIPSAW_FILTER_ENABLED')}"
    )

    # ── 3) 청산 호가컷 (진입과 분리) ────────────────────────────────
    print("\n[3단계] 「청산」호가컷 DB (진입필터와 다름)")
    try:
        돌파 = BreakoutStrategy(db=db, client=None, ws=None, order_mgr=None)
        print(
            f"  {_전략표시('BREAKOUT')} "
            f"익절호가매도={'ON' if getattr(돌파, 'exit_ob_enabled', False) else 'OFF'} "
            f"(DB {_db원문('BREAKOUT_EXIT_OB_ENABLED')}) | "
            f"손절호가={'ON' if getattr(돌파, 'stop_ob_enabled', False) else 'OFF'} "
            f"(DB {_db원문('BREAKOUT_STOP_OB_ENABLED')})"
        )
        mom_exit = get_env_bool("MOMENTUM_EXIT_OB_ENABLED", False)
        mom_stop = get_env_bool("MOMENTUM_STOP_OB_ENABLED", False)
        print(
            f"  {_전략표시('MOMENTUM')} "
            f"익절호가매도={'ON' if mom_exit else 'OFF'} "
            f"(DB {_db원문('MOMENTUM_EXIT_OB_ENABLED')}) | "
            f"손절호가={'ON' if mom_stop else 'OFF'} "
            f"(DB {_db원문('MOMENTUM_STOP_OB_ENABLED')}) | "
            f"호가추적필요={need_ob_or_history({'exit_ob_enabled': mom_exit, 'stop_ob_enabled': mom_stop})}"
        )
        _성공("청산 호가컷은 진입 호가필터와 독립적으로 DB 값을 읽음")
    except Exception as exc:
        _실패(f"청산 호가컷 검증 오류: {exc}")
        return False

    # ── 4) 가상호가 시뮬 — DB ON인 전략만 ────────────────────────────────
    print("\n[4단계] 가상 호가 주입 시뮬 (DB에서 진입호가필터 ON인 전략만)")
    print("        우량=스프레드 좁고 매수잔량 우세 | 위험=스프레드 과다")
    print("        ※ 필터 OFF 전략은 「실매와 같이 탈락 안 함」만 확인하고 가짜 차단 로그를 안 찍음")

    def _가짜호가(스프레드퍼센트: float, 매수잔량: int, 매도잔량: int) -> OrderbookSnapshot:
        현재가 = 10000
        매도1 = int(현재가 + (현재가 * 스프레드퍼센트 / 100.0))
        매수1 = int(현재가)
        매수호가 = [
            OrderbookLevel(price=매수1 - i * 10, qty=int(매수잔량 / 10))
            for i in range(10)
        ]
        매도호가 = [
            OrderbookLevel(price=매도1 + i * 10, qty=int(매도잔량 / 10))
            for i in range(10)
        ]
        return OrderbookSnapshot(
            code="005930",
            bids=매수호가,
            asks=매도호가,
            total_bid_qty=매수잔량,
            total_ask_qty=매도잔량,
            ts=1234567890.0,
            source="kiwoom_0d",
        )

    우량호가 = _가짜호가(0.1, 6000, 2000)
    위험호가 = _가짜호가(3.5, 500, 50000)

    for sid in 전략목록:
        필터켜짐 = orderbook_filter_enabled(sid)
        if not 필터켜짐:
            # OFF면 엔진에 스냅을 넣어도 실매 경로와 같이 탈락코드 없음이 정상
            탈락코드, _ = orderbook_reject_for_entry(
                {
                    "_backtest_orderbook_snapshot": 위험호가,
                    "slot_money": 10_000_000,
                },
                sid,
                current_price=10000.0,
            )
            if 탈락코드:
                실패목록.append(f"{sid}: 필터 OFF인데 탈락코드={탈락코드}")
                _실패(
                    f"{_전략표시(sid)} DB=OFF 인데도 탈락({탈락코드}) — 버그 가능"
                )
            else:
                _성공(
                    f"{_전략표시(sid)} 진입호가필터 OFF → 위험호가를 넣어도 탈락 없음(실매와 동일)"
                )
            continue

        우량탈락, _ = orderbook_reject_for_entry(
            {"_backtest_orderbook_snapshot": 우량호가, "slot_money": 10_000_000},
            sid,
            current_price=10000.0,
        )
        위험탈락, 위험사유 = orderbook_reject_for_entry(
            {"_backtest_orderbook_snapshot": 위험호가, "slot_money": 10_000_000},
            sid,
            current_price=10000.0,
        )
        if 우량탈락:
            실패목록.append(f"{sid}: 우량호가 탈락 {우량탈락}")
            _실패(f"{_전략표시(sid)} 우량호가인데 탈락: {우량탈락}")
        else:
            _성공(f"{_전략표시(sid)} 우량호가 → 통과")
        if not 위험탈락:
            실패목록.append(f"{sid}: 위험호가 미차단")
            _실패(f"{_전략표시(sid)} 위험호가 미차단 (필터 ON인데 통과함)")
        else:
            _성공(f"{_전략표시(sid)} 위험호가 → 차단 ({위험탈락}: {위험사유})")

    # ── 5) WS 단절 · ALIGN (DB) ─────────────────────────────────────
    print("\n[5단계] 호가스냅 없음(None) · 유니버스 ALIGN (DB)")
    print("        hist = ALIGN 교집합용 이력테이블 (시세 LS WS 와 무관)")
    print("        kiwoom→target_candidates_history / ls→ls_candidates_history")

    for sid in 전략목록:
        탈락, _ = orderbook_reject_for_entry(
            {"_backtest_orderbook_snapshot": None, "slot_money": 10_000_000},
            sid,
            current_price=10000.0,
        )
        if 탈락 is None:
            동작 = "스냅 없으면 호가탈락 스킵(Fail-Open)"
        else:
            동작 = f"스냅 없어도 차단({탈락})"
        print(f"  {_전략표시(sid)} 스냅None → {동작}")

    for sid in 전략목록:
        align_on = live_universe_slot_align_enabled(sid)
        hist = resolve_live_universe_history_source(sid)  # DB UNIVERSE_SOURCE
        univ = (get_env_from_db(f"{sid}_UNIVERSE_SOURCE", "") or "").strip() or "(기본)"
        print(
            f"  {_전략표시(sid)} ALIGN={'ON' if align_on else 'OFF'} "
            f"| 유니버스소스={univ} | 이력테이블쪽={hist}"
        )

    # ── 6) 수집 스위치 (필터와 분리) ────────────────────────────────
    print("\n[6단계] 호가/프로그램 「수집」스위치 (진입필터와 별개 · DB 연동만 확인)")
    with patch(
        "kis_trader.engine.trigger_eval_collect.get_env_bool"
    ) as mock_bool, patch(
        "kis_trader.engine.trigger_eval_collect.get_env_from_db", return_value=""
    ), patch(
        "kis_trader.engine.trigger_eval_collect.trigger_eval_save_master_enabled",
        return_value=True,
    ):
        mock_bool.return_value = True
        if not 트리거수집.orderbook_collect_enabled({}, "MOMENTUM"):
            실패목록.append("수집스위치 ON 무시")
            _실패("호가수집이 DB ON을 무시함")
        mock_bool.return_value = False
        if 트리거수집.orderbook_collect_enabled({}, "MOMENTUM"):
            실패목록.append("수집스위치 OFF 무시")
            _실패("호가수집이 DB OFF를 무시함")
        else:
            _성공("수집 스위치가 get_env_bool 따라감 (하드코딩 없음)")

    # ── 7) WS spill ────────────────────────────────────────────────
    print("\n[7단계] 구독 spill (한도초과→다음 증권사, sleep 없음)")
    try:
        src = inspect.getsource(KISWebSocketPriceCache.subscribe)
        if "return False" not in src or "return True" not in src:
            실패목록.append("kis subscribe bool 아님")
            _실패("kis_ws.subscribe bool 반환 패턴 없음")
        else:
            _성공("한투(kis) subscribe → 성공/한도초과 bool")

        class _가짜DB:
            pass

        허브 = WSManager(db=_가짜DB(), kis_client=MagicMock())
        틱체인 = 허브._build_subscribe_chain("tick")
        호가체인 = 허브._build_subscribe_chain("ob")
        if not 틱체인 or 틱체인[-1] != "ls":
            실패목록.append(f"틱체인 말단 ls 아님: {틱체인}")
            _실패(f"틱 spill 체인 이상: {틱체인}")
        else:
            _성공(f"틱 구독 우선순위={틱체인} (마지막=3차 LS)")
        if not 호가체인 or 호가체인[-1] != "ls":
            실패목록.append(f"호가체인 말단 ls 아님: {호가체인}")
            _실패(f"호가 spill 체인 이상: {호가체인}")
        else:
            _성공(f"호가 구독 우선순위={호가체인}")

        # 한투 한도 → LS
        한투 = MagicMock()
        한투.subscribe = MagicMock(return_value=False)
        한투.is_active = True
        한투._subscribed = set()
        한투._sub_lock = threading.Lock()
        허브.ws_cache = 한투
        허브._kiwoom_ws = None
        엘에스 = MagicMock()
        엘에스.is_connected = MagicMock(return_value=True)
        엘에스.subscribe = MagicMock(return_value=True)
        엘에스._subscribed = set()
        엘에스._us_subscribed = set()
        엘에스._sub_lock = threading.Lock()
        허브._get_ls_ws = MagicMock(return_value=엘에스)
        home = 허브._subscribe_tick_prefer_or_spill("005930", "kis")
        라벨 = 허브.get_tick_feed_label("005930")
        if home != "ls" or "spill" not in 라벨:
            실패목록.append(f"한투→LS spill 실패 {home}/{라벨}")
            _실패(f"한투 한도 spill 실패: {home} / {라벨}")
        else:
            _성공(f"한투 한도초과 → 즉시 LS 이관 (시세라벨={라벨})")

        # 키움 한도 → 한투
        허브2 = WSManager(db=_가짜DB(), kis_client=MagicMock())
        키움 = MagicMock()
        키움.subscribe = MagicMock(return_value=False)
        키움.is_connected = MagicMock(return_value=True)
        키움._subscribed = set()
        키움._sub_lock = threading.Lock()
        한투2 = MagicMock()
        한투2.subscribe = MagicMock(return_value=True)
        한투2.is_active = True
        한투2._subscribed = set()
        한투2._sub_lock = threading.Lock()
        허브2._kiwoom_ws = 키움
        허브2.ws_cache = 한투2
        허브2._get_ls_ws = MagicMock(return_value=None)
        home2 = 허브2._subscribe_tick_prefer_or_spill("000660", "kiwoom")
        라벨2 = 허브2.get_tick_feed_label("000660")
        if home2 != "kis" or 라벨2 != "kis(spill)":
            실패목록.append(f"키움→한투 spill 실패 {home2}/{라벨2}")
            _실패(f"키움 한도 spill 실패: {home2}/{라벨2}")
        else:
            _성공(f"키움 한도초과 → 즉시 한투 이관 (시세라벨={라벨2})")

        주문 = OrderManager(client=MagicMock(), db=MagicMock())
        주문.tick_feed_label_provider = 허브2.get_tick_feed_label
        mm줄 = 주문._tick_feed_mm_line("000660")
        if "시세:" not in mm줄:
            실패목록.append(f"MM 시세줄 이상 {mm줄}")
            _실패(f"매터모스트 시세 표기 이상: {mm줄}")
        else:
            _성공(f"체결 알림 시세 한 줄 예: {mm줄}")

        허브3 = WSManager(db=_가짜DB(), kis_client=MagicMock())
        한투3 = MagicMock()
        한투3.subscribe = MagicMock(return_value=False)
        한투3.is_active = True
        허브3.ws_cache = 한투3
        허브3._kiwoom_ws = None
        허브3._get_ls_ws = MagicMock(return_value=엘에스)
        허브3._subscribe_spill_enabled = lambda: False  # type: ignore[method-assign]
        home3 = 허브3._subscribe_tick_prefer_or_spill("035420", "kis")
        if home3 is not None:
            실패목록.append("spill OFF인데 이관됨")
            _실패(f"WS_SUBSCRIBE_SPILL 꺼도 이관됨: {home3}")
        else:
            _성공("구독 spill 스위치 OFF → 한도 시 거절(예전과 동일)")
    except Exception as exc:
        _실패(f"spill 검증 오류: {exc}")
        import traceback

        traceback.print_exc()
        return False

    # ── 8) env / 운영설정 UI ───────────────────────────────────────
    print("\n[8단계] 운영설정·ENV 키 (LS WS / spill)")
    for 키 in (
        "LS_WS_ENABLED",
        "WS_SUBSCRIBE_SPILL",
        "WS_TICK_SUBSCRIBE_CHAIN",
        "WS_OB_SUBSCRIBE_CHAIN",
    ):
        if 키 not in ENV_CONFIG_KEYS:
            실패목록.append(f"ENV 누락 {키}")
            _실패(f"database.ENV_CONFIG_KEYS 에 {키} 없음")
        else:
            _성공(f"ENV 등록됨: {키} (현재값={_db원문(키)})")

    print(
        f"  현재 실매: 구독spill={'ON' if get_env_bool('WS_SUBSCRIBE_SPILL', True) else 'OFF'} | "
        f"LS WS기동={'ON' if get_env_bool('LS_WS_ENABLED', False) else 'OFF'}"
    )
    시세섹션 = next(
        (g for g in build_live_config_groups() if g.get("id") == "ws_feed_settings"),
        None,
    )
    if not 시세섹션:
        실패목록.append("운영설정 시세섹션 없음")
        _실패("운영설정「시세 소스」섹션 없음")
    else:
        ui키들 = {f.get("key") for f in 시세섹션.get("fields") or []}
        for need in ("LS_WS_ENABLED", "WS_SUBSCRIBE_SPILL"):
            if need not in ui키들:
                실패목록.append(f"UI 누락 {need}")
                _실패(f"운영설정 UI에 {need} 없음")
            else:
                _성공(f"운영설정 UI 필드 있음: {need}")

    # ── 최종 ───────────────────────────────────────────────────────
    print("\n" + "=" * 78)
    if 실패목록:
        print(f"최종: 실패 {len(실패목록)}건")
        for x in 실패목록:
            print(f"  - {x}")
        print("=" * 78)
        return False
    print("최종: 통과 (위 DB ON/OFF 표기가 실매 기준과 일치하는지 함께 확인)")
    print("=" * 78)
    return True

if __name__ == "__main__":
    ok = run_validation()
    sys.exit(0 if ok else 1)


Shell

cd /home/hoon/kis_bot && python3 -u scripts/test_live_execution_validation.py 2>&1 | tee logs/test_live_execution_validation_readable.log | rg -v '^\[WARNING\]|^\[INFO\]' | head -100

Run rewritten DB-driven validation; show clear output


그 로그는 **「DB에 진입 호가필터가 켜져 있으면」** 가짜 호가로 탈락이 도는지 보는 시뮬이었습니다. 휩쏘랑은 무관하고, 꺼져 있으면 원래 그런 차단 로그가 나오면 안 됩니다.

지금 DB를 다시 찍어 보니:

- **휩쏘 진입필터**: 전략별 **OFF** (말씀하신 대로)
- **호가 진입필터**: `MOMENTUM/BREAKOUT/SCALP/TAIL_ORDERBOOK_FILTER_ENABLED='true'` → **아직 ON**
- 전역 `ORDERBOOK_FILTER_ENABLED=false` 만 꺼져 있고, **실매는 전략별 키**를 봅니다

그래서 4단계가 “위험호가 차단”을 찍은 겁니다. UI에서 끈 줄 아셨다면 전략별 키가 안 바뀐 상태일 수 있어요.

테스트는 이렇게 고쳤습니다.

- 2단계에서 **DB 원문 + ON/OFF**를 한글로 먼저 표
- 4단계는 **DB에서 진입호가 ON인 전략만** 시뮬, OFF면 “위험호가 넣어도 탈락 없음”
- 진입 / 청산호가 / 휩쏘 / 수집을 문장으로 구분

다시 돌린 로그: `logs/test_live_execution_validation_readable.log`


<timestamp>Thursday, Aug 13, 2026, 4:02 AM (UTC+9)</timestamp>
<user_query>
MOMENTUM_ORDERBOOK_FILTER_ENABLED 이것들은 전략별로 왜 다 켜놨을까 내가 이게 켠게 기억이 난다 옵투나가 아주 잘 나왔었다 근데 웹 ui에 진입도 표시가 되었었나 내가 또 기억이 잘 안나네 켠건 기억해냈다 내가 필요에 의해서
뭐가 두개전략이 달랐었는데 호가 관련해서 묶음이
</user_query>


옵투나 적용·웹 UI에 진입 호가필터가 어떻게 묶여 있는지 확인합니다.


기억하신 그림이 맞습니다.

### 왜 전략마다 `*_ORDERBOOK_FILTER_ENABLED`가 켜져 있나
코드 기본값은 **OFF**인데, DB에 `true`로 남아 있는 건 **예전에 켠 값**입니다.
옵투나/호가 후처리 추천을 적용하면 `build_orderbook_env_patch`가
`{전략}_ORDERBOOK_FILTER_ENABLED=true` 로 넣는 경로도 있습니다.
“옵투나 잘 나와서 켰다”는 기억과 잘 맞습니다.

### 웹 UI에 진입 표시 있었나 → **있음**
**운영 설정** → **「🚫 진입 호가필터 (매수 차단) — 전략별」**
모멘텀/돌파/스캘핑/꼬리 각각
`○○ 진입 호가필터 ON (매수 차단)` 토글이 있습니다.

백테 탭에도 `호가필터 ON` / `호가필터(백테)` 체크가 따로 있습니다 (그날 백테용).

### “호가 관련 묶음이 전략 두 갈래” → 이거입니다

| 묶음 | 무엇인가 | 전략 |
|------|----------|------|
| **진입 호가필터** | 매수 직전에 스프레드·잔량으로 **차단** | 모멘텀·돌파·스캘핑·꼬리 **전부** |
| **매도 호가익절/손절 (EXIT_OB/STOP_OB)** | 보유 중 청산 | **모멘텀·돌파만** (꼬리·스캘프 카드 없음) |

그래서 “호가는 다 같이 있는 것 같은데, 매도 호가컷은 두 전략만 있다”는 느낌이 납니다.
진입 필터를 꺼도 EXIT_OB는 따로 살아 있습니다 (지금 모멘텀/돌파 EXIT/STOP도 ON).

휩쏘는 또 다른 묶음이고, 지금 DB는 휩쏘 OFF · 진입호가는 ON 상태입니다.


<timestamp>Thursday, Aug 13, 2026, 4:04 AM (UTC+9)</timestamp>
<user_query>
옵투나 결과표에도 기본 진입 매도 이렇게 표로 정리가 되어 나오게 했던거같은데 결과에 웹 옵투나는 tpe full인가?
</user_query>


웹 Optuna 기본 mode와 결과표(진입/매도) 표시를 확인합니다.


python3 - <<'PY'
import json
from pathlib import Path
p=Path('/home/hoon/kis_bot/kis_trader/backtest/results/optuna_breakout_tpe_20260813_010648.json')
d=json.loads(p.read_text())
print('keys top:', sorted(d.keys())[:40])
print('strategy', d.get('strategy'), 'mode', d.get('mode'))
ob=d.get('orderbook_recommend')
print('orderbook_recommend:', type(ob), (list(ob.keys()) if isinstance(ob,dict) else ob)[:20] if isinstance(ob,dict) else ob)
if isinstance(ob,dict):
  print('ok', ob.get('ok'), 'params', ob.get('params'))
  print('orig', ob.get('orig_stats'), 'rec', ob.get('recommended_stats'))
ws=d.get('whipsaw_recommend')
print('whipsaw', type(ws), (ws.get('ok') if isinstance(ws,dict) else ws))
mc=d.get('mode_combo')
if isinstance(mc,dict):
  print('mode_combo keys', list(mc.keys())[:20])
  print('mc has orderbook', 'orderbook_recommend' in mc)
PY

Inspect Optuna JSON orderbook fields


Grep

진입.*매도|호가 수급|orderbook_recommend|기본 진입|tpe full|mode.*tpe


**웹 Optuna는 `tpe`입니다. `full`이 아닙니다.**

모드 입력은 읽기 전용으로 `tpe` 고정이고, 잡도 `--mode tpe`로 뜹니다. (`full`은 CLI Grid 모드 중 하나)

---

기억하시는 **「기본 / 호가진입 / 호가매도」표**는 웹 Optuna 탭에 박혀 있는 UI가 아닙니다.
지난번에 CLI/대화에서 Optuna 끝난 뒤 **3-way를 따로 돌려 채팅에 정리한 표**였습니다.

웹에 나오는 결과표는 대략 이것뿐입니다.

| 웹에 있음 | 웹에 없음 |
|-----------|-----------|
| 학습1위 / gated / stable / mode_combo 비교 | 기본·호가진입·호가매도 3-way |
| Top5 gated·stable | `orderbook_recommend` 표 (JSON에는 붙을 수 있음) |
| 일일 트레일 추천 | |

참고: JSON에 `orderbook_recommend`가 있어도 웹 summary에 안 넘기고, 최근 돌파 `tpe` 결과는 호가 후처리가 `ok: false`라 추천 수치도 비어 있습니다.

원하시면 웹 Optuna 결과에 **진입/매도 호가 추천·3-way 표**를 붙이는 설계만 먼저 짧게 보고드릴 수 있습니다.


<timestamp>Thursday, Aug 13, 2026, 4:09 AM (UTC+9)</timestamp>
<user_query>
지금 후처리가 3개니까 3가지 경우가 다 표로 정리되어 나와야되겠는데 기본 호가진입 매도익절 매도손절 이렇게?
설계안을 줘보지 설계할게잇나 이게 표만 그리면 되는거 아냐?
</user_query>


후처리 3종이 실제로 무엇인지 코드 기준으로 짧게 확인한 뒤, 표만으로 되는지·설계가 필요한지를 정리하겠습니다.


**표만 그리면 되는 게 아닙니다.**
웹에 그릴 숫자 행이 JSON에 아직 없기 때문입니다. “후처리 3개”와 “호가 ON/OFF 시나리오”도 서로 다른 축입니다.

---

### 1) 지금 후처리 3개 ≠ 기본·진입·매도 표

| 후처리 | 하는 일 | 웹에 이미 |
|--------|---------|-----------|
| `daily_trail_recommend` | 다단트레일 ARM/TIERS 추천 | 문구로 있음 |
| `orderbook_recommend` | 진입 스프레드·잔량 + **EXIT_OB(익절)** 합의 | JSON만, 표 없음 |
| `whipsaw_recommend` | 휩쏘 수치 추천 | JSON만, 표 없음 |

→ 후처리 3개를 “표로 정리”하면 **추천값·통계 카드 3장**이지,
**기본 / 호가진입 / 매도익절 / 매도손절** 비교표가 아닙니다.

지난번 채팅표는 Optuna 끝난 뒤 **같은 파라미터로 백테를 추가로 돌린 결과**였습니다.

---

### 2) 말씀하신 표는 “호가 시나리오 비교” (별도 축)

제안 행:

| 행 | 의미 |
|----|------|
| 기본 | 진입·EXIT·STOP 전부 OFF (탐색과 동일·`--orderbook-filter off`) |
| 호가진입 | `*_ORDERBOOK_FILTER_ENABLED` ON + 추천/고정 스프레드·잔량 |
| 매도익절 | EXIT_OB ON (+ 추천 임계) · 진입은 OFF 유지가 보통 |
| 매도손절 | STOP_OB ON · 진입 OFF |

여기는 **설계 포인트가 몇 개 있습니다.**

1. **조합**
   - A안: 위 4행 = **한 스위치만** 켠 단독 효과 (읽기 쉬움)
   - B안: 기본 + 진입만 + 익절만 + 손절만 + (선택) 진입+익절+손절 풀세트
   - “표만”이면 A가 맞고, 풀세트는 행이 늘어남.

2. **매도손절은 지금 후처리에 없음**
   `orderbook_recommend`는 진입 + EXIT_OB만 넣습니다. STOP_OB 합의·패치 없음.
   → 손절 행을 넣으려면 **수치 소스**(DB 현재값 / 고정 그리드 / 신규 recommend)를 정해야 함.

3. **전략별 해당 여부**
   EXIT/STOP_OB는 **모멘텀·돌파만**. 꼬리·스캘프는 익절/손절 행 = `해당없음`.

4. **앵커 파라미터**
   gated1위 vs mode_combo 실측 — 어느 쪽 기준으로 4행을 돌릴지.

5. **언제 돌리나**
   Optuna 종료 직후 엔진에서 4회 백테 → JSON에 박기 (웹은 읽기만).
   웹 클릭 시 돌리면 느리고 API 부하.

6. **휩쏘·트레일**
   같은 표에 넣지 말고, **옆 카드**(추천 ON/OFF·수치·적용여부)로 분리하는 게 맞음.
   한 표에 섞으면 “후처리 3개”와 “호가 4행”이 헷갈림.

---

### 3) 현실적인 설계안 (추천)

**표 A — 호가 시나리오 (모멘텀/돌파만, Optuna 후 자동 실측)**
열: 구분 | 거래 | WR | PF | PnL | vs기본
행: 기본 / 호가진입 / 매도익절 / 매도손절
(원하면 5행: 진입+익절+손절 풀세트)

**카드 B — 후처리 추천 3종 (이미 있는 JSON 표시)**
트레일 / 호가수급합의 / 휩쏘 — ok·수치·적용 가능 여부만.

웹 작업량: summary에 필드 전달 + 표·카드 렌더.
무거운 쪽: **A용 4회(또는 5회) 실측을 Optuna 파이프라인에 넣는 것** + STOP 수치 소스 결정.

---

### 한 줄

- **후처리 3개 표출** → 거의 표/카드만 (숫자는 이미 JSON).
- **기본·진입·익절·손절** → 표 UI만이 아니라 **시나리오 실측 + 손절 수치 소스** 설계가 필요.

원하시면 다음으로 **A안 4행 단독 / 앵커=gated1위 / STOP=현재 DB값**으로 고정할지 승인만 주시면, 그에 맞춰 구현 범위(실매 영향 없음·웹+JSON만) 잡고 들어가겠습니다.


<timestamp>Thursday, Aug 13, 2026, 4:15 AM (UTC+9)</timestamp>
<user_query>
후처리에 손절쪽이 없었다고? 그럼 이전에 뭘 보고 디비에 넣었지 이거 참~ ide에서 적용시켜달라한건가 표 보고
</user_query>


DB 적용 경로에 손절(STOP_OB)이 실제로 들어갔는지, 후처리·Optuna apply·웹 적용 중 어디 경로인지 확인합니다.


Shell

cd /home/hoon/kis_bot && python3 - <<'PY'
from database import TradeDB
from kis_trader.utils.env import invalidate_merged_env_cache, get_env_from_db
invalidate_merged_env_cache()
db = TradeDB()
keys = [
  "MOMENTUM_ORDERBOOK_FILTER_ENABLED","MOMENTUM_EXIT_OB_ENABLED","MOMENTUM_STOP_OB_ENABLED",
  "BREAKOUT_ORDERBOOK_FILTER_ENABLED","BREAKOUT_EXIT_OB_ENABLED","BREAKOUT_STOP_OB_ENABLED",
  "MOMENTUM_EXIT_OB_RATIO_MIN","MOMENTUM_STOP_OB_RATIO_MIN",
  "BREAKOUT_EXIT_OB_RATIO_MIN","BREAKOUT_STOP_OB_RATIO_MIN",
]
for k in keys:
    print(f"{k}={get_env_from_db(k)!r}")
# raw latest row snippets
for tbl, cols in [
  ("config_momentum", ["MOMENTUM_EXIT_OB_ENABLED","MOMENTUM_STOP_OB_ENABLED","MOMENTUM_ORDERBOOK_FILTER_ENABLED","updated_at","id"]),
  ("config_breakout", ["BREAKOUT_EXIT_OB_ENABLED","BREAKOUT_STOP_OB_ENABLED","BREAKOUT_ORDERBOOK_FILTER_ENABLED","updated_at","id"]),
]:
    cols_exist = [r["Field"] for r in db.conn.execute(f"SHOW COLUMNS FROM {tbl}").fetchall()]
    use = [c for c in cols if c in cols_exist]
    if not use:
        print(tbl, "no cols"); continue
    row = db.conn.execute(f"SELECT {', '.join(use)} FROM {tbl} ORDER BY id DESC LIMIT 1").fetchone()
    print(tbl, dict(row) if row else None)
db.close()
PY

Read current EXIT/STOP OB DB values


맞습니다. **손절(STOP_OB)은 Optuna 후처리 표를 보고 DB에 들어간 게 아닙니다.**

### 후처리가 실제로 DB에 넣을 수 있는 것
`orderbook_recommend` → `build_orderbook_env_patch`는 이것만 패치합니다.

- 진입: `*_ORDERBOOK_FILTER_ENABLED` + 스프레드/잔량비
- 익절: `*_EXIT_OB_*` (모멘텀·돌파만)
- **손절 `*_STOP_OB_*` → 코드에 없음**

웹 Optuna「적용」도 후보 파라미터 + 다단트레일 쪽이고, STOP 합의를 넣지 않습니다.

### 그럼 DB의 STOP은 어디서?
**8/12 IDE에서 직접 켠 것**입니다.
그날 “꺼져 있으니 EXIT/STOP 둘 다 켜줘” 요청 → 에이전트가 모멘텀·돌파 `EXIT_OB`/`STOP_OB`를 `true`로 DB에 씀.
Optuna 결과표·3-way 표와 무관합니다. (8/10경에도 EXIT는 켜져 있었고, 당시 STOP은 꺼져 있던 쪽으로 정리된 적 있음.)

지난번 채팅의 「기본 / 호가진입 / 호가매도」표는 **비교 설명용**이었고, 그 표로 STOP을 apply한 적도 없습니다.

### 지금 DB 상태 (참고)
모멘텀·돌파 모두 진입·EXIT·STOP **ON**.
익절 임계(예: EXIT ratio)는 숫자가 있는데, 모멘텀 `STOP_OB_RATIO_MIN`은 비어 있어 **“ON만 켠 상태”**에 가깝습니다.

정리하면: **후처리에 손절 축이 없었고, STOP은 IDE 수동 ON**입니다.


<timestamp>Thursday, Aug 13, 2026, 4:28 AM (UTC+9)</timestamp>
<user_query>
STOP_OB_RATIO_MIN 이건 뭔데?  내 기억에 두 전략 호가 관련 수치중에 2.5 인가 있었던거같은데 그래서 두 전략이 2점대라고 신기해했었는데
</user_query>


`STOP_OB_RATIO_MIN` 의미와, 기억하시는 2.x 수치가 어떤 키인지 DB·코드에서 대조합니다.

cd /home/hoon/kis_bot && python3 - <<'PY'
from kis_trader.utils.env import invalidate_merged_env_cache, get_env_from_db
invalidate_merged_env_cache()
keys = []
for pfx in ("MOMENTUM", "BREAKOUT"):
    for k in (
        "ORDERBOOK_FILTER_ENABLED","ORDERBOOK_MAX_SPREAD_PCT","ORDERBOOK_MIN_BID_ASK_RATIO",
        "EXIT_OB_ENABLED","EXIT_OB_RATIO_MIN","EXIT_OB_MA_WINDOW","EXIT_OB_MIN_PROFIT_PCT","EXIT_OB_MIN_HOLD_BARS",
        "STOP_OB_ENABLED","STOP_OB_RATIO_MIN","STOP_OB_MA_WINDOW","STOP_OB_MIN_LOSS_PCT","STOP_OB_MIN_HOLD_BARS",
    ):
        keys.append(f"{pfx}_{k}")
for k in keys:
    v = get_env_from_db(k)
    if v not in (None, ""):
        print(f"{k}={v}")
    else:
        print(f"{k}=(empty)")
PY

Dump OB-related env values both strategies


cd /home/hoon/kis_bot && python3 - <<'PY'
from kis_trader.utils.env import invalidate_merged_env_cache, get_env_from_db
invalidate_merged_env_cache()
for pfx in ("MOMENTUM","BREAKOUT"):
  for k in ("ORDERBOOK_ENTRY_ASK_MAX_MULT","ORDERBOOK_ENTRY_BID_DEPTH_MULT","ORDERBOOK_ENTRY_BID_LEVELS","ORDERBOOK_ASK_WALL_MAX_QTY"):
    print(f"{pfx}_{k}={get_env_from_db(f'{pfx}_{k}')!r}")
PY

Dump entry OB mult values both strats


Grep

스프레드≤|잔량비≥|호가 수급|2\.[0-9].*잔량|MIN_BID_ASK.*2


**`STOP_OB_RATIO_MIN`은 “2.5%” 같은 숫자가 아닙니다.**
손실 구간에서 **호가 OR(매수잔량/매도잔량)의 이동평균(OR_MA)이 이 값보다 작아지면** 손절호가 청산을 거는 임계값입니다. 기본·통상 범위는 **0.4 근처(비율 0~수)** 입니다.

쉬운 말로:
이미 조금 손해인 상태에서, 호가가 “매도 쪽이 세다”(OR이 낮아짐)고 판단되면 조기 손절.

같은 종류의 익절 쪽은 `EXIT_OB_RATIO_MIN` (지금 모멘텀 0.8, 돌파 0.64).

---

### 기억의 2.5 / 2점대는 이쪽일 가능성이 큼

후처리 로그에 나온 **진입 스프레드 상한(%)** 입니다. 예:

| 시점 | 모멘텀 | 돌파 |
|------|--------|------|
| 예전 합의 로그 | 스프레드 ≤ **3.24~3.42%** | ≤ **0.30~0.40%** |

“2.5”에 가깝게 기억하신 건 **모멘텀 스프레드 3%대** 쪽일 수 있고,
**두 전략이 둘 다 2점대**였던 기록은 이 로그에는 안 보입니다. (돌파는 줄곧 0.3대)

지금 DB도:

| | 모멘텀 | 돌파 |
|--|--------|------|
| 진입 스프레드 | **0.84%** | **0.3%** |
| `STOP_OB_RATIO_MIN` | **비어 있음 → 코드 기본 0.4** | **0.4** |

8/12에 STOP은 **ON만** 켰고, 손절 OR 임계는 합의로 안 넣어서 모멘텀 STOP 숫자는 비어 있습니다.

---

한 줄: **`STOP_OB_RATIO_MIN` = 손절용 OR_MA 문턱(≈0.4)** · **2.5는 거의 확실히 진입 스프레드(%) 쪽 기억**입니다.


<timestamp>Thursday, Aug 13, 2026, 4:33 AM (UTC+9)</timestamp>
<user_query>
글머 디비  env값 쭉 찾아서 2점대 찾아봐 뭐였는지
</user_query>


DB 병합 env에서 값이 2.0~2.99인 키를 전부 찾아봅니다.

Shell

cd /home/hoon/kis_bot && python3 - <<'PY'
from database import TradeDB
from kis_trader.utils.env import invalidate_merged_env_cache

invalidate_merged_env_cache()
db = TradeDB()
merged = db.get_merged_env_snapshot() or {}

hits = []
for k, v in merged.items():
    if v is None:
        continue
    s = str(v).strip()
    if not s:
        continue
    # plain float/int in [2, 3)
    try:
        # reject bool-like
        if s.lower() in ("true", "false", "on", "off", "1", "0"):
            continue
        f = float(s)
    except Exception:
        # maybe csv / tiers like 5.5:2.5
        if "2." in s or ":2" in s or ",2" in s:
            hits.append((k, s, "str_contains_2x"))
        continue
    if 2.0 <= f < 3.0:
        hits.append((k, s, f))

# also scan strategy config latest rows for numeric 2.x in any column
extra = []
for tbl in ("config_momentum", "config_breakout", "config_scalp", "config_short", "env_config"):
    try:
        cols = [r["Field"] for r in db.conn.execute(f"SHOW COLUMNS FROM {tbl}").fetchall()]
    except Exception as e:
        print(f"skip {tbl}: {e}")
        continue
    if "id" not in cols:
        continue
    row = db.conn.execute(f"SELECT * FROM {tbl} ORDER BY id DESC LIMIT 1").fetchone()
    if not row:
        continue
    for c in cols:
        if c in ("id", "created_at", "updated_at", "note", "comment"):
            continue
        v = row.get(c)
        if v is None:
            continue
        s = str(v).strip()
        if not s:
            continue
        try:
            if s.lower() in ("true", "false", "on", "off"):
                continue
            f = float(s)
        except Exception:
            if "2." in s:
                extra.append((tbl, c, s, "str"))
            continue
        if 2.0 <= f < 3.0:
            extra.append((tbl, c, s, f))

print("=== merged_env 2.0~2.99 ===")
for k, s, f in sorted(hits, key=lambda x: (str(x[0]),)):
    print(f"{k}={s}")

print("\n=== latest config_* / env_config columns 2.0~2.99 ===")
seen = set()
for item in extra:
    key = (item[0], item[1], item[2])
    if key in seen:
        continue
    seen.add(key)
    print(f"{item[0]}.{item[1]}={item[2]}")

# focus orderbook-ish keys even if outside 2.x for context
print("\n=== OB-ish keys (any value) ===")
for k in sorted(merged.keys()):
    ku = k.upper()
    if any(x in ku for x in ("ORDERBOOK", "EXIT_OB", "STOP_OB", "BID_ASK", "SPREAD", "WHIPSAW", "ASK_MAX", "BID_DEPTH")):
        print(f"{k}={merged.get(k)!r}")

db.close()
PY

Find DB env values in 2.0-2.99 range


DB 병합 env에서 **2.0~2.99**를 훑었습니다. 호가 쪽 “둘 다 2점대”로 가장 잘 맞는 건 아래입니다.

### 두 전략이 같이 2.5인 것 (이게 기억과 제일 맞음)

| 키 | 값 | 의미 |
|----|-----|------|
| `MOMENTUM_DAILY_PROFIT_TARGET_PCT` | **2.5** | 일일 수익 목표(%) |
| `BREAKOUT_DAILY_PROFIT_TARGET_PCT` | **2.5** | 동일 |

호가 필터 숫자가 아니라 **일일 수익 목표 %** 입니다. 둘 다 2.5라 “신기하다”고 하신 장면과 잘 맞습니다. (꼬리 `SHORT_DAILY_PROFIT_TARGET_PCT`도 2.5)

---

### 호가/청산 쪽에서 2.x가 보이는 것

| 키 | 값 | 비고 |
|----|-----|------|
| `BREAKOUT_RATCHET_TIERS` | **`5.5:2.5`** | 익절 래칫 구간(수익%:컷%). **2.5는 돌파만** |
| `MOMENTUM_RATCHET_TIERS` | `7:2` / 스냅엔 `5:2` | 모멘텀은 **2**이지 2.5 아님 |
| `BREAKOUT_EXIT/STOP_OB_MIN_HOLD_BARS` | **2** | 분 단위(정수 2) |
| `MOMENTUM/BREAKOUT_ORDERBOOK_ENTRY_ASK_MAX_MULT` | **3.0** | 둘 다 같지만 **3점대** |
| 진입 스프레드·잔량비 | 0.3 / 0.84, 0.56 / 0.73 | **2점대 아님** |
| `STOP_OB_RATIO_MIN` | 0.4 / 비어있음 | **2.5 아님** |

---

### 그 외 2점대 (호가·두 전략 공통 아님)

예: `TAIL_TARGET_ATR_MULT=2.5`, `UPDOWN_BOX_TP_PCT=2.5`, `MOMENTUM_VOL_MULT=2.0`, `BREAKOUT_ATR_SL_MULT` 계열, `gemini-2.5-flash` 등.

---

**결론:** 기억의 **둘 다 2.5**는 호가 합의값보다 **`*_DAILY_PROFIT_TARGET_PCT=2.5`(모멘텀·돌파)** 가능성이 큽니다. 호가에서 숫자 **2.5**가 박힌 건 **`BREAKOUT_RATCHET_TIERS`의 `:2.5`** 정도입니다.


<timestamp>Thursday, Aug 13, 2026, 4:41 AM (UTC+9)</timestamp>
<user_query>
아 아닌데 호가쪽인데 분명 0.25였나 env 히스토리 다 본거야?
</user_query>


맞습니다. 방금은 **현재 스냅샷만** 봤습니다. env/config 히스토리 테이블이 있는지 스키마부터 확인한 뒤, 호가 관련·0.2x 값을 추적합니다.

cd /home/hoon/kis_bot && python3 - <<'PY'
from database import TradeDB
db = TradeDB()
# find history-like tables
rows = db.conn.execute(
    "SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND "
    "(TABLE_NAME LIKE %s OR TABLE_NAME LIKE %s OR TABLE_NAME LIKE %s OR TABLE_NAME LIKE %s) ORDER BY TABLE_NAME",
    ("%hist%", "%history%", "%env%", "%config%"),
).fetchall()
print("tables:")
for r in rows:
    print(" ", r["TABLE_NAME"])
db.close()
PY

List env/config history-related tables


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

def cols(tbl):
    return [r["Field"] for r in db.conn.execute(f"SHOW COLUMNS FROM {tbl}").fetchall()]

for tbl in ("config_momentum", "config_breakout", "env_config", "env_config_ext"):
    c = cols(tbl)
    print(f"\n=== {tbl} ({len(c)} cols) count=", db.conn.execute(f"SELECT COUNT(*) c FROM {tbl}").fetchone()["c"])
    ob = [x for x in c if any(t in x.upper() for t in ("ORDERBOOK", "EXIT_OB", "STOP_OB", "BID_ASK", "SPREAD", "ASK_MAX", "BID_DEPTH", "WHIPSAW"))]
    print("  ob-ish cols:", ob[:40], ("..." if len(ob)>40 else ""))
    if "id" in c and "created_at" in c:
        r = db.conn.execute(f"SELECT MIN(id) a, MAX(id) b, MIN(created_at) c0, MAX(created_at) c1 FROM {tbl}").fetchone()
        print("  id/created:", dict(r))
    elif "env_key" in c:
        print("  sample keys:", [x["env_key"] for x in db.conn.execute("SELECT env_key FROM env_config_ext WHERE env_key LIKE %s OR env_key LIKE %s LIMIT 20", ("%ORDERBOOK%", "%EXIT_OB%")).fetchall()])

db.close()
PY

Schema and counts for config history tables


cd /home/hoon/kis_bot && python3 - <<'PY'
from collections import defaultdict, Counter
from database import TradeDB

db = TradeDB()

MOM_COLS = [
    "MOMENTUM_ORDERBOOK_MAX_SPREAD_PCT",
    "MOMENTUM_ORDERBOOK_MIN_BID_ASK_RATIO",
    "MOMENTUM_ORDERBOOK_ENTRY_ASK_MAX_MULT",
    "MOMENTUM_ORDERBOOK_ENTRY_BID_DEPTH_MULT",
    "MOMENTUM_EXIT_OB_RATIO_MIN",
    "MOMENTUM_EXIT_OB_MIN_PROFIT_PCT",
    "MOMENTUM_STOP_OB_RATIO_MIN",
    "MOMENTUM_STOP_OB_MIN_LOSS_PCT",
    "MOMENTUM_WHIPSAW_DIP_PCT",
    "MOMENTUM_WHIPSAW_RECOVERY_TOL_PCT",
]
BO_COLS = [
    "BREAKOUT_ORDERBOOK_MAX_SPREAD_PCT",
    "BREAKOUT_ORDERBOOK_MIN_BID_ASK_RATIO",
    "BREAKOUT_ORDERBOOK_ENTRY_ASK_MAX_MULT",
    "BREAKOUT_ORDERBOOK_ENTRY_BID_DEPTH_MULT",
    "BREAKOUT_EXIT_OB_RATIO_MIN",
    "BREAKOUT_EXIT_OB_MIN_PROFIT_PCT",
    "BREAKOUT_STOP_OB_RATIO_MIN",
    "BREAKOUT_STOP_OB_MIN_LOSS_PCT",
    "BREAKOUT_WHIPSAW_DIP_PCT",
    "BREAKOUT_WHIPSAW_RECOVERY_TOL_PCT",
]

def in_025_band(s):
    try:
        f = float(str(s).strip())
    except Exception:
        return False
    # 0.20~0.30 inclusive, or exactly 0.25
    return 0.20 <= f <= 0.30

def collect(tbl, cols, id_col="id"):
    # verify cols exist
    have = {r["Field"] for r in db.conn.execute(f"SHOW COLUMNS FROM {tbl}").fetchall()}
    use = [c for c in cols if c in have]
    time_col = "created_at" if "created_at" in have else None
    sel = [id_col] + ([time_col] if time_col else []) + use
    rows = db.conn.execute(f"SELECT {', '.join('`'+c+'`' for c in sel)} FROM {tbl} ORDER BY {id_col}").fetchall()
    by_col = defaultdict(Counter)
    samples = defaultdict(list)  # value -> list of (id, time)
    for r in rows:
        for c in use:
            v = r.get(c)
            if v is None or str(v).strip() == "":
                continue
            s = str(v).strip()
            by_col[c][s] += 1
            if in_025_band(s) or s in ("0.25", "0.2", "0.22", "0.23", "0.24", "0.26", "0.27", "0.28", "0.29", ".25"):
                if len(samples[(c, s)]) < 5:
                    samples[(c, s)].append((r[id_col], r.get(time_col) if time_col else None))
    return by_col, samples, len(rows)

print("######## config_momentum HISTORY (all rows) — unique OB values + 0.20~0.30 hits")
by, samples, n = collect("config_momentum", MOM_COLS)
print(f"rows={n}")
for c, ctr in by.items():
    # show top values
    items = ctr.most_common(12)
    print(f"\n{c}:")
    for v, cnt in items:
        mark = " <<0.2x" if in_025_band(v) else ""
        print(f"  {v!r} x{cnt}{mark}")
print("\n-- 0.20~0.30 sample rows --")
for (c, s), lst in sorted(samples.items()):
    print(f"{c}={s}: {lst}")

print("\n######## config_breakout HISTORY")
by, samples, n = collect("config_breakout", BO_COLS)
print(f"rows={n}")
for c, ctr in by.items():
    items = ctr.most_common(12)
    print(f"\n{c}:")
    for v, cnt in items:
        mark = " <<0.2x" if in_025_band(v) else ""
        print(f"  {v!r} x{cnt}{mark}")
print("\n-- 0.20~0.30 sample rows --")
for (c, s), lst in sorted(samples.items()):
    print(f"{c}={s}: {lst}")

# env_config global ORDERBOOK history 0.2x
print("\n######## env_config ORDERBOOK* history 0.20~0.30")
have = {r["Field"] for r in db.conn.execute("SHOW COLUMNS FROM env_config").fetchall()}
ecol = [c for c in [
    "ORDERBOOK_MAX_SPREAD_PCT","ORDERBOOK_MIN_BID_ASK_RATIO",
    "ORDERBOOK_ENTRY_ASK_MAX_MULT","ORDERBOOK_ENTRY_BID_DEPTH_MULT",
    "WHIPSAW_DIP_PCT","WHIPSAW_RECOVERY_TOL_PCT",
] if c in have]
rows = db.conn.execute(
    "SELECT id, created_at, " + ", ".join(f"`{c}`" for c in ecol) + " FROM env_config ORDER BY id"
).fetchall()
hits = []
for r in rows:
    for c in ecol:
        v = r.get(c)
        if v is not None and str(v).strip() != "" and in_025_band(v):
            hits.append((r["id"], r["created_at"], c, str(v).strip()))
# unique
seen = Counter((c, v) for _, _, c, v in hits)
print("value freq:", dict(seen))
print("first/last few hits:")
for h in hits[:8]:
    print(" ", h)
for h in hits[-8:]:
    print(" ", h)

# Find values that appeared in BOTH momentum and breakout around same time in 0.2x
print("\n######## BOTH strategies shared 0.20~0.30 OB values (any col)")
# collect set of (shortname, value) for mom/bo
def short(c):
    for p in ("MOMENTUM_", "BREAKOUT_"):
        if c.startswith(p):
            return c[len(p):]
    return c

mom_vals = defaultdict(set)
bo_vals = defaultdict(set)
for c, ctr in collect("config_momentum", MOM_COLS)[0].items():
    for v in ctr:
        if in_025_band(v):
            mom_vals[short(c)].add(v)
for c, ctr in collect("config_breakout", BO_COLS)[0].items():
    for v in ctr:
        if in_025_band(v):
            bo_vals[short(c)].add(v)
for k in sorted(set(mom_vals) | set(bo_vals)):
    inter = mom_vals.get(k, set()) & bo_vals.get(k, set())
    print(f"{k}: mom={sorted(mom_vals.get(k,[]))} bo={sorted(bo_vals.get(k,[]))} both={sorted(inter)}")

db.close()
PY

Scan config history for 0.20-0.30 OB values


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

# Exact 0.25 anywhere in mom/bo OB cols across history
print("=== exact 0.25 / near 0.25 in ALL history ===")
for tbl, cols in [
    ("config_momentum", [
        "MOMENTUM_ORDERBOOK_MAX_SPREAD_PCT","MOMENTUM_ORDERBOOK_MIN_BID_ASK_RATIO",
        "MOMENTUM_EXIT_OB_RATIO_MIN","MOMENTUM_EXIT_OB_MIN_PROFIT_PCT",
        "MOMENTUM_STOP_OB_RATIO_MIN","MOMENTUM_STOP_OB_MIN_LOSS_PCT",
        "MOMENTUM_WHIPSAW_DIP_PCT","MOMENTUM_ORDERBOOK_ENTRY_BID_DEPTH_MULT",
    ]),
    ("config_breakout", [
        "BREAKOUT_ORDERBOOK_MAX_SPREAD_PCT","BREAKOUT_ORDERBOOK_MIN_BID_ASK_RATIO",
        "BREAKOUT_EXIT_OB_RATIO_MIN","BREAKOUT_EXIT_OB_MIN_PROFIT_PCT",
        "BREAKOUT_STOP_OB_RATIO_MIN","BREAKOUT_STOP_OB_MIN_LOSS_PCT",
        "BREAKOUT_WHIPSAW_DIP_PCT","BREAKOUT_ORDERBOOK_ENTRY_BID_DEPTH_MULT",
    ]),
]:
    have = {r["Field"] for r in db.conn.execute(f"SHOW COLUMNS FROM {tbl}").fetchall()}
    use = [c for c in cols if c in have]
    for c in use:
        rows = db.conn.execute(
            f"SELECT id, created_at, `{c}` AS v FROM {tbl} WHERE `{c}` IS NOT NULL AND TRIM(`{c}`)<>'' "
            f"AND (`{c}`=%s OR `{c}`=%s OR `{c}`=%s OR `{c}` LIKE %s) ORDER BY id",
            ("0.25", "0.250", ".25", "0.25%"),
        ).fetchall()
        if rows:
            print(f"{tbl}.{c}: {len(rows)} rows", [(r["id"], str(r["created_at"]), r["v"]) for r in rows[:5]])

# Spread 2.x timeline for both strategies
print("\n=== SPREAD history where value >= 2 (likely % mis-scale or consensus) ===")
for tbl, col in [
    ("config_momentum", "MOMENTUM_ORDERBOOK_MAX_SPREAD_PCT"),
    ("config_breakout", "BREAKOUT_ORDERBOOK_MAX_SPREAD_PCT"),
]:
    rows = db.conn.execute(
        f"SELECT id, created_at, `{col}` AS v FROM {tbl} WHERE `{col}` IS NOT NULL AND TRIM(`{col}`)<>'' ORDER BY id"
    ).fetchall()
    big = []
    for r in rows:
        try:
            f = float(r["v"])
        except Exception:
            continue
        if f >= 2.0:
            big.append((r["id"], str(r["created_at"]), r["v"]))
    print(f"\n{col}: {len(big)} rows >=2")
    # unique values with first/last
    byv = {}
    for id_, t, v in big:
        byv.setdefault(str(v), []).append((id_, t))
    for v, lst in sorted(byv.items(), key=lambda x: -len(x[1])):
        print(f"  {v}: n={len(lst)} first={lst[0]} last={lst[-1]}")

# Same-day pairs: mom spread & bo spread when either is 2.x or 0.2x bid_ask
print("\n=== Recent rows (id>=400) OB snapshot pair ===")
mom = db.conn.execute(
    "SELECT id, created_at, MOMENTUM_ORDERBOOK_MAX_SPREAD_PCT s, MOMENTUM_ORDERBOOK_MIN_BID_ASK_RATIO r, "
    "MOMENTUM_EXIT_OB_RATIO_MIN ex, MOMENTUM_EXIT_OB_MIN_PROFIT_PCT ep "
    "FROM config_momentum WHERE id>=400 ORDER BY id"
).fetchall()
bo = db.conn.execute(
    "SELECT id, created_at, BREAKOUT_ORDERBOOK_MAX_SPREAD_PCT s, BREAKOUT_ORDERBOOK_MIN_BID_ASK_RATIO r, "
    "BREAKOUT_EXIT_OB_RATIO_MIN ex, BREAKOUT_EXIT_OB_MIN_PROFIT_PCT ep "
    "FROM config_breakout WHERE id>=400 ORDER BY id"
).fetchall()
print("MOM id>=400 unique (s,r,ex):", Counter((str(r['s']), str(r['r']), str(r['ex'])) for r in mom).most_common(10))
print("BO  id>=400 unique (s,r,ex):", Counter((str(r['s']), str(r['r']), str(r['ex'])) for r in bo).most_common(10))

# When breakout spread was 2.5, what was momentum same day?
print("\n=== When BREAKOUT spread was 2.5 / 2.78, nearest MOMENTUM spread same created_at day ===")
bo25 = db.conn.execute(
    "SELECT id, created_at, BREAKOUT_ORDERBOOK_MAX_SPREAD_PCT s, BREAKOUT_ORDERBOOK_MIN_BID_ASK_RATIO r "
    "FROM config_breakout WHERE BREAKOUT_ORDERBOOK_MAX_SPREAD_PCT IN ('2.5','2.78','2.96','3.0') ORDER BY id"
).fetchall()
for r in bo25[:15]:
    day = str(r["created_at"])[:10]
    m = db.conn.execute(
        "SELECT id, created_at, MOMENTUM_ORDERBOOK_MAX_SPREAD_PCT s, MOMENTUM_ORDERBOOK_MIN_BID_ASK_RATIO r "
        "FROM config_momentum WHERE created_at >= %s AND created_at < DATE_ADD(%s, INTERVAL 1 DAY) "
        "AND MOMENTUM_ORDERBOOK_MAX_SPREAD_PCT IS NOT NULL AND TRIM(MOMENTUM_ORDERBOOK_MAX_SPREAD_PCT)<>'' "
        "ORDER BY ABS(TIMESTAMPDIFF(SECOND, created_at, %s)) LIMIT 1",
        (day, day, r["created_at"]),
    ).fetchone()
    print(f"BO id={r['id']} {r['created_at']} s={r['s']} r={r['r']} | MOM nearest={dict(m) if m else None}")

print("\n=== MOM BID_ASK 0.27 period with BO values ===")
m027 = db.conn.execute(
    "SELECT id, created_at, MOMENTUM_ORDERBOOK_MAX_SPREAD_PCT s, MOMENTUM_ORDERBOOK_MIN_BID_ASK_RATIO r "
    "FROM config_momentum WHERE MOMENTUM_ORDERBOOK_MIN_BID_ASK_RATIO='0.27' ORDER BY id"
).fetchall()
print(f"mom 0.27 count={len(m027)} first={m027[0]['created_at'] if m027 else None} last={m027[-1]['created_at'] if m027 else None}")
if m027:
    t0, t1 = m027[0]["created_at"], m027[-1]["created_at"]
    bo_same = db.conn.execute(
        "SELECT BREAKOUT_ORDERBOOK_MAX_SPREAD_PCT s, BREAKOUT_ORDERBOOK_MIN_BID_ASK_RATIO r, COUNT(*) n "
        "FROM config_breakout WHERE created_at BETWEEN %s AND %s "
        "GROUP BY s, r ORDER BY n DESC LIMIT 10",
        (t0, t1),
    ).fetchall()
    print("BO during mom 0.27 window:", [dict(x) for x in bo_same])

db.close()
PY

Trace 0.25 and 2.x spread across config history


맞습니다. **아까는 현재값만** 봤고, 히스토리는 이번에 돌렸습니다.

별도 `env_history` 테이블은 없고, **`config_momentum` / `config_breakout` 행 전체(440·441행)** 가 이력입니다.

### 결론

| 찾는 값 | 결과 |
|---------|------|
| 정확히 **0.25** | 호가 컬럼 이력에 **없음** |
| 0.2x에 가장 가까운 것 | 모멘텀 **`ORDERBOOK_MIN_BID_ASK_RATIO = 0.27`** (8/7, 9행) |
| 말씀하신 **2점대** | 돌파 **`ORDERBOOK_MAX_SPREAD_PCT = 2.5`** (그리고 2.78) |

---

### 8/6~8/8 호가 합의 구간 (이게 “둘 다 2~3점대” 장면)

| 시점 | 모멘텀 스프레드 | 돌파 스프레드 |
|------|-----------------|---------------|
| 8/6 새벽 | **3.0** | **2.96 → 2.78** |
| 8/7 저녁 | 0.35 + 잔량비 **0.27** | **2.5** |
| 8/8 | **3.3** | **2.5** |

즉 **2.5는 돌파 진입 스프레드(%)** 이고,
**0.27(≈0.25로 기억)은 모멘텀 잔량비 하한**입니다. 같은 날(8/7)에 같이 보였을 가능성이 큽니다.

`STOP_OB_RATIO_MIN` / `EXIT_OB_*` 이력에는 0.25·2.5가 없습니다.


<timestamp>Thursday, Aug 13, 2026, 4:44 AM (UTC+9)</timestamp>
<user_query>
그래 진입쪽 스프레드였네 지금은 이게 값이 뭐지? 모멘텀 돌파가 한짝이었네
</user_query>


cd /home/hoon/kis_bot && python3 - <<'PY'
from kis_trader.utils.env import invalidate_merged_env_cache, get_env_from_db
invalidate_merged_env_cache()
for k in (
  "MOMENTUM_ORDERBOOK_MAX_SPREAD_PCT","MOMENTUM_ORDERBOOK_MIN_BID_ASK_RATIO","MOMENTUM_ORDERBOOK_FILTER_ENABLED",
  "BREAKOUT_ORDERBOOK_MAX_SPREAD_PCT","BREAKOUT_ORDERBOOK_MIN_BID_ASK_RATIO","BREAKOUT_ORDERBOOK_FILTER_ENABLED",
):
    print(f"{k}={get_env_from_db(k)!r}")
PY

Read current entry spread env values


지금 진입 스프레드는 이렇게입니다. (둘 다 필터 ON)

| 전략 | 스프레드 상한 | 잔량비 하한 |
|------|---------------|------------|
| 모멘텀 | **0.84%** | 0.56 |
| 돌파 | **0.3%** | 0.73 |

예전에 한짝처럼 보이던 구간은 모멘텀 **3.0~3.3%** / 돌파 **2.5~2.78%** 였고, 지금은 둘 다 **1% 미만**으로 내려와 있습니다.


<timestamp>Thursday, Aug 13, 2026, 4:45 AM (UTC+9)</timestamp>
<user_query>
아 좆같아진거네 지금 그래서 매매가 없었구나 왜지? 왜 값이 이렇게 바뀌었지?
</user_query>


스프레드가 2~3%대에서 지금(0.84/0.3)으로 바뀐 시점과 원인을 config 이력에서 추적합니다.

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

print("=== MOMENTUM spread/ratio timeline (non-null, last 80 changes of interest) ===")
rows = db.conn.execute(
    """
    SELECT id, created_at,
           MOMENTUM_ORDERBOOK_FILTER_ENABLED en,
           MOMENTUM_ORDERBOOK_MAX_SPREAD_PCT s,
           MOMENTUM_ORDERBOOK_MIN_BID_ASK_RATIO r,
           MOMENTUM_EXIT_OB_ENABLED ex,
           MOMENTUM_EXIT_OB_RATIO_MIN exr
    FROM config_momentum
    WHERE MOMENTUM_ORDERBOOK_MAX_SPREAD_PCT IS NOT NULL
      AND TRIM(MOMENTUM_ORDERBOOK_MAX_SPREAD_PCT) <> ''
    ORDER BY id
    """
).fetchall()
prev = None
for r in rows:
    key = (str(r["en"]), str(r["s"]), str(r["r"]), str(r["ex"]), str(r["exr"]))
    if key != prev:
        print(f"id={r['id']:4d} {r['created_at']} en={r['en']} s={r['s']} r={r['r']} exit={r['ex']}/{r['exr']}")
        prev = key

print("\n=== BREAKOUT spread/ratio timeline ===")
rows = db.conn.execute(
    """
    SELECT id, created_at,
           BREAKOUT_ORDERBOOK_FILTER_ENABLED en,
           BREAKOUT_ORDERBOOK_MAX_SPREAD_PCT s,
           BREAKOUT_ORDERBOOK_MIN_BID_ASK_RATIO r,
           BREAKOUT_EXIT_OB_ENABLED ex,
           BREAKOUT_EXIT_OB_RATIO_MIN exr
    FROM config_breakout
    WHERE BREAKOUT_ORDERBOOK_MAX_SPREAD_PCT IS NOT NULL
      AND TRIM(BREAKOUT_ORDERBOOK_MAX_SPREAD_PCT) <> ''
    ORDER BY id
    """
).fetchall()
prev = None
for r in rows:
    key = (str(r["en"]), str(r["s"]), str(r["r"]), str(r["ex"]), str(r["exr"]))
    if key != prev:
        print(f"id={r['id']:4d} {r['created_at']} en={r['en']} s={r['s']} r={r['r']} exit={r['ex']}/{r['exr']}")
        prev = key

# note/source columns if any
for tbl in ("config_momentum", "config_breakout"):
    cols = [x["Field"] for x in db.conn.execute(f"SHOW COLUMNS FROM {tbl}").fetchall()]
    meta = [c for c in cols if any(t in c.lower() for t in ("note", "source", "comment", "apply", "optuna", "user"))]
    print(f"\n{tbl} meta cols:", meta)

db.close()
PY

Timeline of spread value changes in config history


ls -la /home/hoon/kis_bot/logs/postprocess_output.log /home/hoon/kis_bot/logs/apply_*ob*.log 2>/dev/null; head -n 25 /home/hoon/kis_bot/logs/postprocess_output.log; echo '---'; head -n 30 /home/hoon/kis_bot/logs/apply_brk_ob_final.log; echo '---'; head -n 30 /home/hoon/kis_bot/logs/apply_mom_ob_final.log

Check apply OB consensus log timestamps


원인은 거의 확정입니다. **Optuna 호가 후처리(`apply_optuna_ob_consensus`)가 DB에 다시 덮어쓴 것**입니다. 웹에서 손댄 게 아닙니다.

### 언제 / 무엇으로

| 시점 | 사건 | 모멘텀 스프레드 | 돌파 스프레드 |
|------|------|-----------------|---------------|
| 8/6 00:39 | `apply_*_ob_final` | (직후 0.46으로 바뀜) | **2.78** ← 그 “2점대” |
| 8/7 저녁 | 또 합의 반영 | 0.35 + 잔량 **0.27** | **2.5** |
| **8/8 08:04** | **`logs/postprocess_output.log` 일괄 후처리** | **0.84** | **0.3** |
| 이후~지금 | 그대로 | **0.84** | **0.3** |

8/8 로그 문구 그대로:

- 모멘텀: `스프레드≤0.84%` → DB 반영
- 돌파: `스프레드≤0.30%` → DB 반영

스크립트: `scripts/apply_optuna_ob_consensus.py` (실매 `trade_history` 모수로 1000회 합의 후 **즉시 DB 패치**).

### 왜 이렇게 빡세졌나

후처리는 “좋은 스프레드를 고르는” 게 아니라, **그때 쌓인 실매+호가 스냅으로 다시 합의**합니다.
모수·기간이 바뀌면 3%대 → 0.3%대로 **완전히 다른 값**이 나오고, apply가 **옛 2.5를 지워 버립니다.**

돌파 **0.3% + 필터 ON**이면 진입이 많이 막히는 쪽이 맞습니다.
다만 8/12 오후 0건은 스프레드만이 아니라 **유니버스/TRIGGER**도 겹쳤던 날이라, “스프레드만의 탓”으로만 단정하진 않는 게 맞습니다.

원하시면 **8/6~7 값(돌파 2.5·모멘텀 3.x대)으로 되돌리는지**, 아니면 **필터만 OFF**할지 정해 주시면 그에 맞춰 처리안만 먼저 보고하겠습니다. (지금은 설명만, DB는 안 건드림.)


<timestamp>Thursday, Aug 13, 2026, 4:47 AM (UTC+9)</timestamp>
<user_query>
호가쪽 수치는 그때로 돌려줘 그리고 아까 웹옵투나 ui  표 만든는거 해주고 일단 지금 있는 후처리들만 우선 손절은 아직안됐다고했지?
</user_query>


호가 수치는 8/6~7 ‘한짝’ 구간으로 되돌리고, 웹 Optuna에는 기존 후처리(호가·휩쏘·트레일)만 표로 붙입니다. 손절(STOP) 합의는 아직 없으니 넣지 않습니다.

cd /home/hoon/kis_bot && python3 - <<'PY'
from database import TradeDB
db = TradeDB()
# Full OB snapshot at "pair" moments
print('=== MOM id=396 (3.0) ===')
r = db.conn.execute(
  "SELECT id, created_at, MOMENTUM_ORDERBOOK_FILTER_ENABLED, MOMENTUM_ORDERBOOK_MAX_SPREAD_PCT, "
  "MOMENTUM_ORDERBOOK_MIN_BID_ASK_RATIO, MOMENTUM_ORDERBOOK_ENTRY_ASK_MAX_MULT, "
  "MOMENTUM_ORDERBOOK_ENTRY_BID_DEPTH_MULT, MOMENTUM_ORDERBOOK_ENTRY_BID_LEVELS, "
  "MOMENTUM_EXIT_OB_ENABLED, MOMENTUM_EXIT_OB_RATIO_MIN, MOMENTUM_EXIT_OB_MA_WINDOW, "
  "MOMENTUM_EXIT_OB_MIN_PROFIT_PCT, MOMENTUM_EXIT_OB_MIN_HOLD_BARS, "
  "MOMENTUM_STOP_OB_ENABLED, MOMENTUM_STOP_OB_RATIO_MIN, MOMENTUM_STOP_OB_MA_WINDOW, "
  "MOMENTUM_STOP_OB_MIN_LOSS_PCT, MOMENTUM_STOP_OB_MIN_HOLD_BARS "
  "FROM config_momentum WHERE id=396"
).fetchone()
print(dict(r) if r else None)

print('=== BO id=400 (2.78) ===')
r = db.conn.execute(
  "SELECT id, created_at, BREAKOUT_ORDERBOOK_FILTER_ENABLED, BREAKOUT_ORDERBOOK_MAX_SPREAD_PCT, "
  "BREAKOUT_ORDERBOOK_MIN_BID_ASK_RATIO, BREAKOUT_ORDERBOOK_ENTRY_ASK_MAX_MULT, "
  "BREAKOUT_ORDERBOOK_ENTRY_BID_DEPTH_MULT, BREAKOUT_ORDERBOOK_ENTRY_BID_LEVELS, "
  "BREAKOUT_ORDERBOOK_ASK_WALL_MAX_QTY, "
  "BREAKOUT_EXIT_OB_ENABLED, BREAKOUT_EXIT_OB_RATIO_MIN, BREAKOUT_EXIT_OB_MA_WINDOW, "
  "BREAKOUT_EXIT_OB_MIN_PROFIT_PCT, BREAKOUT_EXIT_OB_MIN_HOLD_BARS, "
  "BREAKOUT_STOP_OB_ENABLED, BREAKOUT_STOP_OB_RATIO_MIN, BREAKOUT_STOP_OB_MA_WINDOW, "
  "BREAKOUT_STOP_OB_MIN_LOSS_PCT, BREAKOUT_STOP_OB_MIN_HOLD_BARS "
  "FROM config_breakout WHERE id=400"
).fetchone()
print(dict(r) if r else None)

print('=== BO id=412 (2.5) ===')
r = db.conn.execute(
  "SELECT id, created_at, BREAKOUT_ORDERBOOK_MAX_SPREAD_PCT, BREAKOUT_ORDERBOOK_MIN_BID_ASK_RATIO, "
  "BREAKOUT_EXIT_OB_ENABLED, BREAKOUT_EXIT_OB_RATIO_MIN, BREAKOUT_EXIT_OB_MA_WINDOW, "
  "BREAKOUT_EXIT_OB_MIN_PROFIT_PCT, BREAKOUT_EXIT_OB_MIN_HOLD_BARS FROM config_breakout WHERE id=412"
).fetchone()
print(dict(r) if r else None)
db.close()
PY

Load full OB snapshots from pair period


TodoWrite

모멘텀/돌파 호가 수치 8/5~6 한짝 구간으로 DB 복원·검증

웹 Optuna 후처리(호가·휩쏘·트레일) 표 UI + summary 전달

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


Shell

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

# 8/5~6 한짝: 모멘텀 3.0% / 돌파 2.78% + 당시 EXIT_OB 합의
# STOP은 후처리에 없음 → ENABLED만 현재처럼 true 유지, 임계는 건드리지 않음
patch = {
    "MOMENTUM_ORDERBOOK_FILTER_ENABLED": "true",
    "MOMENTUM_ORDERBOOK_MAX_SPREAD_PCT": "3.0",
    "MOMENTUM_ORDERBOOK_MIN_BID_ASK_RATIO": "0.58",
    "MOMENTUM_ORDERBOOK_ENTRY_ASK_MAX_MULT": "3.0",
    "MOMENTUM_ORDERBOOK_ENTRY_BID_DEPTH_MULT": "1.2",
    "MOMENTUM_ORDERBOOK_ENTRY_BID_LEVELS": "3",
    "MOMENTUM_EXIT_OB_ENABLED": "true",
    "MOMENTUM_EXIT_OB_RATIO_MIN": "0.65",
    "MOMENTUM_EXIT_OB_MA_WINDOW": "5",
    "MOMENTUM_EXIT_OB_MIN_PROFIT_PCT": "0.009",
    "MOMENTUM_EXIT_OB_MIN_HOLD_BARS": "3",
    "MOMENTUM_STOP_OB_ENABLED": "true",
    "BREAKOUT_ORDERBOOK_FILTER_ENABLED": "true",
    "BREAKOUT_ORDERBOOK_MAX_SPREAD_PCT": "2.78",
    "BREAKOUT_ORDERBOOK_MIN_BID_ASK_RATIO": "1.0",
    "BREAKOUT_ORDERBOOK_ENTRY_ASK_MAX_MULT": "3.0",
    "BREAKOUT_ORDERBOOK_ENTRY_BID_DEPTH_MULT": "1.2",
    "BREAKOUT_ORDERBOOK_ENTRY_BID_LEVELS": "3",
    "BREAKOUT_ORDERBOOK_ASK_WALL_MAX_QTY": "5000",
    "BREAKOUT_EXIT_OB_ENABLED": "true",
    "BREAKOUT_EXIT_OB_RATIO_MIN": "0.4",
    "BREAKOUT_EXIT_OB_MA_WINDOW": "10",
    "BREAKOUT_EXIT_OB_MIN_PROFIT_PCT": "0.02",
    "BREAKOUT_EXIT_OB_MIN_HOLD_BARS": "1",
    "BREAKOUT_STOP_OB_ENABLED": "true",
}

db = TradeDB()
rid = db.insert_env_snapshot(patch)
print("insert id", rid)
invalidate_merged_env_cache()
keys = [
    "MOMENTUM_ORDERBOOK_MAX_SPREAD_PCT","MOMENTUM_ORDERBOOK_MIN_BID_ASK_RATIO",
    "MOMENTUM_EXIT_OB_RATIO_MIN","MOMENTUM_STOP_OB_ENABLED",
    "BREAKOUT_ORDERBOOK_MAX_SPREAD_PCT","BREAKOUT_ORDERBOOK_MIN_BID_ASK_RATIO",
    "BREAKOUT_EXIT_OB_RATIO_MIN","BREAKOUT_STOP_OB_ENABLED",
]
for k in keys:
    print(f"{k}={get_env_from_db(k)!r}")
db.close()
PY

Restore OB entry/exit values to Aug 5-6 pair

StrReplace

        "mode_combo_note": mc.get("note"),
        "daily_trail_recommend": trail_rec,
        "apply_ready": bool(gate0) and float(gate0.get("total_pnl") or 0) > 0,
        "apply_stable_ready": bool(stab0) and float(stab0.get("total_pnl") or 0) > 0,
        "briefing_md": briefing,
    }

        "mode_combo_note": mc.get("note"),
        "daily_trail_recommend": trail_rec,
        # 후처리 추천 (탐색 trial 아님) — 웹 Optuna 탭 표용. STOP_OB 합의는 아직 없음.
        "orderbook_recommend": data.get("orderbook_recommend")
        if isinstance(data.get("orderbook_recommend"), dict)
        else (mc.get("orderbook_recommend") if isinstance(mc.get("orderbook_recommend"), dict) else None),
        "whipsaw_recommend": data.get("whipsaw_recommend")
        if isinstance(data.get("whipsaw_recommend"), dict)
        else (mc.get("whipsaw_recommend") if isinstance(mc.get("whipsaw_recommend"), dict) else None),
        "apply_ready": bool(gate0) and float(gate0.get("total_pnl") or 0) > 0,
        "apply_stable_ready": bool(stab0) and float(stab0.get("total_pnl") or 0) > 0,
        "briefing_md": briefing,
    }

다단트레일 추천 (탐색 축 아님 · DB 적용 시에만 반영)

완료 후 표시

다단트레일 추천 (탐색 축 아님 · DB 적용 시에만 반영)

완료 후 표시

후처리 추천 표
          (호가 진입+익절 · 휩쏘 · 트레일 · STOP_OB 합의 아직 없음)

                구분	상태	핵심 수치
                모수/통계	비고

완료 후 표시


optunaRenderTrailRec(sum);
  optunaRenderPostprocess(sum);
}

function optunaRenderTrailRec(sum) {
  const body = $('opt_daily_trail_rec_body');
  if (!body) return;
  const rec = sum && sum.daily_trail_recommend;
  if (!rec) {
    body.textContent = '완료 후 표시';
    return;
  }
  if (!rec.ok) {
    body.innerHTML =
      `<span class="text-muted">추천 없음</span> — ${rec.reason || '양수 PnL 앵커 없음'}`;
    return;
  }
  const arm = Number(rec.arm_krw || 0).toLocaleString();
  const anchor = Number(rec.anchor_krw || 0).toLocaleString();
  body.innerHTML =
    `<b>${rec.prefix || ''}</b> · ENABLED=true · MODE=${rec.mode || 'trailing'}` +
    `<br>ARM <b>${arm}</b>원 · TIERS <code>${rec.tiers || ''}</code>` +
    `<br><span class="text-muted">앵커 ${anchor}원 (mode/median/best×frac) · apply 시에만 DB 기록 · 이후 운영탭에서 끄기/수정 가능</span>`;
}

/** Optuna 후처리 3종 표 — 호가(진입+EXIT익절) · 휩쏘 · 트레일. STOP_OB 합의는 미구현. */
function optunaRenderPostprocess(sum) {
  const tb = $('opt_postprocess_tbody');
  if (!tb) return;
  if (!sum) {
    tb.innerHTML = '
완료 후 표시
';
    return;
  }
  const rows = [];

  const ob = sum.orderbook_recommend;
  if (!ob) {
    rows.push({
      kind: '호가 (진입+익절)',
      st: '미포함',
      nums: '—',
      stats: '—',
      note: '구 JSON / 미생성',
    });
  } else if (!ob.ok) {
    rows.push({
      kind: '호가 (진입+익절)',
      st: '실패',
      nums: '—',
      stats: '—',
      note: ob.reason || 'n/a',
    });
  } else {
    const p = ob.params || {};
    const rs = ob.recommended_stats || {};
    const exitOn = p.exit_ob_enabled ? 'ON' : 'OFF';
    rows.push({
      kind: '호가 (진입+익절)',
      st: 'ok',
      nums:
        `스프레드≤${p.orderbook_max_spread_pct != null ? p.orderbook_max_spread_pct : '—'}%` +
        ` · 잔량≥${p.orderbook_min_bid_ask_ratio != null ? p.orderbook_min_bid_ask_ratio : '—'}` +
        ` · EXIT_OB=${exitOn}` +
        (p.exit_ob_enabled
          ? ` (OR≥${p.exit_ob_ratio_min != null ? p.exit_ob_ratio_min : '—'})`
          : ''),
      stats:
        `모수 ${ob.trade_count != null ? ob.trade_count : '—'}건` +
        (rs.win_rate != null ? ` · WR ${rs.win_rate}%` : '') +
        (rs.pnl != null ? ` · PnL ${Number(rs.pnl).toLocaleString()}원` : ''),
      note: 'STOP_OB 합의 없음 · apply는 별도 스크립트/수동',
    });
  }

  const ws = sum.whipsaw_recommend;
  if (!ws) {
    rows.push({
      kind: '휩쏘',
      st: '미포함',
      nums: '—',
      stats: '—',
      note: '구 JSON / 미생성',
    });
  } else if (!ws.ok) {
    rows.push({
      kind: '휩쏘',
      st: '실패',
      nums: '—',
      stats: '—',
      note: ws.reason || 'n/a',
    });
  } else {
    const p = ws.params || {};
    const rs = ws.recommended_stats || {};
    rows.push({
      kind: '휩쏘',
      st: 'ok',
      nums:
        `subbar=${p.whipsaw_subbar_sec != null ? p.whipsaw_subbar_sec : '—'}` +
        ` · lookback=${p.whipsaw_lookback_sec != null ? p.whipsaw_lookback_sec : '—'}` +
        ` · dip=${p.whipsaw_dip_pct != null ? p.whipsaw_dip_pct : '—'}`,
      stats:
        (rs.win_rate != null ? `WR ${rs.win_rate}%` : '—') +
        (rs.pnl != null ? ` · PnL ${Number(rs.pnl).toLocaleString()}원` : ''),
      note: 'TAIL/BREAKOUT 등은 DB 적용 차단·표시만 가능',
    });
  }

  const tr = sum.daily_trail_recommend;
  if (!tr) {
    rows.push({
      kind: '다단트레일',
      st: '미포함',
      nums: '—',
      stats: '—',
      note: '—',
    });
  } else if (!tr.ok) {
    rows.push({
      kind: '다단트레일',
      st: '실패',
      nums: '—',
      stats: '—',
      note: tr.reason || '양수 PnL 앵커 없음',
    });
  } else {
    rows.push({
      kind: '다단트레일',
      st: 'ok',
      nums: `ARM ${Number(tr.arm_krw || 0).toLocaleString()} · ${tr.tiers || ''}`,
      stats: tr.anchor_krw != null ? `앵커 ${Number(tr.anchor_krw).toLocaleString()}원` : '—',
      note: 'Optuna DB 적용 시에만 반영',
    });
  }

  rows.push({
    kind: '손절호가 STOP_OB',
    st: '미구현',
    nums: '—',
    stats: '—',
    note: '후처리 합의·패치 없음 (DB ON은 수동)',
  });

  tb.innerHTML = rows.map(r => `

    ${r.kind}
    ${r.st}
    ${r.nums}
    ${r.stats}
    ${r.note}

`).join('');
}

function optunaRenderTop5(sum) {

  optunaRenderTrailRec(sum);
}

function optunaRenderTrailRec(sum) {
  const body = $('opt_daily_trail_rec_body');
  if (!body) return;
  const rec = sum && sum.daily_trail_recommend;
  if (!rec) {
    body.textContent = '완료 후 표시';
    return;
  }
  if (!rec.ok) {
    body.innerHTML =
      `<span class="text-muted">추천 없음</span> — ${rec.reason || '양수 PnL 앵커 없음'}`;
    return;
  }
  const arm = Number(rec.arm_krw || 0).toLocaleString();
  const anchor = Number(rec.anchor_krw || 0).toLocaleString();
  body.innerHTML =
    `<b>${rec.prefix || ''}</b> · ENABLED=true · MODE=${rec.mode || 'trailing'}` +
    `<br>ARM <b>${arm}</b>원 · TIERS <code>${rec.tiers || ''}</code>` +
    `<br><span class="text-muted">앵커 ${anchor}원 (mode/median/best×frac) · apply 시에만 DB 기록 · 이후 운영탭에서 끄기/수정 가능</span>`;
}

function optunaRenderTop5(sum) {


if (!rows.length) {
    tb.innerHTML = '
완료 후 표시
';
    if (vsEl) vsEl.textContent = '—';
    if (gatesEl) gatesEl.textContent = '—';
    optunaRenderTrailRec(null);
    optunaRenderPostprocess(null);
    return;
  }

if (!rows.length) {
    tb.innerHTML = '
완료 후 표시
';
    if (vsEl) vsEl.textContent = '—';
    if (gatesEl) gatesEl.textContent = '—';
    optunaRenderTrailRec(null);
    return;
  }

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 and healthcheck


cd /home/hoon/kis_bot && python3 - <<'PY'
from kis_trader.backtest.optuna_web_jobs import _summarize_result_json
s=_summarize_result_json('kis_trader/backtest/results/optuna_momentum_fast_20260812_235406.json')
print('ob', (s or {}).get('orderbook_recommend',{}).get('ok'), (s or {}).get('orderbook_recommend',{}).get('params') if s else None)
print('ws', (s or {}).get('whipsaw_recommend',{}).get('ok') if s else None)
print('trail', (s or {}).get('daily_trail_recommend',{}).get('ok') if s else None)
# breakout tpe may have ok false
s2=_summarize_result_json('kis_trader/backtest/results/optuna_breakout_tpe_20260813_010648.json')
print('bo ob', (s2 or {}).get('orderbook_recommend'))
PY

Smoke summarize includes postprocess fields


CallMcpTool

List open browser tabs


Hard-refresh backtest page with cache bust


Lock browser for Optuna tab clicks


Click Optuna nav tab


Shell

rg -n "후처리|STOP_OB|호가 \(진입|휩쏘|다단트레일" "/home/hoon/.cursor/projects/home-hoon-kis-bot/browser-logs/snapshot-2026-08-12T19-50-00-579Z-i1nwek.log" 2>/dev/null || rg -n "후처리|STOP_OB|호가" /tmp 2>/dev/null; ls /home/hoon/.cursor/projects/home-hoon-kis-bot/browser-logs/ 2>/dev/null | tail -5; find /home/hoon/.cursor -name '*19-50-00*' 2>/dev/null | head -5

Find postprocess text in browser snapshot


CallMcpTool

(() => { const el=document.getElementById('opt_postprocess_tbody'); const card=document.getElementById('opt_postprocess_rec'); const tab=document.getElementById('tab-optuna'); return { hasBody:!!el, bodyText:(el&&el.innerText||'').slice(0,500), cardVisible:card?getComputedStyle(card).display:'no', tabDisplay:tab?tab.style.display:'no', fn:typeof optunaRenderPostprocess }; })()

Check Optuna postprocess table DOM state


(() => { const logs=[]; const s=window._optunaLastSummary||null; return { hasSum:!!s, ob:s&&s.orderbook_recommend, ws:s&&s.whipsaw_recommend, json:(document.getElementById('opt_st_json')||{}).textContent, status:(document.getElementById('opt_st_status')||{}).textContent }; })()

Inspect loaded Optuna summary postprocess fields

(() => { const errs=[]; /* no console capture */; return document.querySelector('#opt_postprocess_rec') ? 'ok' : 'missing'; })()

Confirm postprocess card present


Unlock browser after Optuna UI check


맞습니다. **STOP 합의는 아직 없고**, 지금 있는 후처리만 표에 넣었습니다.

### 1) 호가 DB 복원 (8/5~6 한짝)
| | 모멘텀 | 돌파 |
|--|--------|------|
| 스프레드 | **3.0%** | **2.78%** |
| 잔량비 | **0.58** | **1.0** |
| EXIT_OB | ON · OR 0.65 등 (당시 합의) | ON · OR 0.4 등 |
| STOP_OB | **ENABLED=true 유지** (합의 수치 없음) | 동일 |

`apply_optuna_ob_consensus`로 다시 덮어쓰면 또 0.3대로 갈 수 있으니, 당분간 그 스크립트 일괄 실행은 주의하세요.

### 2) 웹 Optuna 후처리 표
비교표 아래 **후처리 추천 표** 추가:
- 호가 (진입+익절)
- 휩쏘
- 다단트레일
- 손절호가 STOP_OB → **미구현** 행으로 명시

백테웹 재시작 `active` + HTTP 200.
`http://192.168.0.149:5050/` Optuna 탭에서 표 렌더·콘솔 오류 없음 확인.

참고: 지금 화면에 떠 있는 최신 잡이 `optuna_momentum_tpe_20260722_…`라 호가/휩쏘가 JSON에 없어 **미포함**으로 보입니다. `optuna_momentum_fast_20260812_…`처럼 후처리 있는 결과면 ok 수치가 채워집니다.


<timestamp>Thursday, Aug 13, 2026, 11:29 AM (UTC+9)</timestamp>
<user_query>

kis_alert_bot
BOT
오전 3:24

🚀 kis_trader 통합 봇 시작
활성 전략: SCALP, SHORT, MOMENTUM, US_MOMENTUM, BREAKOUT

【유니버스】
SCALP: kiwoom_condition
  └ kiwoom_cond · scalp_re(seq=3)
SHORT: kiwoom_condition
  └ kiwoom_cond · tail(seq=1)
MOMENTUM: kiwoom_condition
  └ kiwoom_cond · momentum(seq=2)
BREAKOUT: kiwoom_condition
  └ kiwoom_cond · breakout(seq=4)

📡 [시세웹소켓] 키움(실전), KIS(실전)

✅ [기동 계좌] 계좌[모의] 501***45-01 | 보유 7종목 / 총자산 478,132,435원 | cash_basis=dnca | 예수금 468,155,952 / D+2 471,997,465 / 주문가능 469,633,948 / 사용기준 468,155,952

kis_alert_bot
BOT
오전 9:00

🚨 [운영알림/CRITICAL] LS 시세 WS 틱 공백 → 강제 재연결

코드: ws_tick_silence
시각: 2026-08-13 09:00:03
상세: silence=603s subs=7 streak=1 jstatus=21
kis_alert_bot
BOT
오전 9:00

🚨 [오류감시] 로그 오류 매칭

시각: 2026-08-13 09:00:04
유닛: kis_trader_main.service
2026-08-13T09:00:03+09:00 ubuntu2404 python[1109180]: [09:00:03] [LSWebSocket] LS WS watchdog: 603s 틱 없음 (subs=7 streak=1 silence=45s jstatus=21) → 강제 재연결
kis_alert_bot
BOT
오전 9:00

🌅 [장 시작 알림 - 09:00]
━━━━━━━━━━━━━━━━━━━━

🤖 활성 전략: SCALP, SHORT, MOMENTUM, US_MOMENTUM, BREAKOUT
계좌: 모의 501***45-01
현재 자산: 478,189,075원
보유 종목: 7개
예수금(선택기준:dnca): 468,155,952원 | 주문가능금액: 469,633,948원 | 예수금총액: 468,155,952원 | D+2예수금: 471,997,465원
━━━━━━━━━━━━━━━━━━━━
📈 오늘도 안전 매매! 손절 라인 준수.

kis_alert_bot
BOT
오전 9:03

🚨 [운영알림/CRITICAL] SCALP 유니버스 0 지속 182초

코드: universe_zero
시각: 2026-08-13 09:03:43
상세: source=kiwoom_condition kw=0 ls=-1
kis_alert_bot
BOT
오전 9:03

🚨 [오류감시] 로그 오류 매칭

시각: 2026-08-13 09:03:46
유닛: kis_trader_main.service
2026-08-13T09:03:43+09:00 ubuntu2404 python[1109180]: [09:03:43] [kis_trader.ops_alert] [ops_alert] sent code=universe_zero ok_mm=True title=SCALP 유니버스 0 지속 182초
kis_alert_bot
BOT
오전 9:04

🚨 [운영알림/CRITICAL] 키움 history 공백 20430초

코드: history_stale
시각: 2026-08-13 09:04:44
상세: last_event=2026-08-13 03:24:14
kis_alert_bot
BOT
오전 9:04

🚨 [오류감시] 로그 오류 매칭

시각: 2026-08-13 09:04:45
유닛: kis_trader_main.service
2026-08-13T09:04:44+09:00 ubuntu2404 python[1109180]: [09:04:44] [kis_trader.ops_alert] [ops_alert] sent code=history_stale ok_mm=True title=키움 history 공백 20430초

kis_alert_bot
BOT
오전 9:12

🚨 [운영알림/CRITICAL] SHORT 유니버스 0 지속 182초

코드: universe_zero
시각: 2026-08-13 09:12:48
상세: source=kiwoom_condition kw=0 ls=-1
kis_alert_bot
BOT
오전 9:12

🚨 [오류감시] 로그 오류 매칭

시각: 2026-08-13 09:12:48
유닛: kis_trader_main.service
2026-08-13T09:12:48+09:00 ubuntu2404 python[1109180]: [09:12:48] [kis_trader.ops_alert] [ops_alert] sent code=universe_zero ok_mm=True title=SHORT 유니버스 0 지속 182초

kis_alert_bot
BOT
오전 9:18

🚨 [운영알림/CRITICAL] SHORT 유니버스 0 지속 180초

코드: universe_zero
시각: 2026-08-13 09:18:51
상세: source=kiwoom_condition kw=0 ls=-1
kis_alert_bot
BOT
오전 9:18

🚨 [오류감시] 로그 오류 매칭

시각: 2026-08-13 09:18:51
유닛: kis_trader_main.service
2026-08-13T09:18:51+09:00 ubuntu2404 python[1109180]: [09:18:51] [kis_trader.ops_alert] [ops_alert] sent code=universe_zero ok_mm=True title=SHORT 유니버스 0 지속 180초
kis_alert_bot
BOT
오전 9:21

🚨 [운영알림/CRITICAL] MOMENTUM 유니버스 전량탈락 1→0

코드: universe_wipe
시각: 2026-08-13 09:21:33
상세: history=kiwoom 교집합 제외 1
kis_alert_bot
BOT
오전 9:21

🚨 [오류감시] 로그 오류 매칭

시각: 2026-08-13 09:21:33
유닛: kis_trader_main.service
2026-08-13T09:21:33+09:00 ubuntu2404 python[1109180]: [09:21:33] [kis_trader.strategy.MOMENTUM] 🔍 [T:kiwoom|O:kiwoom|유니버스슬롯] MOMENTUM 후보 1→0 (history=kiwoom 교집합, 제외 1)
kis_alert_bot
BOT
오전 9:22

🚨 [오류감시] 로그 오류 매칭

시각: 2026-08-13 09:22:21
유닛: kis_trader_main.service
2026-08-13T09:22:21+09:00 ubuntu2404 python[1109180]: [09:22:21] [kis_trader.strategy.SCALP] 🔍 [T:kiwoom|O:kiwoom|유니버스슬롯] SCALP 후보 1→0 (history=kiwoom 교집합, 제외 1)
kis_alert_bot
BOT
오전 9:25

🚨 [운영알림/CRITICAL] SHORT 유니버스 0 지속 181초

코드: universe_zero
시각: 2026-08-13 09:25:54
상세: source=kiwoom_condition kw=0 ls=-1
kis_alert_bot
BOT
오전 9:25

🚨 [오류감시] 로그 오류 매칭

시각: 2026-08-13 09:25:55
유닛: kis_trader_main.service
2026-08-13T09:25:54+09:00 ubuntu2404 python[1109180]: [09:25:54] [kis_trader.ops_alert] [ops_alert] sent code=universe_zero ok_mm=True title=SHORT 유니버스 0 지속 181초

kis_alert_bot
BOT
오전 9:45

🚨 [운영알림/CRITICAL] SHORT 유니버스 0 지속 181초

코드: universe_zero
시각: 2026-08-13 09:45:05
상세: source=kiwoom_condition kw=0 ls=-1
kis_alert_bot
BOT
오전 9:45

🚨 [오류감시] 로그 오류 매칭

시각: 2026-08-13 09:45:06
유닛: kis_trader_main.service
2026-08-13T09:45:05+09:00 ubuntu2404 python[1109180]: [09:45:05] [kis_trader.ops_alert] [ops_alert] sent code=universe_zero ok_mm=True title=SHORT 유니버스 0 지속 181초

kis_alert_bot
BOT
오전 9:51

🚨 [운영알림/CRITICAL] SHORT 유니버스 0 지속 182초

코드: universe_zero
시각: 2026-08-13 09:51:11
상세: source=kiwoom_condition kw=0 ls=-1
kis_alert_bot
BOT
오전 9:51

🚨 [오류감시] 로그 오류 매칭

시각: 2026-08-13 09:51:11
유닛: kis_trader_main.service
2026-08-13T09:51:11+09:00 ubuntu2404 python[1109180]: [09:51:11] [kis_trader.ops_alert] [ops_alert] sent code=universe_zero ok_mm=True title=SHORT 유니버스 0 지속 182초

kis_alert_bot
BOT
오전 10:00

🚨 [운영알림/CRITICAL] SHORT 유니버스 0 지속 180초

코드: universe_zero
시각: 2026-08-13 10:00:14
상세: source=kiwoom_condition kw=0 ls=-1
kis_alert_bot
BOT
오전 10:00

🚨 [오류감시] 로그 오류 매칭

시각: 2026-08-13 10:00:15
유닛: kis_trader_main.service
2026-08-13T10:00:14+09:00 ubuntu2404 python[1109180]: [10:00:14] [kis_trader.ops_alert] [ops_alert] sent code=universe_zero ok_mm=True title=SHORT 유니버스 0 지속 180초

kis_alert_bot
BOT
오전 10:06

🚨 [운영알림/CRITICAL] SHORT 유니버스 0 지속 181초

코드: universe_zero
시각: 2026-08-13 10:06:19
상세: source=kiwoom_condition kw=0 ls=-1
kis_alert_bot
BOT
오전 10:06

🚨 [오류감시] 로그 오류 매칭

시각: 2026-08-13 10:06:20
유닛: kis_trader_main.service
2026-08-13T10:06:19+09:00 ubuntu2404 python[1109180]: [10:06:19] [kis_trader.ops_alert] [ops_alert] sent code=universe_zero ok_mm=True title=SHORT 유니버스 0 지속 181초

kis_alert_bot
BOT
오전 10:12

🚨 [운영알림/CRITICAL] SHORT 유니버스 0 지속 181초

kis_alert_bot
BOT
오전 3:24

🚀 kis_trader 통합 봇 시작
활성 전략: SCALP, SHORT, MOMENTUM, US_MOMENTUM, BREAKOUT

【유니버스】
SCALP: kiwoom_condition
  └ kiwoom_cond · scalp_re(seq=3)
SHORT: kiwoom_condition
  └ kiwoom_cond · tail(seq=1)
MOMENTUM: kiwoom_condition
  └ kiwoom_cond · momentum(seq=2)
BREAKOUT: kiwoom_condition
  └ kiwoom_cond · breakout(seq=4)

📡 [시세웹소켓] 키움(실전), KIS(실전)

✅ [기동 계좌] 계좌[모의] 501***45-01 | 보유 7종목 / 총자산 478,132,435원 | cash_basis=dnca | 예수금 468,155,952 / D+2 471,997,465 / 주문가능 469,633,948 / 사용기준 468,155,952

kis_alert_bot
BOT
오전 9:09

🔷 [매수체결:MOMENTUM] 원일티엔아이(136150)
8,833원 × 21주 = 185,493원
손절 8,444 / 목표 9,188
시세: kiwoom
ODNO=0000006153
📊 MOMENTUM 당일 +0원 · 청산 0건

━━━━━━━━━━━━━━━━━━━━
💼 계좌
예수금 468,155,952원
총자산 478,284,225원 · 보유 8종목

📊 손익 (당일)
🤖 봇 실현 +0원 · 청산 0건
📈 계좌평가 +121,790원 (+0.03%)

💰 누적 (입금 대비)
-21,715,775원 (-4.34%)
━━━━━━━━━━━━━━━━━━━━

kis_alert_bot
BOT
오전 9:09

🔷 [매수체결(재조회):MOMENTUM] 원일티엔아이(136150)
8,840원 × 34주 = 300,560원
손절 8,444 / 목표 9,188
시세: kiwoom
ODNO=0000006153
(추가체결 +13주 · 누적 34주)
📊 MOMENTUM 당일 +0원 · 청산 0건

━━━━━━━━━━━━━━━━━━━━
💼 계좌
예수금 468,155,952원
총자산 478,277,295원 · 보유 8종목

📊 손익 (당일)
🤖 봇 실현 +0원 · 청산 0건
📈 계좌평가 +114,860원 (+0.02%)

💰 누적 (입금 대비)
-21,722,705원 (-4.34%)
━━━━━━━━━━━━━━━━━━━━

kis_alert_bot
BOT
오전 9:16

🔷 [매수체결:BREAKOUT] 한화시스템(272210)
77,700원 × 3주 = 233,100원
손절 74,592 / 목표 86,247
시세: kiwoom
ODNO=0000008231
📊 BREAKOUT 당일 +0원 · 청산 0건

━━━━━━━━━━━━━━━━━━━━
💼 계좌
예수금 468,155,952원
총자산 478,249,865원 · 보유 8종목

📊 손익 (당일)
🤖 봇 실현 +0원 · 청산 1건
📈 계좌평가 +87,430원 (+0.02%)

💰 누적 (입금 대비)
-21,750,135원 (-4.35%)
━━━━━━━━━━━━━━━━━━━━

kis_alert_bot
BOT
오전 9:51

🔷 [매수체결:SCALP] 위메이드(112040)
16,830원 × 1주 = 16,830원
손절 16,419 / 목표 17,345
시세: kiwoom
ODNO=0000014436
📊 SCALP 당일 +0원 · 청산 0건

━━━━━━━━━━━━━━━━━━━━
💼 계좌
예수금 468,155,952원
총자산 478,236,865원 · 보유 11종목

📊 손익 (당일)
🤖 봇 실현 +0원 · 청산 1건
📈 계좌평가 +74,430원 (+0.02%)

💰 누적 (입금 대비)
-21,763,135원 (-4.35%)
━━━━━━━━━━━━━━━━━━━━

kis_alert_bot
BOT
오전 9:52

🔷 [매수체결(재조회):SCALP] 위메이드(112040)
16,836원 × 17주 = 286,212원
손절 16,419 / 목표 17,345
시세: kiwoom
ODNO=0000014436
(추가체결 +16주 · 누적 17주)
📊 SCALP 당일 +0원 · 청산 0건

━━━━━━━━━━━━━━━━━━━━
💼 계좌
예수금 468,155,952원
총자산 478,235,495원 · 보유 11종목

📊 손익 (당일)
🤖 봇 실현 +0원 · 청산 1건
📈 계좌평가 +73,060원 (+0.02%)

💰 누적 (입금 대비)
-21,764,505원 (-4.35%)
━━━━━━━━━━━━━━━━━━━━

kis_alert_bot
BOT
오전 10:28

🔷 [매수체결:MOMENTUM] 빅솔론(093190)
8,980원 × 33주 = 296,340원
손절 8,646 / 목표 9,408
시세: kiwoom
ODNO=0000018523
📊 MOMENTUM 당일 +0원 · 청산 1건

━━━━━━━━━━━━━━━━━━━━
💼 계좌
예수금 468,155,952원
총자산 478,176,625원 · 보유 14종목

📊 손익 (당일)
🤖 봇 실현 +0원 · 청산 1건
📈 계좌평가 +14,190원 (+0.00%)

💰 누적 (입금 대비)
-21,823,375원 (-4.36%)
━━━━━━━━━━━━━━━━━━━━

kis_alert_bot
BOT
오전 10:39

🟢 [매도체결:MOMENTUM] 빅솔론(093190)
9,080원 × 33주
호가컷 · 수익률 +1.11%
실현 +2,671원 · ODNO=0000019635
시세: kiwoom
📊 MOMENTUM 당일 +2,671원 · 청산 2건

━━━━━━━━━━━━━━━━━━━━
💼 계좌
예수금 468,155,952원
총자산 478,178,486원 · 보유 14종목

📊 손익 (당일)
🤖 봇 실현 +2,671원 · 청산 2건
📈 계좌평가 +16,051원 (+0.00%)

💰 누적 (입금 대비)
-21,821,514원 (-4.36%)
━━━━━━━━━━━━━━━━━━━━

새로운 메시지

kis_alert_bot
BOT
오전 11:11

🔷 [매수체결:MOMENTUM] 삼성공조(006660)
11,650원 × 25주 = 291,250원
손절 11,271 / 목표 12,264
시세: kiwoom
ODNO=0000022346
📊 MOMENTUM 당일 +2,671원 · 청산 2건

━━━━━━━━━━━━━━━━━━━━
💼 계좌
예수금 468,155,952원
총자산 478,136,196원 · 보유 15종목

📊 손익 (당일)
🤖 봇 실현 +2,671원 · 청산 2건
📈 계좌평가 -26,239원 (-0.01%)

💰 누적 (입금 대비)
-21,863,804원 (-4.37%)
━━━━━━━━━━━━━━━━━━━━
코드: universe_zero
시각: 2026-08-13 10:12:23
상세: source=kiwoom_condition kw=0 ls=-1
kis_alert_bot
BOT
오전 10:12

🚨 [오류감시] 로그 오류 매칭

시각: 2026-08-13 10:12:24
유닛: kis_trader_main.service
2026-08-13T10:12:23+09:00 ubuntu2404 python[1109180]: [10:12:23] [kis_trader.ops_alert] [ops_alert] sent code=universe_zero ok_mm=True title=SHORT 유니버스 0 지속 181초
새로운 메시지

kis_alert_bot
BOT
오전 11:02

🚨 [운영알림/CRITICAL] 키움 시세 WS LOGIN 안 됨

코드: ws_kiwoom_down
시각: 2026-08-13 11:02:12
상세: 장중 미인증 — 조건검색·시세 위험
kis_alert_bot
BOT
오전 11:02

🚨 [오류감시] 로그 오류 매칭

시각: 2026-08-13 11:02:12
유닛: kis_trader_main.service
2026-08-13T11:02:12+09:00 ubuntu2404 python[1109180]: [11:02:12] [kis_trader.ops_alert] [ops_alert] sent code=ws_kiwoom_down ok_mm=True title=키움 시세 WS LOGIN 안 됨

오늘 매매도 너무 안되고 유니버스 오류도 뭐 엄청 많고 jou 로그도 엄청 천천히 올라가는데 무슨 문제 있는거 아니냐? 첫 매숟 9분이나 첫매수가 나타나고
</user_query>


오늘 장 알림·로그를 기준으로 유니버스 공백·키움 WS·첫 매수 지연의 근본 원인부터 확인합니다.

Shell

systemctl is-active kis_trader_main.service; journalctl -u kis_trader_main.service --since "2026-08-13 08:55:00" --until "2026-08-13 09:30:00" --no-pager 2>/dev/null | rg -i "kiwoom|LOGIN|CNSR|condition|universe|history_stale|ws_kiwoom|인증|연결|disconnect|error|CRITICAL|seq=" | head -n 120

Scan morning journal for WS/universe errors

cd /home/hoon/kis_bot && python3 - <<'PY'
from database import TradeDB
db = TradeDB()
# schema first
for tbl in ("target_candidates_history", "condition_job_events", "ws_ticks"):
    cols = [r["Field"] for r in db.conn.execute(f"SHOW COLUMNS FROM {tbl}").fetchall()]
    print(tbl, cols[:12], "...")

# today history by slot/strategy hint
rows = db.conn.execute(
    "SELECT LEFT(slot_key,8) d, LEFT(slot_key,12) hm, COUNT(*) n "
    "FROM target_candidates_history WHERE slot_key LIKE %s "
    "GROUP BY d, hm ORDER BY hm DESC LIMIT 30",
    ("20260813%",),
).fetchall()
print("history slots today (top):")
for r in rows:
    print(dict(r))

# earliest/latest event today
r = db.conn.execute(
    "SELECT MIN(updated_at) a, MAX(updated_at) b, COUNT(*) n FROM target_candidates_history WHERE slot_key LIKE %s",
    ("20260813%",),
).fetchone()
print("today history range", dict(r) if r else None)

# condition_job_events today
cols = [r["Field"] for r in db.conn.execute("SHOW COLUMNS FROM condition_job_events").fetchall()]
print("cje cols", cols)
# pick safe columns
sel = [c for c in ("id","created_at","event_type","strategy","source","status","detail","msg","name","seq") if c in cols]
if sel:
    q = f"SELECT {', '.join(sel)} FROM condition_job_events WHERE created_at >= %s ORDER BY id DESC LIMIT 25"
    ev = db.conn.execute(q, ("2026-08-13 08:50:00",)).fetchall()
    for e in ev:
        print(dict(e))
db.close()
PY

Check today candidate history and job events


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

# first history after open
rows = db.conn.execute(
    "SELECT MIN(scan_time) mn, MAX(scan_time) mx, COUNT(*) n, COUNT(DISTINCT strategy_id) ns "
    "FROM target_candidates_history WHERE slot_key LIKE %s AND scan_time >= %s",
    ("20260813%", "20260813090000"),
).fetchone()
print("after 09:00", dict(rows))

first = db.conn.execute(
    "SELECT scan_time, strategy_id, COUNT(*) n FROM target_candidates_history "
    "WHERE slot_key LIKE %s AND scan_time >= %s "
    "GROUP BY scan_time, strategy_id ORDER BY scan_time ASC LIMIT 40",
    ("20260813%", "20260813000000"),
).fetchall()
print("earliest strategy buckets:")
for r in first[:40]:
    print(dict(r))

# overnight gap: last before 9 and first after 9
last_pre = db.conn.execute(
    "SELECT MAX(scan_time) t FROM target_candidates_history WHERE slot_key LIKE %s AND scan_time < %s",
    ("20260813%", "20260813090000"),
).fetchone()
first_post = db.conn.execute(
    "SELECT MIN(scan_time) t FROM target_candidates_history WHERE slot_key LIKE %s AND scan_time >= %s",
    ("20260813%", "20260813090000"),
).fetchone()
print("last_pre", dict(last_pre), "first_post", dict(first_post))

# SHORT/tail counts by hour
for sid in ("SHORT","TAIL","SCALP","MOMENTUM","BREAKOUT"):
    r = db.conn.execute(
        "SELECT COUNT(*) n, MIN(scan_time) a, MAX(scan_time) b FROM target_candidates_history "
        "WHERE slot_key LIKE %s AND strategy_id=%s",
        ("20260813%", sid),
    ).fetchone()
    print(f"hist {sid}", dict(r))

# condition events morning
ev = db.conn.execute(
    "SELECT event_time, strategy_id, job_flag, job_ko, broker, code, name FROM condition_job_events "
    "WHERE event_time >= %s AND event_time < %s ORDER BY id ASC LIMIT 40",
    ("2026-08-13 08:55:00", "2026-08-13 09:20:00"),
).fetchall()
print("cje morning", len(ev))
for e in ev[:30]:
    print(dict(e))

# trades today
cols = [r["Field"] for r in db.conn.execute("SHOW COLUMNS FROM trade_history").fetchall()]
# use buy_time or similar
print("trade cols sample", [c for c in cols if "time" in c.lower() or "strat" in c.lower() or "side" in c.lower()][:20])
db.close()
PY

Analyze history gap and morning condition events

journalctl -u kis_trader_main.service --since "2026-08-13 03:20:00" --until "2026-08-13 03:40:00" --no-pager 2>/dev/null | rg -i "키움|LOGIN|CNSR|조건|condition|등록|REAL|HOLD|장외|대기" | head -n 80; echo '===='; journalctl -u kis_trader_main.service --since "2026-08-13 10:55:00" --until "2026-08-13 11:10:00" --no-pager 2>/dev/null | rg -i "키움|LOGIN|disconnect|재연결|ws_kiwoom|CNSR|REG" | head -n 60; echo '==== NOW'; journalctl -u kis_trader_main.service --since "2026-08-13 11:20:00" --no-pager 2>/dev/null | rg -i "LOGIN|키움 WS|universe_zero|SHORT|CNSR" | tail -n 40

Check overnight boot and 11:02 Kiwoom down logs


journalctl -u kis_trader_main.service --since "2026-08-13 11:01:50" --until "2026-08-13 11:05:00" --no-pager 2>/dev/null | rg -i "LOGIN|연결 종료|재연결|ws_kiwoom|Connection|인증" | head -n 40; echo '---'; cd /home/hoon/kis_bot && python3 - <<'PY'
from database import TradeDB
db=TradeDB()
# SHORT history by hour today
rows=db.conn.execute(
  "SELECT DATE_FORMAT(scan_time,'%%H') h, COUNT(*) n FROM target_candidates_history "
  "WHERE strategy_id=%s AND scan_time>=%s GROUP BY h ORDER BY h",
  ("SHORT","2026-08-13 00:00:00"),
).fetchall()
print("SHORT hist by hour", [dict(r) for r in rows])
rows=db.conn.execute(
  "SELECT DATE_FORMAT(scan_time,'%%H') h, COUNT(*) n FROM target_candidates_history "
  "WHERE strategy_id=%s AND scan_time>=%s GROUP BY h ORDER BY h",
  ("SCALP","2026-08-13 00:00:00"),
).fetchall()
print("SCALP hist by hour", [dict(r) for r in rows])
# first MOMENTUM buy
cols=[r['Field'] for r in db.conn.execute('SHOW COLUMNS FROM trade_history').fetchall()]
print('has', [c for c in cols if c in ('buy_time','sell_time','entry_time','created_at','code','strategy')])
# find time col
bt='buy_time' if 'buy_time' in cols else ('entry_time' if 'entry_time' in cols else None)
if bt:
  r=db.conn.execute(
    f"SELECT {bt} t, code, strategy, buy_price, qty FROM trade_history WHERE {bt}>=%s ORDER BY {bt} ASC LIMIT 8",
    ("2026-08-13 00:00:00",),
  ).fetchall()
  for x in r: print(dict(x))
db.close()
PY

Confirm 11:02 WS down and trade timing


**문제 있습니다.** 다만 “코드 한 줄 깨짐”보다 **새벽 재시작 + 키움 WS 끊김 + SHORT 공백 알림 폭주 + 장초 TRIGGER 탈락**이 겹친 날에 가깝습니다.

### 타임라인 (로그 기준)

| 시각 | 사건 |
|------|------|
| **03:24** | 봇 재시작. 키움 조건 등록은 됨(장외). SHORT 9·MOM 14·BREAKOUT 2·SCALP 0 |
| **03:24~09:00** | 장외. history 사실상 멈춤 → 09:04 `history_stale 20430초`(≈5.7h, last=03:24) |
| **09:00** | LS `틱 공백 603s` → 강제 재연결 (장 시작 hold 해제 직후 워치독) |
| **09:00~** | BREAKOUT는 후보 있음. 대부분 **직전봉약세(prev=0%) / 저항미돌파 / 호가수급** 탈락 |
| **09:04~05** | 키움 WS `socket closed` → 끊김 → 재LOGIN. 재등록 직후 seq 초기매칭 **0** → SCALP/SHORT `universe_zero` |
| **09:09** | 첫 매수(모멘텀) — 개장 후 ~9분 |
| **이후** | SHORT `universe_zero` 반복 (키움 tail 후보가 자주 0) |
| **11:02** | 키움 WS 또 `Connection lost` → 알림 → **5초 뒤 LOGIN OK** (일시) |

지금은 heartbeat상 조건 살아 있음: `kwcond[SCALP=2,SHORT=2,BREAKOUT=10,MOMENTUM=1]`.

---

### 알림별로 뭔 뜻인지

1. **`history_stale`**
   새벽 기동 후 history 갱신이 안 되다 장 시작·WS 재연결 구간에 터진 것. “종일 유니버스 죽음” 신호는 아님. (이후 SCALP history는 시간당 수천 건 쌓임)

2. **`universe_zero` (SHORT 위주)**
   오늘 SHORT history가 시간당 수십 건뿐(SCALP 수천 대비). **HTS tail이 비는 구간이 길다** + 있어도 **봉등락 -10~-0.5% 미달**로 전원 탈락. 알림이 3분마다 CRITICAL로 쌓여 “장애처럼” 보임.

3. **`universe_wipe` (1→0)**
   history 교집합으로 마지막 1종 제외 — 조건 이탈 정리. 버그라기보다 슬롯정렬 동작.

4. **`ws_kiwoom_down` / journal 느림**
   키움 시세 소켓이 **두 번 끊김**(09:05, 11:02). 끊기는 동안 REG/조건/시세가 흔들림.
   journal이 느린 건 초단위 **탈락 로그 폭주 + 갭보정 REST** 때문일 가능성 큼.

5. **첫 매수 9분**
   봇이 9시부터 안 돈 게 아니라, 장초엔 봉이 안 쌓여 **직전봉약세**가 많고, 09:05 WS 재접속 공백이 겹침.
   돌파는 후보가 있어도 복원한 **`MIN_BID_ASK_RATIO=1.0`(잔량비 1.0)** 이 빡세서 `호가수급` 탈락이 보임.

---

### 한 줄 진단

- **실장애:** 키움 WS 장중 단절(재연결됨) + 새벽 재시작으로 장초 조건/history 워밍 꼬임.
- **알림 과다:** SHORT 유니버스  thrash를 CRITICAL로 반복.
- **매매 적음:** 장초 필터 + (돌파) 잔량비 1.0 + SHORT 조건 빈약. 스프레드 3%/2.78%만의 문제는 아님.

원하시면 다음으로 **(1) 키움 WS 단절 원인·재연결 시 CNSR 공백 줄이기** / **(2) SHORT universe_zero 알림 쿨다운** / **(3) 돌파 잔량비 1.0이 과도한지** 중 어디부터 손댈지 정해 주시면 됩니다. (지금은 진단만, 코드·DB 미변경)


<timestamp>Thursday, Aug 13, 2026, 11:33 AM (UTC+9)</timestamp>
<user_query>
2.5 스캘핑 손절컷 라인인데 지금 종목하나가 매도안되고 있음 이것도 봐주고 지금 버그있나 쭉 봐줘 이거 뭐 그리고 매매내역에 매수 매도 호가가 안나옴

종목	매수시각	매도시각	매수가	매도가	수량	손익(원)	수익률%	누적손익	누적%	보유(분)	체결디버그	매수호가	매도호가	매도사유
위메이드
112040
2026-08-13 09:51:52	보유중	16,836	16,910	17	1,258 평가
-3.65%
당일 · 매수 +0.44%
-	-	98	-	M:0.0%
W:0.0%	보유중

거래 내역 (청산 1건 · 보유 0건 — 상단 요약·차트는 청산만)
📋 실매 trade_history · 한도 1,200,000원 | 장중 누적 최고 +2,671원 (2026-08-13 10:39:40) | 최종 누적 2,671원
정렬
종목	매수시각	매도시각	매수가	매도가	수량	손익(원)	수익률%	누적손익	누적%	보유(분)	체결디버그	매수호가	매도호가	매도사유
빅솔론
093190
2026-08-13 10:28:53	2026-08-13 10:39:40	8,980	9,080	33	2,671	+0.90%	2,671	+0.22%	10	실매체결	M:0.0%
W:0.0%	M:0.0%
W:0.0%	호가컷

</user_query>


위메이드 미청산·스캘프 2.5% 손절·매수/매도호가 M:0% 표시를 실매 DB·로그·코드로 확인합니다.

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

invalidate_merged_env_cache()
db = TradeDB()

# SCALP stop/exit related env
for k in [
  "SCALP_STOP_LOSS_PCT","SCALP_TAKE_PROFIT_PCT","SCALP_TP_MAX_PCT",
  "SCALP_SHOULDER_MIN_HIGH_PCT","SCALP_SHOULDER_CUT_PCT",
  "SCALP_ORDERBOOK_FILTER_ENABLED","SCALP_TICK_SELL","TICK_SELL",
]:
    print(f"{k}={get_env_from_db(k)!r} get_float={get_env_float(k, None) if 'PCT' in k else ''}")

# active trades schema + 112040
cols=[r['Field'] for r in db.conn.execute('SHOW COLUMNS FROM active_trades').fetchall()]
print('active_trades cols', cols)
rows=db.conn.execute(
  "SELECT * FROM active_trades WHERE code=%s OR strategy LIKE %s ORDER BY id DESC LIMIT 20",
  ("112040","%SCALP%"),
).fetchall()
print('active count', len(rows))
for r in rows:
    d=dict(r)
    # print key fields only
    keep={k:d.get(k) for k in d if k.lower() in (
      'id','code','name','strategy','qty','quantity','buy_price','avg_price','entry_price',
      'buy_time','entry_time','max_price','high_price','stop_loss','tp','status','created_at','updated_at'
    ) or 'price' in k.lower() or 'time' in k.lower() or 'qty' in k.lower() or 'strat' in k.lower() or 'max' in k.lower() or 'stop' in k.lower()}
    print(keep)

# all open positions
open_rows=db.conn.execute("SELECT code, strategy, qty, buy_price, buy_time FROM active_trades ORDER BY id DESC LIMIT 30").fetchall() if 'buy_price' in cols else None
if open_rows is None:
    # discover
    sample=db.conn.execute("SELECT * FROM active_trades ORDER BY id DESC LIMIT 1").fetchone()
    print('sample keys', list(dict(sample).keys()) if sample else None)
else:
    for r in open_rows:
        print('open', dict(r))
db.close()
PY

Check SCALP stop env and active 112040 position


cd /home/hoon/kis_bot && python3 - <<'PY'
from database import TradeDB
db=TradeDB()
rows=db.conn.execute(
  "SELECT code,name,strategy,avg_buy_price,current_price,stop_price,target_price,max_price,"
  "current_qty,status,buy_date,updated_at FROM active_trades WHERE code=%s OR strategy=%s",
  ("112040","SCALP"),
).fetchall()
for r in rows:
    d=dict(r)
    buy=float(d['avg_buy_price'] or 0)
    cur=float(d['current_price'] or 0)
    mx=float(d['max_price'] or 0)
    stop=float(d['stop_price'] or 0)
    print(d)
    if buy:
        print(f"  vs_entry={(cur/buy-1)*100:.3f}%  vs_max={(cur/mx-1)*100 if mx else 0:.3f}%  stop={stop} stop_from_entry={(stop/buy-1)*100:.3f}%")

# all SCALP/open
allr=db.conn.execute(
  "SELECT code,name,strategy,avg_buy_price,current_price,stop_price,max_price,current_qty,status,buy_date "
  "FROM active_trades WHERE status=%s OR current_qty>0 ORDER BY buy_date",
  ("HOLDING",),
).fetchall()
print('holding', len(allr))
for r in allr:
    d=dict(r)
    buy=float(d['avg_buy_price'] or 0); cur=float(d['current_price'] or 0)
    print(f"{d['strategy']} {d['code']} buy={buy} cur={cur} pnl%={(cur/buy-1)*100 if buy else 0:.2f} stop={d['stop_price']} max={d['max_price']} qty={d['current_qty']} st={d['status']}")

# trade_history OB fields for 093190
cols=[r['Field'] for r in db.conn.execute('SHOW COLUMNS FROM trade_history').fetchall()]
obcols=[c for c in cols if 'ob' in c.lower() or 'order' in c.lower() or 'bid' in c.lower() or 'ask' in c.lower() or 'whale' in c.lower() or 'mid' in c.lower()]
print('trade_history obcols', obcols)
# recent scalp/momentum trades
tc=[c for c in ('code','strategy','buy_date','sell_date','buy_price','sell_price','qty','pnl','sell_reason','entry_ob','exit_ob','buy_ob','sell_ob') if c in cols]
# pick available
use=[c for c in cols if c in ('code','strategy','buy_date','sell_date','avg_buy_price','sell_price','quantity','profit_loss','sell_reason','reason')]
print('use', [c for c in cols if any(x in c.lower() for x in ('buy','sell','reason','pnl','profit','qty','code','strat'))][:40])
db.close()
PY

Inspect 112040 position prices vs stop

journalctl -u kis_trader_main.service --since "2026-08-13 09:50:00" --no-pager 2>/dev/null | rg -i "112040|위메이드|SCALP.*(손절|매도|sell|청산|stop)|탈락.*112040|호가" | head -n 80

Find SCALP/Wemade sell-related logs


`active_trades`의 `current_price`가 09:52 이후 갱신되지 않습니다. 청산 루프·호가 표시 경로를 이어서 확인합니다.

journalctl -u kis_trader_main.service --since "2026-08-13 09:52:00" --no-pager 2>/dev/null | rg -i "112040|위메이드|SCALP.*(매도|손절|청산|check_sell|보유)|구독.*112040|unsubscribe.*112040" | rg -v "봉강제확정|갭보정|탈락-" | head -n 60
journalctl -u kis_trader_main.service --since "2026-08-13 10:00:00" --no-pager 2>/dev/null | rg -i "112040|위메이드" | rg -v "봉강제확정" | head -n 40
echo '--- recent price ticks ---'
cd /home/hoon/kis_bot && python3 - <<'PY'
from database import TradeDB
db=TradeDB()
# ws ticks recent for 112040
for t in ['ws_ticks','ls_ws_ticks']:
  try:
    cols=[r['Field'] for r in db.conn.execute(f'SHOW COLUMNS FROM {t}').fetchall()]
    print(t, cols[:12])
    rows=db.conn.execute(
      f"SELECT * FROM {t} WHERE code=%s ORDER BY 1 DESC LIMIT 3",
      ("112040",),
    ).fetchall()
  except Exception as e:
    print(t, e)
# try proper
try:
  rows=db.conn.execute(
    "SELECT code, price, tick_time, created_at FROM ws_ticks WHERE code=%s ORDER BY tick_time DESC LIMIT 5",
    ("112040",),
  ).fetchall()
  print('ws_ticks', [dict(r) for r in rows])
except Exception as e:
  print('ws_ticks err', e)
  cols=[r['Field'] for r in db.conn.execute('SHOW COLUMNS FROM ws_ticks').fetchall()]
  print(cols)
db.close()
PY

Check sell logs and recent ticks 112040


cd /home/hoon/kis_bot && python3 - <<'PY'
from database import TradeDB
db=TradeDB()
rows=db.conn.execute(
  "SELECT tick_time, price, volume, source FROM ws_ticks WHERE code=%s AND tick_time>=%s ORDER BY tick_time ASC LIMIT 5",
  ("112040","2026-08-13 09:51:00"),
).fetchall()
print('first ticks', [dict(r) for r in rows])
rows=db.conn.execute(
  "SELECT MIN(price) mn, MAX(price) mx, COUNT(*) n, MAX(tick_time) last_t, MIN(tick_time) first_t FROM ws_ticks WHERE code=%s AND tick_time>=%s",
  ("112040","2026-08-13 09:51:00"),
).fetchall()
print('range', dict(rows[0]) if rows else None)
# last 10
rows=db.conn.execute(
  "SELECT tick_time, price FROM ws_ticks WHERE code=%s ORDER BY tick_time DESC LIMIT 10",
  ("112040",),
).fetchall()
print('last', [dict(r) for r in rows])
# candles 1m low
cols=[r['Field'] for r in db.conn.execute('SHOW COLUMNS FROM ws_candles').fetchall()]
print('candle cols', cols[:15])
# find low since buy
try:
  r=db.conn.execute(
    "SELECT MIN(low) mn, MAX(high) mx, COUNT(*) n FROM ws_candles WHERE code=%s AND candle_time>=%s AND timeframe=%s",
    ("112040","202608130951","1M"),
  ).fetchone()
  print('1m since buy', dict(r) if r else None)
except Exception as e:
  print('candle err', e)
  # try alternate
  for tf in ['1','1m','1M']:
    try:
      r=db.conn.execute(
        "SELECT MIN(low) mn, MAX(high) mx, COUNT(*) n FROM ws_candles WHERE code=%s AND candle_time LIKE %s LIMIT 1",
        ("112040","20260813%"),
      ).fetchone()
      print('alt', dict(r))
      break
    except Exception as e2:
      print(e2)
db.close()
PY

Price range for 112040 since buy


cd /home/hoon/kis_bot && python3 - <<'PY'
from database import TradeDB
db=TradeDB()
# 093190 trade
cols=[r['Field'] for r in db.conn.execute('SHOW COLUMNS FROM trade_history').fetchall()]
print(cols)
row=db.conn.execute(
  "SELECT * FROM trade_history WHERE code=%s AND buy_date LIKE %s ORDER BY buy_date DESC LIMIT 1",
  ("093190","2026-08-13%"),
).fetchone()
print(dict(row) if row else None)
# scalp open display path - day change?
# prev close approx from ticks yesterday
r=db.conn.execute(
  "SELECT price FROM ws_ticks WHERE code=%s AND tick_time LIKE %s ORDER BY tick_time DESC LIMIT 1",
  ("112040","20260812%"),
).fetchone()
print('yest last', dict(r) if r else None)
buy=16836
cur=16910
prev=float(r['price']) if r else None
if prev:
  print(f'day_chg={(cur/prev-1)*100:.2f}% entry={(cur/buy-1)*100:.2f}%')
db.close()
PY

Inspect trade_history row and day PnL math


cd /home/hoon/kis_bot && python3 - <<'PY'
from database import TradeDB
from kis_trader.backtest.trade_orderbook_enrich import enrich_trades_with_orderbook

db=TradeDB()
# orderbook table
for t in ['ws_orderbook','ls_ws_orderbook']:
  cols=[r['Field'] for r in db.conn.execute(f'SHOW COLUMNS FROM {t}').fetchall()]
  print(t, cols[:20])

# snaps near 093190 buy/sell
for code, t0 in [('093190','202608131028'),('093190','202608131039'),('112040','202608130951')]:
  rows=db.conn.execute(
    "SELECT snap_time, strategy, mid_ratio, whale_ratio, or_ma, spread_pct, source FROM ws_orderbook WHERE code=%s AND snap_time LIKE %s ORDER BY snap_time LIMIT 5",
    (code, t0+'%'),
  ).fetchall() if False else None
  # discover cols first
cols=[r['Field'] for r in db.conn.execute('SHOW COLUMNS FROM ws_orderbook').fetchall()]
print('ws_orderbook full', cols)

# sample query with actual cols
sample=db.conn.execute("SELECT * FROM ws_orderbook WHERE code=%s ORDER BY snap_time DESC LIMIT 1",("093190",)).fetchone()
print('sample 093190', dict(sample) if sample else None)

# count around trade window
rows=db.conn.execute(
  "SELECT snap_time, code FROM ws_orderbook WHERE code=%s AND snap_time BETWEEN %s AND %s ORDER BY snap_time LIMIT 20",
  ("093190","20260813102800","20260813104000"),
).fetchall()
print('ob count window', len(rows), [dict(r) for r in rows[:5]])

trades=[{
  'code':'093190','strategy':'MOMENTUM',
  'buy_date':'2026-08-13 10:28:53','sell_date':'2026-08-13 10:39:40',
  'buy_price':8980,'sell_price':9080,
}]
enrich_trades_with_orderbook(db, trades, strategy_hint='MOMENTUM')
print('enriched', trades[0].get('entry_ob'), trades[0].get('exit_ob'))

trades2=[{
  'code':'112040','strategy':'SCALP',
  'buy_date':'2026-08-13 09:51:52','sell_date':None,
  'buy_price':16836,
}]
enrich_trades_with_orderbook(db, trades2, strategy_hint='SCALP')
print('scalp enriched', trades2[0].get('entry_ob'), trades2[0].get('exit_ob'))
db.close()
PY

Debug orderbook enrich for live trades


호가 표시 키 불일치를 확인했습니다. `mid_ratio`/`whale_ratio` 정의를 찾아 UI를 맞춥니다.


cd /home/hoon/kis_bot && python3 - <<'PY'
# Confirm live API returns entry_ob without mid_ratio
import json, urllib.request
# find live trades endpoint
import re
from pathlib import Path
text=Path('backtest_web.py').read_text()
for m in re.finditer(r'@app\.(?:route|get|post)\([^\)]+\)[\s\S]{0,80}def\s+(\w+)', text):
  pass
# grep routes
import subprocess
r=subprocess.check_output("rg -n 'entry_ob|live.*trade|/api/.*actual|/api/.*live|trade_history' backtest_web.py | head -40", shell=True, text=True)
print(r)
PY

Find live trades API route for OB


curl -s 'http://127.0.0.1:5050/api/actual?strategy=MOMENTUM&limit=3' | python3 -c "import sys,json; d=json.load(sys.stdin); t=(d.get('trades') or d.get('data') or d)[0] if isinstance(d,dict) else d[0]; print(type(d), list(d)[:20] if isinstance(d,dict) else 'list');
trades=d.get('trades') or d.get('closed') or d.get('rows') or []
print('n', len(trades) if isinstance(trades,list) else type(trades))
if isinstance(d,dict):
  for k,v in d.items():
    if isinstance(v,list) and v and isinstance(v[0],dict):
      print('listkey', k, 'sample keys', list(v[0].keys())[:30]);
      print('entry_ob', v[0].get('entry_ob')); print('exit_ob', v[0].get('exit_ob'));
      break
"

Sample /api/actual OB fields from web


Grep

중간비율|고래|whale|mid_ratio|or_ratio \* 100|ratio \* 100


Shell

curl -s 'http://127.0.0.1:5050/api/actual?strategy=MOMENTUM&start=2026-08-13&end=2026-08-13' | python3 - <<'PY'
import sys,json
d=json.load(sys.stdin)
for t in d.get('trades') or []:
  if t.get('code')=='093190':
    print('entry', t.get('entry_ob'))
    print('exit', t.get('exit_ob'))
    break
else:
  print('not found', len(d.get('trades') or []))
  for t in (d.get('trades') or [])[:3]:
    print(t.get('code'), t.get('entry_ob'), t.get('exit_ob'))
PY
curl -s 'http://127.0.0.1:5050/api/actual?strategy=SCALP&start=2026-08-13&end=2026-08-13' | python3 - <<'PY'
import sys,json
d=json.load(sys.stdin)
for t in d.get('trades') or []:
  if str(t.get('code'))=='112040':
    print('open', t.get('is_open'), 'px', t.get('buy_price'), t.get('sell_price'), 'rate', t.get('profit_rate'))
    print('entry_ob', t.get('entry_ob'))
    break
print('open_count', d.get('meta'))
PY

Confirm API OB keys for today's trades


cd /home/hoon/kis_bot && python3 - <<'PY'
import json, urllib.request
for strat in ('MOMENTUM','SCALP'):
  url=f'http://127.0.0.1:5050/api/actual?strategy={strat}&start=2026-08-13&end=2026-08-13'
  with urllib.request.urlopen(url, timeout=60) as r:
    d=json.load(r)
  print('===', strat, 'trades', len(d.get('trades') or []), 'meta', d.get('meta'))
  for t in d.get('trades') or []:
    code=str(t.get('code'))
    if code in ('093190','112040') or (strat=='MOMENTUM' and t.get('entry_ob')):
      eo=t.get('entry_ob') or {}
      xo=t.get('exit_ob') or {}
      print(code, 'open', t.get('is_open'),
            'keys_entry', sorted(eo.keys()) if eo else None,
            'mid', eo.get('mid_ratio'), 'ratio', eo.get('ratio'), 'or', eo.get('or_ratio'),
            'exit_mid', xo.get('mid_ratio') if xo else None, 'exit_ratio', xo.get('ratio') if xo else None)
      if code in ('093190','112040'):
        break
PY

Fetch actual API OB field names


journalctl -u kis_trader_main.service --since "2026-08-13 09:51:00" --no-pager 2>/dev/null | rg -i "구독 해제: 112040|구독 추가: 112040|틱매도|TICK_SELL|SCALP.*매도" | head -n 30
cd /home/hoon/kis_bot && python3 - <<'PY'
from kis_trader.utils.env import invalidate_merged_env_cache, get_env_bool, get_env_from_db
invalidate_merged_env_cache()
for k in ['TICK_SELL_ENABLED','SCALP_TICK_SELL','TICK_SELL','STRATEGY_TICK_SELL','LIVE_TICK_SELL']:
  print(k, get_env_from_db(k), get_env_bool(k, False))
PY

Check tick-sell env and 112040 subscribe