    if alive and m.get("status") != "done":
        m["status"] = "running"
        m["finished_at"] = None
    elif not alive:

    if m.get("status") not in ("done", "error") or alive:
        # 로그에 결과 JSON 이 이미 찍혔어도, 프로세스가 살아 있으면 후처리 중일 수 있음.
        # 「최종 JSON」이 찍히고 PID 가 죽은 뒤에만 완료.
        rj = m.get("result_json")
        log_tail = _tail_text(log_path, 80) or ""
        final_json = ("최종 JSON" in log_tail) or ("[후처리 TopN]" in log_tail)
        if m.get("kind") not in ("seq", "seq4") and alive:
            m["status"] = "running"
            m["finished_at"] = None
        elif m.get("kind") not in ("seq", "seq4") and rj and Path(str(rj)).is_file() and (
            "OPTUNA_RESULT_JSON=" in log_tail
        ) and final_json:
            alive = False
        elif m.get("kind") not in ("seq", "seq4") and rj and Path(str(rj)).is_file() and (
            "OPTUNA_RESULT_JSON=" in log_tail
        ) and not alive:
            pass  # 프로세스 종료 + JSON 있음 → 아래 done
        if alive:
            m["status"] = "running"
            m["finished_at"] = None
        else:

    from kis_trader.backtest.optuna_orderbook_recommend import (
        _env_pfx,
        build_entry_ob_env_patch,
        build_exit_ob_env_patch,
        build_stop_ob_env_patch,
    )
    from kis_trader.backtest.optuna_whipsaw_recommend import build_whipsaw_env_patch

    u = str(upto or "base").strip().lower()
    if u not in _UPTO_ORDER:
        raise ValueError(f"upto 는 {_UPTO_ORDER} 만 (got {upto})")
    notes: List[str] = []
    strat_u = str(strategy or data.get("strategy") or "").strip().upper()
    if strat_u in ("TAIL", "SHORT"):
        strat_u = "TAIL"
    elif strat_u in ("SCALPING", "SCALP"):
        strat_u = "SCALP"
    elif strat_u in ("US_MOMENTUM",):
        strat_u = "US_MOMENTUM"
    elif strat_u in ("MOMENTUM",):
        strat_u = "MOMENTUM"
    epfx = _env_pfx(strat_u)

    anchor = pick_postprocess_anchor(data, source, rank)
    if not anchor:
        notes.append("후처리 앵커 없음(구 JSON이면 재실행 필요)")
        return {}, notes
    ob = dict(anchor.get("orderbook") or {})
    ob["strategy"] = strat_u
    ws = dict(anchor.get("whipsaw") or {})
    ws["strategy"] = strat_u
    patch: Dict[str, str] = {}
    idx = _UPTO_ORDER.index(u)
    if u == "base":
        patch.update(_later_axes_off_patch(strat_u, u))
        notes.append("뒤쪽 후처리축 ENABLED=false (숫자는 유지)")
        return patch, notes
    if idx >= _UPTO_ORDER.index("entry"):
        ep = build_entry_ob_env_patch(ob, strat_u)
        if ep:
            patch.update(ep)
        else:
            notes.append("진입호가 추천 없음 → 진입 필터 OFF")
            if epfx:
                patch[f"{epfx}_ORDERBOOK_FILTER_ENABLED"] = "false"
    if idx >= _UPTO_ORDER.index("exit"):
        xp = build_exit_ob_env_patch(ob, strat_u)
        if xp:
            patch.update(xp)
        elif strat_u not in ("MOMENTUM", "BREAKOUT"):
            notes.append("익절호가 해당없음")
        else:
            notes.append("익절호가 추천 없음 → 익절호가 OFF")
            if epfx:
                patch[f"{epfx}_EXIT_OB_ENABLED"] = "false"
    if idx >= _UPTO_ORDER.index("stop"):
        sp = build_stop_ob_env_patch(ob, strat_u)
        if sp:
            patch.update(sp)
        elif strat_u not in ("MOMENTUM", "BREAKOUT"):
            notes.append("손절호가 해당없음")
        else:
            notes.append("손절호가 추천 없음 → 손절호가 OFF")
            if epfx:
                patch[f"{epfx}_STOP_OB_ENABLED"] = "false"

    from kis_trader.backtest.optuna_orderbook_recommend import (
        build_entry_ob_env_patch,
        build_exit_ob_env_patch,
        build_stop_ob_env_patch,
    )
    from kis_trader.backtest.optuna_whipsaw_recommend import build_whipsaw_env_patch

    u = str(upto or "base").strip().lower()
    if u not in _UPTO_ORDER:
        raise ValueError(f"upto 는 {_UPTO_ORDER} 만 (got {upto})")
    notes: List[str] = []
    strat_u = str(strategy or data.get("strategy") or "").strip().upper()
    if strat_u in ("TAIL", "SHORT"):
        strat_u = "TAIL"
    elif strat_u in ("SCALPING", "SCALP"):
        strat_u = "SCALP"
    elif strat_u in ("US_MOMENTUM",):
        strat_u = "US_MOMENTUM"
    elif strat_u in ("MOMENTUM",):
        strat_u = "MOMENTUM"

    anchor = pick_postprocess_anchor(data, source, rank)
    if not anchor:
        notes.append("후처리 앵커 없음(구 JSON이면 재실행 필요)")
        return {}, notes
    ob = dict(anchor.get("orderbook") or {})
    ob["strategy"] = strat_u
    ws = dict(anchor.get("whipsaw") or {})
    ws["strategy"] = strat_u
    patch: Dict[str, str] = {}
    idx = _UPTO_ORDER.index(u)
    if u == "base":
        patch.update(_later_axes_off_patch(strat_u, u))
        notes.append("뒤쪽 후처리축 ENABLED=false (숫자는 유지)")
        return patch, notes
    if idx >= _UPTO_ORDER.index("entry"):
        ep = build_entry_ob_env_patch(ob, strat_u)
        if ep:
            patch.update(ep)
        else:
            notes.append("진입호가 추천 없음 → 진입 필터 OFF")
            pfx = _later_axes_off_patch.__doc__  # noqa: keep pfx via helper
            from kis_trader.backtest.optuna_orderbook_recommend import _env_pfx
            epfx = _env_pfx(strat_u)
            if epfx:
                patch[f"{epfx}_ORDERBOOK_FILTER_ENABLED"] = "false"
    if idx >= _UPTO_ORDER.index("exit"):
        xp = build_exit_ob_env_patch(ob, strat_u)
        if xp:
            patch.update(xp)
        elif strat_u not in ("MOMENTUM", "BREAKOUT"):
            notes.append("익절호가 해당없음")
        else:
            notes.append("익절호가 추천 없음 → 익절호가 OFF")
            from kis_trader.backtest.optuna_orderbook_recommend import _env_pfx
            epfx = _env_pfx(strat_u)
            if epfx:
                patch[f"{epfx}_EXIT_OB_ENABLED"] = "false"
    if idx >= _UPTO_ORDER.index("stop"):
        sp = build_stop_ob_env_patch(ob, strat_u)
        if sp:
            patch.update(sp)
        elif strat_u not in ("MOMENTUM", "BREAKOUT"):
            notes.append("손절호가 해당없음")
        else:
            notes.append("손절호가 추천 없음 → 손절호가 OFF")
            from kis_trader.backtest.optuna_orderbook_recommend import _env_pfx
            epfx = _env_pfx(strat_u)
            if epfx:
                patch[f"{epfx}_STOP_OB_ENABLED"] = "false"

light_skip: '후처리 미저장 · 진행 중이면 완료 후 새로고침',


StrReplace

후처리가 로그에만 있고 표가 비면, 잡이 끝난 뒤 새로고침하세요. 진짜 구 JSON만 재실행.

구 JSON은 재실행해야 호가/휩쏘 숫자가 채워집니다.


    if alive:
        m["status"] = "running"
        m["finished_at"] = None
    elif not alive:
        # 프로세스 종료
        if m.get("result_json") and Path(str(m["result_json"])).is_file():
            m["status"] = "done"

    if alive and m.get("status") != "done":
        m["status"] = "running"
        m["finished_at"] = None
    elif not alive:
            # 프로세스 종료
            if m.get("result_json") and Path(str(m["result_json"])).is_file():
                m["status"] = "done"


    if alive:
        m["status"] = "running"
        m["finished_at"] = None
    elif not alive:
        # 프로세스 종료
        if m.get("result_json") and Path(str(m["result_json"])).is_file():
            m["status"] = "done"
        elif m.get("kind") in ("seq4", "seq") and m.get("status") != "done":
            tail = _tail_text(log_path, 20)
            if "ALL DONE" in (tail or ""):
                m["status"] = "done"
            else:
                m["status"] = "error"
                m["error"] = m.get("error") or "process ended without ALL DONE"
        else:
            strat = str(m.get("strategy") or "")
            if strat and strat not in ("all", "seq"):
                globs = [
                    f"optuna_{strat}_{m.get('mode') or 'tpe'}_*.json",
                    f"optuna_{strat}_*.json",
                ]
                if strat == "us_momentum":
                    globs.append("optuna_momentum_tpe_*.json")
                    globs.append("optuna_momentum_*.json")
                cands = []
                for g in globs:
                    cands.extend(RESULTS_DIR.glob(g))
                cands = sorted(
                    {p.resolve(): p for p in cands}.values(),
                    key=lambda p: p.stat().st_mtime,
                    reverse=True,
                )
                if cands and cands[0].stat().st_mtime >= float(m.get("started_ts") or 0) - 5:
                    m["result_json"] = str(cands[0])
                    brief = Path(str(cands[0]).replace(".json", ".briefing.md"))
                    if brief.is_file():
                        m["briefing_md"] = str(brief)
                    m["status"] = "done"
                else:
                    m["status"] = "error"
                    m["error"] = m.get("error") or "process ended (no result json)"
            else:
                m["status"] = "error"
                m["error"] = m.get("error") or "process ended"
        if m["status"] in ("done", "error") and not m.get("finished_at"):
            m["finished_at"] = _now_iso()
            m["finished_ts"] = time.time()
        elif m["status"] in ("done", "error") and not m.get("finished_ts"):
            fa = str(m.get("finished_at") or "")[:19]
            try:
                m["finished_ts"] = datetime.strptime(fa, "%Y-%m-%dT%H:%M:%S").timestamp()
            except Exception:
                m["finished_ts"] = float(m.get("started_ts") or time.time())

    if alive:
        m["status"] = "running"
        m["finished_at"] = None
    elif not alive:
        # 프로세스 종료
        if m.get("result_json") and Path(str(m["result_json"])).is_file():
            m["status"] = "done"
            elif m.get("kind") in ("seq4", "seq") and m.get("status") != "done":
                # seq 마스터가 죽었는데 ALL DONE 없으면 error 가능
                tail = _tail_text(log_path, 20)
                if "ALL DONE" in (tail or ""):
                    m["status"] = "done"
                else:
                    m["status"] = "error"
                    m["error"] = m.get("error") or "process ended without ALL DONE"
            else:
                # 싱글: 결과 JSON 최신 glob 보조
                strat = str(m.get("strategy") or "")
                if strat and strat not in ("all", "seq"):
                    # us_momentum 결과 파일명: optuna_us_momentum_* (구버전 momentum_* 폴백)
                    globs = [
                        f"optuna_{strat}_{m.get('mode') or 'tpe'}_*.json",
                        f"optuna_{strat}_*.json",
                    ]
                    if strat == "us_momentum":
                        globs.append("optuna_momentum_tpe_*.json")
                        globs.append("optuna_momentum_*.json")
                    cands = []
                    for g in globs:
                        cands.extend(RESULTS_DIR.glob(g))
                    cands = sorted(
                        {p.resolve(): p for p in cands}.values(),
                        key=lambda p: p.stat().st_mtime,
                        reverse=True,
                    )
                    if cands and cands[0].stat().st_mtime >= float(m.get("started_ts") or 0) - 5:
                        m["result_json"] = str(cands[0])
                        brief = Path(str(cands[0]).replace(".json", ".briefing.md"))
                        if brief.is_file():
                            m["briefing_md"] = str(brief)
                        m["status"] = "done"
                    else:
                        m["status"] = "error"
                        m["error"] = m.get("error") or "process ended (no result json)"
                else:
                    m["status"] = "error"
                    m["error"] = m.get("error") or "process ended"
            if m["status"] in ("done", "error") and not m.get("finished_at"):
                m["finished_at"] = _now_iso()
                m["finished_ts"] = time.time()
            elif m["status"] in ("done", "error") and not m.get("finished_ts"):
                fa = str(m.get("finished_at") or "")[:19]
                try:
                    m["finished_ts"] = datetime.strptime(fa, "%Y-%m-%dT%H:%M:%S").timestamp()
                except Exception:
                    m["finished_ts"] = float(m.get("started_ts") or time.time())


Shell

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

Compile, restart web, health-check


**구JSON이 아닙니다.** 돌파 Optuna가 **아직 후처리 중인데**, 웹이 중간 저장 파일을 보고 **완료 처리**한 겁니다.

로그에 진입/익절 합의가 찍히는 건 맞습니다. 그때는 메모리에서 계산 중이고, **디스크 JSON에 `postprocess_topn`이 아직 없습니다.** trial이 끝난 직후 `OPTUNA_RESULT_JSON=`이 먼저 나와서, 웹이 PID가 살아 있는데도 끝낸 줄 알고 빈 칸에 「구JSON」을 붙였습니다. **꼬리·모멘텀·스캘프도 같은 함정**입니다.

방금 그 돌파 프로세스(호가 후처리)는 제가 볼 때도 살아 있었습니다. **끝날 때까지 기다린 뒤 새로고침**하면 표가 채워집니다. 돌아가는 중에 「이 잡 후처리 재실행」은 누르지 마세요.

웹은 이제 **PID가 살아 있으면 진행 중**으로 두고, 빈 후처리를 구JSON으로 위장하지 않습니다.

---

**진입 없음 + 익절 있음**에서 「익절까지」를 누르면:

- **진입 호가 필터 OFF** (`BREAKOUT_ORDERBOOK_FILTER_ENABLED=false`)
- **익절 호가 ON** (로그에 나온 합의값)
- 그보다 뒤(손절 호가)는 OFF

예전에는 진입 추천이 없으면 **DB에 있던 진입 설정을 그대로 두는** 구멍이 있었습니다. 지금은 위처럼 끕니다.

돌파 휩쏘는 로그에 숫자가 나와도 **DB 적용은 스킵**입니다(돌파 특성). 실매 엔진은 그대로입니다. 웹 `active` · HTTP 200.


<timestamp>Sunday, Aug 16, 2026, 12:19 AM (UTC+9)</timestamp>
<user_query>
📈 KIS Quant Dashboard
돌파 완료 30/30
100% · best#20 WR 77.8% · PnL 36,942원 · PF 23.09 · MDD — · 9건
돌파 백테 완료 2026-08-12~2026-08-14
100%
업데이트: 2026. 8. 16. 오전 12:16:41
매매
모의 · active
시세:키움(실) | 호가:키움(실) | DB:후보
📊 실거래 분석
📈 오늘 운영
⚙ 운영 설정
💼 보유·매도
📉 스캘핑 백테스트
🎣 꼬리잡기 백테스트
🚀 돌파매매 백테스트
🌊 모멘텀 백테스트
🇺🇸 해외 모멘텀
🔬 Optuna
📈 홀딩 전략
📦 박스엔진
📡 영구구독 관리
📢 DART 수주
시작일
2026-08-12
종료일
2026-08-14
Lookback(분봉)
1
VolWin(분봉)
2
1분회전율min(%)
유통주식 대비 · BREAKOUT_MIN_TURNOVER_1M_PCT
0.25
거래량배수(보조)
0=OFF · BREAKOUT_VOL_MULT
1.7
직전봉↑min(%)
0.30000000000000004
직전봉↑max(%)
17
진입모드
B intrabar
B슬리피지(%)
0
1회투자(원)
300000
동시보유(종)
8
총운용한도(원)
1200000
TRIGGER 필터 — HTS breakout SCAN 통과 후 엔진 검사
HTS중복스킵
손절모드
BREAKOUT_SL_MODE

fixed(%)
손절(%)
4
ATR기간
BREAKOUT_ATR_PERIOD
14
ATR손절배
BREAKOUT_ATR_SL_MULT
3.2
ATR손절하한%
MIN_PCT
0.8
ATR손절상한%
MAX_PCT
6
익절(%)
11
트레일(%)
1.9
트레일무장(%)
1.6
어깨발동(%)
4.3
어깨폭(%)
0.6
매수시작
900
매수종료
1530
EOD청산
EOD시각
15:15
최대보유(분)
150
일일최대
100
쿨다운(분)
30
급등주(%)
32
최소가격
2500
돌파확인여유(%)
0
양봉몸통최소(%)
0.1
래칫티어
5.5:2.5
1회최대손실
150000
EMA추세필터 (TRIGGER)
호가필터(백테)
프로그램필터(백테)
스프레드(%)
MAX_SPREAD
2.78
EMA빠름
fast · BREAKOUT_EMA_FAST_PERIOD
9
EMA느림
slow · BREAKOUT_EMA_SLOW_PERIOD
34
📅 다단트레일 ON이면 당일 실현손익 고점 대비 되돌릴 때 신규매수만 중단. 리스크버짓(B) ON이면 hit 후 보유최악손절합>cushion 이면 전량정리. DB=BREAKOUT_DAILY_PROFIT_*.
다단트레일
리스크버짓(B)
다단규칙(수익원:컷%,…)
프리셋 선택…(직접입력)
15000:40,30000:30,60000:20
단일되돌림(%)
0
발동최소수익(원)
0
방식
trailing
저장 후보 이력 — 체크: DB 슬롯·후보 / 해제: ws_candles 전 종목
이력소스

키움 (target_candidates)
캔들

기본
시세(틱)

기본
호가

기본
env 타임라인 — 체크: 그 시각 DB설정 재현 / 해제(기본): 폼 파람으로 구간 전체
탐색: CLI · 전역 1잡
done 100% 2026-08-12~2026-08-14 · 완료 trades=9 PnL=6203 · /home/hoon/kis_bot/logs/breakout_bt_20260812_20260814_20260816_001657.log · 전역 1잡 · 탭 이동 OK
📖 키움 'breakout' 조건식 ↔ 봇 룰 매핑
진입모드 B	intrabar=봉 안 저항 돌파·현재가 (실매·HTS E) · align=확정봉 후 다음봉 시가 (구 A안)
Lookback / VolWin	둘 다 1이면 키움 'breakout' 조건과 100% 동일 (직전 1봉 기준)
1분회전율 / 거래량배수	회전율=유통주식 대비 1분 체결%(ka10001) 1차 · 거래량배수=직전 N봉 평균×배수(0=OFF, HTS 300%=3)
직전봉 등락률	키움 조건은 1~10%. 0.5~15% 범위에서 튜닝 가능.
매수시작/종료	파라서치 fast는 10:30·11:30 종료 탐색. 웹에서 15:30이면 1위 조합과 불일치·수익 악화.
운용한도	총수익률·일평균 % 분모. 파라서치 --total-budget 와 동일해야 비교 가능.
손절/익절/트레일	봇 BREAKOUT_STOP_LOSS_PCT=-2% / TAKE_PROFIT=+5% / TRAIL=1.5% 기본
트레일무장(%)	고점이 진입가×(1+무장%) 도달 후에만 보조 트레일 작동. 0=즉시 · 백테 1.5% 권장(본전청산↓)
급등주(%)	당일 시가 대비 누적 상승률. 이 값 초과 종목은 이격 과열로 진입 금지.
일일최대 / 쿨다운	돌파는 종목당 1번/일 권장. 손절 후 30분 재진입 금지로 휩쏘 회피.
유니버스: 저장 이력 1383슬롯 진입 INTRABAR | Lookback 7분 | 회전율≥0.2% · VolWin9분×2.4배 | 직전봉 0.9~24.0% | 가짜돌파필터(여유0.3%·몸통0.0%) | EMA필터 OFF | 손절-5.6% 익절+5.5% 트레일5.1%(무장3.7%) | 0900-1530 | 2026-08-12~2026-08-14 | 일100회 쿨다운30분 | 1회 300,000원 · 동시8종 · 한도 1,200,000원 | 종목수 540개
💰 총한도 1,200,000원 < 동시8×1회투자 2,400,000원 — 잔여금 소액매수·과다 회전 위험. 실매 정렬: 총한도↑ 또는 동시보유↓
📊 매수재생 틱DB(ws_ticks) | 틱 790,950건 · 분봉커버 구독구간 13.52% · 거래종목 35.45%(9종) · 전체 9.43% · 틱종목 9.47% (534/540종)
총 거래
9건
승률
44.44%
순손익(수수료·세금 포함)
6,203원
Profit Factor
1.22
최대 낙폭(MDD)
-26,537원
평균 보유(분)
83분
총 수익률(운용한도 대비)
+0.52%
일평균 수익률
+0.173%
🟢 승 4건 (44.44%)
🔴 패 5건 (55.6%)
누적 손익 곡선 (가상)
매도 이유 분포
일별 손익 (가상)
가상 거래 내역 (최근 200건)
📋 백테 BREAKOUT · 한도 1,200,000원 | 장중 누적 최고 +12,469원 (2026-08-13 09:20:00) | 최종 누적 6,203원
정렬
종목	매수시각	매도시각	매수가	매도가	수량	손익(원)	수익률%	누적손익	누적%	보유(분)	체결디버그	매수호가
녹OK · 노랑참고 · 빨강나쁨	매도호가
녹OK · 노랑참고 · 빨강나쁨	매도사유
DN오토모티브
007340
2026-08-14 13:36:00	2026-08-14 13:39:16	51,500	55,000	5	16,925	+6.80%	6,203	+0.52%	3.3	-→-	팔대기많음
가격간격 OK 0.20% (2.78%까지)
살 힘 OK 1.09 (1.00 이상)
사겠음 580주 · 팔겠음 534주
시차 +1s · filter_eval	수급약함
OR 약함 0.11 (총사겠음÷총팔겠음, 기준 0.65)
가격간격 OK 0.18%
총사겠음 273 · 총팔겠음 2,388
시차 +0s · kiwoom_0d	take_profit
현대해상
001450
2026-08-14 10:19:00	2026-08-14 12:09:01	45,200	45,275	6	-120	+0.17%	-10,722	-0.89%	110	-→-	살힘약함
가격간격 OK 0.11% (2.78%까지)
살 힘 약함 0.09 (1.00 이상)
사겠음 865주 · 팔겠음 10,026주
시차 +0s · kiwoom_0d	수급약함
OR 약함 0.31 (총사겠음÷총팔겠음, 기준 0.65)
가격간격 OK 0.11%
총사겠음 4,079 · 총팔겠음 13,116
시차 +0s · kiwoom_0d	max_hold
클로봇
466100
2026-08-14 09:07:00	2026-08-14 10:20:00	30,500	30,950	9	3,466	+1.48%	-10,602	-0.88%	73	-→bt_flatten	살사람적음
가격간격 OK 0.16% (2.78%까지)
살 힘 약함 0.59 (1.00 이상)
사겠음 1,305주 · 팔겠음 2,207주
시차 +107s · filter_eval	수급OK
OR OK 2.49 (총사겠음÷총팔겠음, 기준 0.65)
가격간격 OK 0.16%
총사겠음 20,300 · 총팔겠음 8,138
시차 +3s · kiwoom_0d	bt_flatten
다날
064260
2026-08-14 09:01:00	2026-08-14 09:14:00	5,300	5,180	56	-7,330	-2.26%	-14,068	-1.17%	13	-→bt_flatten	살사람적음
가격간격 OK 0.19% (2.78%까지)
살 힘 약함 0.40 (1.00 이상)
사겠음 3,563주 · 팔겠음 8,915주
시차 +43s · filter_eval	수급OK
OR OK 1.66 (총사겠음÷총팔겠음, 기준 0.65)
가격간격 OK 0.19%
총사겠음 61,298 · 총팔겠음 36,924
시차 +0s · kiwoom_0d	bt_flatten
씨이랩
189330
2026-08-13 13:47:00	2026-08-13 15:40:00	17,670	17,610	16	-1,552	-0.34%	-6,738	-0.56%	113	-→-	살힘약함
가격간격 OK 0.11% (2.78%까지)
살 힘 약함 0.10 (1.00 이상)
사겠음 143주 · 팔겠음 1,445주
시차 +77s · kiwoom_0d	기록없음	eod
코칩
126730
2026-08-13 10:09:00	2026-08-13 13:06:52	22,250	22,200	13	-1,256	-0.22%	-5,186	-0.43%	177.9	-→-	살힘약함
가격간격 OK 0.23% (2.78%까지)
살 힘 약함 0.22 (1.00 이상)
사겠음 3,072주 · 팔겠음 13,914주
시차 +14s · kiwoom_0d	수급약함
OR 약함 0.56 (총사겠음÷총팔겠음, 기준 0.65)
가격간격 OK 0.23%
총사겠음 15,018 · 총팔겠음 26,809
시차 +0s · kiwoom_0d	max_hold
뉴파워프라즈마
144960
2026-08-13 10:44:00	2026-08-13 12:53:37	12,840	12,930	23	1,446	+0.70%	-3,930	-0.33%	129.6	-→-	살힘약함
가격간격 OK 0.08% (2.78%까지)
살 힘 약함 0.10 (1.00 이상)
사겠음 1,177주 · 팔겠음 11,627주
시차 +0s · kiwoom_0d	수급OK
OR OK 1.71 (총사겠음÷총팔겠음, 기준 0.65)
가격간격 OK 0.08%
총사겠음 7,422 · 총팔겠음 4,350
시차 +0s · kiwoom_0d	max_hold
한국화장품제조
003350
2026-08-13 10:13:00	2026-08-13 12:01:15	13,010	12,260	23	-17,845	-5.76%	-5,376	-0.45%	108.2	-→-	살힘약함
가격간격 OK 0.23% (2.78%까지)
살 힘 약함 0.20 (1.00 이상)
사겠음 496주 · 팔겠음 2,530주
시차 +0s · kiwoom_0d	수급OK
OR OK 3.31 (총사겠음÷총팔겠음, 기준 0.65)
가격간격 OK 0.08%
총사겠음 10,094 · 총팔겠음 3,045
시차 +0s · kiwoom_0d	stop_loss
PS일렉트로닉스
332570
2026-08-13 09:01:00	2026-08-13 09:20:00	9,310	9,720	32	12,469	+4.40%	12,469	+1.04%	19	-→-	살사람적음
가격간격 OK 0.32% (2.78%까지)
살 힘 약함 0.07 (1.00 이상)
사겠음 877주 · 팔겠음 12,650주
시차 +33s · filter_eval	수급약함
OR 약함 0.13 (총사겠음÷총팔겠음, 기준 0.65)
가격간격 OK 0.21%
총사겠음 10,027 · 총팔겠음 76,499
시차 +0s · kiwoom_0d	호가컷

백테 돌려보니 매수호가 매도호가 이렇게 나오는데 이거  ok가 안떠도 통과네?
</user_query>


매수·매도 호가 칸의 색(OK/참고/나쁨)이 실제 필터 통과와 같은지 코드를 확인하겠습니다.


**맞습니다. 빨간 칸이어도 매수는 나갈 수 있습니다.** 그 칸은 **통과 도장이 아니라, 체결 시각 근처 호가창을 나중에 붙여 보여 주는 참고**입니다.

동작은 이렇게 갈립니다.

| | 하는 일 |
|---|---|
| **호가필터(백테) 체크 ON** | 사기 **직전**에 스프레드·살힘(잔량비)·매도벽을 보고 **탈락이면 안 삼** |
| **체크 OFF** (Optuna `--orderbook-filter off`도 이쪽) | 호가로 **막지 않음**. 사진은 남겨 두거나, 나중에 `kiwoom_0d` 근처 호가를 붙임 |
| **표의 녹/노랑/빨강** | 그 사진 숫자가 **지금 폼의 기준**(예: 살힘 1.00, 스프레드 2.78%)에 맞는지 **색만** 칠함 |

그래서 `살힘약함 0.09 (1.00 이상)` 인데도 산 겁니다. **그 순간 필터가 꺼져 있어서** 엔진은 통과시켰고, 표만 “이 숫자면 기준 미달”이라고 빨강으로 그린 겁니다. `filter_eval`은 검사 스냅샷이고, `kiwoom_0d`는 본체 호가라 **판정에 쓴 장과 표에 붙인 장이 다를 수도** 있습니다.

매도칸 `수급약함`도 같습니다. **익절 호가(`EXIT_OB`)가 켜져 있어야** 매도를 막고, 꺼져 있으면 트레일·손절·max_hold로 그냥 팝니다. 마지막 `호가컷`만 매도 사유가 호가 쪽입니다.

정리하면:

- **빨강 ≠ 그 거래가 필터를 통과했다**
- **빨강 + 거래 있음 ≈ 그때 진입 호가필터가 꺼져 있었거나, 표가 근처 호가를 보여 주는 중**
- 필터를 켠 백테라면, `살힘약함` 같은 **진입 빨강 거래는 원칙적으로 없어야** 합니다. 있으면 시차 매칭이거나 버그 후보입니다.

Optuna 1위(9건 · 3.6만)와 방금 웹 백테(9건 · 6,203원)가 다른 것도, 폼 숫자·호가필터 ON/OFF가 스터디와 안 맞으면 흔합니다. 호가로 걸러 보고 싶으면 **호가필터(백테)를 켠 채로** 같은 기간을 한 번 더 돌리면 됩니다.


<timestamp>Sunday, Aug 16, 2026, 12:23 AM (UTC+9)</timestamp>
<user_query>
적용을 눌렀는데?? 그럼 저장이 덜 된거네?? 시바꺼?
</user_query>


적용이 DB에 실제로 들어갔는지, 호가필터·익절호가까지 저장됐는지 확인하겠습니다.


python3 - <<'PY'
import json
from pathlib import Path
from database import TradeDB

p = Path('/home/hoon/kis_bot/kis_trader/backtest/results/optuna_breakout_tpe_20260815_235924.json')
data = json.loads(p.read_text())
pt = data.get('postprocess_topn') or {}
print('run_ob', pt.get('run_ob_whipsaw'), 'n_anc', len(pt.get('postprocess_by_anchor') or []))
for a in (pt.get('postprocess_by_anchor') or []):
    if a.get('role')=='stable' and a.get('rank')==1:
        ob=a.get('orderbook') or {}
        en=ob.get('entry') or {}
        ex=ob.get('exit') or {}
        print('stable#1 trial', a.get('optuna_trial_number'))
        print(' entry', en.get('ok'), en.get('reason'), en.get('params'))
        print(' exit', ex.get('ok'), ex.get('reason'), ex.get('params'))
        print(' ob.reason', ob.get('ok'), ob.get('reason'))
        break
else:
    print('no stable#1 anchor')
    print('roles', [ (x.get('id'), x.get('orderbook',{}).get('reason')) for x in (pt.get('postprocess_by_anchor') or [])[:8]])

db = TradeDB()
cols = [r['Field'] for r in db.conn.execute('SHOW COLUMNS FROM config_breakout').fetchall()]
keys = [
    'BREAKOUT_ORDERBOOK_FILTER_ENABLED','BREAKOUT_ORDERBOOK_MAX_SPREAD_PCT',
    'BREAKOUT_ORDERBOOK_MIN_BID_ASK_RATIO','BREAKOUT_EXIT_OB_ENABLED',
    'BREAKOUT_EXIT_OB_RATIO_MIN','BREAKOUT_STOP_LOSS_PCT','BREAKOUT_TAKE_PROFIT_PCT',
    'BREAKOUT_SL_MODE','BREAKOUT_TRAIL_PCT','BREAKOUT_MIN_PRICE',
]
in_tbl = [k for k in keys if k in cols]
print('in config_breakout', in_tbl)
if in_tbl:
    q = 'SELECT ' + ','.join(in_tbl) + ' FROM config_breakout LIMIT 1'
    row = db.conn.execute(q).fetchone()
    print(dict(row) if row else None)

# env_config_ext leftover?
ext_cols = [r['Field'] for r in db.conn.execute('SHOW COLUMNS FROM env_config_ext').fetchall()]
print('ext cols sample', ext_cols[:15])
PY

Check postprocess JSON and DB breakout orderbook keys


python3 - <<'PY'
from database import TradeDB
from kis_trader.utils.env import get_env_from_db, invalidate_merged_env_cache

invalidate_merged_env_cache()
keys = [
    'BREAKOUT_STOP_LOSS_PCT','BREAKOUT_TAKE_PROFIT_PCT','BREAKOUT_TRAIL_PCT',
    'BREAKOUT_TRAIL_ARM_PCT','BREAKOUT_SL_MODE','BREAKOUT_LOOKBACK_MIN',
    'BREAKOUT_VOL_WIN','BREAKOUT_VOL_MULT','BREAKOUT_MIN_TURNOVER_1M_PCT',
    'BREAKOUT_PREV_CHG_MIN','BREAKOUT_PREV_CHG_MAX','BREAKOUT_MIN_PRICE',
    'BREAKOUT_ORDERBOOK_FILTER_ENABLED','BREAKOUT_ORDERBOOK_MAX_SPREAD_PCT',
    'BREAKOUT_ORDERBOOK_MIN_BID_ASK_RATIO','BREAKOUT_EXIT_OB_ENABLED',
    'BREAKOUT_EXIT_OB_RATIO_MIN','BREAKOUT_EXIT_OB_MIN_HOLD_BARS',
    'BREAKOUT_EXIT_OB_MIN_PROFIT_PCT','BREAKOUT_MAX_HOLD_BARS',
]
print('=== get_env_from_db ===')
for k in keys:
    print(f'{k}={get_env_from_db(k, "")!r}')

db = TradeDB()
cols = [r['Field'] for r in db.conn.execute('SHOW COLUMNS FROM config_breakout').fetchall()]
want = [k for k in keys if k in cols]
q = 'SELECT id, created_at, ' + ','.join(want) + ' FROM config_breakout ORDER BY id DESC LIMIT 1'
row = db.conn.execute(q).fetchone()
print('=== config_breakout latest ===')
print(dict(row) if row else None)

# overflow
ext = db.conn.execute(
    'SELECT env_key, env_value FROM env_config_ext WHERE env_key LIKE %s ORDER BY env_key',
    ('BREAKOUT_%OB%',),
).fetchall()
print('=== ext OB ===')
for r in ext:
    print(dict(r))
PY

Read latest DB breakout env after Optuna apply


python3 - <<'PY'
import json
from pathlib import Path
data=json.loads(Path('/home/hoon/kis_bot/kis_trader/backtest/results/optuna_breakout_tpe_20260815_235924.json').read_text())

def show(tag, row):
    p=row.get('merged_params') or row.get('params') or {}
    keys='lookback_min vol_window vol_mult min_turnover_1m_pct prev_chg_min prev_chg_max min_price tp_pct sl_pct trail_pct trail_arm_pct max_hold_bars optuna_trial_number'.split()
    out={k: row.get(k) if k=='optuna_trial_number' else p.get(k) for k in keys}
    out['trial']=row.get('optuna_trial_number')
    out['pnl']=row.get('total_pnl')
    print(tag, out)

st=(data.get('results_stable') or [None])[0]
g20=None
for r in (data.get('results_gated') or []):
    if r.get('optuna_trial_number')==20:
        g20=r
        break
show('stable0', st)
show('gated20', g20)
print('stable trial', st.get('optuna_trial_number') if st else None)
PY

Compare stable#1 vs trial 20 params in Optuna JSON


**적용은 됐습니다.** 덜 저장된 게 아니라, **진입 호가만 일부러 꺼진 채** 들어간 겁니다.

00:16:25에 `config_breakout` id=456으로 들어갔습니다. 안정 1위 · trial #20 · **익절까지**.

| 항목 | DB 지금 값 | 의미 |
|---|---|---|
| 타점 | 손절 -5.6% · 익절 +5.5% · 트레일 5.1% · lookback 7 · vol 9×2.4 등 | trial #20 타점 **저장됨** |
| **진입 호가필터** | **`false`** | 로그에 `진입호가 유효 trial 없음` → 켤 숫자가 없어서 **OFF로 적용** |
| 익절 호가 | **`true`** · OR≥0.25 · 보유 4봉 | 익절까지 눌러서 **ON** |
| 스프레드 2.78 / 살힘 1.0 | 숫자는 남아 있음 | 필터가 꺼져 있어서 **막지는 않음** |

그래서 백테 표에 `살힘약함`이 빨강이어도 **산 겁니다.** 색은 “이 숫자면 기준 미달”이고, 실제 게이트는 꺼져 있습니다.

웹 돌파 탭 백테는 **DB가 아니라 폼**입니다. 적용 후 폼을 다시 안 넣으면 입력칸(lookback 1, 손절 4% 같은 예전 값)과 실제 런이 어긋져 보입니다. Optuna 3.6만 원과 웹 6,203원도 그 차이(폼·호가 ON/OFF·틱 커버)입니다.

다시 맞추려면 Optuna에서 그 후보 **보기 → 폼에 넣기** 한 뒤, 진입 호가필터는 **끈 상태**로 같은 기간을 돌리면 됩니다. 실매 봇은 env를 다시 읽어야 반영됩니다.


<timestamp>Sunday, Aug 16, 2026, 12:30 AM (UTC+9)</timestamp>
<user_query>
지금 내가 적용한 json이랑 백테탭 돌리는거랑 값 대조해봐 맞나
</user_query>


적용한 Optuna JSON과 돌파 탭 백테 로그의 실제 파라미터를 대조하겠습니다.


python3 - <<'PY'
import json
from pathlib import Path

opt = json.loads(Path('/home/hoon/kis_bot/kis_trader/backtest/results/optuna_breakout_tpe_20260815_235924.json').read_text())
bt = json.loads(Path('/home/hoon/kis_bot/kis_trader/backtest/results/breakout_bt_20260812_20260814_20260816_001657.json').read_text())

st = (opt.get('results_stable') or [None])[0]
op = dict(st.get('merged_params') or st.get('params') or {})
bp = dict(bt.get('params') or {})

# mapping: optuna UI keys vs backtest engine/ui keys
pairs = [
    ('lookback_min', 'lookback_min', 1),
    ('vol_window', 'vol_window', 1),
    ('vol_mult', 'vol_mult', 1),
    ('min_turnover_1m_pct', 'min_turnover_1m_pct', 1),
    ('prev_chg_min', 'prev_chg_min', 1),
    ('prev_chg_max', 'prev_chg_max', 1),
    ('min_price', 'min_price', 1),
    ('tp_pct', 'tp_pct', 1),  # both UI %?
    ('sl_pct', 'sl_pct', 1),
    ('trail_pct', 'trail_pct', 1),
    ('trail_arm_pct', 'trail_arm_pct', 1),
    ('max_hold_bars', 'max_hold_bars', 1),
    ('sl_mode', 'sl_mode', 1),
    ('atr_sl_mult', 'atr_sl_mult', 1),
    ('atr_period', 'atr_period', 1),
    ('atr_sl_min_pct', 'atr_sl_min_pct', 1),
    ('atr_sl_max_pct', 'atr_sl_max_pct', 1),
    ('shoulder_min_high_pct', 'shoulder_min_high_pct', 1),
    ('shoulder_cut_pct', 'shoulder_cut_pct', 1),
    ('ratchet_on', 'ratchet_on', 1),
    ('ratchet_tiers', 'ratchet_tiers', 1),
    ('confirm_margin_pct', 'confirm_margin_pct', 1),
    ('body_min_pct', 'body_min_pct', 1),
    ('max_daily_chg', 'max_daily_chg', 1),
    ('entry_mode', 'entry_mode', 1),
    ('intrabar_slippage_pct', 'intrabar_slippage_pct', 1),
    ('use_ema_filter', 'use_ema_filter', 1),
    ('ema_fast_period', 'ema_fast_period', 1),
    ('ema_slow_period', 'ema_slow_period', 1),
    ('time_start_hm', 'time_start_hm', 1),
    ('time_end_hm', 'time_end_hm', 1),
    ('max_daily', 'max_daily', 1),
    ('cooldown_min', 'cooldown_min', 1),
    ('max_loss_krw', 'max_loss_krw', 1),
    ('slot_money', 'slot_money', 1),
    ('max_stocks', 'max_stocks', 1),
    ('total_budget_krw', 'total_budget_krw', 1),
    ('_orderbook_filter_enabled', '_orderbook_filter_enabled', 1),
    ('orderbook_filter_enabled', 'orderbook_filter_enabled', 1),
    ('max_spread_pct', 'max_spread_pct', 1),
    ('min_bid_ask_ratio', 'min_bid_ask_ratio', 1),
]

print('optuna trial', st.get('optuna_trial_number'), 'pnl', st.get('total_pnl'), 'trades', st.get('total_trades'), 'wr', st.get('win_rate'), 'pf', st.get('pf'))
print('web bt pnl', bt.get('total_pnl') or (bt.get('summary') or {}).get('total_pnl'), 'trades', (bt.get('summary') or bt).get('total_trades'))
print('opt start/end', opt.get('start'), opt.get('end'))
print('bt start/end', bt.get('start'), bt.get('end'))
print('--- keys only in opt', sorted(set(op)-set(bp))[:40])
print('--- keys only in bt', sorted(set(bp)-set(op))[:40])
print()
print(f"{'key':<28} {'optuna':<22} {'web_bt':<22} match")
print('-'*80)

def norm(v):
    if isinstance(v, bool):
        return bool(v)
    try:
        return round(float(v), 6)
    except Exception:
        return v

for a,b,_ in pairs:
    va = op.get(a)
    vb = bp.get(b)
    if va is None and a not in op:
        # try engine fraction keys
        pass
    same = False
    if va is None and vb is None:
        same = True
        flag = '둘다없음'
    else:
        try:
            same = abs(float(va)-float(vb)) < 1e-6
            flag = 'OK' if same else 'DIFF'
        except Exception:
            same = str(va)==str(vb)
            flag = 'OK' if same else 'DIFF'
    if va is None and vb is None:
        continue
    print(f'{a:<28} {str(va):<22} {str(vb):<22} {flag}')

print('\n=== extra bt flags ===')
for k in sorted(bp):
    if 'orderbook' in k.lower() or 'ob_' in k.lower() or 'filter' in k.lower() or 'exit_ob' in k.lower() or 'program' in k.lower() or 'tick' in k.lower() or 'universe' in k.lower() or 'budget' in k.lower() or 'slot' in k.lower() or 'max_stock' in k.lower():
        print(k, '=', bp.get(k))
PY

Diff Optuna stable#1 params vs web breakout BT JSON


python3 - <<'PY'
import json
from pathlib import Path
opt = json.loads(Path('/home/hoon/kis_bot/kis_trader/backtest/results/optuna_breakout_tpe_20260815_235924.json').read_text())
bt = json.loads(Path('/home/hoon/kis_bot/kis_trader/backtest/results/breakout_bt_20260812_20260814_20260816_001657.json').read_text())
st = (opt.get('results_stable') or [None])[0]
op = dict(st.get('merged_params') or st.get('params') or {})
bp = dict(bt.get('params') or {})
print('=== optuna all keys (non-huge) ===')
for k in sorted(op):
    v=op[k]
    if k in ('params','candles'): continue
    print(f'  {k}={v!r}')
print('\n=== bt ob/exit/program ===')
for k in sorted(bp):
    if any(x in k.lower() for x in ('ob','orderbook','exit','stop_ob','program','ratchet','spread','bid')):
        print(f'  {k}={bp[k]!r}')
print('\noptuna _orderbook', op.get('_orderbook_filter_enabled'), 'max_spread', op.get('max_spread_pct'), 'ratio', op.get('min_bid_ask_ratio'))
print('bt missing _orderbook_filter_enabled', '_orderbook_filter_enabled' in bp)
# tick
print('optuna tick?', {k:opt.get(k) for k in opt if 'tick' in k.lower()})
print('bt summary tick', (bt.get('summary') or {}).get('tick_backtest'))
print('optuna trades', st.get('total_trades'), 'wr', st.get('win_rate'), 'pf', st.get('pf'), 'pnl', st.get('total_pnl'))
print('bt summary', {k: bt.get('summary',{}).get(k) for k in ('total_trades','win_rate','profit_factor','total_pnl')})
PY

Dump full Optuna trial params vs BT orderbook/exit flags


python3 - <<'PY'
from kis_trader.utils.env import get_env_from_db, invalidate_merged_env_cache
invalidate_merged_env_cache()
for k in ['BREAKOUT_RATCHET_TIERS','BREAKOUT_EOD_ENABLED','BREAKOUT_EOD_HM','BREAKOUT_ORDERBOOK_FILTER_ENABLED','BREAKOUT_EXIT_OB_ENABLED']:
    print(k, repr(get_env_from_db(k,'')))
PY

Read DB ratchet/EOD/OB after apply


**타점 숫자는 거의 같고, 엔진이 같은 건 아닙니다.** 그래서 Optuna 3.6만 원 / 승률 77.8% vs 웹 6,203원 / 승률 44.4%가 납니다. 거래 수(9건)만 같습니다.

대조 파일: 적용 JSON `optuna_breakout_tpe_20260815_235924.json` 안정1위 trial **#20**, 웹 잡 `breakout_bt_...001657.json` (00:16:57, 적용 직후).

**같은 것 (TRIGGER·손익 축)**

| 항목 | JSON #20 | 웹 백테 |
|---|---|---|
| 기간 | 08-12~14 | 같음 |
| lookback / vol | 7 / 9×2.4 | 같음 |
| 회전율·직전봉 | 0.2% · 0.9~24% | 같음 |
| 손절/익절/트레일 | 5.6 / 5.5 / 5.1 (무장 3.7) | 같음 |
| sl_mode · ATR배 | fixed · 3.1 | 같음 |
| 진입 | intrabar · 0900–1530 | 같음 |
| 한도 | 30만×8 · 120만 | 같음 |
| 최소가·급등 | 2000 · 26% | 같음 |
| max_hold | 110 | 같음 |

입력칸에 보이던 lookback 1·손절 4%는 **그 런에 안 쓰였습니다.** 실제 런 파라미터는 trial #20입니다.

**다른 것 (PnL이 깨진 쪽)**

| 항목 | Optuna trial #20 | 웹 백테 (적용 후) |
|---|---|---|
| **익절 호가** | 탐색 때 **없음/OFF** | **ON** OR≥0.25 · 보유4 · 최소익절 1.12% |
| **진입 호가필터** | **OFF** | 파라미터에 플래그 없음(DB는 false) |
| **EOD** | **OFF** | **ON** 15:15 |
| **래칫** | `ratchet_on=True`인데 `tiers=''` (조각 정렬 실패) | **`5.5:2.5` ON** |
| 성적 | 9건 · WR 77.8% · PF 23.09 · **+36,942** | 9건 · WR 44.4% · PF 1.22 · **+6,203** |

웹 매도사유에 `호가컷` 1건·`eod` 1건이 있는 게 이 차이입니다. 「익절까지」적용이 **타점 JSON에 없던 익절호가를 켜서** 백테가 Optuna와 다른 엔진이 됐습니다.

같은 숫자를 보려면 웹에서 **익절호가 OFF · EOD를 trial과 같게 · 래칫을 trial처럼 빈 값**으로 맞춘 뒤 다시 돌려야 합니다. 지금은 **타점은 맞고, 호가익절·EOD·래칫은 다릅니다.**


<timestamp>Sunday, Aug 16, 2026, 12:36 AM (UTC+9)</timestamp>
<user_query>
내가 적용한건 20에 익절호가까지야  엔진이 다륻다는건 뭐야 실매랑 백테랑 다르게 돈다고?
</user_query>


Optuna 탐색 때와 ‘익절까지 적용’ 이후가 어떻게 다른지, 실매·백테가 같은 엔진인지 코드로 확인하겠습니다.


**실매랑 백테가 다른 엔진으로 도는 게 아닙니다.** 돌파 실매·웹백테는 같은 `breakout` 엔진이고, `BREAKOUT_EXIT_OB_ENABLED`도 둘 다 봅니다.

다르게 말한 대상은 **Optuna가 trial #20을 돌릴 때** vs **당신이 「익절까지」적용한 뒤의 엔진 설정**입니다.

| 단계 | 익절호가 | 그때의 성적 |
|---|---|---|
| Optuna **탐색** trial #20 | **꺼져 있음** (후처리는 trial 끝난 뒤에 숫자만 추천) | 9건 · +36,942원 |
| **익절까지 적용** 후 DB | **켜짐** (당신이 누른 그대로) | — |
| 그다음 **웹 백테** | **켜진 채** 돌음 | 9건 · +6,203원 |
| **실매** (지금 DB) | **웹과 같이 켜짐** | 웹 쪽에 더 가깝습니다 |

「익절까지」는 trial PnL을 다시 만든 게 아니라, **타점(#20) + 후처리로 찾은 익절호가**를 DB에 얹은 겁니다. 그래서 적용 후 백테가 Optuna 숫자와 다른 게 맞고, **실매가 백테와 따로 논다는 뜻이 아닙니다.**

지금 실매도 익절호가 ON입니다. Optuna 3.6만을 실매가 재현하진 않습니다. 웹 6,203이 “적용된 설정”에 더 가깝습니다. (EOD·래칫 `5.5:2.5`는 trial JSON과 조금 더 다릅니다. 그건 익절호가와 별개입니다.)


<timestamp>Sunday, Aug 16, 2026, 12:43 AM (UTC+9)</timestamp>
<user_query>
습 Top5 results · Optuna 목적함수 1위 풀 · 진행바 best=여기 1위
#	trial	거래	승률	PF	PnL	손실일	최악일	안정점수 ↑	과적합% ↓
1	#6	37	48.6%	1.08	9,600	1	-8,967	-7456.45	16% · 상대적으로 낮음
사후합격 Top5 results_gated · 「상세」=아래 후처리 표(진입호가)
#	trial	거래	승률	PF	PnL	손실일	최악일	안정점수 ↑	과적합% ↓
1	#6	37	48.6%	1.08	9,600	1	-8,967	-7456.45	16% · 상대적으로 낮음
안정 Top5 results_stable · 「상세」=아래 후처리 표(진입호가) · 손실일/최악일 게이트
안정점수 = 일평균PnL − λ×일표준편차 (원). 높을수록 좋음 · 만점 없음. 게이트: 손실일≤1 · 최악일≥-30,000 · 활성일≥2 · λ=1
#	trial	거래	승률	PF	PnL	손실일	최악일	안정점수 ↑	과적합% ↓
1	#6	37	48.6%	1.08	9,600	1	-8,967	-7456.45	16% · 상대적으로 낮음

어라? 이건 또 신기하네 과적합지수는 좋은데 안정점수가 마이너스야 어떻게 된거야 이건?
</user_query>


python3 - <<'PY'
import json
from pathlib import Path
p=Path('/home/hoon/kis_bot/kis_trader/backtest/results/optuna_momentum_tpe_20260815_231106.json')
data=json.loads(p.read_text())
for pool in ('results_stable','results_gated','results'):
    for r in data.get(pool) or []:
        if r.get('optuna_trial_number')==6:
            print('file', p.name, 'pool', pool)
            print('trades', r.get('total_trades'), 'wr', r.get('win_rate'), 'pf', r.get('pf'), 'pnl', r.get('total_pnl'))
            print('n_active', r.get('n_active_days'), 'n_lose', r.get('n_losing_days'))
            print('worst', r.get('worst_day_pnl'), 'best', r.get('best_day_pnl'))
            print('mean', r.get('daily_pnl_mean'), 'std', r.get('daily_pnl_std'), 'score', r.get('stability_score'))
            print('daily', r.get('daily_pnl'))
            raise SystemExit
print('not in momentum 231106')
PY

Load trial #6 daily PnL from momentum Optuna JSON


**버그 아닙니다.** 과적합%와 안정점수는 아예 다른 질문입니다.

이 후보(모멘텀 trial #6) 일별 손익은 이렇습니다.

| 날짜 | 일 PnL |
|---|---|
| 08-12 | **-8,967** |
| 08-13 | **+16,985** |
| 08-14 | **+1,582** |
| 합계 | **+9,600** |

- 일평균 = 3,200원
- 일 표준편차 ≈ 10,656원
- 안정점수 = 3,200 − 1×10,656 = **-7,456원**

한 날은 크게 잃고, 다음 날은 더 크게 먹었습니다. **합은 플러스인데 날이 들쭉날쭉**해서 점수가 마이너스입니다. 안정점수는 0~100 만점이 아니라 **원 단위**라, 출렁이면 수익이 나도 음수가 됩니다.

**과적합 16%(낮음)** 은 다른 검사입니다.

- 거래 **37건** → 표본이 너무 적지 않음
- 승률 **48.6%**, PF **1.08** → 100%/PF 9999 같은 이상치 아님
- 그래서 “이 숫자만 믿기엔 너무 가짜다” 쪽 점수가 낮음

게이트도 통과합니다. 손실일 1≤1, 최악일 -8,967 ≥ -30,000, 활성일 3≥2.

한 줄로: **과적합% = 숫자가 너무 예쁘지 않은지. 안정점수 = 날짜별로 얼마나 출렁이는지.** 37건짜리 평범한 수익 + 하루 -9천이면, 과적합은 좋게 나오고 안정은 마이너스가 됩니다.


<timestamp>Sunday, Aug 16, 2026, 1:17 AM (UTC+9)</timestamp>
<user_query>
📈 KIS Quant Dashboard
스캘핑 완료 50/50
100% · best#42 WR 55.6% · PnL 34,683원 · PF 4.39 · MDD 5,311 · 18건
모멘텀 백테 완료 2026-08-12~2026-08-14
100%
업데이트: 2026. 8. 16. 오전 1:01:50
매매
모의 · active
시세:키움(실) | 호가:키움(실) | DB:후보
📊 실거래 분석
📈 오늘 운영
⚙ 운영 설정
💼 보유·매도
📉 스캘핑 백테스트
🎣 꼬리잡기 백테스트
🚀 돌파매매 백테스트
🌊 모멘텀 백테스트
🇺🇸 해외 모멘텀
🔬 Optuna
📈 홀딩 전략
📦 박스엔진
📡 영구구독 관리
📢 DART 수주
🌊 모멘텀 전략 = SCALP(reversal)와 분리된 별도 추세추종 전략
• 진입: 키움 scalp 조건검색(갭상승+신고가+거래량 폭증) → 봇 자체 RSI 강세/거래량 spike/양봉 검증
• 청산: momentum_engine — 래칫/어깨·트레일 우선, 익절%는 tp_max 상한(마지막) · 손절/EOD
• 봇 ON: STRATEGY_MOMENTUM_ENABLED=true + CONDITION_MOMENTUM_NAME/SEQ 설정
• env 키: MOMENTUM_* 전용
시작일
2026-08-12
종료일
2026-08-14
1회투자(원)
MOMENTUM_SLOT_MONEY
300000
동시보유(종)
MOMENTUM_MAX_STOCKS
8
총운용한도(원)
MOMENTUM_TOTAL_BUDGET_KRW
1200000
※ 1회투자=포트폴리오 매수금(돌파 탭과 동일). 1회최대손실÷손절% 자동계산값(천만 등)과 다릅니다.
저장 후보 이력 사용 — 체크(기본) = DB 슬롯·후보 재현 (param_search_momentum 기본과 동일). 체크 해제 = 모멘텀 시뮬 유니버스 (--fallback-universe).
이력소스

키움 (target_candidates)
캔들

기본
시세(틱)

기본
호가

기본
📈 모멘텀 TRIGGER V4 (추격 패턴)
추격 패턴 — 하나라도 충족 시 진입 (env MOMENTUM_PATTERN_*)
N분 고가 돌파 + 양봉 + 거래량
스윙고점 → 눌림 → 재돌파
돌파관찰(분)
chase lookback · MOMENTUM_CHASE_LOOKBACK_MIN
10
눌림관찰(분)
pullback window · MOMENTUM_PULLBACK_LOOKBACK_MIN
15
눌림min(%)
pullback floor · MOMENTUM_PULLBACK_MIN_PCT
0.3
눌림max(%)
pullback cap · MOMENTUM_PULLBACK_MAX_PCT
3
눌림Vol×
MOMENTUM_SETUP_VOL_MAX_MULT
0.5
눌림음봉min
MOMENTUM_SETUP_BEAR_BARS_MIN
3
RSI min
RSI floor · MOMENTUM_RSI_MIN
50
RSI max
RSI cap · MOMENTUM_RSI_MAX
88
RSI 상한필터 (기본 OFF)
거래량배수
vol spike · MOMENTUM_VOL_MULT
2.4000000000000004
관찰봉수
vol window · MOMENTUM_VOL_WIN
9
전일종가+%(%)
HTS K · MOMENTUM_E_MIN_CHG_PCT
2.6
매수종료
buy cutoff · MOMENTUM_TIME_END_HM
1530
ℹ V4 TRIGGER: 돌파 OR 눌림재돌파 + RSI 하한 + 거래량 + (선택) EMA 추세. 청산은 V4 어깨·트레일·손절 순. 고점추격·시가과열·RSI상한은 기본 OFF — 필요 시 아래 방어 체크박스에서 켜세요.
레거시 시가컷: mom_max_from_open=999 이면 비활성.
시가+과열(%)
legacy · MOMENTUM_MAX_FROM_OPEN_PCT
31
시가+약세(%)
legacy · MOMENTUM_MIN_FROM_OPEN_PCT
-999
🛑 청산 / 공통 (momentum_engine 전용)
청산 순서: 래칫/어깨 → 트레일 → 손절 → 시간컷 → 금액손실컷 → 익절(tp_max 상한) → 장마감 (실매 MomentumStrategy · param_search 와 동일. SCALP reversal V자 청산과 별도)

손절(%)
stop-loss % · MOMENTUM_STOP_LOSS_PCT
7.9
익절(%)
take-profit cap · MOMENTUM_TAKE_PROFIT_PCT
9.5
익절상한%
tp max cap · MOMENTUM_TP_MAX_PCT
16
어깨발동%
shoulder arm · MOMENTUM_SHOULDER_MIN_HIGH_PCT
5
어깨폭%
shoulder cut · MOMENTUM_SHOULDER_CUT_PCT
0.45
트레일%
trail cut · MOMENTUM_TRAIL_PCT
3.9
트레일무장%
trail arm · MOMENTUM_TRAIL_ARM_PCT
4.3
최대보유(봉)
time cut · MOMENTUM_MAX_HOLD_BARS
240
래칫티어
ratchet tiers · MOMENTUM_RATCHET_TIERS
7:2,12:1.5
쿨다운(분)
re-entry cooldown · MOMENTUM_COOLDOWN_SEC
11
매수시작
buy start HHMM · MOMENTUM_TIME_START
900
일일최대매수
max entries/day · MOMENTUM_MAX_DAILY
100
EOD청산
구독전분제외
MOMENTUM_BACKTEST_SKIP_PRE_SUBSCRIBE
EOD시각
MOMENTUM_EOD_HM
15:20
방어로직
entry filters · MOMENTUM_USE_DEFENSE_FILTERS
진입 방어 ON
TRIGGER 필터(백테)
호가·프로그램 · 끄고/켜고 비교용
호가필터 ON
글로벌 OFF · 실매 ON · 모멘텀 전용 설정값
프로그램필터 ON
글로벌 OFF · 실매 OFF · 모멘텀 전용 설정값
스프레드상한(%)
MOMENTUM_ORDERBOOK_MAX_SPREAD_PCT
3
고점추격필터 (기본 OFF)
당일변동필터 (기본 OFF)
EMA추세필터 (기본 ON)
EMA빠름
fast period · MOMENTUM_EMA_FAST_PERIOD
9
EMA느림
slow period · MOMENTUM_EMA_SLOW_PERIOD
21
고점추격방지
near-high gate · HIGH_CHASE_THR
0.96
급등주필터(%)
intraday range cap · MAX_DAILY_CHG
52
최소가격(원)
min tick price · MOMENTUM_MIN_PRICE
1000
1회최대손실(원)
loss cap KRW · MOMENTUM_MAX_LOSS_PER_TRADE_KRW
200000
본절마진(%)
breakeven buffer · MOMENTUM_MIN_PROFIT_PCT
1.9000000000000001
📅 다단트레일 ON이면 당일 실현손익 고점 대비 되돌릴 때 신규매수만 중단. 리스크버짓(B) ON이면 hit 후 보유최악손절합>cushion 이면 전량정리. DB=MOMENTUM_DAILY_PROFIT_*.
다단트레일
리스크버짓(B)
다단규칙(수익원:컷%,…)
프리셋 선택…(직접입력)
25000:40,50000:30,100000:20
단일되돌림(%)
0
발동최소수익(원)
0
방식
trailing
저장 후보 이력 — 체크: DB 슬롯·후보 / 해제: 시뮬 유니버스 (이력소스 셀렉트는 위 포트폴리오 카드)
env 타임라인 — 체크: 그 시각 DB설정 재현 / 해제(기본): 폼 파람으로 구간 전체
탐색: CLI · 전역 1잡
done 100% 2026-08-12~2026-08-14 · 완료 trades=20 PnL=11062 · /home/hoon/kis_bot/logs/momentum_bt_20260812_20260814_20260816_011435.log · 전역 1잡 · 탭 이동 OK
유니버스: 저장 이력 1358슬롯·strict 돌파+눌림(10분) | RSI≥51 | 거래량×8.600000000000001 | 손절-5.5% | 어깨(3.1%/0.7%)·래칫7:2,12:1.5 | 익절상한+2.1% | 2026-08-12~2026-08-14 | EMA ON(9/21) | 1회 300,000원 · 동시8종 · 한도 1,200,000원 | 쿨다운16분 | 일80회 | 종목수 538개
💰 총한도 1,200,000원 < 동시8×1회투자 2,400,000원 — 잔여금 소액매수·과다 회전 위험. 실매 정렬: 총한도↑ 또는 동시보유↓
📊 ws_ticks 749,872건 · 분봉커버 구독구간 13.58% · 거래종목 37%(18종) · 전체 8.34% · 틱종목 8.38% (532/538종) | 진입 틱17/시가3 · 청산 틱5/OHLC0
🔄 매수큐 실매형 10초 · 스캔6840 · 매수20 | 유니버스 스캔시각·디바운스30초
💡 장중 누적 최고 +34,281원 (2026-08-13 09:00:00) · 최종 11,062원
총 거래
20건
승률
65%
순손익(수수료·세금 포함)
11,062원
Profit Factor
1.17
최대 낙폭(MDD)
-34,279원
평균 보유(분)
273분
총 수익률(운용한도 대비)
+0.92%
일평균 수익률
+0.307%
🟢 승 13건 (65%)
🔴 패 7건 (35.0%)
누적 손익 곡선 (가상)
매도 이유 분포
일별 손익 (가상)
가상 거래 내역 (최근 200건)
📋 백테 MOMENTUM · 한도 1,200,000원 | 장중 누적 최고 +34,281원 (2026-08-13 09:00:00) | 최종 누적 11,062원
💰 총한도 1,200,000원 < 동시8×1회투자 2,400,000원 — 잔여금 소액매수·과다 회전 위험. 실매 정렬: 총한도↑ 또는 동시보유↓
📊 ws_ticks 749,872건 · 분봉커버 구독구간 13.58% · 거래종목 37%(18종) · 전체 8.34% · 틱종목 8.38% (532/538종) | 진입 틱17/시가3 · 청산 틱5/OHLC0
🔄 매수큐 실매형 10초 · 스캔6840 · 매수20 | 유니버스 스캔시각·디바운스30초
정렬
종목	매수시각	매도시각	매수가	매도가	수량	손익(원)	수익률%	누적손익	누적%	보유(분)	체결디버그	매수호가
녹OK · 노랑참고 · 빨강나쁨	매도호가
녹OK · 노랑참고 · 빨강나쁨	매도사유
대림제지
017650
2026-08-14 15:17:12	2026-08-14 15:54:00	10,280	10,380	29	2,268	+0.97%	11,062	+0.92%	36.8	-→bt_flatten	괜찮음·근처
가격간격 OK 0.10% (5.54%까지)
살 힘 OK 389.19 (0.44 이상)
사겠음 6,227주 · 팔겠음 16주
시차 +535s · kiwoom_0d	기록없음	eod
해태제과식품
101530
2026-08-14 12:40:16	2026-08-14 15:45:00	5,840	5,880	51	1,411	+0.68%	8,794	+0.73%	184.7	-→bt_flatten	팔대기많음
가격간격 OK 0.17% (5.54%까지)
살 힘 OK 0.67 (0.44 이상)
사겠음 1,051주 · 팔겠음 1,567주
시차 +21s · filter_eval	수급OK·근처
OR OK 1.51 (총사겠음÷총팔겠음, 기준 0.76)
가격간격 OK 0.17%
총사겠음 490 · 총팔겠음 324
시차 +896s · kiwoom_0d	eod
제이씨현시스템
033320
2026-08-14 11:34:00	2026-08-14 15:14:09	3,200	3,175	93	-2,945	-0.78%	7,383	+0.62%	220.2	-→틱청산	괜찮음
가격간격 OK 0.31% (5.54%까지)
살 힘 OK 2.29 (0.44 이상)
사겠음 5,287주 · 팔겠음 2,309주
시차 +64s · kiwoom_0d	수급OK·근처
OR OK 1.66 (총사겠음÷총팔겠음, 기준 0.76)
가격간격 OK 0.32%
총사겠음 12,821 · 총팔겠음 7,706
시차 +350s · kiwoom_0d	시간컷
017480
017480
2026-08-14 13:04:16	2026-08-14 14:13:00	4,890	4,935	61	2,113	+0.92%	10,328	+0.86%	68.7	-→bt_flatten	괜찮음
가격간격 OK 0.20% (5.54%까지)
살 힘 OK 1.28 (0.44 이상)
사겠음 616주 · 팔겠음 480주
시차 +0s · kiwoom_0d	기록없음	bt_flatten
인화정공
101930
2026-08-14 13:13:05	2026-08-14 13:45:00	8,280	8,270	36	-985	-0.12%	8,215	+0.68%	31.9	-→bt_flatten	괜찮음
가격간격 OK 0.24% (5.54%까지)
살 힘 OK 1.49 (0.44 이상)
사겠음 110주 · 팔겠음 74주
시차 +0s · kiwoom_0d	수급OK
OR OK 3.38 (총사겠음÷총팔겠음, 기준 0.76)
가격간격 OK 0.24%
총사겠음 3,975 · 총팔겠음 1,175
시차 +0s · kiwoom_0d	bt_flatten
틱공백EOD대창단조
015230
2026-08-14 11:46:00	2026-08-14 12:42:00	6,100	6,170	49	2,796	+1.15%	9,200	+0.77%	56	-→틱공백EOD	괜찮음
가격간격 OK 0.33% (5.54%까지)
살 힘 OK 4.20 (0.44 이상)
사겠음 2,249주 · 팔겠음 536주
시차 +158s · kiwoom_0d	수급약함
OR 약함 0.67 (총사겠음÷총팔겠음, 기준 0.76)
가격간격 OK 0.32%
총사겠음 9,372 · 총팔겠음 13,971
시차 +80s · kiwoom_0d	호가컷
명신산업
009900
2026-08-14 11:06:45	2026-08-14 12:29:37	7,900	8,090	37	6,402	+2.41%	6,404	+0.53%	82.9	-→틱청산	팔대기많음
가격간격 OK 0.26% (5.54%까지)
살 힘 OK 1.65 (0.44 이상)
사겠음 1,778주 · 팔겠음 1,078주
시차 +5s · filter_eval	수급OK
OR OK 0.86 (총사겠음÷총팔겠음, 기준 0.76)
가격간격 OK 0.13%
총사겠음 6,131 · 총팔겠음 7,090
시차 +0s · kiwoom_0d	익절
틱공백EOD네오오토
212560
2026-08-14 12:17:44	2026-08-14 12:27:00	12,820	12,010	23	-19,213	-6.32%	2	+0.00%	9.3	-→틱공백EOD	팔대기많음
가격간격 OK 0.08% (5.54%까지)
살 힘 OK 5.79 (0.44 이상)
사겠음 1,685주 · 팔겠음 291주
시차 +0s · filter_eval	수급OK
OR OK 4.22 (총사겠음÷총팔겠음, 기준 0.76)
가격간격 OK 0.08%
총사겠음 16,827 · 총팔겠음 3,986
시차 +159s · kiwoom_0d	손절
틱공백EOD삼보모터스
053700
2026-08-14 11:14:14	2026-08-14 12:16:00	4,360	4,465	68	6,503	+2.41%	19,215	+1.60%	61.8	-→틱공백EOD	팔대기많음
가격간격 OK 0.92% (5.54%까지)
살 힘 OK 1.80 (0.44 이상)
사겠음 1,043주 · 팔겠음 580주
시차 +17s · filter_eval	수급OK
OR OK 2.12 (총사겠음÷총팔겠음, 기준 0.76)
가격간격 OK 0.23%
총사겠음 7,011 · 총팔겠음 3,315
시차 +0s · kiwoom_0d	익절
틱공백EOD에코아이
448280
2026-08-14 10:51:32	2026-08-14 11:44:00	8,850	8,650	33	-7,200	-2.26%	12,712	+1.06%	52.5	-→틱공백EOD	살힘약함
가격간격 OK 0.45% (5.54%까지)
살 힘 약함 0.13 (0.44 이상)
사겠음 14주 · 팔겠음 107주
시차 +0s · kiwoom_0d	수급약함
OR 약함 0.68 (총사겠음÷총팔겠음, 기준 0.76)
가격간격 OK 0.12%
총사겠음 477 · 총팔겠음 704
시차 +15s · kiwoom_0d	트레일컷
틱공백EOD해태제과식품
101530
2026-08-14 10:38:25	2026-08-14 10:50:00	5,840	6,010	51	8,028	+2.91%	19,912	+1.66%	11.6	-→틱공백EOD	괜찮음
가격간격 OK 0.17% (5.54%까지)
살 힘 OK 2.26 (0.44 이상)
사겠음 1,112주 · 팔겠음 493주
시차 +0s · kiwoom_0d	수급약함
OR 약함 0.39 (총사겠음÷총팔겠음, 기준 0.76)
가격간격 OK 0.34%
총사겠음 3,489 · 총팔겠음 8,870
시차 +23s · kiwoom_0d	익절
에이럭스
475580
2026-08-14 10:04:05	2026-08-14 10:27:25	8,320	8,500	36	5,838	+2.16%	11,884	+0.99%	23.3	-→틱청산	괜찮음
가격간격 OK 0.36% (5.54%까지)
살 힘 OK 1.50 (0.44 이상)
사겠음 420주 · 팔겠음 279주
시차 +0s · kiwoom_0d	수급약함
OR 약함 0.21 (총사겠음÷총팔겠음, 기준 0.76)
가격간격 OK 0.12%
총사겠음 358 · 총팔겠음 1,733
시차 +0s · kiwoom_0d	익절
코콤
015710
2026-08-14 09:28:26	2026-08-14 09:55:59	3,030	3,095	99	5,793	+2.15%	6,046	+0.50%	27.6	-→틱청산	괜찮음
가격간격 OK 0.49% (5.54%까지)
살 힘 OK 27.63 (0.44 이상)
사겠음 2,459주 · 팔겠음 89주
시차 +37s · kiwoom_0d	수급OK
OR OK 1.26 (총사겠음÷총팔겠음, 기준 0.76)
가격간격 OK 0.49%
총사겠음 5,495 · 총팔겠음 4,352
시차 +3s · kiwoom_0d	익절
틱공백EOD에이유브랜즈
481070
2026-08-13 14:23:32	2026-08-14 09:00:00	21,250	20,400	14	-12,502	-4.00%	253	+0.02%	1116.5	-→틱공백EOD	괜찮음·근처
가격간격 OK 0.24% (5.54%까지)
살 힘 OK 1.20 (0.44 이상)
사겠음 484주 · 팔겠음 403주
시차 +223s · kiwoom_0d	기록없음	시간컷
틱공백EOD빅솔론
093190
2026-08-13 12:42:07	2026-08-14 09:00:00	9,200	9,070	32	-4,770	-1.41%	12,755	+1.06%	1217.9	-→틱공백EOD	팔대기많음
가격간격 OK 0.11% (5.54%까지)
살 힘 OK 1.51 (0.44 이상)
사겠음 395주 · 팔겠음 262주
시차 +91s · filter_eval	기록없음	시간컷
틱공백EOD에이유브랜즈
481070
2026-08-13 12:06:00	2026-08-13 13:04:00	23,300	21,950	12	-16,756	-5.79%	17,525	+1.46%	58	-→틱공백EOD	괜찮음
가격간격 OK 0.65% (5.54%까지)
살 힘 OK 0.57 (0.44 이상)
사겠음 149주 · 팔겠음 261주
시차 +0s · kiwoom_0d	수급OK·근처
OR OK 2.39 (총사겠음÷총팔겠음, 기준 0.76)
가격간격 OK 0.22%
총사겠음 6,021 · 총팔겠음 2,523
시차 +248s · kiwoom_0d	손절
틱공백EOD이노테크
469610
2026-08-12 15:02:00	2026-08-13 09:00:00	15,440	15,900	19	8,107	+2.98%	34,281	+2.86%	1078	-→틱공백EOD	괜찮음
가격간격 OK 0.26% (5.54%까지)
살 힘 OK 3.25 (0.44 이상)
사겠음 387주 · 팔겠음 119주
시차 +180s · kiwoom_0d	기록없음	시간컷
틱공백EOD001390
001390
2026-08-12 14:42:00	2026-08-13 09:00:00	4,625	4,670	64	2,253	+0.97%	26,174	+2.18%	1098	-→틱공백EOD	괜찮음
가격간격 OK 0.11% (5.54%까지)
살 힘 OK 0.61 (0.44 이상)
사겠음 1,308주 · 팔겠음 2,154주
시차 +170s · kiwoom_0d	기록없음	시간컷
틱공백EOD빛샘전자
072950
2026-08-12 14:43:00	2026-08-12 15:02:00	9,830	10,110	30	7,764	+2.85%	23,921	+1.99%	19	-→틱공백EOD	괜찮음
가격간격 OK 0.31% (5.54%까지)
살 힘 OK 0.66 (0.44 이상)
사겠음 1,955주 · 팔겠음 2,977주
시차 +0s · kiwoom_0d	수급OK·근처
OR OK 1.66 (총사겠음÷총팔겠음, 기준 0.76)
가격간격 OK 0.10%
총사겠음 8,236 · 총팔겠음 4,953
시차 +239s · kiwoom_0d	익절
347890
347890
2026-08-12 14:45:04	2026-08-12 14:50:39	5,170	5,460	58	16,157	+5.61%	16,157	+1.35%	5.6	-→틱청산	괜찮음
가격간격 OK 0.19% (5.54%까지)
살 힘 OK 15.06 (0.44 이상)
사겠음 7,137주 · 팔겠음 474주
시차 +0s · kiwoom_0d	수급약함
OR 약함 0.66 (총사겠음÷총팔겠음, 기준 0.76)
가격간격 OK 0.18%
총사겠음 13,043 · 총팔겠음 19,750
시차 +0s · kiwoom_0d	익절
파라미터 그리드 탐색은 CLI param_search_momentum.py — 웹은 페이지 로드 시 /api/env/params → config_momentum + env_config 병합값으로 입력란 자동 채움.

여기 기록없음은 이제 버그 수정 다 해서 화요일 부터는 기록없음이 사라지나?
</user_query>


「기록없음」이 백테 표시인지 실매 호가 저장인지, 화요일부터 없어지는 수정인지 코드와 문서를 확인하겠습니다.


**화요일부터 「기록없음」이 전부 사라지지는 않습니다.** 토·일 고친 구멍과 이 칸은 다른 이야기입니다.

화요일 패치는 **살 때**입니다. 호가 사진이 없으면(또는 만료를 통과로 치면) 필터 ON인데도 사던 것을 막습니다. 표의 「매도호가 · 기록없음」은 **팔린 시각 ±15분 안에 DB에 호가 사진이 없다**는 표시입니다. 매수를 막았다고 매도 칸이 채워지지는 않습니다.

지금 붙여 주신 행을 보면 패턴이 분명합니다.

| 매도시각 | 사유 | 왜 사진이 없나 |
|---|---|---|
| 15:45~15:54 | eod | 장 끝난 뒤·장마감 플랫. 호가창이 이미 꺼진 시각 |
| 09:00 | 시간컷 | 전날 들고 다음날 시가에 강제청산. 09:00:00에 0D 호가가 아직 없거나 구독 전 |
| 13:45·14:13 | `bt_flatten` | 백테가 구간 끝에서 남은 포지션을 봉으로 접음. 그 시각에 호가 시계열이 없으면 표시 불가 |

매수칸은 대부분 초록/노랑으로 **사진이 있습니다.** 사는 순간에는 구독·검사가 있었고, 파는 순간(EOD·시가·시뮬 플랫)에는 사진이 없는 경우가 많습니다.

그래서:

- **화요일 실매**: 호가 없이 사는 건 줄어야 함. 매수칸 「기록없음」+체결은 예전 구멍이면 줄 것.
- **화요일 이후에도 남을 수 있는 것**: EOD·시가 시간컷·백테 `bt_flatten`의 **매도칸** 기록없음. 팔 때 지정가/익절호가는 이번 패치에서 **안 바꿨습니다.**
- **8/12~14 백테를 다시 돌려도**: 그때 저장된 `ws_orderbook`이 없으면 표는 그대로입니다. 코드가 과거 사진을 만들어 넣지 않습니다.

초등으로: 칠판 사진이 있어야 칸에 숫자가 나옵니다. 화요일 규칙은 「칠판이 없으면 **사지 마**」이지, 「없는 칠판을 **그려 넣어**」가 아닙니다.


<timestamp>Sunday, Aug 16, 2026, 1:20 AM (UTC+9)</timestamp>
<user_query>
화요일 이후에도 남을 수 있는 것: EOD·시가 시간컷·백테 bt_flatten의 매도칸 기록없음. 팔 때 지정가/익절호가는 이번 패치에서 안 바꿨습니다.

익절호가를 안바꿨다는게 뭐야??
</user_query>


익절호가가 코드에서 무엇을 가리키는지 짧게 확인한 뒤, 화요일 패치와 어떻게 다른지 설명하겠습니다.


**「익절호가를 안 바꿨다」= 익절 기능이 고장 났다는 뜻이 아닙니다.** 화요일에 손댄 스위치가 **사는 쪽만**이라는 뜻입니다.

호가가 세 갈래입니다.

1. **살 때 검사** (`ORDERBOOK_FILTER` 등)
   사기 직전에 대기표를 보고 간격·살 힘이 나쁘면 **안 산다.**
   → 화요일에 고친 것. 사진 없음/만료를 통과로 치던 구멍.

2. **익절 호가매도** (`MOMENTUM_EXIT_OB_ENABLED` 같은 `*_EXIT_OB_*`)
   이미 들고 있을 때, 익절 신호가 나면 **호가 수급이 좋을 때만** 팔거나 미룬다.
   → 이번 패치에서 **손대지 않음.** 켜짐/꺼짐, 기준값 그대로.

3. **익절 지정가** (`SELL_USE_ORDERBOOK_ON_PROFIT`)
   익절을 시장가가 아니라 **매수1호가 지정가**로 낸다.
   → 이것도 **손대지 않음.**

표의 「매도호가 · 기록없음」은 위 2번도 3번도 아닙니다. **팔린 시각 근처 사진을 표에 붙여 보여 주는 칸**일 뿐입니다. EOD·09:00 시간컷이면 그 시각에 사진이 없어서 칸이 비는 것이고, 익절 로직을 고치지 않아서 생기는 현상이 아닙니다.

한 줄: 화요일 = **빈 칠판인데 사던 것 금지.** 익절을 호가로 팔지/지정가로 팔지는 **그대로.**


<timestamp>Sunday, Aug 16, 2026, 1:23 AM (UTC+9)</timestamp>
<user_query>
익절 호가매도 (MOMENTUM_EXIT_OB_ENABLED 같은 *_EXIT_OB_*)
이미 들고 있을 때, 익절 신호가 나면 호가 수급이 좋을 때만 팔거나 미룬다.
→ 이번 패치에서 손대지 않음. 켜짐/꺼짐, 기준값 그대로.

익절 지정가 (SELL_USE_ORDERBOOK_ON_PROFIT)
익절을 시장가가 아니라 매수1호가 지정가로 낸다.
→ 이것도 손대지 않음.

아니 이거 익절을 호가로 팔지 지정가로 팔지의 문제가 아니고 호가잔량에 따라 익절시에 못따가면 매도하는 로직은 들어가 있는거지?
</user_query>


익절 때 호가잔량으로 ‘못 따라가면 판다’가 실매 엔진에 실제로 있는지, 스위치 이름과 동작을 코드에서 확인하겠습니다.


맞습니다. **그건 지정가/시장가 문제가 아니라, 이미 이득일 때 호가잔량이 무너지면 팔라는 매도 신호**이고, **코드에 들어가 있습니다.** 제가 앞에서 「익절 신호가 난 뒤에 호가 좋으면만 팔고 미룬다」고 한 건 그 로직이 아닙니다.

동작은 이렇습니다.

1. 이미 샀다.
2. **최소 이득**(기본 +0.5%) + **최소 보유**(기본 3분)를 넘었다.
3. 호가 **사겠음÷팔겠음**의 이동평균이 기준(기본 0.4) 아래로 떨어졌다 = 따라갈 매수 대기줄이 짧아졌다.
4. 그때 **「호가컷」으로 판다.**

순서도 정해져 있습니다. 래칫 → 어깨 → **호가컷** → 손절호가 → −% 손절 → 트레일. 모멘텀·돌파 실매·백테가 같은 `_check_exit_ob_l3`를 씁니다. 호가 사진이 모자라면 **안 팝니다**(폴백으로 억지 발동 없음).

스위치는 `MOMENTUM_EXIT_OB_ENABLED` / `BREAKOUT_EXIT_OB_ENABLED`입니다. **코드 기본은 꺼짐**이고, DB에서 켜져 있으면 그날부터 씁니다. 화요일 패치는 이 판정을 건드리지 않았습니다. 있던 그대로입니다.

헷갈리기 쉬운 다른 축 하나만:

- **호가컷** = *팔지 말지* (잔량 붕괴 → 익절 매도)
- **`SELL_USE_ORDERBOOK_ON_PROFIT`** = 이미 팔기로 한 뒤 *지정가로 낼지 시장가로 낼지*

표의 「매도호가 기록없음」은 이 호가컷이 없다는 뜻이 아닙니다. EOD·시가 플랫 시각에 **표시용 사진이 없다**는 뜻입니다.


<timestamp>Sunday, Aug 16, 2026, 1:33 AM (UTC+9)</timestamp>
<user_query>
옵투나 화면에 후처리 프로그레스바도 잇어야겠는데 언제 끝날지 모르겟어 상세를 언제 눌러야될지 모르겠네
</user_query>


후처리 진행을 화면에 보이게 하려면, 지금 잡이 후처리를 어떻게 찍고 웹이 그걸 읽는지부터 확인하겠습니다.


학습 trial 바와 별도로 후처리 진행을 로그에서 읽어 두 번째 바로 보여 주고, 끝나기 전에는 「상세」를 누르지 않게 안내하겠습니다.

Write

"""Optuna 후처리 진행 한 줄 로그 — 웹 프로그레스바 파서용.

실매 엔진과 무관. 형식:
OPTUNA_POST_PROGRESS pct=.. step=.. total=.. stage=.. axis=a/b 설명
"""
from __future__ import annotations

import threading
from typing import Any, Optional

_tls = threading.local()

def _ctx() -> dict:
    c = getattr(_tls, "ctx", None)
    if not isinstance(c, dict):
        c = {
            "log": None,
            "step": 1,
            "total": 1,
            "stage": "idle",
            "n_axes": 1,
            "axis_i": 0,
            "axis_trials": 0,
        }
        _tls.ctx = c
    return c

def begin_job(log: Any, total: int) -> None:
    c = _ctx()
    c["log"] = log
    c["total"] = max(1, int(total or 1))
    c["step"] = 0
    c["stage"] = "start"
    c["n_axes"] = 1
    c["axis_i"] = 0
    c["axis_trials"] = 0
    emit(stage="start", detail="후처리 시작")

def begin_unit(step: int, stage: str, detail: str = "") -> None:
    c = _ctx()
    c["step"] = max(1, int(step))
    c["stage"] = str(stage or "")
    c["n_axes"] = 1
    c["axis_i"] = 0
    c["axis_trials"] = 0
    emit(stage=stage, detail=detail or stage)

def set_ob_axes(n_axes: int, axis_trials: int) -> None:
    c = _ctx()
    c["n_axes"] = max(1, int(n_axes or 1))
    c["axis_trials"] = max(0, int(axis_trials or 0))

def begin_ob_axis(axis_name: str, axis_i: int) -> None:
    c = _ctx()
    c["axis_i"] = max(0, int(axis_i))
    emit(
        stage=str(c.get("stage") or "ob"),
        detail=str(axis_name or ""),
        axis_done=0,
        axis_total=int(c.get("axis_trials") or 0),
    )

def on_ob_axis_trial(done: int, total: int, axis_name: str) -> None:
    emit(
        stage=str(_ctx().get("stage") or "ob"),
        detail=str(axis_name or ""),
        axis_done=int(done),
        axis_total=int(total),
    )

def finish_job(log: Optional[Any] = None) -> None:
    c = _ctx()
    if log is not None:
        c["log"] = log
    c["step"] = int(c.get("total") or 1)
    emit(stage="done", detail="상세가능", done=True)

def emit(
    *,
    stage: str,
    detail: str = "",
    axis_done: int = 0,
    axis_total: int = 0,
    done: bool = False,
) -> None:
    c = _ctx()
    log = c.get("log")
    if log is None:
        return
    step = max(0, int(c.get("step") or 0))
    total = max(1, int(c.get("total") or 1))
    n_axes = max(1, int(c.get("n_axes") or 1))
    axis_i = max(0, int(c.get("axis_i") or 0))
    ax_tot = int(axis_total or c.get("axis_trials") or 0)
    ax_done = int(axis_done or 0)
    if done:
        pct = 100.0
    else:
        inner = 0.0
        if ax_tot > 0:
            inner = (axis_i + min(1.0, ax_done / float(ax_tot))) / float(n_axes)
        elif n_axes > 1:
            inner = axis_i / float(n_axes)
        frac = (max(0, step - 1) + min(1.0, inner)) / float(total)
        pct = round(min(99.9, max(0.0, 100.0 * frac)), 1)
    bits = [
        f"OPTUNA_POST_PROGRESS pct={pct}",
        f"step={max(1, step) if step else 0}",
        f"total={total}",
        f"stage={stage}",
        f"axis={ax_done}/{ax_tot}",
    ]
    if detail:
        bits.append(str(detail).replace("\n", " ")[:80])
    if done:
        bits.append("상세가능")
    try:
        log.info("%s", " ".join(bits))
    except Exception:
        pass


StrReplace

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

    for i, row in enumerate(gated, start=1):
        combo = _combo_from_row(row)
        fills: List[Dict[str, Any]] = []
        if do_ob and evaluate_fn is not None:
            lg.info("📌 [후처리] gated#%d 백테 재실행 (호가/휩쏘 체결)", i)

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

    from kis_trader.backtest import optuna_post_progress as opp

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

    for i, row in enumerate(gated, start=1):
        combo = _combo_from_row(row)
        fills: List[Dict[str, Any]] = []
        if do_ob and evaluate_fn is not None:
            unit += 1
            opp.begin_unit(unit, f"gated#{i}", "백테재실행·호가")
            lg.info("📌 [후처리] gated#%d 백테 재실행 (호가/휩쏘 체결)", i)

        if reused is not None:
            ob = reused.get("orderbook") or {"ok": False, "reason": "light_skip"}
            ws = reused.get("whipsaw") or {"ok": False, "reason": "light_skip"}
        else:
            ob = {"ok": False, "reason": "light_skip"}
            ws = {"ok": False, "reason": "light_skip"}
            if do_ob and evaluate_fn is not None:
                lg.info("📌 [후처리] stable#%d 백테 재실행 (호가/휩쏘 체결)", i)
                fills = _replay_fills(evaluate_fn, combo, lg)

        if reused is not None:
            ob = reused.get("orderbook") or {"ok": False, "reason": "light_skip"}
            ws = reused.get("whipsaw") or {"ok": False, "reason": "light_skip"}
            if do_ob:
                from kis_trader.backtest import optuna_post_progress as opp
                # stable 재사용도 한 칸 진행 (표 앵커 채우는 중)
                # step 은 attach 쪽에서 begin_unit 로 맞춤 — 여기선 로그만
                lg.info("📌 [후처리] stable#%d 호가 재사용 (gated와 동일 trial)", i)
        else:
            ob = {"ok": False, "reason": "light_skip"}
            ws = {"ok": False, "reason": "light_skip"}
            if do_ob and evaluate_fn is not None:
                lg.info("📌 [후처리] stable#%d 백테 재실행 (호가/휩쏘 체결)", i)
                fills = _replay_fills(evaluate_fn, combo, lg)


def begin_unit(step: int, stage: str, detail: str = "") -> None:
    c = _ctx()
    c["step"] = max(1, int(step))
    c["stage"] = str(stage or "")
    c["n_axes"] = 1
    c["axis_i"] = 0
    c["axis_trials"] = 0
    emit(stage=stage, detail=detail or stage)

def begin_unit(step: int, stage: str, detail: str = "") -> None:
    c = _ctx()
    c["step"] = max(1, int(step))
    c["stage"] = str(stage or "")
    c["n_axes"] = 1
    c["axis_i"] = 0
    c["axis_trials"] = 0
    emit(stage=stage, detail=detail or stage)

def next_unit(stage: str, detail: str = "") -> int:
    c = _ctx()
    step = int(c.get("step") or 0) + 1
    begin_unit(step, stage, detail)
    return step

        reused = _reuse_ob_ws(anchors, trial)
        fills: List[Dict[str, Any]] = []
        pnl = row.get("total_pnl")
        trail = _slim_trail(_trail_for_pnl(strat, pnl))
        if reused is not None:
            ob = reused.get("orderbook") or {"ok": False, "reason": "light_skip"}
            ws = reused.get("whipsaw") or {"ok": False, "reason": "light_skip"}
            if do_ob:
                from kis_trader.backtest import optuna_post_progress as opp
                # stable 재사용도 한 칸 진행 (표 앵커 채우는 중)
                # step 은 attach 쪽에서 begin_unit 로 맞춤 — 여기선 로그만
                lg.info("📌 [후처리] stable#%d 호가 재사용 (gated와 동일 trial)", i)
        else:

        reused = _reuse_ob_ws(anchors, trial)
        fills: List[Dict[str, Any]] = []
        pnl = row.get("total_pnl")
        trail = _slim_trail(_trail_for_pnl(strat, pnl))
        if do_ob:
            from kis_trader.backtest import optuna_post_progress as opp
            opp.next_unit(
                f"stable#{i}",
                "호가재사용" if reused is not None else "백테재실행·호가",
            )
        if reused is not None:
            ob = reused.get("orderbook") or {"ok": False, "reason": "light_skip"}
            ws = reused.get("whipsaw") or {"ok": False, "reason": "light_skip"}
            if do_ob:
                lg.info("📌 [후처리] stable#%d 호가 재사용 (gated와 동일 trial)", i)
        else:

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

    for i, row in enumerate(gated, start=1):
        combo = _combo_from_row(row)
        fills: List[Dict[str, Any]] = []
        if do_ob and evaluate_fn is not None:
            unit += 1
            opp.begin_unit(unit, f"gated#{i}", "백테재실행·호가")
            lg.info("📌 [후처리] gated#%d 백테 재실행 (호가/휩쏘 체결)", i)
            fills = _replay_fills(evaluate_fn, combo, lg)

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

    for i, row in enumerate(gated, start=1):
        combo = _combo_from_row(row)
        fills: List[Dict[str, Any]] = []
        if do_ob:
            opp.next_unit(f"gated#{i}", "백테재실행·호가")
        if do_ob and evaluate_fn is not None:
            lg.info("📌 [후처리] gated#%d 백테 재실행 (호가/휩쏘 체결)", i)
            fills = _replay_fills(evaluate_fn, combo, lg)

        fills_m = list(mode_fills or [])
        if do_ob and not fills_m and evaluate_fn is not None:
            mode_params = dict(mc.get("params") or {})
            if mode_params:
                lg.info("📌 [후처리] mode_combo 백테 재실행")
                fills_m = _replay_fills(evaluate_fn, mode_params, lg)

        fills_m = list(mode_fills or [])
        if do_ob:
            from kis_trader.backtest import optuna_post_progress as opp
            opp.next_unit("mode", "mode_combo 호가")
        if do_ob and not fills_m and evaluate_fn is not None:
            mode_params = dict(mc.get("params") or {})
            if mode_params:
                lg.info("📌 [후처리] mode_combo 백테 재실행")
                fills_m = _replay_fills(evaluate_fn, mode_params, lg)

        live_ob = {"ok": False, "reason": "light_skip"}
        live_ws = {"ok": False, "reason": "light_skip"}
        if do_ob:
            live_ob = _ob_for_anchor(strategy=strat_u, out_data=data, fills=None, live=True, lg=lg, n_trials=ob_n_trials)

        live_ob = {"ok": False, "reason": "light_skip"}
        live_ws = {"ok": False, "reason": "light_skip"}
        if do_ob:
            from kis_trader.backtest import optuna_post_progress as opp
            opp.next_unit("live", "실매참고 호가")
            live_ob = _ob_for_anchor(strategy=strat_u, out_data=data, fills=None, live=True, lg=lg, n_trials=ob_n_trials)


    lg.info(
        "📌 [후처리 TopN] anchors=%d · 과적합%%=%.1f(%s) · ob_whipsaw=%s",
        len(anchors), apply_pct, verd, do_ob,
    )
    if do_ob:
        from kis_trader.backtest import optuna_post_progress as opp
        opp.finish_job(lg)
    return data

    lg.info(
        "📌 [후처리 TopN] anchors=%d · 과적합%%=%.1f(%s) · ob_whipsaw=%s",
        len(anchors), apply_pct, verd, do_ob,
    )
    return data

    study = optuna.create_study(direction="maximize")
    from kis_trader.backtest import optuna_post_progress as opp

    def _cb(_study: Any, _trial: Any) -> None:
        n = len(_study.trials)
        if n == 1 or n == int(n_trials) or n % 5 == 0:
            opp.on_ob_axis_trial(n, int(n_trials), axis_name)

    study.optimize(obj_func, n_trials=n_trials, callbacks=[_cb])

    study = optuna.create_study(direction="maximize")
    study.optimize(obj_func, n_trials=n_trials)

    axis_n = _ob_axis_n_trials(int(n_trials))
    can_exit_stop = strat_upper in ("MOMENTUM", "BREAKOUT")
    from kis_trader.backtest import optuna_post_progress as opp
    n_ax = 3 if can_exit_stop else 1
    opp.set_ob_axes(n_ax, axis_n)
    opp.begin_ob_axis("진입호가", 0)
    entry_axis = _optimize_entry_axis(trades, orig_cnt=orig_cnt, n_trials=axis_n, lg=lg)
    exit_axis: Dict[str, Any]
    stop_axis: Dict[str, Any]
    if can_exit_stop:
        opp.begin_ob_axis("익절호가", 1)
        exit_axis = _optimize_exit_axis(trades, orig_cnt=orig_cnt, n_trials=axis_n, lg=lg)
        opp.begin_ob_axis("손절호가", 2)
        stop_axis = _optimize_stop_axis(trades, orig_cnt=orig_cnt, n_trials=axis_n, lg=lg)

    axis_n = _ob_axis_n_trials(int(n_trials))
    can_exit_stop = strat_upper in ("MOMENTUM", "BREAKOUT")

    entry_axis = _optimize_entry_axis(trades, orig_cnt=orig_cnt, n_trials=axis_n, lg=lg)
    exit_axis: Dict[str, Any]
    stop_axis: Dict[str, Any]
    if can_exit_stop:
        exit_axis = _optimize_exit_axis(trades, orig_cnt=orig_cnt, n_trials=axis_n, lg=lg)
        stop_axis = _optimize_stop_axis(trades, orig_cnt=orig_cnt, n_trials=axis_n, lg=lg)


        return "\n".join(lines[-n:])
    except Exception:
        return ""

_POST_PROG_RE = re.compile(
    r"OPTUNA_POST_PROGRESS\s+pct=(?P<pct>[0-9.]+)\s+step=(?P<step>\d+)\s+"
    r"total=(?P<total>\d+)\s+stage=(?P<stage>\S+)\s+axis=(?P<ad>\d+)/(?P<at>\d+)"
    r"(?P<detail>.*)$"
)

def _parse_postprocess_progress(log_path: Optional[str]) -> Dict[str, Any]:
    """후처리 한 줄 진행. 없으면 구로그 휴리스틱."""
    out: Dict[str, Any] = {
        "pct": 0.0,
        "step": 0,
        "total": 0,
        "stage": "",
        "axis_done": 0,
        "axis_total": 0,
        "detail": "",
        "ready": False,
        "hint": "",
    }
    if not log_path:
        return out
    text = _tail_text(log_path, 250) or ""
    last = None
    for mm in _POST_PROG_RE.finditer(text):
        last = mm
    if last:
        d = last.groupdict()
        try:
            out["pct"] = float(d.get("pct") or 0)
        except (TypeError, ValueError):
            out["pct"] = 0.0
        out["step"] = int(d.get("step") or 0)
        out["total"] = int(d.get("total") or 0)
        out["stage"] = str(d.get("stage") or "")
        out["axis_done"] = int(d.get("ad") or 0)
        out["axis_total"] = int(d.get("at") or 0)
        det = str(d.get("detail") or "").strip()
        out["detail"] = det
        if out["stage"] == "done" or "상세가능" in det:
            out["ready"] = True
            out["pct"] = 100.0
            out["hint"] = "후처리 끝 · 「상세」 가능"
        else:
            ax = ""
            if out["axis_total"]:
                ax = f" · 축 {out['axis_done']}/{out['axis_total']}"
            out["hint"] = (
                f"후처리 {out['step']}/{out['total']} · {out['stage']}{ax} · 「상세」는 끝난 뒤"
            )
        return out
    g = re.findall(r"\[후처리\] gated#(\d+)", text)
    if g:
        out["stage"] = "gated#" + g[-1]
        out["pct"] = min(90.0, float(g[-1]) * 12.0)
        out["hint"] = "후처리 중 · 「상세」는 끝난 뒤"
    if "[후처리 TopN]" in text or "상세가능" in text:
        out["ready"] = True
        out["pct"] = 100.0
        out["stage"] = out.get("stage") or "done"
        out["hint"] = "후처리 끝 · 「상세」 가능"
    return out

        return "\n".join(lines[-n:])
    except Exception:
        return ""

    m["result_summary"] = _summarize_result_json(m.get("result_json"))
    if m.get("briefing_md") and Path(str(m["briefing_md"])).is_file():
        try:
            m["briefing_preview"] = Path(str(m["briefing_md"])).read_text(encoding="utf-8")[:4000]
        except Exception:
            m["briefing_preview"] = None

    rerun = m.get("postprocess_rerun") if isinstance(m.get("postprocess_rerun"), dict) else {}
    pp_log = str((rerun or {}).get("log_path") or "") if str((rerun or {}).get("status") or "") == "running" else ""
    post = _parse_postprocess_progress(pp_log or active_log)
    topn = ((m.get("result_summary") or {}).get("postprocess_topn") or {}) if isinstance(m.get("result_summary"), dict) else {}
    if isinstance(topn, dict) and topn.get("postprocess_by_anchor") and topn.get("run_ob_whipsaw"):
        if not alive and str((rerun or {}).get("status") or "") != "running":
            post["ready"] = True
            post["pct"] = 100.0
            post["hint"] = "후처리 끝 · 「상세」 가능"
            post["stage"] = post.get("stage") or "done"
    trials_tot = int(prog.get("trials_total") or 0)
    trials_done = int(prog.get("trials_done") or 0)
    trial_finished = bool(trials_tot and trials_done >= trials_tot)
    rerun_run = str((rerun or {}).get("status") or "") == "running"
    if rerun_run:
        m["phase"] = "postprocess"
        if not post.get("hint"):
            post["hint"] = "후처리 재실행 중 · 「상세」는 끝난 뒤"
    elif alive and trial_finished and not post.get("ready"):
        m["phase"] = "postprocess"
        if not post.get("stage"):
            post["stage"] = "wait"
            post["hint"] = "학습 끝 · 후처리 시작 대기 · 「상세」는 아직"
    elif alive:
        m["phase"] = "trials"
        if not post.get("hint"):
            post["hint"] = "학습 trial 중 · 후처리는 그 다음"
    else:
        m["phase"] = str(m.get("status") or "idle")
        if post.get("ready") and not post.get("hint"):
            post["hint"] = "후처리 끝 · 「상세」 가능"
    m["postprocess"] = post

    save_job(m)
    return m

    m["result_summary"] = _summarize_result_json(m.get("result_json"))
    if m.get("briefing_md") and Path(str(m["briefing_md"])).is_file():
        try:
            m["briefing_preview"] = Path(str(m["briefing_md"])).read_text(encoding="utf-8")[:4000]
        except Exception:
            m["briefing_preview"] = None
    save_job(m)
    return m


진행

학습 trial

후처리 (호가·휩쏘 · 끝나면 「상세」)

후처리 — trial 끝난 뒤 시작

후처리 바가 100%·「상세 가능」이 뜬 뒤에 「상세」를 누르세요. 진짜 구 JSON만 재실행.

후처리가 로그에만 있고 표가 비면, 잡이 끝난 뒤 새로고침하세요. 진짜 구 JSON만 재실행.

  .optuna-prog-fill.is-done { background: var(--green); }
  .optuna-prog-fill.is-error { background: var(--red); }
  .optuna-prog-fill.is-post { background: #d29922; }

  .optuna-prog-fill.is-done { background: var(--green); }
  .optuna-prog-fill.is-error { background: var(--red); }


function optunaSetNav(job) {
  const bar = $('optuna_nav_bar');
  if (!bar) return;
  if (!job) {
    bar.classList.add('d-none');
    return;
  }
  const prog = job.progress || {};
  const post = job.postprocess || {};
  const pct = Number(prog.pct || 0);
  const st = job.status || '';
  const phase = job.phase || '';
  const postBusy = phase === 'postprocess' || (st === 'running' && pct >= 99.9 && !post.ready);
  const label = (job.label || job.strategy || 'Optuna') +
    (postBusy ? ' 후처리' : (st === 'running' ? ' 실행중' : (st === 'done' ? ' 완료' : (st === 'error' ? ' 오류' : ''))));
  const done = prog.trials_done != null ? prog.trials_done : '?';
  const tot = prog.trials_total != null ? prog.trials_total : '?';
  $('optuna_nav_label').textContent = `${label} ${done}/${tot}`;
  const met = optunaBestMetricsText(prog, job);
  const postPct = Number(post.pct || 0);
  if (postBusy) {
    $('optuna_nav_pct').textContent = `후처리 ${postPct}%` + (post.stage ? ` · ${post.stage}` : '');
  } else {
    $('optuna_nav_pct').textContent = met ? `${pct}% · ${met}` : `${pct}%`;
  }
  const fill = $('optuna_nav_fill');
  fill.style.width = `${Math.min(100, pct)}%`;
  fill.classList.toggle('is-done', st === 'done' && !postBusy);
  fill.classList.toggle('is-error', st === 'error');
  const postTrack = $('optuna_nav_post_track');
  const postFill = $('optuna_nav_post_fill');
  if (postTrack && postFill) {
    const showPost = postBusy || post.ready || postPct > 0;
    postTrack.classList.toggle('d-none', !showPost);
    postFill.style.width = `${Math.min(100, post.ready ? 100 : postPct)}%`;
    postFill.classList.toggle('is-done', !!post.ready);
  }
  bar.classList.remove('d-none');
}

function optunaSetNav(job) {
  const bar = $('optuna_nav_bar');
  if (!bar) return;
  if (!job) {
    bar.classList.add('d-none');
    return;
  }
  const prog = job.progress || {};
  const pct = Number(prog.pct || 0);
  const st = job.status || '';
  const label = (job.label || job.strategy || 'Optuna') +
    (st === 'running' ? ' 실행중' : (st === 'done' ? ' 완료' : (st === 'error' ? ' 오류' : '')));
  const done = prog.trials_done != null ? prog.trials_done : '?';
  const tot = prog.trials_total != null ? prog.trials_total : '?';
  $('optuna_nav_label').textContent = `${label} ${done}/${tot}`;
  const met = optunaBestMetricsText(prog, job);
  $('optuna_nav_pct').textContent = met ? `${pct}% · ${met}` : `${pct}%`;
  const fill = $('optuna_nav_fill');
  fill.style.width = `${Math.min(100, pct)}%`;
  fill.classList.toggle('is-done', st === 'done');
  fill.classList.toggle('is-error', st === 'error');
  bar.classList.remove('d-none');
}

function optunaPostBusy(job) {
  if (!job) return false;
  const post = job.postprocess || {};
  const rerun = job.postprocess_rerun || {};
  if (String(rerun.status || '') === 'running') return true;
  if (post.ready) return false;
  if (job.phase === 'postprocess') return true;
  const prog = job.progress || {};
  return job.status === 'running' && Number(prog.pct || 0) >= 99.9;
}

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

function optunaRankTableRow(r, source) {
  const rank = r.rank || 1;
  const src = source;
  const detailBtn = (src === 'learn')
    ? ''
    : `<button class="btn btn-sm btn-outline-primary py-0" onclick="optunaSelectPostprocess('${src}',${rank})">상세</button>`;

  if ($('opt_st_trials')) {
    const met = optunaBestMetricsText(prog, job);
    const nowBits = [
      job.current_strategy,
      job.current_entry_mode,
    ].filter(Boolean).join('/');
    $('opt_st_trials').textContent =
      `${prog.trials_done ?? '—'} / ${prog.trials_total ?? job.trials ?? '—'} (${pct}%)` +
      (nowBits ? ` · now=${nowBits}` : '') +
      (met ? ` · ${met}` : '');
  }
  const post = job.postprocess || {};
  const postFill = $('opt_post_fill');
  const postPct = post.ready ? 100 : Number(post.pct || 0);
  if (postFill) {
    postFill.style.width = `${Math.min(100, postPct)}%`;
    postFill.classList.toggle('is-done', !!post.ready);
    postFill.classList.toggle('is-error', false);
  }
  if ($('opt_st_post')) {
    const busy = optunaPostBusy(job);
    $('opt_st_post').textContent = post.hint
      || (busy ? '후처리 중 · 「상세」는 끝난 뒤' : (job.status === 'done' ? '후처리 — 표가 있으면 「상세」' : '후처리 — trial 끝난 뒤 시작'));
  }

  if ($('opt_st_trials')) {
    const met = optunaBestMetricsText(prog, job);
    const nowBits = [
      job.current_strategy,
      job.current_entry_mode,
    ].filter(Boolean).join('/');
    $('opt_st_trials').textContent =
      `${prog.trials_done ?? '—'} / ${prog.trials_total ?? job.trials ?? '—'} (${pct}%)` +
      (nowBits ? ` · now=${nowBits}` : '') +
      (met ? ` · ${met}` : '');
  }

  if (stEl) {
    const pp = job && job.postprocess_rerun;
    const prog = job && job.postprocess;
    if (pp && pp.status === 'running') {
      stEl.textContent = (prog && prog.hint) || ('재실행 중… 로그 ' + (pp.log_path || ''));
    } else if (pp && pp.status === 'done') {
      stEl.textContent = '재실행 완료 · ' + (pp.log_path || '');
    } else if (pp && pp.status === 'error') {
      stEl.textContent = '재실행 실패 · ' + (pp.error || '');
    } else if (prog && prog.hint) {
      stEl.textContent = prog.hint;
    }
  }

  if (stEl) {
    const pp = job && job.postprocess_rerun;
    if (pp && pp.status === 'running') {
      stEl.textContent = '재실행 중… 로그 ' + (pp.log_path || '');
    } else if (pp && pp.status === 'done') {
      stEl.textContent = '재실행 완료 · ' + (pp.log_path || '');
    } else if (pp && pp.status === 'error') {
      stEl.textContent = '재실행 실패 · ' + (pp.error || '');
    }
  }

| `optuna_post_progress.py` | — | 🔬 백테스트 | (후처리 진행 로그, 웹 바) |
| `optuna_postprocess_topn.py` | — | 🔬 백테스트 | `optuna_orderbook_recommend`, `optuna_whipsaw_recommend`, `optuna_post_progress` |

| `optuna_postprocess_topn.py` | — | 🔬 백테스트 | `optuna_orderbook_recommend`, `optuna_whipsaw_recommend` |


<button class="btn btn-sm btn-outline-secondary py-0 ms-1" id="opt_btn_mode_detail" onclick="optunaSelectPostprocess('mode',1)">mode 상세</button>

<button class="btn btn-sm btn-outline-secondary py-0 ms-1" onclick="optunaSelectPostprocess('mode',1)">mode 상세</button>

  if ($('opt_st_post')) {
    const busy = optunaPostBusy(job);
    $('opt_st_post').textContent = post.hint
      || (busy ? '후처리 중 · 「상세」는 끝난 뒤' : (job.status === 'done' ? '후처리 — 표가 있으면 「상세」' : '후처리 — trial 끝난 뒤 시작'));
  }
  if ($('opt_btn_mode_detail')) $('opt_btn_mode_detail').disabled = optunaPostBusy(job);
  if ($('opt_btn_pp_rerun')) $('opt_btn_pp_rerun').disabled = optunaPostBusy(job) && job.status === 'running';

  if ($('opt_st_post')) {
    const busy = optunaPostBusy(job);
    $('opt_st_post').textContent = post.hint
      || (busy ? '후처리 중 · 「상세」는 끝난 뒤' : (job.status === 'done' ? '후처리 — 표가 있으면 「상세」' : '후처리 — trial 끝난 뒤 시작'));
  }

${job.status || ''}${job.phase === 'postprocess' ? ' ·후처리' : ''}
        ${prog.trials_done ?? '—'}/${prog.trials_total ?? job.trials ?? '—'}${job.postprocess && job.postprocess.pct != null && job.phase === 'postprocess' ? ' ·후 ' + job.postprocess.pct + '%' : ''}

${job.status || ''}
        ${prog.trials_done ?? '—'}/${prog.trials_total ?? job.trials ?? '—'}

Shell

python3 -m py_compile /home/hoon/kis_bot/kis_trader/backtest/optuna_post_progress.py /home/hoon/kis_bot/kis_trader/backtest/optuna_postprocess_topn.py /home/hoon/kis_bot/kis_trader/backtest/optuna_orderbook_recommend.py /home/hoon/kis_bot/kis_trader/backtest/optuna_web_jobs.py && python3 - <<'PY'
from kis_trader.backtest.optuna_web_jobs import _parse_postprocess_progress, _POST_PROG_RE
line = "OPTUNA_POST_PROGRESS pct=37.5 step=3 total=8 stage=gated#2 axis=12/80 진입호가"
m = _POST_PROG_RE.search(line)
print("re", bool(m), m.groupdict() if m else None)
class L:
    def info(self, fmt, *a):
        print(fmt % a if a else fmt)
from kis_trader.backtest import optuna_post_progress as opp
opp.begin_job(L(), 4)
opp.next_unit("gated#1", "백테재실행·호가")
opp.set_ob_axes(3, 80)
opp.begin_ob_axis("진입호가", 0)
opp.on_ob_axis_trial(12, 80, "진입호가")
opp.finish_job(L())
PY

Compile Python and smoke-test progress parser


StrReplace

def finish_job(log: Optional[Any] = None) -> None:
    c = _ctx()
    if log is not None:
        c["log"] = log
    c["step"] = int(c.get("total") or 1)
    emit(stage="done", detail="", done=True)

def finish_job(log: Optional[Any] = None) -> None:
    c = _ctx()
    if log is not None:
        c["log"] = log
    c["step"] = int(c.get("total") or 1)
    emit(stage="done", detail="상세가능", done=True)

    if done:
        bits.append("상세가능")

    if done:
        bits.append("상세가능")


  if ($('opt_btn_mode_detail')) $('opt_btn_mode_detail').disabled = optunaPostBusy(job);
  if ($('opt_btn_pp_rerun')) $('opt_btn_pp_rerun').disabled = optunaPostBusy(job);

  if ($('opt_btn_mode_detail')) $('opt_btn_mode_detail').disabled = optunaPostBusy(job);
  if ($('opt_btn_pp_rerun')) $('opt_btn_pp_rerun').disabled = optunaPostBusy(job) && job.status === 'running';


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 health-check localhost


CallMcpTool

List open browser tabs


Open dashboard with cache-bust query


Lock browser for Optuna UI check


Click Optuna tab


({post: document.getElementById('opt_st_post') && document.getElementById('opt_st_post').textContent, fill: document.getElementById('opt_post_fill') && document.getElementById('opt_post_fill').style.width, trial: document.getElementById('opt_tab_fill') && document.getElementById('opt_tab_fill').style.width, hint: document.getElementById('opt_pp_rerun_status') && document.getElementById('opt_pp_rerun_status').textContent.slice(0,120), cons: window.__bt_console_errs || null})

Read postprocess bar DOM text


(() => { const btns = [...document.querySelectorAll('#tab-optuna button')].filter(b => b.textContent.trim()==='상세'); return { n: btns.length, disabled: btns.map(b => b.disabled), labels: [...document.querySelectorAll('#tab-optuna h6, #opt_st_post')].slice(0,8).map(e => e.textContent.slice(0,80)) }; })()

Count Optuna 상세 buttons and disabled state

(() => { const errs = []; const orig = console.error; /* peek performance */ return { href: location.href, hasPostFill: !!document.getElementById('opt_post_fill'), hasNavPost: !!document.getElementById('optuna_nav_post_fill') }; })()

Confirm post bar elements exist


(() => { const b = [...document.querySelectorAll('#tab-optuna button')].find(x => x.textContent.trim()==='상세'); if (b) b.click(); const cards = document.getElementById('opt_postprocess_cards'); return { clicked: !!b, cards: (cards && cards.innerText || '').slice(0,240) }; })()

Click first 상세 and read postprocess table


(() => { const msgs=[]; /* check last exceptions via performance */ return document.querySelectorAll('.text-danger').length; })()

Quick check for on-page error text

Unlock browser after Optuna check


**학습 trial이 100%여도 후처리가 끝난 게 아닙니다.** 그래서 노란 **후처리 바**를 넣었습니다. 「상세」는 그 바가 차고 **「후처리 끝 · 상세 가능」**이 뜬 뒤에 누르세요.

동작:

- 위쪽 바 = 학습 trial
- 아래쪽 노란 바 = 후처리(호가·휩쏘). `gated#2 진입호가 12/80`처럼 지금 어디인지 표시
- 후처리 중에는 「상세」가 비활성
- 상단 미니바도 trial 100%에서 멈추지 않고 **후처리 %**로 바뀜

**영향:** 웹 Optuna 탭만. 실매 엔진·익절호가컷은 그대로입니다. 해외 종목 Optuna는 TopN 후처리 표가 없어서 이번 바는 안 넣었습니다.

**검증:** `kis_backtest_web.service` **active**, curl **200**, `http://192.168.0.149:5050/?v=optuna-post-bar-1` → Optuna 탭. 완료 잡에서 후처리 100%·「상세 가능」, 「상세」 클릭해 표 확인. 콘솔 오류 없음.

이미 돌아가던 잡은 새 로그 줄이 없어서 축 trial 숫자는 거칠 수 있습니다. **다음 웹 Optuna부터** 바가 잘게 찹니다.


<timestamp>Sunday, Aug 16, 2026, 1:43 AM (UTC+9)</timestamp>
<user_query>
익절호가가 몇초면 끝났었는데 이번에 법위 확 늘리면서 이렇게 오래 걸리는건가?
</user_query>


익절호가 후처리가 길어진 이유가 탐색 범위를 넓힌 탓인지, trial 수·앵커 수가 늘어서인지 코드와 실제 설정을 확인하겠습니다.


Shell

python3 - <<'PY'
from kis_trader.utils.env import get_env_int, get_env_float, get_env_bool
keys = [
    "OPTUNA_OB_RECOMMEND_TRIALS","OPTUNA_OB_AXIS_TRIALS",
    "OPTUNA_OB_ENTRY_SPREAD_MIN","OPTUNA_OB_ENTRY_SPREAD_MAX",
    "OPTUNA_OB_ENTRY_RATIO_MIN","OPTUNA_OB_ENTRY_RATIO_MAX",
    "OPTUNA_OB_ENTRY_ASK_MULT_MIN","OPTUNA_OB_ENTRY_ASK_MULT_MAX",
    "OPTUNA_OB_EXIT_HOLD_MIN","OPTUNA_OB_EXIT_HOLD_MAX",
    "OPTUNA_OB_EXIT_RATIO_MIN","OPTUNA_OB_EXIT_RATIO_MAX",
    "OPTUNA_OB_EXIT_PROFIT_MIN","OPTUNA_OB_EXIT_PROFIT_MAX",
    "OPTUNA_OB_EXIT_MA_MIN","OPTUNA_OB_EXIT_MA_MAX",
    "OPTUNA_POST_TOP_N","OPTUNA_POST_INCLUDE_STABLE","OPTUNA_POST_INCLUDE_LIVE","OPTUNA_POST_INCLUDE_MODE",
]
for k in keys:
    if "INCLUDE" in k or k.startswith("OPTUNA_POST_INCLUDE"):
        print(f"{k}={get_env_bool(k, True)}")
    elif any(x in k for x in ("MIN","MAX","TRIALS","TOP")):
        if "PROFIT" in k or "RATIO" in k or "SPREAD" in k or "MULT" in k:
            print(f"{k}={get_env_float(k, -1)}")
        else:
            print(f"{k}={get_env_int(k, -1)}")
    else:
        print(k, get_env_int(k,-1))
print("POST_TOP", get_env_int("OPTUNA_POST_TOP_N", 5))
print("STABLE", get_env_bool("OPTUNA_POST_INCLUDE_STABLE", True))
print("LIVE", get_env_bool("OPTUNA_POST_INCLUDE_LIVE", True))
print("MODE", get_env_bool("OPTUNA_POST_INCLUDE_MODE", True))
PY

Read live Optuna OB postprocess env values


**범위가 넓어져서가 아닙니다.** 후처리는 격자 전부가 아니라 **축마다 정해진 횟수(지금 1000번)** 만 돌립니다. 구간을 0.1~8로 늘려도 trial 수가 같으면 초 단위가 그에 비례해 늘지 않습니다.

지금 DB 값:

- `OPTUNA_OB_RECOMMEND_TRIALS=1000` / `OPTUNA_OB_AXIS_TRIALS=0` → **축당 1000**
- 모멘텀·돌파는 축이 **진입 + 익절 + 손절 = 3번**
- Top5 사후합격 + 안정(다른 trial) + mode + 실매참고 → 그 1000×3을 **여러 번**

예: 호가 탐색만 대략 `앵커 수 × 3 × 1000`. 예전에 「합의 한 번」이던 때와 차원이 다릅니다.

익절이 특히 길게 느껴지는 이유:

- **진입** = 체결 한 장의 간격·잔량만 봄 (가벼움)
- **익절** = 들고 있는 동안 호가 사진을 **장마다** 훑음 (`holding_snaps`). 같은 1000회여도 한 바퀴가 훨씬 무겁습니다

8/15에 넓힌 건 **진입** 격자(스프레드 0.1~8, 살힘, 매도벽)입니다. 익절 구간(OR 0.2~0.8, 보유 1~5분 등)은 그 정도로 확 늘린 축이 아닙니다.

예전에 몇 초였던 건 보통 **익절축을 안 돌리거나, trial이 적거나, TopN마다 안 돌릴 때**입니다. 지금은 그게 기본입니다.

줄이려면 `OPTUNA_OB_AXIS_TRIALS`를 80~200 정도로 두면 됩니다. 범위 축소와는 다른 레버입니다. 원하시면 숫자만 맞춰 드리겠습니다.


<timestamp>Sunday, Aug 16, 2026, 1:44 AM (UTC+9)</timestamp>
<user_query>
진입 익절 손절 다 각각 500으로 줄여줘
</user_query>


진입·익절·손절 후처리 trial을 축마다 500으로 맞추려면, 코드 기본값과 DB에 이미 있는 `OPTUNA_OB_*` 값을 함께 바꿔야 합니다.


StrReplace

        "OPTUNA_OB_RECOMMEND_TRIALS": "1000",
        "OPTUNA_OB_AXIS_TRIALS": "0",

        "OPTUNA_OB_RECOMMEND_TRIALS": "500",
        "OPTUNA_OB_AXIS_TRIALS": "500",

return max(10, int(n_trials or _ob_n_trials(1000)))

return max(10, int(n_trials or _ob_n_trials(500)))

`OPTUNA_OB_AXIS_TRIALS=0` 이면 `OPTUNA_OB_RECOMMEND_TRIALS`(기본 1000)/축.

`OPTUNA_OB_AXIS_TRIALS`(기본 500) = 진입·익절·손절 **각 축** trial 수.
0이면 `OPTUNA_OB_RECOMMEND_TRIALS`(기본 500)/축.

진입 격자(2026-08-15): OPTUNA_OB_ENTRY_SPREAD 0.1~8 / RATIO 0.05~1.5 / ASK_MULT 1~80 / LOOKBACK 30분.

  진입 격자(2026-08-15): OPTUNA_OB_ENTRY_SPREAD 0.1~8 / RATIO 0.05~1.5 / ASK_MULT 1~80 / LOOKBACK 30분.
  축 trial(2026-08-16): OPTUNA_OB_AXIS_TRIALS=500 (진입·익절·손절 각 500).


Shell

python3 - <<'PY'
from datetime import datetime
from database import TradeDB
from kis_trader.utils.env import invalidate_merged_env_cache, get_env_from_db, get_env_int

db = TradeDB()
cols = [r["Field"] for r in db.conn.execute("SHOW COLUMNS FROM env_config_ext").fetchall()]
print("env_config_ext", cols)
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
n = db._persist_env_config_overflow(
    {"OPTUNA_OB_RECOMMEND_TRIALS": "500", "OPTUNA_OB_AXIS_TRIALS": "500"},
    now,
)
print("overflow saved", n)
invalidate_merged_env_cache()
print("RECOMMEND", get_env_from_db("OPTUNA_OB_RECOMMEND_TRIALS"))
print("AXIS", get_env_from_db("OPTUNA_OB_AXIS_TRIALS"))
print("get_env AXIS", get_env_int("OPTUNA_OB_AXIS_TRIALS", 0))
from kis_trader.backtest.optuna_orderbook_recommend import _ob_axis_n_trials
print("axis_n", _ob_axis_n_trials(0))
db.close()
PY

UPSERT OB trial env to 500 and re-read


StrReplace

Optuna 차트 캔들 최적화(Stage 1)가 완료된 후, 후처리(Stage 2)로 축당 500회 고속 호가 탐색을 수행하여

Optuna 차트 캔들 최적화(Stage 1)가 완료된 후, 후처리(Stage 2)로 1,000회 고속 호가 탐색을 수행하여

ap.add_argument("--n-trials", type=int, default=500, help="탐색 횟수 (기본 500회)")

ap.add_argument("--n-trials", type=int, default=1000, help="탐색 횟수 (기본 1000회)")


ap.add_argument("--n-trials", type=int, default=500, help="Optuna 최적화 탐색 횟수 (기본 500회)")

ap.add_argument("--n-trials", type=int, default=1000, help="Optuna 최적화 탐색 횟수 (기본 1000회)")

축당 500회 고속 Optuna 탐색으로 도출된 전략별 호가 수급 합의 파라미터(Consensus)를

1,000회 고속 Optuna 탐색으로 도출된 전략별 호가 수급 합의 파라미터(Consensus)를

실매매 체결 내역과 호가 스냅샷을 연계하여 축당 500회 고속 Optuna 탐색을 통해

실매매 체결 내역과 호가 스냅샷을 연계하여 1,000회 고속 Optuna 탐색을 통해

.venv/bin/python3 scripts/optuna_ob_filter.py --strategy MOMENTUM --n-trials 500

.venv/bin/python3 scripts/optuna_ob_filter.py --strategy MOMENTUM --n-trials 1000


**진입·익절·손절 후처리를 축마다 500회로 맞춰 두었습니다.** 실매 엔진은 그대로입니다.

| 키 | 이전 | 지금 |
|---|---|---|
| `OPTUNA_OB_AXIS_TRIALS` | 0 (=축당 1000) | **500** |
| `OPTUNA_OB_RECOMMEND_TRIALS` | 1000 | **500** |

DB `env_config_ext`에 저장했고, 재조회도 500입니다. 코드 기본값도 같게 바꿨습니다.

**지금 돌고 있는 후처리**는 이미 1000으로 시작한 축은 그대로 끝납니다. **다음 축·다음 잡**부터 500입니다.


<timestamp>Sunday, Aug 16, 2026, 1:58 AM (UTC+9)</timestamp>
<user_query>
아니 근데 익절호가 진입호가 이런게 방어막인데 왜 수익이랑 승률이 떨어지지? 그리고 상세표에 승률도 넣어줘 pnl만 있고 승률이 없네 승률이 떨어지면 또 고민을 해봐야지
</user_query>


방어막인데 성적이 떨어지는 이유와, 상세표에 승률을 넣을 자리를 코드에서 확인하겠습니다.


        anchors.append({
            "id": f"gated#{i}",
            "role": "gated",
            "rank": i,
            "optuna_trial_number": row.get("optuna_trial_number") or row.get("_trial_number"),
            "total_pnl": _krw_int(pnl),
            "total_trades": int(row.get("total_trades") or 0),
            "win_rate": row.get("win_rate"),
            "pf": row.get("pf"),
            "orderbook": ob,

        anchors.append({
            "id": f"gated#{i}",
            "role": "gated",
            "rank": i,
            "optuna_trial_number": row.get("optuna_trial_number") or row.get("_trial_number"),
            "total_pnl": _krw_int(pnl),
            "total_trades": int(row.get("total_trades") or 0),
            "orderbook": ob,


            "total_pnl": _krw_int(pnl),
            "total_trades": int(row.get("total_trades") or 0),
            "win_rate": row.get("win_rate"),
            "pf": row.get("pf"),
            "orderbook": ob,

            "total_pnl": _krw_int(pnl),
            "total_trades": int(row.get("total_trades") or 0),
            "orderbook": ob,

            "total_pnl": _krw_int(mode_pnl),
            "total_trades": int(bt.get("total_trades") or 0),
            "win_rate": bt.get("win_rate"),
            "pf": bt.get("pf"),
            "orderbook": ob,

            "total_pnl": _krw_int(mode_pnl),
            "total_trades": int(bt.get("total_trades") or 0),
            "orderbook": ob,

    stats_pnls: List[int] = []
    stats_cnt: List[int] = []
    stats_wrs: List[float] = []
    for a in pool:
        ob = a.get("orderbook") or {}
        nested = ob.get(axis) if isinstance(ob.get(axis), dict) else {}
        if nested.get("ok"):
            params_list.append(dict(nested.get("params") or {}))
            rs = nested.get("recommended_stats") or {}
            stats_pnls.append(_krw_int(rs.get("pnl")))
            stats_cnt.append(int(rs.get("count") or 0))
            try:
                stats_wrs.append(float(rs.get("win_rate")))
            except (TypeError, ValueError):
                pass

    stats_pnls: List[int] = []
    stats_cnt: List[int] = []
    for a in pool:
        ob = a.get("orderbook") or {}
        nested = ob.get(axis) if isinstance(ob.get(axis), dict) else {}
        if nested.get("ok"):
            params_list.append(dict(nested.get("params") or {}))
            rs = nested.get("recommended_stats") or {}
            stats_pnls.append(_krw_int(rs.get("pnl")))
            stats_cnt.append(int(rs.get("count") or 0))

    rec_st: Dict[str, Any] = {}
    if stats_pnls:
        rec_st = {
            "count": int(statistics.median(stats_cnt)) if stats_cnt else 0,
            "pnl": _krw_int(statistics.median(stats_pnls)),
        }
        if stats_wrs:
            rec_st["win_rate"] = round(float(statistics.median(stats_wrs)), 1)

    rec_st: Dict[str, Any] = {}
    if stats_pnls:
        rec_st = {
            "count": int(statistics.median(stats_cnt)) if stats_cnt else 0,
            "pnl": _krw_int(statistics.median(stats_pnls)),
        }


            "total_pnl": _krw_int(live_pnl),
            "total_trades": int((live_ob.get("orig_stats") or live_ws.get("orig_stats") or {}).get("count") or 0),
            "win_rate": (live_ob.get("orig_stats") or live_ws.get("orig_stats") or {}).get("win_rate"),
            "orderbook": live_ob,

            "total_pnl": _krw_int(live_pnl),
            "total_trades": int((live_ob.get("orig_stats") or live_ws.get("orig_stats") or {}).get("count") or 0),
            "orderbook": live_ob,

function _optunaAxisStatsHtml(ax, recHint) {
  if (!ax || !ax.ok) {
    const r = (ax && ax.reason) || (recHint && recHint.reason) || '없음';
    const ko = {
      light_skip: '후처리 미저장 · 진행 중이면 완료 후 새로고침',
      no_replay_fills: '체결 재실행 없음',
      not_enough_trades: '체결 부족',
      'n/a_strategy': '해당없음',
      none: '없음',
    };
    return `<span class="text-muted">${ko[r] || r}</span>`;
  }
  const p = ax.params || {};
  const rs = ax.recommended_stats || {};
  const os = ax.orig_stats || {};
  const pnl = rs.pnl != null ? Math.trunc(Number(rs.pnl)) : null;
  const cnt = rs.count;
  const wr = rs.win_rate != null ? Number(rs.win_rate) : NaN;
  const origWr = os.win_rate != null ? Number(os.win_rate) : NaN;
  const origPnl = os.pnl != null ? Math.trunc(Number(os.pnl)) : null;
  let recLine = '';
  if (p.orderbook_max_spread_pct != null) {
    recLine = `스프레드≤${p.orderbook_max_spread_pct}% · 잔량≥${p.orderbook_min_bid_ask_ratio}`;
  } else if (p.exit_ob_enabled != null) {
    recLine = (p.exit_ob_enabled ? 'EXIT ON' : 'EXIT OFF')
      + (p.exit_ob_ratio_min != null ? ` · OR≥${p.exit_ob_ratio_min}` : '')
      + (p.exit_ob_min_hold_bars != null ? ` · 보유${p.exit_ob_min_hold_bars}` : '');
  } else if (p.stop_ob_enabled != null) {
    recLine = (p.stop_ob_enabled ? 'STOP ON' : 'STOP OFF')
      + (p.stop_ob_min_loss_pct != null ? ` · 손실${p.stop_ob_min_loss_pct}` : '')
      + (p.stop_ob_ratio_min != null ? ` · OR≥${p.stop_ob_ratio_min}` : '');
  } else if (p.whipsaw_dip_pct != null) {
    recLine = `sub=${p.whipsaw_subbar_sec} · lb=${p.whipsaw_lookback_sec} · dip=${p.whipsaw_dip_pct}`;
  }
  const wrCls = (Number.isFinite(wr) && Number.isFinite(origWr) && wr + 0.05 < origWr)
    ? 'text-warning' : 'text-muted';
  const recBits = [];
  if (cnt != null) recBits.push(cnt + '건');
  if (Number.isFinite(wr)) recBits.push('승률 ' + wr.toFixed(1) + '%');
  if (pnl != null) recBits.push(Number(pnl).toLocaleString() + '원');
  const origBits = [];
  if (os.count != null && os.count !== cnt) origBits.push(os.count + '건');
  if (Number.isFinite(origWr)) origBits.push('승률 ' + origWr.toFixed(1) + '%');
  if (origPnl != null && origPnl !== pnl) origBits.push(Number(origPnl).toLocaleString() + '원');
  return `${recLine || '추천있음'}`
    + (recBits.length ? `<div class="${wrCls}">필터후 ${recBits.join(' · ')}</div>` : '')
    + (origBits.length ? `<div class="text-muted">필터전 ${origBits.join(' · ')}</div>` : '');
}

function _optunaBaseCell(a) {
  const pnl = a.total_pnl != null ? Math.trunc(Number(a.total_pnl)) : null;
  const wr = a.win_rate != null ? Number(a.win_rate) : NaN;
  const bits = [];
  if (a.total_trades != null) bits.push(a.total_trades + '건');
  if (Number.isFinite(wr)) bits.push('승률 ' + wr.toFixed(1) + '%');
  if (pnl != null) bits.push(Number(pnl).toLocaleString() + '원');
  return `trial ${a.optuna_trial_number != null ? '#' + a.optuna_trial_number : '—'}`
    + (bits.length ? `<div class="text-muted">${bits.join(' · ')}</div>` : '');
}

function _optunaAxisStatsHtml(ax, recHint) {
  if (!ax || !ax.ok) {
    const r = (ax && ax.reason) || (recHint && recHint.reason) || '없음';
    const ko = {
      light_skip: '후처리 미저장 · 진행 중이면 완료 후 새로고침',
      no_replay_fills: '체결 재실행 없음',
      not_enough_trades: '체결 부족',
      'n/a_strategy': '해당없음',
      none: '없음',
    };
    return `<span class="text-muted">${ko[r] || r}</span>`;
  }
  const p = ax.params || {};
  const rs = ax.recommended_stats || {};
  const pnl = rs.pnl != null ? Math.trunc(Number(rs.pnl)) : null;
  const cnt = rs.count;
  let recLine = '';
  if (p.orderbook_max_spread_pct != null) {
    recLine = `스프레드≤${p.orderbook_max_spread_pct}% · 잔량≥${p.orderbook_min_bid_ask_ratio}`;
  } else if (p.exit_ob_enabled != null) {
    recLine = (p.exit_ob_enabled ? 'EXIT ON' : 'EXIT OFF')
      + (p.exit_ob_ratio_min != null ? ` · OR≥${p.exit_ob_ratio_min}` : '')
      + (p.exit_ob_min_hold_bars != null ? ` · 보유${p.exit_ob_min_hold_bars}` : '');
  } else if (p.stop_ob_enabled != null) {
    recLine = (p.stop_ob_enabled ? 'STOP ON' : 'STOP OFF')
      + (p.stop_ob_min_loss_pct != null ? ` · 손실${p.stop_ob_min_loss_pct}` : '')
      + (p.stop_ob_ratio_min != null ? ` · OR≥${p.stop_ob_ratio_min}` : '');
  } else if (p.whipsaw_dip_pct != null) {
    recLine = `sub=${p.whipsaw_subbar_sec} · lb=${p.whipsaw_lookback_sec} · dip=${p.whipsaw_dip_pct}`;
  }
  return `${recLine || '추천있음'}`
    + `<div class="text-muted">${cnt != null ? cnt + '건' : ''}`
    + (pnl != null ? ` · ${Number(pnl).toLocaleString()}원` : '') + '
';
}

function _optunaBaseCell(a) {
  const pnl = a.total_pnl != null ? Math.trunc(Number(a.total_pnl)) : null;
  return `trial ${a.optuna_trial_number != null ? '#' + a.optuna_trial_number : '—'}`
    + `<div class="text-muted">${a.total_trades != null ? a.total_trades + '건' : ''}`
    + (pnl != null ? ` · ${Number(pnl).toLocaleString()}원` : '') + '
';
}


Shell

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

Compile postprocess and restart backtest web


CallMcpTool

Open dashboard with cache-bust for WR column


Lock browser for Optuna WR check


Open Optuna tab


(() => { const b=[...document.querySelectorAll('#tab-optuna button')].find(x=>x.textContent.trim()==='상세'); if(b) b.click(); const t=document.getElementById('opt_postprocess_cards'); const txt=(t&&t.innerText)||''; return {clicked:!!b, hasWr:txt.includes('승률'), snippet:txt.slice(0,500)}; })()

Click 상세 and check win-rate text


Check if browser has new JS function


StrReplace

    const wr = row.win_rate != null ? row.win_rate
      : (isLive
        ? (((row.orderbook || {}).orig_stats || {}).win_rate)
        : (ofRow && ofRow.win_rate));
    const rowForBase = Object.assign({}, row, { win_rate: wr });


    const wr = row.win_rate != null ? row.win_rate
      : (isLive
        ? (((row.orderbook || {}).orig_stats || {}).win_rate)
        : (ofRow && ofRow.win_rate));
    const rowForBase = Object.assign({}, row, { win_rate: wr });
    const title = isLive
      ? `${row.id} · 실매 참고 (적용 없음)`
      : `${row.id} · trial ${row.optuna_trial_number != null ? '#' + row.optuna_trial_number : '—'}`
        + ` · 과적합 가능도 ${ofPct != null ? ofPct : '—'}% · ${verd || ''}`;

    const wr = row.win_rate != null ? row.win_rate
      : (isLive
        ? (((row.orderbook || {}).orig_stats || {}).win_rate)
        : (ofRow && ofRow.win_rate));
    const rowForBase = Object.assign({}, row, { win_rate: wr });
      ? `${row.id} · 실매 참고 (적용 없음)`
      : `${row.id} · trial ${row.optuna_trial_number != null ? '#' + row.optuna_trial_number : '—'}`
        + ` · 과적합 가능도 ${ofPct != null ? ofPct : '—'}% · ${verd || ''}`;


CallMcpTool

Hard-reload dashboard for fixed WR JS


Click Optuna after reload


(async()=>{ await new Promise(r=>setTimeout(r,2500)); const b=[...document.querySelectorAll('#tab-optuna button')].find(x=>x.textContent.trim()==='상세' && !x.disabled); if(b) b.click(); await new Promise(r=>setTimeout(r,300)); const t=document.getElementById('opt_postprocess_cards'); const txt=(t&&t.innerText)||''; return {hasFn:String(_optunaBaseCell).includes('승률'), hasWr:txt.includes('승률'), snippet:txt.slice(0,450)}; })()

Wait for Top5 then click 상세, check 승률


Unlock browser after WR table check


**방어막은 ‘더 벌게’가 아니라 ‘이런 호가는 안 산다/일찍 판다’입니다.** 그래서 수익·승률이 떨어지는 게 이상한 버그가 아닙니다.

진입호가는 대기표가 나쁜 체결을 **통째로 지웁니다.** 그중에 나중에 이긴 건이 있으면 승도 같이 지워집니다. 얇은 호가에서 급등한 건이 특히 그렇습니다. 남은 건만으로 승률을 다시 세면, 전보다 낮아질 수 있습니다.

익절호가는 사겠음이 줄면 **이득 중에 팝니다.** 더 갈 수 있던 승을 짧게 자르면 PnL이 줄고, 작은 승이 사라지면 승률도 내려갑니다. 손절호가도 손실을 빨리 확정해서 승률 분모·분자 둘 다 바뀝니다.

후처리 점수는 “필터 켠 시뮬이 원본보다 무조건 좋다”가 아닙니다. 같은 체결을 호가로 다시 깎아 본 **가정 성적**입니다. 차트 타점 1위와 숫자가 달라도 정상입니다.

**상세표:** 이제 `건수 · 승률% · 원`이 나옵니다. 호가 칸은 **필터후 / 필터전**을 같이 보여 주고, 승률이 필터전보다 낮으면 노랗게 됩니다. 그때 적용을 고민하면 됩니다.

**검증:** 웹 재시작 `active` · HTTP 200 · `http://192.168.0.149:5050/?v=optuna-wr-detail-2` Optuna「상세」→ 기본칸 `3건 · 승률 100.0% · 10,533원`. 콘솔 오류 없음.

실매 엔진은 안 바꿨습니다. 이미 끝난 잡도 기본칸 승률은 Top5에서 바로 붙습니다. 호가칸 필터후/전은 그 잡 JSON에 후처리 숫자가 있을 때 보입니다.


<timestamp>Sunday, Aug 16, 2026, 2:27 AM (UTC+9)</timestamp>
<user_query>
그렇지 승률이 올라야지 수익은 줄어도 근데 호가손절은 아직 매도체크쪽에 안붙지 않았나? 그리고 왜 승률이 더 깎이냐? 익절 손절 같이 넣는데 익절에서 이긴게 80퍼면 손절에서 더 걸러내서 90이 되어야되는거 아닌가
</user_query>


호가손절이 실매 매도 검사에 실제로 붙어 있는지, 후처리에서 익절·손절을 따로 돌리는지부터 확인하겠습니다. 승률이 쌓여 올라갈 거라는 가정과 코드가 다를 수 있습니다.


**호가손절은 매도 체크에 이미 붙어 있습니다.** 안 붙인 게 아닙니다. 기본이 **꺼짐**이라 스위치를 켜야 실매에서 돕니다.

모멘텀·돌파 실매/백테 청산 순서:

1. 래칫
2. 어깨
3. 수익구간 호가컷 (`EXIT_OB`)
4. **손절호가** (`STOP_OB`) ← `_check_stop_ob`
5. −% 손절

`MOMENTUM_STOP_OB_ENABLED` / `BREAKOUT_STOP_OB_ENABLED` 가 false면 4번은 그냥 통과합니다. 코드가 없는 게 아니라 **OFF**입니다.

---

**80%에서 손절을 더 넣으면 90%가 되는 구조가 아닙니다.** 상세표의 진입·익절·손절은 **한 파이프라인에 쌓는 숫자가 아닙니다.** 각자 따로 시뮬합니다.

- **진입호가** = 그 체결을 **안 산다** → 나쁜 건을 빼서 승률이 오를 수 있음
- **익절호가** = 이미 산 뒤, **이득일 때** 호가 무너지면 판다 → 승은 승으로 남고 금액만 줄어드는 경우가 많음
- **손절호가** = 이미 산 뒤, **손해일 때** 호가 무너지면 판다 → 나중에 회복해서 이겼을 건을 **손실로 확정**할 수 있음 → **승률이 오히려 깎임**

손절호가는 ‘패를 골라 빼는 필터’가 아니라 **패를 더 빨리 찍는 매도**입니다. 그래서 익절 80% 위에 얹으면 90%가 되지 않고, 손절 칸만 보면 승률이 더 낮을 수 있습니다.

표에 익절·손절을 나란히 넣은 것은 “둘 다 켠 합성 성적”이 아닙니다. **익절만 / 손절만** 가정입니다.

승률을 올리고 싶으면 먼저 볼 축은 **진입호가**(안 사기)입니다. 손절호가는 낙폭을 줄이려는 축이지, 승률 부스팅이 아닙니다.


<timestamp>Sunday, Aug 16, 2026, 2:29 AM (UTC+9)</timestamp>
<user_query>
하 그럼 이걸 각각 따로 돌릴게 아니고 큰 기본값이 있고 경우의수대로 돌려야되지 않을까
3개니까 3의 몇제곱이냐? 이러게 꼐산이 맞나
</user_query>


계산은 **3의 몇제곱이 아닙니다.** 스위치가 3개면 켜짐/꺼짐이라 **2³ = 8가지**입니다. 3³=27은 “각 축이 선택지가 3개일 때”입니다.

지금 후처리는 그 8가지를 안 돌립니다. 진입 500회, 익절 500회, 손절 500회를 **각자** 돌립니다. 그래서 익절 80% 위에 손절을 얹은 숫자가 안 나옵니다. 손절 칸은 “익절은 없는 세상”의 성적입니다.

맞게 보려면 **한 번 시뮬에 세 축을 같이** 넣어야 합니다.

1. 산다/안 산다 (진입호가)
2. 이득이면 호가컷 (익절호가)
3. 손해면 호가손절

이 순서로 같은 체결을 통과시킨 뒤 승률·PnL을 셉니다.

경우의 수:

| 무엇을 돌리나 | 개수 |
|---|---|
| 켜기/끄기만 | **8** (2×2×2) |
| 숫자까지 TPE로 같이 | 제곱이 아님. **한 스터디**에서 차원만 늘어남 (지금처럼 축당 500×3=1500보다, 합쳐서 500이 더 싸고 맞음) |

기본값은 지금처럼 **차트 Optuna 타점**이고, 그 위에 호가 3축만 경우의 수로 얹는 그림이 맞습니다.

이건 후처리 점수 설계를 바꾸는 일입니다. 실매 엔진 순서는 이미 래칫→어깨→익절호가→손절호가라 그대로 두면 됩니다.

원하시면 **합쳐 시뮬(스택) + 켜짐 8조합 또는 합친 TPE 500** 중 어떤 걸로 갈지 정해 주시면, 그다음에 표의 진입/익절/손절 칸을 “따로 성적”이 아니라 **쌓은 성적**으로 바꾸겠습니다.


<timestamp>Sunday, Aug 16, 2026, 2:30 AM (UTC+9)</timestamp>
<user_query>
무엇을 돌리나	개수
켜기/끄기만
8 (2×2×2)
숫자까지 TPE로 같이
제곱이 아님. 한 스터디에서 차원만 늘어남 (지금처럼 축당 500×3=1500보다, 합쳐서 500이 더 싸고 맞음)
둘의 차이가 뭕데
</user_query>


둘 다 “세 축을 한 시뮬에 쌓는다”는 점은 같습니다. 다른 건 **숫자를 찾느냐, 켜기만 보느냐**입니다.

**켜기/끄기 8가지**
숫자는 지금 DB 값(또는 이미 나온 추천값)으로 **고정**합니다.
돌리는 건 이런 질문뿐입니다.

- 진입만 / 익절만 / 손절만 / 둘 / 셋 다 / 전부 끔

8번만 시뮬하면 끝입니다. 빠르고, “손절을 켜면 승률이 어떻게 되나”가 바로 보입니다.
단점: 스프레드 3%가 맞는지, OR 0.4가 맞는지는 **안 찾습니다.**

**숫자까지 TPE 한 스터디**
켜짐만이 아니라 스프레드·잔량비·OR·최소이득 같은 **숫자도 같이** 고릅니다.
한 trial = “진입 ON + 스프레드 2.1 + 익절 ON + OR 0.35 + 손절 OFF …”처럼 조합 하나를 쌓아서 성적 냄.
그걸 500번 해서 제일 나은 숫자 세트를 찾습니다.
8이 아니라, 연속 숫자라 경우의 수가 사실상 무한 → TPE가 찍어서 500번만 봄.

비유:

- 8가지 = 양념 **쓸지 말지** 8접시
- TPE = 소금 얼마나, 간장 얼마나까지 **간 보기** 500접시

표에서 “익절까지 적용 / 손절까지 적용”을 **쌓인 성적**으로 보려면 최소 8가지면 됩니다.
숫자까지 다시 고르려면 TPE입니다.


<timestamp>Sunday, Aug 16, 2026, 2:34 AM (UTC+9)</timestamp>
<user_query>
아니 그게 아닌데 8가지 축을 tpe 까지 합쳐서를 생각하고 잇는데 나는
아 내 생각을 모르나 무슨말인지 다 뒤죽박죽 섞는건 또 아닌데
</user_query>