---

## 소스 4개가 뭔지 먼저

```
_ALL_CANDLE_SOURCES = ("kis", "kiwoom", "rest", "rollup_1m")
```

| 소스 | 언제 들어오나 |
|---|---|
| `"kis"` | KIS 웹소켓으로 실시간 틱이 들어와서 만들어진 봉 |
| `"kiwoom"` | 키움 웹소켓으로 실시간 틱이 들어와서 만들어진 봉 |
| `"rest"` | 갭보정 때 키움 REST API로 과거 봉 가져온 것 |
| `"rollup_1m"` | 1분봉을 3분/15분/60분으로 합산해서 만든 봉 |

---

## 왜 소스별로 분리했나 (c6bd62a에서 바꾼 이유)

**구버전**은 모든 봉을 `(종목코드, 타임프레임)` 하나의 서랍에 다 집어넣었습니다.

```
서랍 (005930, 1분) = [봉1, 봉2, 봉3, ...]  ← 누가 만든 봉인지 구분 없음
```

**신버전**은 누가 만들었는지 태그를 붙여서 서랍을 분리합니다.

```
서랍 (005930, 1분, "kis")      = [봉1, 봉2, ...]
서랍 (005930, 1분, "kiwoom")   = [봉A, 봉B, ...]
서랍 (005930, 1분, "rest")     = [봉X, 봉Y, ...]
서랍 (005930, 1분, "rollup_1m")= [봉P,봉Q, ...]
```

**이렇게 분리한 목적**: 어떤 소스의 봉인지 추적해서 디버깅, DB 저장, 갭보정 중복 방지를 위해.

---

## 근데 "같은 봉이 두 서랍에 들어있는" 문제가 생김

예를 들어 10:00~10:01 봉이:
- `"rest"` 서랍에 갭보정으로 들어옴
- `"kis"` 서랍에도 WS 틱으로 만들어짐

같은 시간대 봉이 **두 군데에 중복** 존재합니다. 그냥 합치면 10:00봉이 2개가 되니까 안 됩니다.

그래서 `get_candles` 호출 때마다:

1. 4개 서랍을 전부 꺼냄
2. 같은 시간대 봉은 하나만 남김(dedup)
3. 시간순으로 줄 세움(sorted)

이게 바로 **병합(merge) + 정렬(sort)** 작업입니다.

---

## 왜 느려졌나 (락 문제)

```
[전략 스레드 1 - BREAKOUT] get_candles() 호출
    → 🔒 락 잠금
    → 4개 서랍 × 500봉 = 2000개 dict 순회
    → sorted(2000개) ← 여기서 수십ms 걸림
    → 🔓 락 해제

[전략 스레드 2 - SCALP] get_candles() 호출 (동시에)
    → 🔒 락 잠그려는데... BREAKOUT이 이미 잡고 있어서 대기 ⏳
    → 수십ms 기다림
    → ...
```

**모든 전략이 봉 조회를 위해 줄서서 대기**했던 것입니다.

---

## 현재 수정 후

```
[전략 스레드 1 - BREAKOUT] get_candles() 호출
    → 🔒 락 잠금
    → 4개 서랍 참조만 복사 (list.extend × 4번) ← 수 마이크로초
    → 🔓 락 해제  ← 거의 즉시!
    → 락 밖에서 sorted(2000개)  ← 느려도 다른 전략 안 막힘

[전략 스레드 2 - SCALP] get_candles() 호출 (동시에)
    → 🔒 락 잠금 (거의 즉시 획득 가능)
    → 복사 후 즉시 해제
    → 락 밖에서 정렬
```

핵심은 **"문 잠근 시간"을 최소화**한 것입니다. 서랍에서 꺼내는 건 빠르게, 줄 세우기는 문 열고 나와서 하는 방식으로 바꿨습니다.


<timestamp>Wednesday, Aug 12, 2026, 3:51 PM (UTC+9)</timestamp>
<user_query>
아 이것도 지금 캔들을 증권사별로 또 rest까지 쌓아서 문제인거네 그전엔 하나만 쌓았었고 지금 상태에서는 어떻게 짜야 효율적이지? 믿을만한건 rest는 갭보정으로 들어온걸테고 kis kiwoom 의 퀄리티를 비교하고 롤업은 어느 증권사 기준으로 롤업하고잇지?
</user_query>


소스별 우선순위와 롤업이 어느 증권사 1분봉을 쓰는지 코드에서 확인합니다.

[REDACTED]


[REDACTED]


맞습니다. **여러 서랍에 쌓아 두고, 읽을 때마다 합치는 구조**가 느려진 핵심입니다.

---

## 지금 실제로 어떻게 합치나

우선순위는 **품질 비교가 아니라 “먼저 본 소스 승”**입니다.

```
조회/롤업 순서: kis → kiwoom → rest → (롤업은 결과물)
같은 시각 봉이 있으면 → 앞쪽 소스가 이김
```

그래서:
- **품질 비교(OHLC 맞추기)는 안 함**
- 같은 `10:01` 봉이 kis·kiwoom에 둘 다 있으면 → **kis가 이김**
- rest는 **없는 구멍만** 채움
- `rollup_1m`은 “3분/15분/60분을 1분으로 합친 결과” 태그

---

## 롤업은 어느 증권사 기준?

`rollup_tf_from_1m`은 1분봉을 이렇게 모읍니다:

```2124:2131:kis_trader/ws/kis_ws.py
            # 모든 소스(kis / kiwoom / rest)의 1M 봉을 candle_time 기준으로 병합
            seen: dict = {}
            for src in ("kis", "kiwoom", "rest"):
                for bar in self._confirmed.get((code, 1, src), []):
                    ...
                    if ct and ct not in seen:
                        seen[ct] = bar
```

즉 **한 증권사 전용 롤업이 아님**.
같은 시각이면 **kis 1분 → 없으면 kiwoom → 없으면 rest** 순으로 섞어서 3/15/60을 만듭니다.

운영 철학(키움=시세 메인)과 **어긋날 수 있는 지점**입니다.

---

## 소스 4개 한 줄 요약

| 태그 | 정체 |
|---|---|
| `kis` | KIS WS 틱으로 만든 봉 |
| `kiwoom` | 키움 WS 틱으로 만든 봉 |
| `rest` | 갭보정 REST로 가져온 과거 봉 (믿을 만한 “구멍 메우기”) |
| `rollup_1m` | 위 1분들을 합쳐 만든 상위봉 (원천이 아님) |

---

## 효율적으로 짜려면 (추천 설계)

**원칙: 읽을 때 합치지 말고, 쓸 때 하나만 남긴다.**

1. **전략이 보는 RAM은 1개만**
   `(종목, TF)` → 확정봉 리스트 하나.
   예전처럼 `get_candles` = 슬라이스만.

2. **넣을 때만 우선순위**
   - 실시간: **키움 우선** (메인 시세)
   - 키움 없을 때만 KIS
   - REST는 **그 시각이 비어 있을 때만** insert
   - 롤업은 **그 1개 확정 1분봉**만으로 3/15/60 생성 → `rollup_1m`을 전략 조회 경로에 섞지 않음

3. **DB에는 source 태그 유지 OK**
   감사/백테용. RAM 조회 경로와 분리.

4. **지금처럼 소스별 서랍 4개 + 매 조회 merge**
   → 느리고, 우선순위도 kis가 이겨서 키움 메인과도 안 맞음.

---

## 한 줄로

> rest는 “구멍 메우기”로 믿을 만하고, kis/kiwoom은 품질 비교 없이 **kis가 먼저면 kis 승**, 롤업은 **kis→키움→rest 섞인 1분** 기준입니다.
> 효율은 **서랍 4개 유지 + 읽을 때 합치기**가 아니라 **쓸 때 키움 우선으로 하나만 쌓기**입니다.

원하시면 다음 단계로 **“쓰기 시 키움 우선 단일 버퍼”** 설계안(어디 고칠지·실매 영향)만 먼저 짧게 보고하고, 승인 후에 손대겠습니다.

[REDACTED]


<timestamp>Wednesday, Aug 12, 2026, 3:55 PM (UTC+9)</timestamp>
<user_query>
그래 계산하고 자시고 하면 속도만 느려지지 실매 매수체크처럼 우선순위 바꾸는걸로 해 키움메인이면 키움메인 풀백 키스 키스메인이면 키움 풀백 지금 틱이 그렇게 되어잇잖아 호가랑
</user_query>


틱·호가와 같은 우선순위로 봉 병합 순서를 맞추겠습니다. 먼저 현재 시세 폴백 로직을 확인합니다.

[REDACTED]

Grep

_ALL_CANDLE_SOURCES|for src in \(|kis.*kiwoom|kiwoom.*kis|_merge_from_raw|rollup_tf_from_1m|_copy_raw_for_merge


[REDACTED]

TodoWrite

get_candles 락 타임아웃 (폐기—락밖정렬로 대체)

merge 결과 락 밖 +소스 우선순위 LIVE_TICK_PROVIDER

문법/스모크 확인


틱과 같이 `LIVE_TICK_PROVIDER` 기준으로 봉 병합·롤업 우선순위를 맞춥니다.

[REDACTED]

StrReplace

    def rollup_tf_from_1m(self, code: str, target_tf: int = 3) -> int:
        """
        RAM 1분 확정봉 → target_tf 분봉 재합성 후 구멍만 보강.
        꼬리(3M) 트리거 웜업: 1M REST 1회만으로 3M 준비 (WS_GAP_ROLLUP_3M_FROM_1M).

        소스 통합: 재시작 직후 "kis" 버퍼가 비더라도 REST 갭보정("rest")·키움틱("kiwoom")
        에서 1M 봉을 모아 롤업한다. candle_time 기준으로 중복 제거 후 시간순 정렬.
        """
        from kis_trader.engine.candle_rollup import rollup_1m_bars_to_tf

        tf = int(target_tf)
        if tf <= 1:
            return 0
        with self._lock:
            # 모든 소스(kis / kiwoom / rest)의 1M 봉을 candle_time 기준으로 병합
            seen: dict = {}
            for src in ("kis", "kiwoom", "rest"):
                for bar in self._confirmed.get((code, 1, src), []):
                    ct = str(bar.get("candle_time") or "")[:12]
                    if ct and ct not in seen:
                        seen[ct] = bar
            bars_1m = sorted(seen.values(), key=lambda x: str(x.get("candle_time") or ""))
        if not bars_1m:
            return 0
        rolled = rollup_1m_bars_to_tf(bars_1m, tf)
        return self.merge_confirmed_bars(
            code, tf, rolled, log_tag=f"rollup_1m→{tf}M",
        )

    # ------------------------------------------------------------------
    # [트랙 1] RAM 버퍼 조회 — 매수/매도 루프에서 직접 호출 (DB 조회 없음)
    # ------------------------------------------------------------------
    # NOTE: source="" (기본) → kis / kiwoom / rest / rollup_1m 등 모든 소스 집계
    #       재시작 직후 REST·키움 갭보정 데이터를 전략/롤업이 즉시 인식하기 위함.
    #       source를 명시할 경우 (예: source="kis") 해당 소스만 조회.
    _ALL_CANDLE_SOURCES = ("kis", "kiwoom", "rest", "rollup_1m")

    def _copy_raw_for_merge(self, code: str, tf: int) -> list:
        """락 안에서 호출 — 모든 소스의 봉을 shallow copy 반환 (정렬 없음).
        락 보유 시간을 최소화하기 위해 정렬은 락 밖(_merge_from_raw)에서 수행.
        """
        items: list = []
        for src in self._ALL_CANDLE_SOURCES:
            buf = self._confirmed.get((code, tf, src))
            if buf:
                items.extend(buf)  # 참조 복사 (봉 dict는 생성 후 불변이므로 안전)
        return items

    @staticmethod
    def _live_candle_source_order() -> tuple:
        """봉 소스 우선순위 — 틱(LIVE_TICK_PROVIDER)과 동일.

        키움 메인 → kiwoom → kis → rest → rollup_1m
        KIS 메인  → kis → kiwoom → rest → rollup_1m
        (품질 비교 없음. 같은 candle_time이면 앞 소스가 이김.)
        """
        try:
            provider = (
                get_env_from_db("LIVE_TICK_PROVIDER", "kiwoom") or "kiwoom"
            ).strip().lower()
        except Exception:
            provider = "kiwoom"
        if provider == "kis":
            return ("kis", "kiwoom", "rest", "rollup_1m")
        return ("kiwoom", "kis", "rest", "rollup_1m")

    def rollup_tf_from_1m(self, code: str, target_tf: int = 3) -> int:
        """
        RAM 1분 확정봉 → target_tf 분봉 재합성 후 구멍만 보강.
        꼬리(3M) 트리거 웜업: 1M REST 1회만으로 3M 준비 (WS_GAP_ROLLUP_3M_FROM_1M).

        1M 소스 우선순위는 LIVE_TICK_PROVIDER와 동일(메인 WS → 폴백 WS → rest).
        candle_time 중복은 앞 소스 승, 품질 비교 없음.
        """
        from kis_trader.engine.candle_rollup import rollup_1m_bars_to_tf

        tf = int(target_tf)
        if tf <= 1:
            return 0
        # 롤업 입력은 원천 1M만 (rollup_1m 자신은 제외)
        src_order = tuple(s for s in self._live_candle_source_order() if s != "rollup_1m")
        with self._lock:
            seen: dict = {}
            for src in src_order:
                for bar in self._confirmed.get((code, 1, src), []):
                    ct = str(bar.get("candle_time") or "")[:12]
                    if ct and ct not in seen:
                        seen[ct] = bar
            bars_1m = sorted(seen.values(), key=lambda x: str(x.get("candle_time") or ""))
        if not bars_1m:
            return 0
        rolled = rollup_1m_bars_to_tf(bars_1m, tf)
        return self.merge_confirmed_bars(
            code, tf, rolled, log_tag=f"rollup_1m→{tf}M",
        )

    # ------------------------------------------------------------------
    # [트랙 1] RAM 버퍼 조회 — 매수/매도 루프에서 직접 호출 (DB 조회 없음)
    # ------------------------------------------------------------------
    # NOTE: source="" (기본) → LIVE_TICK_PROVIDER 우선으로 소스 병합
    #       재시작 직후 REST·WS 갭보정 데이터를 전략/롤업이 즉시 인식하기 위함.
    #       source를 명시할 경우 (예: source="kis") 해당 소스만 조회.

    def _copy_raw_for_merge(self, code: str, tf: int) -> list:
        """락 안에서 호출 — 우선순위 순으로 봉을 이어붙임 (정렬 없음).
        락 보유 시간을 최소화하기 위해 정렬은 락 밖(_merge_from_raw)에서 수행.
        같은 candle_time이면 앞에 나온 소스(메인)가 이김.
        """
        items: list = []
        for src in self._live_candle_source_order():
            buf = self._confirmed.get((code, tf, src))
            if buf:
                items.extend(buf)  # 참조 복사 (봉 dict는 생성 후 불변이므로 안전)
        return items


[REDACTED]

        with self._lock:
            for tf in list(self.timeframes):
                for src in ("kis", "kiwoom", "rest", "rollup_1m", "ls", "ws", "kw_rest"):
                    key = (code, tf, src)
                    self._confirmed.pop(key, None)
                    self._closes.pop(key, None)
                    self._current.pop(key, None)
        logger.debug("🗑 CandleAggregator RAM 정리: %s", code)

        with self._lock:
            for tf in list(self.timeframes):
                for src in ("kis", "kiwoom", "ls", "ws", "kw_rest"):
                    key = (code, tf, src)
                    self._confirmed.pop(key, None)
                    self._closes.pop(key, None)
                    self._current.pop(key, None)
        logger.debug("🗑 CandleAggregator RAM 정리: %s", code)


[REDACTED]

Shell

cd /home/hoon/kis_bot && python3 -m py_compile kis_trader/ws/kis_ws.py && python3 - <<'PY'
from kis_trader.ws.kis_ws import CandleAggregator
# 기본(kiwoom)
o = CandleAggregator._live_candle_source_order()
print("default_order", o)
assert o[0] == "kiwoom" and o[1] == "kis" and "rest" in o and "rollup_1m" in o
print("OK")
PY

Compile and smoke-test source order


반영했습니다. 틱/호가와 같은 `LIVE_TICK_PROVIDER`만 봅니다. 품질 비교 없음.

| `LIVE_TICK_PROVIDER` | 봉 병합·롤업 1M 순서 |
|---|---|
| `kiwoom` (현재 기본) | **kiwoom → kis → rest → rollup** |
| `kis` | **kis → kiwoom → rest → rollup** |

같은 시각 봉이면 **앞에 있는 소스 승**. rest는 구멍만, rollup은 그 1분으로 상위봉 생성.

스모크: 현재 DB 기준 순서 `('kiwoom', 'kis', 'rest', 'rollup_1m')` 확인됨.

장중/재시작 후 코드 로드되면 적용됩니다. 원하면 지금 `kis_trader_main` 재시작해도 됩니다.

[REDACTED]


<timestamp>Wednesday, Aug 12, 2026, 3:58 PM (UTC+9)</timestamp>
<user_query>
백테랑 옵툰아도 동일하게 되어있나?
</user_query>


백테·Optuna의 캔들 소스 선택 로직을 확인합니다.

[REDACTED]


[REDACTED]

cd /home/hoon/kis_bot && python3 - <<'PY'
from database import TradeDB
db = TradeDB()
# 오늘 소스별 1M 건수
rows = db.conn.execute(
    "SELECT source, COUNT(*) n FROM ws_candles WHERE timeframe=1 AND candle_time LIKE %s GROUP BY source ORDER BY n DESC",
    ("20260812%",),
).fetchall()
print("20260812 sources:", [(r['source'], r['n']) for r in rows])
# CANDLE_SOURCE / LIVE_TICK
for k in ("CANDLE_SOURCE", "LIVE_TICK_PROVIDER"):
    r = db.conn.execute(
        "SELECT env_key, env_value FROM env_config_ext WHERE env_key=%s", (k,)
    ).fetchone()
    if not r:
        # try other tables via get
        pass
    print(k, r)
from kis_trader.utils.env import get_env_from_db
print("get CANDLE_SOURCE", repr(get_env_from_db("CANDLE_SOURCE", "")))
print("get LIVE_TICK", repr(get_env_from_db("LIVE_TICK_PROVIDER", "kiwoom")))
PY

Check DB candle sources and env defaults


**아니요. 지금 백테·Optuna는 실매랑 다릅니다.**

| | 실매 (방금 수정) | 백테 / Optuna |
|---|---|---|
| 기준 | `LIVE_TICK_PROVIDER` | 별도 `CANDLE_SOURCE` (또는 미필터) |
| 기본 | **kiwoom → kis → rest** | 코드 기본값이 **`"kis"`** 인 곳이 많음 |
| 방식 | 같은 시각이면 메인 소스 승 | **한 소스만** 고르거나, **source 조건 없이 전부** 읽음 |

세부:
- **모멘텀 Optuna/파람**: `AND source=%s`, 기본 `CANDLE_SOURCE=kis` → 키움 메인 실매와 어긋남
- **돌파/스캘프 로더**: `CANDLE_SOURCE`를 읽기만 하고 SQL에 안 씀 → 소스 혼합·중복 시각 가능
- **꼬리**: source 필터 없이 `ws_candles` 전부
- Optuna `--candle-source` 도움말도 “빈값 = kis” — **`LIVE_TICK_PROVIDER`와 연동 없음**

오늘(8/12) DB 1분봉도 rest가 가장 많고, kiwoom/kis가 따로 쌓여 있어서 “한 소스만” vs “실매 병합” 차이가 납니다.

원하면 다음으로 **백테/Optuna도 `LIVE_TICK_PROVIDER`와 같은 우선순위로 candle_time 디듑** 하도록 맞추는 설계만 먼저 보고하겠습니다. (핵심 정합이라 승인 후 수정)

[REDACTED]


<timestamp>Wednesday, Aug 12, 2026, 4:01 PM (UTC+9)</timestamp>
<user_query>
이것도 옵션으로 추가해야겠네 캔들 어디 볼건지 CANDLE_SOURCE  이게 그거 아냐? 근데 왜 기본 kis인곳이 많은거지? 어떻게 수정해야되지?
</user_query>


`CANDLE_SOURCE`가 어디서 쓰이고 왜 기본이 kis인지 확인한 뒤, 수정 방향을 정리합니다.

[REDACTED]


[REDACTED]

cd /home/hoon/kis_bot && git log -S 'CANDLE_SOURCE' --oneline --all | head -15 && echo '---' && git log -S 'CANDLE_SOURCE", "kis"' --oneline --all | head -10; git log -S "CANDLE_SOURCE\", \"kis\"" --oneline --all | head -10; git log -S 'get("CANDLE_SOURCE", "kis")' --oneline --all | head -10

Find when CANDLE_SOURCE default kis was introduced


맞아요. **UI의「캔들」셀렉트 → `CANDLE_SOURCE`가 그 옵션**입니다. 이미 탭마다 있어요.

---

## 왜 기본이 kis인가

`c6bd62a`에서 소스 분리하면서 **예전에 봉이 전부 KIS 태그(`kis`)였다**는 가정으로 코드 기본값을 `"kis"`로 박았습니다.
그 뒤 실매는 `LIVE_TICK_PROVIDER=kiwoom`으로 바뀌었는데, **백테/Optuna 기본값만 안 따라간 것**입니다.

UI「기본」= 빈값(`""`)인데, Python이 빈값을 **`kis`로 치환**해서 키움 메인과 어긋납니다.

추가로 돌파/스캘프는 `CANDLE_SOURCE`를 **읽기만 하고 SQL에 안 쓰는** 상태입니다.

---

## 어떻게 고치면 되는지 (제안)

공통 헬퍼 하나:

```
resolve_bt_candle_source():
  UI/환경 CANDLE_SOURCE가 kis|kiwoom 이면 → 그 소스만
  비어 있으면(「기본」) → LIVE_TICK_PROVIDER 따라
    kiwoom 메인: kiwoom → kis → rest (같은 시각이면 앞 승)
    kis 메인:    kis → kiwoom → rest
```

| UI | 의미 |
|---|---|
| **기본** | 실매와 동일 우선순위 병합 |
| **KIS / 키움** | 그 소스만 (비교·디버그용) |

할 일:
1. 공통 `resolve` + `load` 헬퍼
2. 모멘텀/돌파/스캘프/꼬리/Optuna가 전부 그 헬퍼 사용
3. 하드코딩 `default="kis"` 제거 → 「기본」= `LIVE_TICK_PROVIDER`
4. 돌파/스캘프에 실제로 source 필터/병합 적용
5. (선택) UI「기본」툴팁에 “실매 LIVE_TICK_PROVIDER 따름” 명시

**영향**: 백테·Optuna 숫자만 (실매 엔진 로직 자체는 이미 우선순위 반영됨). Optuna 재실행 시 study는 새 이름 권장.

이 방향으로 공통 헬퍼부터 패치할까요?

[REDACTED]


<timestamp>Wednesday, Aug 12, 2026, 4:03 PM (UTC+9)</timestamp>
<user_query>
오케이 진행해
</user_query>


백테·Optuna가 `CANDLE_SOURCE`와 `LIVE_TICK_PROVIDER`를 같이 쓰도록 공통 헬퍼부터 만들고, 각 로더에 연결합니다.

[REDACTED]


공통 헬퍼를 만들고, 백테·Optuna 로더에 연결합니다.

[REDACTED]

Write

#!/usr/bin/env python3
"""
백테·Optuna ws_candles 소스 선택 — 실매 LIVE_TICK_PROVIDER 와 동일 우선순위.

UI/CLI ``CANDLE_SOURCE``:
  - 빈값(「기본」): LIVE_TICK_PROVIDER 순서로 candle_time 디듑
    kiwoom 메인 → kiwoom, kis, rest, rollup_1m
    kis 메인    → kis, kiwoom, rest, rollup_1m
  - ``kis`` / ``kiwoom``: 해당 소스만 (디버그·비교용)
"""
from __future__ import annotations

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

from kis_trader.utils.env import get_env_from_db

# 실매 CandleAggregator._ALL_CANDLE_SOURCES 와 동일
BT_WS_CANDLE_SOURCES: Tuple[str, ...] = ("kiwoom", "kis", "rest", "rollup_1m")

def resolve_bt_candle_source_override() -> str:
    """'' = 병합 모드, 'kis'|'kiwoom' = 단일 소스."""
    raw = os.environ.get("CANDLE_SOURCE")
    if raw is None:
        try:
            raw = get_env_from_db("CANDLE_SOURCE", "")
        except Exception:
            raw = ""
    s = (str(raw or "")).strip().lower()
    if s in ("kis", "kiwoom"):
        return s
    return ""

def live_candle_source_order() -> Tuple[str, ...]:
    """실매 get_candles 병합 순서 (LIVE_TICK_PROVIDER)."""
    try:
        provider = (
            get_env_from_db("LIVE_TICK_PROVIDER", "kiwoom") or "kiwoom"
        ).strip().lower()
    except Exception:
        provider = "kiwoom"
    if provider == "kis":
        return ("kis", "kiwoom", "rest", "rollup_1m")
    return ("kiwoom", "kis", "rest", "rollup_1m")

def resolve_bt_candle_source_order() -> Tuple[str, ...]:
    """백테/Optuna 조회에 쓸 소스 순서."""
    override = resolve_bt_candle_source_override()
    if override:
        return (override,)
    return live_candle_source_order()

def resolve_bt_candle_source_label() -> str:
    """상태 표시용 — 'KIS'|'KIWOOM'|'LIVE(kiwoom)' 등."""
    override = resolve_bt_candle_source_override()
    if override:
        return override.upper()
    try:
        provider = (
            get_env_from_db("LIVE_TICK_PROVIDER", "kiwoom") or "kiwoom"
        ).strip().lower()
    except Exception:
        provider = "kiwoom"
    return f"LIVE({provider})"

def dedupe_candle_rows(
    rows: Sequence[Dict[str, Any]],
    source_order: Optional[Sequence[str]] = None,
) -> List[Dict[str, Any]]:
    """candle_time 기준 디듑 — 앞 소스 우선. ``source`` 컬럼은 결과에서 제거."""
    order = tuple(source_order or resolve_bt_candle_source_order())
    rank = {s: i for i, s in enumerate(order)}
    seen: Dict[str, Tuple[int, Dict[str, Any]]] = {}
    for row in rows:
        ct = str(row.get("candle_time") or "")[:12]
        if not ct:
            continue
        src = str(row.get("source") or "kis").strip().lower()
        pri = rank.get(src, 999)
        prev = seen.get(ct)
        if prev is None or pri < prev[0]:
            seen[ct] = (pri, dict(row))
    out: List[Dict[str, Any]] = []
    for ct in sorted(seen.keys()):
        d = seen[ct][1]
        d.pop("source", None)
        out.append(d)
    return out

def _source_in_sql(sources: Sequence[str]) -> Tuple[str, List[str]]:
    if len(sources) == 1:
        return " AND source=%s", [sources[0]]
    ph = ",".join(["%s"] * len(sources))
    return f" AND source IN ({ph})", list(sources)

def list_ws_candle_codes(
    db,
    timeframe: int,
    start_key: str,
    end_key: str,
    *,
    market: Optional[str] = None,
) -> List[str]:
    """기간 내 종목 코드 — 선택된 소스 기준 DISTINCT."""
    sources = resolve_bt_candle_source_order()
    src_sql, src_params = _source_in_sql(sources)
    mk = (market or "").strip().upper()
    if mk:
        rows = db.conn.execute(
            "SELECT DISTINCT code FROM ws_candles WHERE timeframe=%s AND market=%s "
            "AND candle_time >= %s AND candle_time <= %s"
            + src_sql
            + " ORDER BY code",
            [int(timeframe), mk, start_key, end_key, *src_params],
        ).fetchall()
    else:
        rows = db.conn.execute(
            "SELECT DISTINCT code FROM ws_candles WHERE timeframe=%s "
            "AND candle_time >= %s AND candle_time <= %s"
            + src_sql
            + " ORDER BY code",
            [int(timeframe), start_key, end_key, *src_params],
        ).fetchall()
    return [r["code"] for r in rows]

def fetch_ws_candles_for_code(
    db,
    code: str,
    timeframe: int,
    start_key: str,
    end_key: str,
    *,
    extra_select: str = "",
    peak_sel: str = "",
    market: Optional[str] = None,
    confirmed_only: bool = True,
) -> List[Dict[str, Any]]:
    """단일 종목·기간 봉 로드 — 소스 필터/병합 적용."""
    sources = resolve_bt_candle_source_order()
    confirmed_sql = " AND is_confirmed=1" if confirmed_only else ""
    mk = (market or "").strip().upper()

    if len(sources) == 1:
        cols = f"candle_time, open, high, low, close, volume{peak_sel}{extra_select}"
        src = sources[0]
        if mk:
            rows = db.conn.execute(
                f"SELECT {cols} FROM ws_candles "
                "WHERE timeframe=%s AND code=%s AND market=%s "
                "AND candle_time >= %s AND candle_time <= %s"
                + confirmed_sql
                + " AND source=%s ORDER BY candle_time ASC",
                [int(timeframe), code, mk, start_key, end_key, src],
            ).fetchall()
        else:
            rows = db.conn.execute(
                f"SELECT {cols} FROM ws_candles "
                "WHERE timeframe=%s AND code=%s "
                "AND candle_time >= %s AND candle_time <= %s"
                + confirmed_sql
                + " AND source=%s ORDER BY candle_time ASC",
                [int(timeframe), code, start_key, end_key, src],
            ).fetchall()
        return [dict(r) for r in rows]

    cols = f"candle_time, open, high, low, close, volume, source{peak_sel}{extra_select}"
    src_sql, src_params = _source_in_sql(sources)
    if mk:
        rows = db.conn.execute(
            f"SELECT {cols} FROM ws_candles "
            "WHERE timeframe=%s AND code=%s AND market=%s "
            "AND candle_time >= %s AND candle_time <= %s"
            + confirmed_sql
            + src_sql
            + " ORDER BY candle_time ASC",
            [int(timeframe), code, mk, start_key, end_key, *src_params],
        ).fetchall()
    else:
        rows = db.conn.execute(
            f"SELECT {cols} FROM ws_candles "
            "WHERE timeframe=%s AND code=%s "
            "AND candle_time >= %s AND candle_time <= %s"
            + confirmed_sql
            + src_sql
            + " ORDER BY candle_time ASC",
            [int(timeframe), code, start_key, end_key, *src_params],
        ).fetchall()
    return dedupe_candle_rows([dict(r) for r in rows], sources)

def fetch_ws_candles_warmup_before(
    db,
    code: str,
    timeframe: int,
    before_candle_time: str,
    limit: int,
    *,
    extra_select: str = "",
    peak_sel: str = "",
    confirmed_only: bool = True,
) -> List[Dict[str, Any]]:
    """기간 시작 이전 N봉 — prepend 웜업용 (오래된→최신)."""
    if limit <= 0 or not before_candle_time:
        return []
    sources = resolve_bt_candle_source_order()
    confirmed_sql = " AND is_confirmed=1" if confirmed_only else ""
    fetch_limit = max(int(limit) * max(len(sources), 1), int(limit) + 50)

    if len(sources) == 1:
        cols = f"candle_time, open, high, low, close, volume{peak_sel}{extra_select}"
        rows = db.conn.execute(
            f"SELECT {cols} FROM ws_candles "
            "WHERE timeframe=%s AND code=%s AND candle_time < %s"
            + confirmed_sql
            + " AND source=%s ORDER BY candle_time DESC LIMIT %s",
            [int(timeframe), code, before_candle_time, sources[0], fetch_limit],
        ).fetchall()
        bars = [dict(r) for r in reversed(rows)]
        return bars[-limit:] if len(bars) > limit else bars

    cols = f"candle_time, open, high, low, close, volume, source{peak_sel}{extra_select}"
    src_sql, src_params = _source_in_sql(sources)
    rows = db.conn.execute(
        f"SELECT {cols} FROM ws_candles "
        "WHERE timeframe=%s AND code=%s AND candle_time < %s"
        + confirmed_sql
        + src_sql
        + " ORDER BY candle_time DESC LIMIT %s",
        [int(timeframe), code, before_candle_time, *src_params, fetch_limit],
    ).fetchall()
    bars = dedupe_candle_rows([dict(r) for r in rows], sources)
    return bars[-limit:] if len(bars) > limit else bars


[REDACTED]

StrReplace

    ind_cols = ws_candles_select_indicator_cols(db)
    total_prepended = 0
    from kis_trader.backtest.bt_candle_source import fetch_ws_candles_warmup_before
    for code, rows in list(candles_by_code.items()):

    ind_cols = ws_candles_select_indicator_cols(db)
    total_prepended = 0
    for code, rows in list(candles_by_code.items()):

        warm_rows = fetch_ws_candles_warmup_before(
            db, code, 1, first_ct, wb,
            extra_select=ind_cols,
        )
        if not warm_rows:
            continue
        prefix = warm_rows

        warm_rows = db.conn.execute(
            f"SELECT candle_time, open, high, low, close, volume, is_confirmed{ind_cols} "
            "FROM ws_candles WHERE timeframe=1 AND code=%s "
            "AND candle_time < %s ORDER BY candle_time DESC LIMIT %s",
            [code, first_ct, wb],
        ).fetchall()
        if not warm_rows:
            continue
        prefix = [dict(r) for r in reversed(warm_rows)]

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

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

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

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

    candle_src = os.environ.get("CANDLE_SOURCE", "kis") or "kis"

    ind_cols = ws_candles_select_indicator_cols(db)
    codes_raw = db.conn.execute(
        "SELECT DISTINCT code FROM ws_candles WHERE timeframe=1 "
        "AND candle_time >= %s AND candle_time <= %s ORDER BY code",
        [start_key, end_key],
    ).fetchall()
    codes = [r["code"] for r in codes_raw]

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

    for code in codes:
        rows = db.conn.execute(
            f"SELECT candle_time, open, high, low, close, volume {ind_cols} "
            f"FROM ws_candles WHERE timeframe=1 AND code=%s "
            f"AND candle_time >= %s AND candle_time <= %s AND is_confirmed=1 "
            f"ORDER BY candle_time ASC",
            [code, start_key, end_key],
        ).fetchall()
        if len(rows) < min_bars:
            continue
        candles_by_code[code] = [dict(r) for r in rows]
        total_candles += len(rows)


[REDACTED]

    total_prepended = 0
    from kis_trader.backtest.bt_candle_source import fetch_ws_candles_warmup_before
    for code, rows in list(candles_by_code.items()):

    total_prepended = 0
    for code, rows in list(candles_by_code.items()):

        warm_rows = fetch_ws_candles_warmup_before(
            db, code, 1, first_ct, wb,
        )
        if not warm_rows:
            continue
        prefix = warm_rows

        warm_rows = db.conn.execute(
            "SELECT candle_time, open, high, low, close, volume "
            "FROM ws_candles WHERE timeframe=1 AND code=%s "
            "AND candle_time < %s AND is_confirmed=1 "
            "ORDER BY candle_time DESC LIMIT %s",
            [code, first_ct, wb],
        ).fetchall()
        if not warm_rows:
            continue
        prefix = [dict(r) for r in reversed(warm_rows)]

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

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

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

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

    candle_src = os.environ.get("CANDLE_SOURCE", "kis") or "kis"

    codes_raw = db.conn.execute(
        "SELECT DISTINCT code FROM ws_candles WHERE timeframe=1 "
        "AND candle_time >= %s AND candle_time <= %s ORDER BY code",
        [start_key, end_key],
    ).fetchall()
    codes = [r["code"] for r in codes_raw]

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

    for code in codes:
        rows = db.conn.execute(
            "SELECT candle_time, open, high, low, close, volume "
            "FROM ws_candles WHERE timeframe=1 AND code=%s "
            "AND candle_time >= %s AND candle_time <= %s AND is_confirmed=1 "
            "ORDER BY candle_time ASC",
            [code, start_key, end_key],
        ).fetchall()
        if len(rows) < min_bars:
            continue
        candles_by_code[code] = [dict(r) for r in rows]
        total_candles += len(rows)

    ind_cols = ws_candles_select_indicator_cols(db)
    total_prepended = 0
    from kis_trader.backtest.bt_candle_source import fetch_ws_candles_warmup_before
    for code, rows in list(candles_by_code.items()):

    ind_cols = ws_candles_select_indicator_cols(db)
    total_prepended = 0
    for code, rows in list(candles_by_code.items()):

        warm_rows = fetch_ws_candles_warmup_before(
            db, code, 1, first_ct, wb,
            extra_select=ind_cols,
        )
        if not warm_rows:
            continue
        prefix = warm_rows

        warm_rows = db.conn.execute(
            f"SELECT candle_time, open, high, low, close, volume, is_confirmed{ind_cols} "
            "FROM ws_candles WHERE timeframe=1 AND code=%s "
            "AND candle_time < %s ORDER BY candle_time DESC LIMIT %s",
            [code, first_ct, wb],
        ).fetchall()
        if not warm_rows:
            continue
        prefix = [dict(r) for r in reversed(warm_rows)]

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

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

    period_start = str(start_key)[:12]
    mk = (market or "").strip().upper()
    if mk:
        codes_raw = db.conn.execute(
            "SELECT DISTINCT code FROM ws_candles WHERE timeframe=1 AND market=%s "
            "AND candle_time >= %s AND candle_time <= %s ORDER BY code",
            [mk, start_key, end_key],
        ).fetchall()
    else:
        codes_raw = db.conn.execute(
            "SELECT DISTINCT code FROM ws_candles WHERE timeframe=1 "
            "AND candle_time >= %s AND candle_time <= %s ORDER BY code",
            [start_key, end_key],
        ).fetchall()
    codes = [r["code"] for r in codes_raw]
    ind_cols = ws_candles_select_indicator_cols(db)
    candles_by_code: Dict[str, List[Dict]] = {}
    total = 0
    for code in codes:
        if mk:
            rows = db.conn.execute(
                f"SELECT candle_time, open, high, low, close, volume, is_confirmed{ind_cols} "
                "FROM ws_candles WHERE timeframe=1 AND code=%s AND market=%s "
                "AND candle_time >= %s AND candle_time <= %s "
                "ORDER BY candle_time ASC",
                [code, mk, start_key, end_key],
            ).fetchall()
        else:
            rows = db.conn.execute(
                f"SELECT candle_time, open, high, low, close, volume, is_confirmed{ind_cols} "
                "FROM ws_candles WHERE timeframe=1 AND code=%s "
                "AND candle_time >= %s AND candle_time <= %s "
                "ORDER BY candle_time ASC",
                [code, start_key, end_key],
            ).fetchall()
        if len(rows) < 6:
            continue
        candles_by_code[code] = [dict(r) for r in rows]
        total += len(rows)


[REDACTED]

    ind_cols = ws_candles_select_indicator_cols(db)
    total_prepended = 0
    from kis_trader.backtest.bt_candle_source import fetch_ws_candles_warmup_before
    for code, rows in list(candles_by_code.items()):

    ind_cols = ws_candles_select_indicator_cols(db)
    total_prepended = 0
    for code, rows in list(candles_by_code.items()):

        warm_rows = fetch_ws_candles_warmup_before(
            db, code, tf, first_ct, wb,
            extra_select=ind_cols,
            peak_sel=peak_sel,
        )
        if not warm_rows:
            continue
        prefix = warm_rows

        warm_rows = db.conn.execute(
            f"SELECT candle_time, open, high, low, close, volume{peak_sel}{ind_cols} "
            "FROM ws_candles WHERE timeframe=%s AND code=%s "
            "AND candle_time < %s AND is_confirmed=1 "
            "ORDER BY candle_time DESC LIMIT %s",
            [tf, code, first_ct, wb],
        ).fetchall()
        if not warm_rows:
            continue
        prefix = [dict(r) for r in reversed(warm_rows)]

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

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

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

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

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

    codes_raw = db.conn.execute(
        "SELECT DISTINCT code FROM ws_candles WHERE timeframe=%s "
        "AND candle_time >= %s AND candle_time <= %s ORDER BY code",
        [tail_tf, start_key, end_key],
    ).fetchall()
    codes = [r["code"] for r in codes_raw]

    # 3분 합성 시 1분만 있는 종목도 후보에 포함
    synth_on = (
        tail_tf == 3
        and get_env_bool("TAIL_BT_SYNTH_3M_FROM_1M", True)
    )
    if synth_on:
        try:
            codes_1m = db.conn.execute(
                "SELECT DISTINCT code FROM ws_candles WHERE timeframe=1 "
                "AND candle_time >= %s AND candle_time <= %s ORDER BY code",
                [start_key, end_key],
            ).fetchall()
            for r in codes_1m:
                c = r["code"]
                if c not in codes:
                    codes.append(c)
            codes = sorted(set(codes))
        except Exception:
            pass

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

    for code in codes:
        rows = db.conn.execute(
            f"SELECT candle_time, open, high, low, close, volume{peak_sel}{ind_cols} "
            "FROM ws_candles WHERE timeframe=%s AND code=%s "
            "AND candle_time >= %s AND candle_time <= %s AND is_confirmed=1 "
            "ORDER BY candle_time ASC",
            [tail_tf, code, start_key, end_key],
        ).fetchall()
        bars = [dict(r) for r in rows]

    from kis_trader.engine.candle_rollup import merge_fill_holes, rollup_1m_bars_to_tf
    from kis_trader.backtest.bt_candle_source import fetch_ws_candles_for_code

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

    from kis_trader.engine.candle_rollup import merge_fill_holes, rollup_1m_bars_to_tf

    try:
        rows_1m = db.conn.execute(
            f"SELECT candle_time, open, high, low, close, volume{peak_sel} "
            "FROM ws_candles WHERE timeframe=1 AND code=%s "
            "AND candle_time >= %s AND candle_time <= %s AND is_confirmed=1 "
            "ORDER BY candle_time ASC",
            [code, start_key, end_key],
        ).fetchall()
    except Exception:
        return bars_3m, 0
    if not rows_1m:
        return bars_3m, 0
    rolled = rollup_1m_bars_to_tf([dict(r) for r in rows_1m], 3)
    return merge_fill_holes(bars_3m, rolled)


[REDACTED]

        want = {
            str(c).strip().upper()
            for c in (codes_filter or [])
            if str(c).strip()
        }

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

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

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

        want = {
            str(c).strip().upper()
            for c in (codes_filter or [])
            if str(c).strip()
        }

        candle_src = os.environ.get("CANDLE_SOURCE", "kis") or "kis"

        if mk in ("US", "KR"):
            codes_raw = db.conn.execute(
                "SELECT DISTINCT code FROM ws_candles WHERE timeframe=1 AND market=%s "
                "AND candle_time >= %s AND candle_time <= %s AND source=%s ORDER BY code",
                [mk, start_key, end_key, candle_src],
            ).fetchall()
        else:
            codes_raw = db.conn.execute(
                "SELECT DISTINCT code FROM ws_candles WHERE timeframe=1 "
                "AND candle_time >= %s AND candle_time <= %s AND source=%s ORDER BY code",
                [start_key, end_key, candle_src]
            ).fetchall()
        codes = [r["code"] for r in codes_raw]
        if mk == "US":
            try:
                from permanent_subs import codes_by_market as _perm_us
                _us_perm = {str(r.get("code") or "").upper() for r in _perm_us(db, "US")}
                if _us_perm:
                    codes = [c for c in codes if str(c).upper() in _us_perm] or codes
            except Exception:
                pass
        if want:
            codes = [c for c in codes if str(c).upper() in want]

        for code in codes:
            if mk in ("US", "KR"):
                rows = db.conn.execute(
                    "SELECT candle_time, open, high, low, close, volume "
                    "FROM ws_candles "
                    "WHERE timeframe=1 AND code=%s AND market=%s "
                    "AND candle_time >= %s AND candle_time <= %s "
                    "AND is_confirmed=1 AND source=%s "
                    "ORDER BY candle_time ASC",
                    [code, mk, start_key, end_key, candle_src]
                ).fetchall()
            else:
                rows = db.conn.execute(
                    "SELECT candle_time, open, high, low, close, volume "
                    "FROM ws_candles "
                    "WHERE timeframe=1 AND code=%s "
                    "AND candle_time >= %s AND candle_time <= %s "
                    "AND is_confirmed=1 AND source=%s "
                    "ORDER BY candle_time ASC",
                    [code, start_key, end_key, candle_src]
                ).fetchall()
            if len(rows) < rsi_period + 5:
                continue
            codes_candles[code] = [dict(r) for r in rows]

        help="캔들 소스: 빈값=실매 LIVE_TICK_PROVIDER 우선 병합, kis|kiwoom=단일 소스",
    )

        help="캔들 소스 필터 (기본 빈문자열 = kis)",
    )

    args = parser.parse_args()

    if getattr(args, "candle_source", ""):
        os.environ["CANDLE_SOURCE"] = str(args.candle_source).strip().lower()
    if getattr(args, "tick_source", ""):
        os.environ["TICK_SOURCE"] = str(args.tick_source).strip().lower()
    if getattr(args, "ob_source", ""):
        os.environ["OB_SOURCE"] = str(args.ob_source).strip().lower()

    n_trials = args.trials

    args = parser.parse_args()

    n_trials = args.trials


[REDACTED]

    # ── 소스 필터 표시용 (백테 디버그 설정) ──────────────────────────────
    from kis_trader.backtest.bt_candle_source import resolve_bt_candle_source_label
    candle_src = resolve_bt_candle_source_label()

    # ── 소스 필터 표시용 (백테 디버그 설정) ──────────────────────────────
    candle_src = (get_env_from_db("CANDLE_SOURCE", "") or "전체").strip()


[REDACTED]

        "candle_source": candle_src if candle_src.startswith("LIVE(") else (
            candle_src.upper() if candle_src else "전체"
        ),

"candle_source": candle_src.upper() if candle_src != "전체" else "전체",


[REDACTED]

            <select class="form-select form-select-sm" id="bt_candle_source" style="width:70px" title="기본=실매 LIVE_TICK_PROVIDER 우선 병합">
              <option value="" title="실매 LIVE_TICK_PROVIDER 순서 (kiwoom→kis→rest)">기본</option><option value="kis">KIS</option><option value="kiwoom">키움</option>

            <select class="form-select form-select-sm" id="bt_candle_source" style="width:70px">
              <option value="">기본</option><option value="kis">KIS</option><option value="kiwoom">키움</option>


[REDACTED]

            <select class="form-select form-select-sm" id="tl_candle_source" style="width:70px" title="기본=실매 LIVE_TICK_PROVIDER 우선 병합">
              <option value="" title="실매 LIVE_TICK_PROVIDER 순서 (kiwoom→kis→rest)">기본</option><option value="kis">KIS</option><option value="kiwoom">키움</option>

            <select class="form-select form-select-sm" id="tl_candle_source" style="width:70px">
              <option value="">기본</option><option value="kis">KIS</option><option value="kiwoom">키움</option>

            <select class="form-select form-select-sm" id="bo_candle_source" style="width:70px" title="기본=실매 LIVE_TICK_PROVIDER 우선 병합">
              <option value="" title="실매 LIVE_TICK_PROVIDER 순서 (kiwoom→kis→rest)">기본</option><option value="kis">KIS</option><option value="kiwoom">키움</option>

            <select class="form-select form-select-sm" id="bo_candle_source" style="width:70px">
              <option value="">기본</option><option value="kis">KIS</option><option value="kiwoom">키움</option>

            <select class="form-select form-select-sm" id="mom_candle_source" style="width:70px" title="기본=실매 LIVE_TICK_PROVIDER 우선 병합">
              <option value="" title="실매 LIVE_TICK_PROVIDER 순서 (kiwoom→kis→rest)">기본</option><option value="kis">KIS</option><option value="kiwoom">키움</option>

            <select class="form-select form-select-sm" id="mom_candle_source" style="width:70px">
              <option value="">기본</option><option value="kis">KIS</option><option value="kiwoom">키움</option>

          <select class="form-select form-select-sm" id="opt_candle_source" style="width:80px" title="기본=실매 LIVE_TICK_PROVIDER 우선 병합">
            <option value="" selected title="실매 LIVE_TICK_PROVIDER 순서 (kiwoom→kis→rest)">기본</option>

          <select class="form-select form-select-sm" id="opt_candle_source" style="width:80px">
            <option value="" selected>기본</option>


[REDACTED]

            <select class="form-select form-select-sm" id="tl_candle_source" style="width:70px" title="기본=실매 LIVE_TICK_PROVIDER 우선 병합">
              <option value="" title="실매 LIVE_TICK_PROVIDER 순서 (kiwoom→kis→rest)">기본</option><option value="kis">KIS</option><option value="kiwoom">키움</option>

            <select class="form-select form-select-sm" id="tl_candle_source" style="width:70px">
              <option value="">전체</option><option value="kis">KIS</option><option value="kiwoom">키움</option>


[REDACTED]

Shell

cd /home/hoon/kis_bot && python3 -m py_compile kis_trader/backtest/bt_candle_source.py kis_trader/backtest/breakout_backtest_common.py kis_trader/backtest/scalping_backtest_common.py kis_trader/backtest/momentum_backtest_common.py kis_trader/backtest/tail_backtest_common.py kis_trader/backtest/param_search_momentum.py kis_trader/backtest/param_search_optuna.py && python3 - <<'PY'
from kis_trader.backtest.bt_candle_source import (
    resolve_bt_candle_source_order,
    resolve_bt_candle_source_label,
    dedupe_candle_rows,
)
import os
os.environ.pop("CANDLE_SOURCE", None)
print("default order", resolve_bt_candle_source_order())
print("default label", resolve_bt_candle_source_label())
os.environ["CANDLE_SOURCE"] = "kiwoom"
print("override order", resolve_bt_candle_source_order())
rows = [
    {"candle_time": "202608121000", "open": 1, "close": 2, "source": "kis"},
    {"candle_time": "202608121000", "open": 10, "close": 20, "source": "kiwoom"},
]
os.environ.pop("CANDLE_SOURCE", None)
deduped = dedupe_candle_rows(rows)
assert deduped[0]["open"] == 10, deduped
print("dedupe kiwoom wins OK")
PY

Compile and smoke-test bt candle source helper


웹 UI도 바꿨으니 백테 웹 서비스를 재시작하고 확인합니다.

[REDACTED]

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 service and health check


[REDACTED]

CallMcpTool

Open backtest web for candle source UI check


[REDACTED]

Shell

curl -s http://127.0.0.1:5050/api/ops/kis_mock | python3 -c "import sys,json; d=json.load(sys.stdin); print('candle_source:', d.get('candle_source')); print('tick_provider:', d.get('tick_provider'))"

Verify status API candle_source label


적용 완료했습니다.

## 변경 요약

**새 공통 모듈** `kis_trader/backtest/bt_candle_source.py`

| UI `CANDLE_SOURCE` | 동작 |
|---|---|
| **기본** (빈값) | `LIVE_TICK_PROVIDER` 순서로 병합 — 지금은 `kiwoom → kis → rest → rollup_1m` |
| **KIS / 키움** | 해당 소스만 |

실매 `get_candles`와 같은 규칙입니다. 품질 비교 없이 **같은 시각이면 앞 소스 승**.

**연결한 로더** (백테 + Optuna 공통):
- 꼬리 / 돌파 / 스캘프 / 모멘텀 `load_*_candles_by_code`
- 웜업 `prepend_*_candle_warmup`
- 꼬리 1M→3M 합성 `_synth_fill_3m_holes_from_1m`
- 모멘텀 Optuna `_load_candles_for_search`
- Optuna CLI `--candle-source` → `os.environ` 반영 (이전엔 인자만 있고 env 설정 안 됨)

**UI**
- 캔들 셀렉트 툴팁: 「기본 = 실매 LIVE_TICK_PROVIDER 우선 병합」
- 상단 상태: `candle_source: LIVE(kiwoom)` 표시 확인

**웹 재시작**: `kis_backtest_web.service` active, HTTP 200

---

## 왜 예전엔 kis 기본이었나

`c6bd62a`에서 DB에 `source` 태그를 넣을 때, **당시 봉이 KIS 태그로만 쌓이던 시절** 가정으로 Python 기본값을 `"kis"`로 박았습니다. 실매는 이미 `LIVE_TICK_PROVIDER=kiwoom`인데 백테만 안 따라간 상태였습니다.

---

## 영향 분류

- **실매**: 이번 패치 **해당 없음** (앞서 `kis_ws.py`만 수정)
- **웹백테 / Optuna**: **숫자 달라질 수 있음** — 「기본」이 이제 kiwoom 우선 병합

Optuna 다시 돌릴 때는 study-name 새로 쓰는 게 좋습니다.

[REDACTED]


<timestamp>Wednesday, Aug 12, 2026, 4:07 PM (UTC+9)</timestamp>
<user_query>
오늘은 그럼 정상데이터가 몇시부터냐?
</user_query>


오늘(8/12) 정상 데이터가 몇 시부터인지 로그·DB로 확인합니다.

[REDACTED]

journalctl -u kis_trader_main.service --since "2026-08-12 00:00:00" --until "2026-08-12 23:59:59" --no-pager 2>/dev/null | grep -E "kis_trader 통합 봇 시작|시세웹소켓|키움.*실|키움.*모의|split_feed|_code_refs|갭보정-롤업|LIVE_TICK" | head -40

Find bot restarts and WS connection events today

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

def hour_counts(table, date_prefix, extra_where="", params=()):
    sql = f"""
    SELECT SUBSTRING(CAST({ 'tick_time' if 'tick' in table else 'created_at' } AS CHAR), 10, 2) AS hh, source, COUNT(*) n
    FROM {table}
    WHERE { 'tick_time' if 'tick' in table else 'created_at' } LIKE %s {extra_where}
    GROUP BY hh, source ORDER BY hh, source
    """
    # ws_ticks uses tick_time YYYYMMDDHHMMSS
    if table == 'ws_ticks':
        rows = db.conn.execute(
            "SELECT SUBSTRING(tick_time, 9, 2) AS hh, source, COUNT(*) n FROM ws_ticks "
            "WHERE tick_time LIKE %s GROUP BY hh, source ORDER BY hh, source",
            (date_prefix + '%',),
        ).fetchall()
    elif table == 'ws_orderbook':
        rows = db.conn.execute(
            "SELECT SUBSTRING(created_at, 12, 2) AS hh, source, COUNT(*) n FROM ws_orderbook "
            "WHERE created_at LIKE %s GROUP BY hh, source ORDER BY hh, source",
            (date_prefix + '%',),
        ).fetchall()
    elif table == 'ws_candles':
        rows = db.conn.execute(
            "SELECT SUBSTRING(candle_time, 9, 2) AS hh, source, COUNT(*) n FROM ws_candles "
            "WHERE candle_time LIKE %s AND timeframe=1 GROUP BY hh, source ORDER BY hh, source",
            (date_prefix + '%',),
        ).fetchall()
    else:
        rows = []
    return rows

print("=== ws_ticks 8/12 by hour/source ===")
for r in hour_counts('ws_ticks', '20260812'):
    print(f"  {r['hh']}시 source={r['source']} n={r['n']}")

print("\n=== ws_orderbook 8/12 by hour/source ===")
for r in hour_counts('ws_orderbook', '20260812'):
    print(f"  {r['hh']}시 source={r['source']} n={r['n']}")

print("\n=== ws_candles 1M 8/12 kiwoom/kis by hour ===")
rows = db.conn.execute(
    "SELECT SUBSTRING(candle_time, 9, 2) AS hh, source, COUNT(*) n FROM ws_candles "
    "WHERE candle_time LIKE %s AND timeframe=1 AND source IN ('kiwoom','kis') "
    "GROUP BY hh, source ORDER BY hh, source",
    ('20260812%',),
).fetchall()
for r in rows:
    print(f"  {r['hh']}시 source={r['source']} n={r['n']}")

# first kiwoom tick today
r = db.conn.execute(
    "SELECT MIN(tick_time) mn, MAX(tick_time) mx, COUNT(*) n FROM ws_ticks WHERE source='kiwoom' AND tick_time LIKE %s",
    ('20260812%',),
).fetchone()
print("\nkiwoom ticks today:", dict(r) if r else None)

r2 = db.conn.execute(
    "SELECT MIN(created_at) mn, MAX(created_at) mx, COUNT(*) n FROM ws_orderbook WHERE source IN ('kiwoom_0d','filter_eval') AND created_at LIKE %s",
    ('2026-08-12%',),
).fetchone()
print("orderbook kiwoom/filter today:", dict(r2) if r2 else None)
PY

Query DB hourly tick/orderbook/candle counts for Aug 12


[REDACTED]

cd /home/hoon/kis_bot && python3 - <<'PY'
from database import TradeDB
db = TradeDB()
cols = [r["Field"] for r in db.conn.execute("SHOW COLUMNS FROM ws_orderbook").fetchall()]
print("ws_orderbook cols:", cols)

# hourly ticks continued
print("\n=== ws_orderbook 8/12 ===")
time_col = "recv_time" if "recv_time" in cols else ("tick_time" if "tick_time" in cols else cols[0])
print("time_col", time_col)
rows = db.conn.execute(
    f"SELECT SUBSTRING({time_col}, 9, 2) AS hh, source, COUNT(*) n FROM ws_orderbook "
    f"WHERE {time_col} LIKE %s GROUP BY hh, source ORDER BY hh, source",
    ('20260812%',),
).fetchall()
for r in rows:
    print(f"  {r['hh']}시 source={r['source']} n={r['n']}")

print("\n=== restarts today ===")
import subprocess
out = subprocess.check_output(
    "journalctl -u kis_trader_main.service --since '2026-08-12' --no-pager | grep -E '통합 봇 시작|systemctl|Started kis_trader'",
    shell=True, text=True
)
print(out[:3000])

print("\n=== _code_refs fix restart? ===")
out2 = subprocess.check_output(
    "journalctl -u kis_trader_main.service --since '2026-08-12 11:00' --until '2026-08-12 16:00' --no-pager | grep -E '통합 봇 시작|시세웹소켓|키움\\(실|키움\\(모'",
    shell=True, text=True
)
print(out2[:4000] if out2 else "none")
PY

Check ws_orderbook schema and restart timeline


[REDACTED]

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

# orderbook by snap_time hour
rows = db.conn.execute(
    "SELECT SUBSTRING(snap_time, 9, 2) AS hh, source, COUNT(*) n FROM ws_orderbook "
    "WHERE snap_time LIKE %s GROUP BY hh, source ORDER BY hh, source",
    ('20260812%',),
).fetchall()
print("=== ws_orderbook snap_time 8/12 ===")
for r in rows:
    print(f"  {r['hh']}시 source={r['source']} n={r['n']}")

# first kiwoom orderbook
r = db.conn.execute(
    "SELECT MIN(snap_time) mn, COUNT(*) n FROM ws_orderbook WHERE source='kiwoom_0d' AND snap_time LIKE %s",
    ('20260812%',),
).fetchone()
print("\nfirst kiwoom_0d ob:", dict(r))

# trade_history with orderbook enrich today
r2 = db.conn.execute(
    "SELECT COUNT(*) n FROM trade_history WHERE buy_date LIKE %s OR sell_date LIKE %s",
    ('2026-08-12%', '2026-08-12%'),
).fetchone()
print("trades today:", r2)

# check 09:00 startup alert for ws status
import subprocess
for t in ["08:14", "09:00", "12:45", "12:55", "14:53", "15:37"]:
    out = subprocess.check_output(
        f"journalctl -u kis_trader_main.service --since '2026-08-12 {t}:00' --until '2026-08-12 {t}:59' --no-pager | grep -E '시세웹소켓|호가|split_feed|_sync_tick|키움 WS|KIS WS|LS WS' | head -5",
        shell=True, text=True
    )
    if out.strip():
        print(f"\n--- {t} ---")
        print(out.strip()[:800])
PY

Orderbook hourly and startup WS status logs


[REDACTED]

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

# minute-level first kiwoom tick/ob
for label, sql in [
    ("kiwoom tick", "SELECT MIN(tick_time) mn FROM ws_ticks WHERE source='kiwoom' AND tick_time LIKE '20260812%'"),
    ("kiwoom ob", "SELECT MIN(snap_time) mn FROM ws_orderbook WHERE source='kiwoom_0d' AND snap_time LIKE '20260812%'"),
    ("kis tick", "SELECT MIN(tick_time) mn FROM ws_ticks WHERE source='kis' AND tick_time LIKE '20260812%'"),
]:
    r = db.conn.execute(sql).fetchone()
    mn = r['mn']
    if mn and len(str(mn))>=12:
        s = str(mn)
        print(f"{label}: {s[8:10]}:{s[10:12]}:{s[12:14] if len(s)>=14 else '00'}")

# kiwoom ticks per 10min 12:00-13:30
print("\nkiwoom ticks 10min buckets 12:00-14:00:")
rows = db.conn.execute(
    "SELECT SUBSTRING(tick_time,1,11) AS bucket, COUNT(*) n FROM ws_ticks "
    "WHERE source='kiwoom' AND tick_time >= '202608121200' AND tick_time < '202608121400' "
    "GROUP BY bucket ORDER BY bucket"
).fetchall()
for r in rows:
    b = str(r['bucket'])
    print(f"  {b[8:10]}:{b[10:12]} n={r['n']}")

# check 09-12 period kiwoom ticks total
r = db.conn.execute(
    "SELECT COUNT(*) n FROM ws_ticks WHERE source='kiwoom' AND tick_time >= '202608120900' AND tick_time < '202608121255'"
).fetchone()
print(f"\nkiwoom ticks 09:00-12:55: {r['n']}")

# WS issues 10:16
import subprocess
out = subprocess.check_output(
    "journalctl -u kis_trader_main.service --since '2026-08-12 10:16' --until '2026-08-12 10:20' --no-pager | grep -E '키움 WS|0B|0D|TickRecorder|_sync_tick|socket|SSL' | head -15",
    shell=True, text=True
)
print("\n10:16 WS errors:\n", out[:1200] if out else "none")
PY

Pinpoint first kiwoom tick/ob timestamps today


[REDACTED]

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

for label, sql, params in [
    ("kiwoom tick", "SELECT MIN(tick_time) mn FROM ws_ticks WHERE source='kiwoom' AND tick_time LIKE %s", (pfx,)),
    ("kiwoom ob", "SELECT MIN(snap_time) mn FROM ws_orderbook WHERE source='kiwoom_0d' AND snap_time LIKE %s", (pfx,)),
    ("kis tick", "SELECT MIN(tick_time) mn FROM ws_ticks WHERE source='kis' AND tick_time LIKE %s", (pfx,)),
]:
    r = db.conn.execute(sql, params).fetchone()
    mn = r['mn']
    if mn:
        s = str(mn)
        hh, mm = s[8:10], s[10:12]
        ss = s[12:14] if len(s) >= 14 else '00'
        print(f"{label}: {hh}:{mm}:{ss}")

print("\nkiwoom ticks 10min 12:00-14:00:")
rows = db.conn.execute(
    "SELECT SUBSTRING(tick_time,1,11) AS bucket, COUNT(*) n FROM ws_ticks "
    "WHERE source='kiwoom' AND tick_time >= %s AND tick_time < %s GROUP BY bucket ORDER BY bucket",
    ('202608121200', '202608121400'),
).fetchall()
for r in rows:
    b = str(r['bucket'])
    print(f"  {b[8:10]}:{b[10:12]} n={r['n']}")

r = db.conn.execute(
    "SELECT COUNT(*) n FROM ws_ticks WHERE source='kiwoom' AND tick_time >= %s AND tick_time < %s",
    ('202608120900', '202608121255'),
).fetchone()
print(f"\nkiwoom ticks 09:00-12:55: {r['n']}")

r2 = db.conn.execute(
    "SELECT COUNT(*) n FROM ws_ticks WHERE source='kiwoom' AND tick_time >= %s AND tick_time < %s",
    ('202608121255', '202608121330'),
).fetchone()
print(f"kiwoom ticks 12:55-13:30: {r2['n']}")
PY

First kiwoom data timestamps with safe SQL binding


[REDACTED]

cd /home/hoon/kis_bot && python3 - <<'PY'
from database import TradeDB
db = TradeDB()
rows = db.conn.execute(
    "SELECT SUBSTRING(candle_time,9,4) AS hm, source, COUNT(*) n FROM ws_candles "
    "WHERE candle_time LIKE %s AND timeframe=1 AND source IN ('kiwoom','kis') "
    "GROUP BY hm, source HAVING hm >= '1250' AND hm <= '1310' ORDER BY hm, source",
    ('20260812%',),
).fetchall()
print("candles kiwoom/kis around 12:55-13:10:")
for r in rows:
    hm = r['hm']
    print(f"  {hm[:2]}:{hm[2:4]} {r['source']} n={r['n']}")

# MOMENTUM EXIT_OB - when trades with 호가컷
rows2 = db.conn.execute(
    "SELECT sell_date, sell_reason FROM trade_history WHERE sell_date LIKE %s ORDER BY sell_date",
    ('2026-08-12%',),
).fetchall()
print("\ntrades sold today:")
for r in rows2:
    print(f"  {r['sell_date']} reason={r['sell_reason']}")
PY

Candle and trade timeline around fix window


DB·로그 기준으로 오늘(8/12) 타임라인입니다.

---

## 한 줄

**DB에 “키움 틱·호가”가 정상 쌓이기 시작한 시각 = 12:55:35**
**실매 RAM 시세(키움 WS 실전) = 09:00 장 시작부터** (단, 10:17 전후 WS 끊김 있음)
**오늘 코드 패치 전부 반영 = 15:37 재시작** (장 마감 직후라 매매 검증은 내일)

---

## 구간별

| 시각 | 상태 |
|---|---|
| **06:18 ~ 08:14** | 장 전 · 키움 WS 실키 연결 |
| **08:14** | 재시작 · 키움 **실전** WS 기동 |
| **09:00 ~ 12:55** | 장중 · 키움 WS는 붙어 있었지만 **`ws_ticks`/`ws_orderbook`에 키움 데이터 0건** (`_code_refs` 버그) · KIS 틱만 DB 적재 |
| **10:16 ~ 10:17** | 키움 WS `socket closed` / SSL 오류 구간 |
| **12:55:02** | 재시작 · `_code_refs` 수정 반영 |
| **12:55:35** | **첫 키움 틱·호가 DB 기록** (`ws_ticks`/`ws_orderbook` min = 12:55:35) |
| **13:00 ~ 14:59** | 키움 틱·호가 **정상 적재** (13시 4만+/14시 4.6만+) |
| **15:00~** | 장 마감 · 적재 급감 (정상) |
| **15:37** | 갭보정·소스 우선순위 등 **최종 코드** 재시작 |

---

## 백테/실매내역에 쓸 때

- **「기본」(LIVE kiwoom) 백테**로 오늘만 돌리면 → **12:55 이후 구간**이 키움 DB 기준으로 가장 정합
- **09:00~12:55**는 DB에 키움 틱/호가가 없어서 → 실매내역 **매수/매도 호가 `-`** 도 이 구간 거래(09:00 익절·손절 등)에 해당
- **09:00~12:55 실매 RAM**은 KIS+키움 WS 혼용이었고, 호가컷(`EXIT_OB`)은 당시 env 미등록으로 **꺼져 있었을 가능성** 큼

**요약**: DB·호가 표시 기준 “정상”은 **12:55:35부터**, 장중 실시간 시세는 **09:00부터**(10:17 glitch, 12:55 전 DB 미적재).

[REDACTED]


<timestamp>Wednesday, Aug 12, 2026, 4:12 PM (UTC+9)</timestamp>
<user_query>
그럼 1시꺼부터 모맨텀 돌려보자 키움으로 200개만 호가 매도쪽 나오나도 확인해야되니까
</user_query>


13시부터 키움 소스로 모멘텀 백테를 돌리고, 매도 호가가 나오는지 확인합니다.

[REDACTED]


[REDACTED]


13:00~ 키움 소스로 모멘텀 백테를 돌리고, 호가(매도) 데이터가 붙는지 확인합니다.

[REDACTED]

cd /home/hoon/kis_bot && python3 - <<'PY'
from database import TradeDB
db = TradeDB()
r = db.conn.execute(
    "SELECT COUNT(*) n FROM ws_orderbook WHERE source='kiwoom_0d' AND snap_time >= %s AND snap_time < %s",
    ('202608121300', '202608121600'),
).fetchone()
print("kiwoom_0d ob 13:00-16:00:", r['n'])
PY

Count kiwoom orderbook rows from 13:00

LOG=/home/hoon/kis_bot/logs/momentum_bt_20260812_1300_kiwoom.log
mkdir -p /home/hoon/kis_bot/logs
nohup curl -s -G "http://127.0.0.1:5050/api/backtest/momentum" \
  --data-urlencode "start=2026-08-12" \
  --data-urlencode "end=2026-08-12" \
  --data-urlencode "time_start=1300" \
  --data-urlencode "time_end=1530" \
  --data-urlencode "candle_source=kiwoom" \
  --data-urlencode "tick_source=kiwoom" \
  --data-urlencode "ob_source=kiwoom_0d" \
  -o "$LOG" \
  > /home/hoon/kis_bot/logs/momentum_bt_20260812_1300_kiwoom.curl.log 2>&1 &
echo "PID=$! LOG=$LOG"

Run momentum backtest 13:00 kiwoom in background


[REDACTED]

python3 - <<'PY'
import json
from pathlib import Path
p = Path('/home/hoon/kis_bot/logs/momentum_bt_20260812_1300_kiwoom.log')
raw = p.read_text()
try:
    d = json.loads(raw)
except Exception as e:
    print('JSON parse fail', e)
    print(raw[:500])
    raise

summary = d.get('summary') or {}
trades = d.get('trades') or []
print('=== summary ===')
for k in ['total_trades','win_rate','total_pnl','profit_factor','bot_pct','daily_avg_pct']:
    if k in summary:
        print(f"  {k}: {summary[k]}")
print('trades:', len(trades))

with_entry_ob = sum(1 for t in trades if t.get('entry_ob'))
with_exit_ob = sum(1 for t in trades if t.get('exit_ob'))
print(f'entry_ob filled: {with_entry_ob}/{len(trades)}')
print(f'exit_ob filled:  {with_exit_ob}/{len(trades)}')

# show sell-side (exit) orderbook details
shown = 0
for t in trades:
    ex = t.get('exit_ob')
    if not ex:
        continue
    shown += 1
    ask = ex.get('ask_qty_l3') or ex.get('total_ask_qty') or ex.get('ask')
    bid = ex.get('bid_qty_l3') or ex.get('total_bid_qty') or ex.get('bid')
    print(f"\n--- trade {shown} {t.get('code')} sell={t.get('sell_time')} reason={t.get('sell_reason')}")
    print(f"  exit_ob: ask_l3={ex.get('ask_qty_l3')} bid_l3={ex.get('bid_qty_l3')} spread={ex.get('spread_pct')} source={ex.get('source')}")
    if shown >= 5:
        break

if with_exit_ob == 0 and trades:
    print('\n(no exit_ob — sample trade keys)', list(trades[0].keys())[:20])
    t0 = trades[0]
    print('sample:', {k:t0.get(k) for k in ['code','buy_time','sell_time','sell_reason','entry_ob','exit_ob']})

meta = d.get('meta') or {}
for k in ['ws_orderbook_rows_loaded','orderbook_codes_with_data','ws_tick_rows_loaded','candle_source']:
    if k in meta:
        print(f'meta.{k}:', meta[k])
# scan whole response for orderbook meta
for k,v in d.items():
    if 'orderbook' in str(k).lower() or 'ob_' in str(k).lower():
        print(k, v if not isinstance(v, dict) else {kk:v[kk] for kk in list(v)[:8]})
PY

Parse momentum backtest JSON for orderbook fields


[REDACTED]

python3 - <<'PY'
import json
from pathlib import Path
d = json.loads(Path('/home/hoon/kis_bot/logs/momentum_bt_20260812_1300_kiwoom.log').read_text())
print('top keys', list(d.keys()))
print('error', d.get('error'))
print('codes_analyzed', d.get('codes_analyzed'))
print('universe', str(d.get('universe_meta') or d.get('meta') or '')[:800])
for k in sorted(d.keys()):
    if any(x in k.lower() for x in ['code','univ','candle','tick','ob','trade','warn','msg']):
        v = d[k]
        if isinstance(v, (list, dict)) and len(str(v))>200:
            print(k, type(v), 'len', len(v))
        else:
            print(k, v)
PY

Inspect zero-trade backtest response details


[REDACTED]

cd /home/hoon/kis_bot && python3 - <<'PY'
import os
os.environ['CANDLE_SOURCE'] = 'kiwoom'
from database import TradeDB
from kis_trader.backtest.bt_candle_source import resolve_bt_candle_source_order, list_ws_candle_codes
from kis_trader.backtest.scalping_backtest_common import load_scalp_candles_by_code

db = TradeDB()
start_key = '202608121300'
end_key = '202608121530'
print('source order', resolve_bt_candle_source_order())
codes = list_ws_candle_codes(db, 1, start_key, end_key)
print('codes in period', len(codes))
cb, total = load_scalp_candles_by_code(db, start_key, end_key, rsi_period=3)
print('candles codes', len(cb), 'rows', total)

# live trades after 13:00
rows = db.conn.execute(
    "SELECT code, buy_date, sell_date, sell_reason FROM trade_history "
    "WHERE strategy LIKE %s AND (buy_date >= %s OR sell_date >= %s) ORDER BY sell_date",
    ('%MOMENTUM%', '2026-08-12 13:00:00', '2026-08-12 13:00:00'),
).fetchall()
print('\nlive momentum trades 13:00+:', len(rows))
for r in rows:
    print(dict(r))

# enrich live trades today with orderbook
from kis_trader.backtest.trade_orderbook_enrich import enrich_trades_with_orderbook
all_today = db.conn.execute(
    "SELECT * FROM trade_history WHERE sell_date LIKE %s ORDER BY sell_date",
    ('2026-08-12%',),
).fetchall()
trades = [dict(r) for r in all_today]
enrich_trades_with_orderbook(db, trades, strategy_hint='MOMENTUM')
after13 = [t for t in trades if str(t.get('sell_date') or '') >= '2026-08-12 13:00:00' or str(t.get('buy_date') or '') >= '2026-08-12 13:00:00']
print('\nenriched today total', len(trades), 'after 13 filter', len(after13))
for t in trades:
    ex = t.get('exit_ob')
    en = t.get('entry_ob')
    if ex or en:
        print(f"{t.get('code')} sell={t.get('sell_date')} entry_ob={bool(en)} exit_ob={bool(ex)}")
        if ex:
            print('  exit ask_l3=', ex.get('ask_qty_l3'), 'bid_l3=', ex.get('bid_qty_l3'), 'src=', ex.get('source'))
PY

Check candle load, live trades, orderbook enrich after 13:00


13~15:30만 돌리면 거래 0건이라, 같은 날 전체 구간으로 한 번 더 돌려 13시 이후 거래·호가를 확인합니다.

[REDACTED]

LOG=/home/hoon/kis_bot/logs/momentum_bt_20260812_fullday_kiwoom.log
curl -s -G "http://127.0.0.1:5050/api/backtest/momentum" \
  --data-urlencode "start=2026-08-12" \
  --data-urlencode "end=2026-08-12" \
  --data-urlencode "time_start=900" \
  --data-urlencode "time_end=1530" \
  --data-urlencode "candle_source=kiwoom" \
  --data-urlencode "tick_source=kiwoom" \
  --data-urlencode "ob_source=kiwoom_0d" \
  -o "$LOG"
python3 - <<'PY'
import json
from pathlib import Path
d = json.loads(Path('/home/hoon/kis_bot/logs/momentum_bt_20260812_fullday_kiwoom.log').read_text())
trades = d.get('trades') or []
print('total trades', len(trades))
after13 = [t for t in trades if str(t.get('buy_time') or t.get('entry_time') or '') >= '13:00' or str(t.get('buy_time') or '')[-8:] >= '13:00:00']
# buy_time format may vary
def hm(t):
    s = str(t.get('buy_time') or t.get('entry_time') or '')
    return s
after13 = [t for t in trades if '13:' in hm(t) or '14:' in hm(t) or '15:' in hm(t)]
print('buy after 13h', len(after13))
for t in trades[:10]:
    ex = t.get('exit_ob')
    print(hm(t), t.get('code'), 'exit_ob', bool(ex), 'reason', t.get('sell_reason') or t.get('reason'))
    if ex:
        print('  ask_l3', ex.get('ask_qty_l3'), 'bid_l3', ex.get('bid_qty_l3'))
print('summary', d.get('summary',{}).get('total_trades'), d.get('summary',{}).get('total_pnl'))
PY

Full-day momentum backtest kiwoom for trade/ob check


## 실행 결과 (2026-08-12, `candle_source=kiwoom`)

### 1) 13:00~15:30 모멘텀 백테
- **거래 0건** — 오늘 오후 실매와 동일하게 모멘텀 신규 진입 없음(한도·유니버스)
- 로그: `/home/hoon/kis_bot/logs/momentum_bt_20260812_1300_kiwoom.log`

### 2) 키움 호가 DB (13:00~)
- **`kiwoom_0d` 88,660건** — 200건 확인용으로는 충분

### 3) 매도호가(exit_ob) 나오는지

**실매내역 enrich (14:51~ 매도 3건)**

| 종목 | 매도시각 | exit_ob | ask_l3 (매도측) | bid_l3 |
|---|---|---|---|---|
| 950140 | 14:51:40 | ✅ | 778 | 4426 |
| 013580 | 14:56:39 | ✅ | 1270 | 2336 |
| 052790 | 14:56:39 | ✅ | 398 | 54 |

→ **13시 이후 매도는 매도호가(L3) 붙음** (`source=kiwoom_0d`)
→ 매수는 어제(12:55 이전)라 **entry_ob 없음** — 정상

**참고: 당일 09~12시 백테 (키움, 전체 구간)**

| 매도시각 | exit_ob | ask_l3 |
|---|---|---|
| 12:02 | ✅ | 888 |
| 11:37 | ✅ | 2940 (호가컷) |
| 11:40 | ✅ | 2685 (호가컷) |
| 10:59 | ✅ | 791 (호가컷) |
| 09:39, 10:34, 11:34 | ❌ | — (12:55 이전 매도 → DB 호가 없음) |

로그: `/home/hoon/kis_bot/logs/momentum_bt_20260812_fullday_kiwoom.log`

---

## 정리

- **13시~ 데이터(키움 틱·호가)**: DB 기준 **12:55:35~** 정상
- **13시~ 모멘텀 백테**: 거래 0건이라 백테 테이블에 exit_ob 검증할 건 없음
- **13시~ 실매 매도 호가**: **나옴** (위 3건)

웹에서 동일 조건으로 보려면 모멘텀 탭 → 시작/종료 `2026-08-12`, **시작 1300**, 캔들 **키움**, 틱 **키움**, 호가 **키움** 후 실행.
실매내역 탭은 14:51 이후 매도 건에서 매도호가 확인하면 됩니다.

[REDACTED]


<timestamp>Wednesday, Aug 12, 2026, 4:20 PM (UTC+9)</timestamp>
<user_query>
저 13시 이후가 다른전략 보라는건가? 그럼 다른 전략 다 돌려봐 13시 이후로
</user_query>


13시 이후 거래가 모멘텀에만 없었는지 확인하려면 다른 전략 백테 API 경로를 찾고, 같은 조건으로 한꺼번에 돌리겠습니다.

[REDACTED]


모멘텀만 0건이었는지 보려면 꼬리·돌파·스캘핑까지 같은 13:00~ 조건으로 API 백테를 병렬 실행합니다.

[REDACTED]

BASE="http://127.0.0.1:5050"
DATE="2026-08-12"
LOGDIR="/home/hoon/kis_bot/logs"
COMMON="start=${DATE}&end=${DATE}&time_start=1300&time_end=1530&candle_source=kiwoom&tick_source=kiwoom&ob_source=kiwoom_0d"

for s in tail momentum breakout scalping; do
  case $s in
    tail) path="/api/backtest/tail" ;;
    momentum) path="/api/backtest/momentum" ;;
    breakout) path="/api/backtest/breakout" ;;
    scalping) path="/api/backtest/scalping" ;;
  esac
  out="${LOGDIR}/bt_20260812_1300_${s}_kiwoom.json"
  echo "=== running $s ==="
  curl -s -G "${BASE}${path}" \
    --data-urlencode "start=${DATE}" \
    --data-urlencode "end=${DATE}" \
    --data-urlencode "time_start=1300" \
    --data-urlencode "time_end=1530" \
    --data-urlencode "candle_source=kiwoom" \
    --data-urlencode "tick_source=kiwoom" \
    --data-urlencode "ob_source=kiwoom_0d" \
    -o "$out" &
done
wait
echo done

Run all 4 strategy backtests 13:00+ kiwoom parallel


[REDACTED]

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

LOGDIR = Path('/home/hoon/kis_bot/logs')
strategies = ['tail', 'momentum', 'breakout', 'scalping']

for s in strategies:
    p = LOGDIR / f'bt_20260812_1300_{s}_kiwoom.json'
    print('='*60, s.upper())
    if not p.exists():
        print('MISSING', p)
        continue
    raw = p.read_text()
    if not raw.strip():
        print('EMPTY')
        continue
    try:
        d = json.loads(raw)
    except Exception as e:
        print('JSON ERR', e, raw[:200])
        continue
    if 'error' in d:
        print('ERROR:', d['error'])
        continue
    sm = d.get('summary') or {}
    trades = d.get('trades') or []
    print('total_trades:', sm.get('total_trades', len(trades)))
    print('total_pnl:', sm.get('total_pnl'))
    print('win_rate:', sm.get('win_rate'))
    if trades:
        for t in trades[:8]:
            bt = t.get('buy_time') or t.get('entry_time') or ''
            st = t.get('sell_time') or t.get('exit_time') or ''
            ex = t.get('exit_ob')
            en = t.get('entry_ob')
            ex_ok = bool(ex)
            ask = ex.get('ask_qty_l3') if isinstance(ex, dict) else None
            bid = ex.get('bid_qty_l3') if isinstance(ex, dict) else None
            print(f"  buy={bt} sell={st} code={t.get('code')} reason={t.get('sell_reason') or t.get('reason')} pnl={t.get('pnl')} exit_ob={ex_ok} ask_l3={ask} bid_l3={bid} entry_ob={bool(en)}")
        if len(trades) > 8:
            print(f'  ... +{len(trades)-8} more')
    else:
        print('  (no trades)')
    meta = d.get('meta') or d.get('context') or {}
    if meta:
        keys = ['candle_source','tick_source','ob_source','time_window','universe']
        print('meta:', {k: meta.get(k) for k in keys if k in meta})
PY

Parse all 4 strategy backtest JSON results

python3 - <<'PY'
from database import TradeDB
from datetime import datetime

db = TradeDB()
# show columns first for trades table
cols = [r['Field'] for r in db.conn.execute('SHOW COLUMNS FROM trades').fetchall()]
print('trades cols sample:', cols[:20], '... total', len(cols))

# live trades on 2026-08-12 after 13:00 by strategy
q = """
SELECT strategy_id, code, buy_time, sell_time, sell_reason, pnl
FROM trades
WHERE DATE(buy_time) = %s OR DATE(sell_time) = %s
ORDER BY COALESCE(sell_time, buy_time)
"""
rows = db.conn.execute(q, ('2026-08-12', '2026-08-12')).fetchall()
print('all trades 2026-08-12:', len(rows))
after13 = []
for r in rows:
    st = str(r.get('sell_time') or '')
    bt = str(r.get('buy_time') or '')
    t = st or bt
    if '13:' in t or '14:' in t or '15:' in t:
        after13.append(r)
print('after 13:00 (buy or sell):', len(after13))
from collections import Counter
c = Counter(r.get('strategy_id') for r in after13)
print('by strategy:', dict(c))
for r in after13[:15]:
    print(dict(r))
PY

Check live trades after 13:00 by strategy


4전략 모두 13시 이후 거래 0건입니다. 실매 DB와 오후 후보 유니버스를 확인합니다.

[REDACTED]

python3 - <<'PY'
from database import TradeDB
db = TradeDB()
tables = [r[0] if isinstance(r, tuple) else list(r.values())[0] for r in db.conn.execute("SHOW TABLES LIKE '%trade%'").fetchall()]
print('trade tables:', tables)
for t in tables[:15]:
    cols = [r['Field'] for r in db.conn.execute(f'SHOW COLUMNS FROM `{t}`').fetchall()]
    print(t, '->', cols[:12])
PY

Find trade-related DB tables


[REDACTED]

python3 - <<'PY'
from database import TradeDB
db = TradeDB()
rows = db.conn.execute("SHOW TABLES").fetchall()
tables = [list(r.values())[0] if hasattr(r,'values') else r[0] for r in rows]
trade_tables = [t for t in tables if 'trade' in t.lower()]
print('trade tables:', trade_tables)

# trade_history after 13:00 on 2026-08-12
cols = [r['Field'] for r in db.conn.execute('SHOW COLUMNS FROM trade_history').fetchall()]
print('trade_history cols:', cols)

q = """
SELECT strategy, code, buy_time, sell_time, sell_reason, pnl
FROM trade_history
WHERE sell_time >= %s AND sell_time < %s
ORDER BY sell_time
"""
rows = db.conn.execute(q, ('2026-08-12 13:00:00', '2026-08-12 16:00:00')).fetchall()
print('live sells 13:00-16:00:', len(rows))
from collections import Counter
print('by strategy:', dict(Counter(r['strategy'] for r in rows)))
for r in rows:
    print(dict(r))

# buys after 13:00
q2 = """
SELECT strategy, code, buy_time, sell_time, sell_reason, pnl
FROM trade_history
WHERE buy_time >= %s AND buy_time < %s
ORDER BY buy_time
"""
rows2 = db.conn.execute(q2, ('2026-08-12 13:00:00', '2026-08-12 16:00:00')).fetchall()
print('live buys 13:00-16:00:', len(rows2))
print('buy by strategy:', dict(Counter(r['strategy'] for r in rows2)))
for r in rows2:
    print(dict(r))
PY

Query trade_history for 13:00+ activity

python3 - <<'PY'
from database import TradeDB
db = TradeDB()
# universe history momentum/tail etc afternoon slots
for tbl in ['target_candidates_history', 'kiwoom_universe_history']:
    try:
        cols = [r['Field'] for r in db.conn.execute(f'SHOW COLUMNS FROM `{tbl}`').fetchall()]
        print(tbl, cols[:15])
    except Exception as e:
        print(tbl, 'ERR', e)

# count candidates after 13:00 slot
q = """
SELECT slot_key, COUNT(*) n
FROM target_candidates_history
WHERE slot_key LIKE %s
GROUP BY slot_key
ORDER BY slot_key
"""
rows = db.conn.execute(q, ('20260812%',)).fetchall()
print('slots today:', len(rows))
for r in rows:
    sk = r['slot_key']
    if sk >= '202608121300' or '1300' in sk[8:]:
        print(dict(r))
PY

Check afternoon universe candidate slots


[REDACTED]

python3 - <<'PY'
from database import TradeDB
db = TradeDB()

# sells after 13:00 today
q = """
SELECT strategy, code, name, buy_date, sell_date, sell_reason, realized_pnl, profit_rate
FROM trade_history
WHERE sell_date >= %s AND sell_date < %s
ORDER BY sell_date
"""
rows = db.conn.execute(q, ('2026-08-12 13:00:00', '2026-08-12 16:00:00')).fetchall()
print('=== 실매 매도 13:00~ ===', len(rows))
from collections import Counter
print('전략별:', dict(Counter(r['strategy'] for r in rows)))
for r in rows:
    print(f"  {r['strategy']} {r['code']} sell={r['sell_date']} buy={r['buy_date']} reason={r['sell_reason']} pnl={r['realized_pnl']}")

q2 = """
SELECT strategy, code, buy_date, sell_date, sell_reason
FROM trade_history
WHERE buy_date >= %s AND buy_date < %s
ORDER BY buy_date
"""
rows2 = db.conn.execute(q2, ('2026-08-12 13:00:00', '2026-08-12 16:00:00')).fetchall()
print('=== 실매 매수 13:00~ ===', len(rows2))
print('전략별:', dict(Counter(r['strategy'] for r in rows2)))
for r in rows2:
    print(f"  {r['strategy']} {r['code']} buy={r['buy_date']} sell={r['sell_date']} reason={r['sell_reason']}")

# active trades still open
q3 = "SELECT strategy, code, buy_date FROM active_trades"
act = db.conn.execute(q3).fetchall()
print('=== active_trades ===', len(act))
for r in act:
    print(dict(r))
PY

Query trade_history with buy_date/sell_date


돌파 API는 `time_start_hm` 파라미터를 쓰는지 확인한 뒤, 필요하면 다시 돌립니다.

[REDACTED]


[REDACTED]

python3 - <<'PY'
import json
from pathlib import Path
for s in ['breakout','scalping','tail']:
    p = Path(f'/home/hoon/kis_bot/logs/bt_20260812_1300_{s}_kiwoom.json')
    d = json.loads(p.read_text())
    ctx = d.get('context') or d.get('params') or {}
    print(s, 'keys', list(d.keys())[:15])
    for k in ['context','params','config','summary','debug']:
        if k in d and d[k]:
            v = d[k]
            if isinstance(v, dict):
                tw = v.get('time_window') or v.get('time_start_hm') or v.get('time_start')
                print(f'  {k} time:', tw, 'sample keys', list(v.keys())[:8])
PY

Check backtest JSON for time_window params

curl -s -G "http://127.0.0.1:5050/api/backtest/breakout" \
  --data-urlencode "start=2026-08-12" \
  --data-urlencode "end=2026-08-12" \
  --data-urlencode "time_start_hm=1300" \
  --data-urlencode "time_end_hm=1530" \
  --data-urlencode "candle_source=kiwoom" \
  --data-urlencode "tick_source=kiwoom" \
  --data-urlencode "ob_source=kiwoom_0d" \
  -o /home/hoon/kis_bot/logs/bt_20260812_1300_breakout_kiwoom_v2.json

curl -s -G "http://127.0.0.1:5050/api/backtest/range_break" \
  --data-urlencode "start=2026-08-12" \
  --data-urlencode "end=2026-08-12" \
  --data-urlencode "time_start_hm=1300" \
  --data-urlencode "time_end_hm=1530" \
  --data-urlencode "candle_source=kiwoom" \
  --data-urlencode "tick_source=kiwoom" \
  --data-urlencode "ob_source=kiwoom_0d" \
  -o /home/hoon/kis_bot/logs/bt_20260812_1300_range_break_kiwoom.json

python3 - <<'PY'
import json
from pathlib import Path
for name in ['breakout_v2','range_break']:
    p = Path(f'/home/hoon/kis_bot/logs/bt_20260812_1300_{name}_kiwoom.json')
    d = json.loads(p.read_text())
    if 'error' in d:
        print(name, 'ERROR', d['error']); continue
    sm = d.get('summary') or {}
    trades = d.get('trades') or []
    print(name, 'trades', sm.get('total_trades', len(trades)), 'pnl', sm.get('total_pnl'))
    for t in trades[:5]:
        print(' ', t.get('buy_time'), t.get('code'), t.get('sell_reason') or t.get('reason'), bool(t.get('exit_ob')))
PY

Re-run breakout/range_break with time_start_hm=1300


[REDACTED]

python3 - <<'PY'
import json
from pathlib import Path
for p in [
    '/home/hoon/kis_bot/logs/bt_20260812_1300_breakout_kiwoom_v2.json',
    '/home/hoon/kis_bot/logs/bt_20260812_1300_range_break_kiwoom.json',
]:
    d = json.loads(Path(p).read_text())
    name = p.split('/')[-1]
    if 'error' in d:
        print(name, 'ERROR', d['error']); continue
    sm = d.get('summary') or {}
    trades = d.get('trades') or []
    params = d.get('params') or {}
    tw = params.get('time_window') or f"{params.get('time_start_hm','?')}-{params.get('time_end_hm','?')}"
    print('='*50, name)
    print('time_window', tw, 'trades', sm.get('total_trades', len(trades)), 'pnl', sm.get('total_pnl'))
    for t in trades[:8]:
        ex = t.get('exit_ob')
        print(f"  {t.get('buy_time')} {t.get('code')} {t.get('sell_reason') or t.get('reason')} exit_ob={bool(ex)}")
PY

Parse breakout v2 and range_break results


[REDACTED]

python3 - <<'PY'
import json
from pathlib import Path
p = Path('/home/hoon/kis_bot/logs/bt_20260812_1300_range_break_kiwoom.json')
d = json.loads(p.read_text())
for t in d.get('trades') or []:
    ex = t.get('exit_ob') or {}
    print(t.get('code'), 'buy', t.get('buy_time'), 'sell', t.get('sell_time'), t.get('sell_reason') or t.get('reason'))
    print('  exit_ob ask_l3', ex.get('ask_qty_l3'), 'bid_l3', ex.get('bid_qty_l3'), 'src', ex.get('source'))
    en = t.get('entry_ob') or {}
    if en:
        print('  entry_ob ask_l3', en.get('ask_qty_l3'), 'bid_l3', en.get('bid_qty_l3'))
PY

Show range_break exit_ob details

# full day 각 전략 13시 이후 매수만 필터
python3 - <<'PY'
import json, subprocess, urllib.parse

BASE = 'http://127.0.0.1:5050'
DATE = '2026-08-12'
common = dict(start=DATE, end=DATE, candle_source='kiwoom', tick_source='kiwoom', ob_source='kiwoom_0d')
routes = {
    'tail': '/api/backtest/tail',
    'momentum': '/api/backtest/momentum',
    'breakout': '/api/backtest/breakout',
    'scalping': '/api/backtest/scalping',
    'range_break': '/api/backtest/range_break',
}

def run(name, path, extra):
    q = urllib.parse.urlencode({**common, **extra})
    import urllib.request
    url = f'{BASE}{path}?{q}'
    with urllib.request.urlopen(url, timeout=180) as r:
        d = json.loads(r.read())
    trades = d.get('trades') or []
    def after13(t):
        bt = str(t.get('buy_time') or '')
        return bt >= '2026-08-12 13:00:00' or ('13:' in bt or '14:' in bt or '15:' in bt)
    a13 = [t for t in trades if after13(t)]
    sm = d.get('summary') or {}
    tw = (d.get('params') or {}).get('time_window','fullday')
    print(f"{name:12} window={extra.get('time_start') or extra.get('time_start_hm') or '900'} total={sm.get('total_trades',len(trades))} buy_after13={len(a13)}")
    for t in a13[:5]:
        print(f"   {t.get('buy_time')} {t.get('code')} exit_ob={bool(t.get('exit_ob'))}")

for name, path in routes.items():
    extra = {'time_start': '900', 'time_end': '1530'} if name != 'breakout' and name != 'range_break' else {'time_start_hm': '900', 'time_end_hm': '1530'}
    if name in ('breakout','range_break'):
        extra = {'time_start_hm': '900', 'time_end_hm': '1530'}
    else:
        extra = {'time_start': '900', 'time_end': '1530'}
    try:
        run(name, path, extra)
    except Exception as e:
        print(name, 'ERR', e)
PY

Full-day backtests: count buys after 13:00 per strategy


## 결론: 「다른 전략 보라」가 아니라, **13시 이후 신규 매수가 거의 없던 날**입니다

국내 4전략 + 레인지브레이크를 **2026-08-12, 13:00~15:30, 키움(캔들/틱/호가)** 로 돌렸습니다.

| 전략 | 13:00~ 거래 | PnL | 비고 |
|------|-------------|-----|------|
| **꼬리(tail)** | **0건** | 0 | |
| **모멘텀** | **0건** | 0 | |
| **돌파(breakout)** | **0건** | 0 | `time_start_hm=1300` 재실행 |
| **스캘핑** | **0건** | 0 | |
| **레인지브레이크** | **6건** | -7,060 | ✅ exit_ob·entry_ob 모두 표시 |

로그:
- `/home/hoon/kis_bot/logs/bt_20260812_1300_{tail,momentum,breakout,scalping}_kiwoom.json`
- `/home/hoon/kis_bot/logs/bt_20260812_1300_breakout_kiwoom_v2.json` (돌파, time_start_hm 보정)
- `/home/hoon/kis_bot/logs/bt_20260812_1300_range_break_kiwoom.json`

---

## 실매와 대조

**13시 이후 신규 매수: 0건** (4전략 공통)

**13시 이후 매도 6건** — 전부 **어제 보유분 정리**(ghost_purge), 오늘 신규 진입 아님:
- MOMENTUM 3건 (950140, 013580, 052790) — 14:51~14:56
- US_MOMENTUM 3건 — 해외

→ 오후에 **모멘텀만** 청산된 것이지, 다른 전략이 더 활발했던 건 아닙니다.

---

## 호가(매도측) 검증 — **레인지브레이크 6건 OK**

| 종목 | 매수 | exit_ob ask_l3 | entry_ob |
|------|------|----------------|----------|
| 095660 | 13:03 | 191 | ✅ |
| 362320 | 13:07 | 930 | ✅ |
| 439090 | 13:45 | 659 | ✅ |
| 096760 | 14:12 | 914 | ✅ |
| 469610 | 14:45 | 172 | ✅ |
| 001390 | 14:51 | 502 | ✅ |

`source=kiwoom_0d`, 매수·매도 호가(L3) 모두 채워짐.

---

## 정리

1. **13시~ 키움 데이터로 백테 거래를 보려면 → 레인지브레이크 탭**이 맞습니다 (6건).
2. 꼬리·모멘텀·돌파·스캘핑은 오늘 오후 **TRIGGER 조건 + 한도** 미충족으로 0건 — 버그가 아니라 **그날 시장/설정 결과**에 가깝습니다.
3. **호가 200건 확인**은 DB에 13시~ **88,660건** 있어서 충분하고, 레인지브레이크 6건으로 **매도호가 표시까지 검증 완료**입니다.

웹에서 보려면: **레인지브레이크 탭** → `2026-08-12`, 시작 **1300**, 캔들/틱/호가 **키움** → 실행 후 거래내역의 매도호가 컬럼 확인.

돌파 첫 실행은 `time_start` 대신 `time_start_hm`이 필요해서 09:00~으로 잘못 돌았지만, 09~15:30이어도 **0건**이라 결과는 동일합니다.

[REDACTED]


<timestamp>Wednesday, Aug 12, 2026, 4:26 PM (UTC+9)</timestamp>
<user_query>
박스권돌파는 코드 안만진지 오래되서 이게 제대로 되어잇는지도 모르겟는데 지금
</user_query>


박스권돌파(레인지브레이크)는 코드·실매·백테 정합부터 확인하겠습니다. 오래 손대지 않은 경로라 어디가 살아 있고 어디가 끊겼는지부터 짚을게요.

[REDACTED]

Task

Explore range_break strategy code

Prompt:
Explore the kis_bot codebase for "range_break" / "박스권돌파" / "RANGE_BREAK" strategy thoroughly.

Find and report:
1. Live trading engine path - is range_break actually running in production (kis_trader_main, strategy registration)?
2. Backtest engine - main files, entry/exit logic, candle/tick/ob source usage
3. Web UI - backtest tab, params, parity with breakout/momentum tabs
4. DB config keys - config_range_break table, ENV keys
5. Optuna/param search support or missing
6. Recent git history / last meaningful changes if visible
7. Any obvious bugs, dead code, or parity gaps vs other strategies (breakout, tail)
8. The 6 trades on 2026-08-12 13:00+ - what strategy_id/engine produced them, is "box_fail" a valid exit reason?

Return file paths, key function names, and a concise health assessment: working / partially working / likely broken with evidence.


핵심만 직접 확인 중입니다 — 실매 ON/OFF, 엔진 정합, 유니버스 이력, 오늘 6건 백테.

[REDACTED]

Shell

python3 - <<'PY'
from database import TradeDB
from kis_trader.engine.range_break_engine import get_range_break_defaults_from_db

db = TradeDB()
# enabled flag
row = db.conn.execute(
    "SELECT config_key, config_value FROM config_range_break WHERE config_key IN (%s,%s,%s,%s)",
    ('STRATEGY_RANGE_BREAK_ENABLED','RANGE_BREAK_TIME_START','RANGE_BREAK_TIME_END_HM','RANGE_BREAK_LIVE_BACKTEST_ALIGN'),
).fetchall()
print('DB flags:')
for r in row:
    print(' ', r['config_key'], '=', r['config_value'])

defs = get_range_break_defaults_from_db()
print('engine defaults time', defs.get('time_start_hm'), defs.get('time_end_hm'))
print('box_lookback', defs.get('box_lookback_min'), 'vol_mult', defs.get('vol_mult'))
print('live_backtest_align', defs.get('live_backtest_align'))
print('backtest_use_tick_exit', defs.get('backtest_use_tick_exit'))

# universe history for RANGE_BREAK today
n = db.conn.execute(
    "SELECT COUNT(*) c FROM target_candidates_history WHERE strategy_id=%s AND slot_key LIKE %s",
    ('RANGE_BREAK', '20260812%'),
).fetchone()['c']
print('RANGE_BREAK universe slots today:', n)

# compare breakout
n2 = db.conn.execute(
    "SELECT COUNT(*) c FROM target_candidates_history WHERE strategy_id=%s AND slot_key LIKE %s",
    ('BREAKOUT', '20260812%'),
).fetchone()['c']
print('BREAKOUT universe slots today:', n2)

# live trades ever for RANGE_BREAK
th = db.conn.execute(
    "SELECT COUNT(*) c FROM trade_history WHERE strategy=%s",
    ('RANGE_BREAK',),
).fetchone()['c']
print('trade_history RANGE_BREAK total:', th)
PY

Check DB config and universe for range_break


[REDACTED]

python3 - <<'PY'
from database import TradeDB
from kis_trader.engine.range_break_engine import get_range_break_defaults_from_db

db = TradeDB()
cols = [r['Field'] for r in db.conn.execute('SHOW COLUMNS FROM config_range_break').fetchall()]
print('cols', cols[:8])

# find enabled key
rows = db.conn.execute('SELECT * FROM config_range_break LIMIT 3').fetchall()
if rows:
    print('sample keys', list(rows[0].keys())[:10])

# try env column name
for key_col in ['env_key','key','config_key','name']:
    if key_col in cols:
        q = f"SELECT {key_col}, config_value FROM config_range_break WHERE {key_col} IN (%s,%s,%s,%s)"
        try:
            r = db.conn.execute(q, ('STRATEGY_RANGE_BREAK_ENABLED','RANGE_BREAK_TIME_START','RANGE_BREAK_TIME_END_HM','RANGE_BREAK_LIVE_BACKTEST_ALIGN')).fetchall()
            print('using', key_col, r)
            break
        except Exception as e:
            print(key_col, e)

defs = get_range_break_defaults_from_db()
print('defaults:', {k: defs.get(k) for k in ['time_start_hm','time_end_hm','box_lookback_min','vol_mult','live_backtest_align','backtest_use_tick_exit','slot_money','max_stocks','total_budget_krw']})

n = db.conn.execute("SELECT COUNT(*) c FROM target_candidates_history WHERE strategy_id=%s AND slot_key LIKE %s", ('RANGE_BREAK','20260812%')).fetchone()['c']
n2 = db.conn.execute("SELECT COUNT(*) c FROM target_candidates_history WHERE strategy_id=%s AND slot_key LIKE %s", ('BREAKOUT','20260812%')).fetchone()['c']
print('universe RANGE_BREAK', n, 'BREAKOUT', n2)

th = db.conn.execute("SELECT COUNT(*) c FROM trade_history WHERE strategy=%s", ('RANGE_BREAK',)).fetchone()['c']
print('live trades RANGE_BREAK ever:', th)
PY

Check config_range_break schema and values


[REDACTED]

python3 - <<'PY'
from database import TradeDB
db = TradeDB()
row = db.conn.execute('SELECT STRATEGY_RANGE_BREAK_ENABLED, RANGE_BREAK_LIVE_BACKTEST_ALIGN, RANGE_BREAK_TIME_START, RANGE_BREAK_TIME_END_HM, RANGE_BREAK_BOX_LOOKBACK_MIN, RANGE_BREAK_VOL_MULT, RANGE_BREAK_UNIVERSE_SOURCE FROM config_range_break ORDER BY id DESC LIMIT 1').fetchone()
print(dict(row))
PY

Read latest config_range_break row


[REDACTED]

python3 - <<'PY'
import json
from pathlib import Path
d = json.loads(Path('/home/hoon/kis_bot/logs/bt_20260812_1300_range_break_kiwoom.json').read_text())
p = d.get('params') or {}
print('universe_source', p.get('universe_source'))
print('universe_history_slots', p.get('universe_history_slots'))
print('codes_analyzed', p.get('codes_analyzed'))
print('universe', p.get('universe'))
sm = d.get('summary') or {}
print('backtest_buy_source', sm.get('backtest_buy_source'))
PY

Check range_break backtest universe metadata


**박스권돌파(RANGE_BREAK)**는 “코드가 완전히 깨졌다”기보다 **백테만 돌아가고, 실매·정합은 아직 미완** 상태에 가깝습니다.

---

## 한 줄 요약

| 구분 | 상태 |
|------|------|
| **실매** | ❌ 꺼져 있음 (`STRATEGY_RANGE_BREAK_ENABLED=false`, 실거래 0건) |
| **백테 엔진** | ⚠ 돌아감 (오늘 13시~ 6건) |
| **실매와 같은 조건인지** | ❌ 유니버스·캔들 소스 등 갭 큼 |
| **Optuna** | ❌ 미연동 (Grid CLI만) |
| **웹 탭** | ⚠ 구현됐지만 nav `display:none` 숨김 |

---

## 오늘 6건이 의미하는 것

**실매가 아니라 백테 결과**입니다.

- `trade_history`에 `RANGE_BREAK` **0건**
- 로그: `/home/hoon/kis_bot/logs/bt_20260812_1300_range_break_kiwoom.json`
- 전량 **`box_fail`** (박스 상단 재이탈 손절) — 엔진에 정의된 **정상 청산 사유**

```260:277:kis_trader/engine/range_break_engine.py
def check_sell_signal_range_break_live(...):
    """박스 상단 재이탈 우선 → breakout 청산 체인."""
    ...
    if box_line > 0 and lo < box_line:
        return ("box_fail", min(cl, box_line))
```

6건 모두 손실(-7,060원, 승률 0%) → **오후 휩쏘(가짜 돌파)** 패턴으로 보는 게 자연스럽고, 버그라기보다 **전략·파라미터·유니버스** 이슈 쪽입니다.

---

## “제대로”라고 보기 어려운 이유 (중요)

### 1) 실매 자체가 한 번도 안 돌았음
- DB: `STRATEGY_RANGE_BREAK_ENABLED = false`
- `trade_history` RANGE_BREAK **누적 0건**
- `main.py`에 등록 코드는 있으나, ON이 아니면 `RangeBreakStrategy`가 안 붙음

### 2) 유니버스 이력 없음 → 백테가 “전 종목”으로 훑음
- 오늘 `target_candidates_history` WHERE `strategy_id='RANGE_BREAK'`: **0건**
- BREAKOUT은 **4,371건**
- 백테 메타: `universe=history` 요청했지만 → **`universe_source=all`, 367종목** (fallback)

→ HTS 조건식으로 좁힌 실매 유니버스와 **다른 풀**에서 6건이 나온 것입니다.

### 3) 키움 캔들/틱/호가 선택이 **range_break API에 미반영**
돌파·모멘텀 등은 `candle_source=kiwoom` 반영하는데, **박스권돌파 API는 아직 없음**.

- `backtest_web.py`의 `/api/backtest/range_break`가 `ws_candles`를 **source 필터 없이** 조회
- `bt_candle_source.py` **미연동**
- 방금 `candle_source=kiwoom` 넣어도 **실제로는 무시**됐을 가능성 큼

호가(`exit_ob`)는 `_finalize_trades_ui` enrich로 **표시는 됨** — 엔진 진입 필터와는 별개.

### 4) 돌파(breakout) 대비 빠진 것
- Optuna 없음
- 진입 호가필터(ob_filter) 없음
- 웹 탭 숨김 + 대시보드 집계 제외
- UPDOW(박스엔진)와 **별개 전략** — 혼동 주의

---

## 잘 되어 있는 부분

- **live ↔ backtest 공통 엔진** (`range_break_engine.py`)
- **T−1 신호 / T 진입** (`live_backtest_align` 기본 ON)
- **청산** — box_fail → breakout 체인(익절·어깨·손절·트레일·EOD) 재사용
- **틱 청산** — `load_breakout_ticks_by_code` + `ws_ticks` (오늘 `backtest_buy_source=ws_ticks`)
- **웹 API·JS·DB config** (`config_range_break`) 골격은 있음
- **Grid 파람** — `param_search_range_break.py`

---

## 판단

> “오래 안 만져서 제대로 되는지 모르겠다” → **맞는 불안**

- **백테가 숫자를 내는 것**과 **실매 켜도 되는 것**은 다릅니다.
- 오늘 6건은 “키움 13시~ 호가 표시 테스트”에는 쓸 만하지만, **실매 정합 검증으로는 부족**합니다.

---

## 실매 전에 손봐야 할 것 (우선순위)

1. **`bt_candle_source` + tick/ob source** — range_break 백테 API에 연동 (다른 전략과 동일)
2. **RANGE_BREAK 유니버스 이력 적재** — SCAN이 `strategy_id=RANGE_BREAK`로 history 저장되는지
3. **Optuna 그리드** (원하면) + 웹 탭 노출
4. **긴 기간 백테** — universe history 채운 뒤, 돌파와 동일 기간 비교
5. 그다음 **`STRATEGY_RANGE_BREAK_ENABLED=true`** — 페이퍼/소액으로 실매 스모크

---

원하시면 다음 중 하나부터 진행할 수 있습니다.

1. **range_break만** 캔들/틱/호가 소스 + `bt_candle_source` 정합 패치
2. **유니버스 이력 0건** 원인 추적 (SCAN → history 적재 경로)
3. **최근 1~2주 백테**로 box_fail 비율·파라미터 sanity check

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

[REDACTED]


<timestamp>Wednesday, Aug 12, 2026, 4:38 PM (UTC+9)</timestamp>
<user_query>
글머 이걸 지금 잘 돌고있는 전략이랑 똑같이 복원하고 옵투나 추가하면 조건식 따로 하나 만들어야되나 그리고 매매빈도는 다른 전략대비 어때? 많아 적어? 그리고 온르 장이 그 5만 수익 본날보다 좋았던거같은데 매매가 백테로 한개도 안잡히다니 뭔가 잘못된거 아닌가이거 세시간이면 한건은 있을법도 한데
</user_query>


좋은 포인트입니다. 바로 감으로 답하지 않고, **(1) 옵투나 추가 시 조건식 분리 필요 여부**, **(2) 전략별 매매빈도 비교**, **(3) 오늘 13~15시 0건이 비정상인지**를 데이터로 짧게 검증해서 결론 드리겠습니다.

[REDACTED]

python3 - <<'PY'
from database import TradeDB
from collections import Counter, defaultdict

db = TradeDB()
# schema check first
cols = [r['Field'] for r in db.conn.execute('SHOW COLUMNS FROM trade_history').fetchall()]
print('trade_history_cols', cols)

# recent 20 trading days-ish by date in table
rows = db.conn.execute("SELECT DATE(sell_date) d, strategy, COUNT(*) n, SUM(realized_pnl) pnl FROM trade_history WHERE sell_date >= %s GROUP BY DATE(sell_date), strategy ORDER BY d DESC", ('2026-07-15',)).fetchall()
print('rows', len(rows))
# aggregate by strategy
taggr = defaultdict(lambda:[0,0.0, set()])
for r in rows:
    s = r['strategy']; n=int(r['n'] or 0); p=float(r['pnl'] or 0.0); d=str(r['d'])
    taggr[s][0]+=n; taggr[s][1]+=p; taggr[s][2].add(d)
print('strategy_summary_since_2026-07-15')
for s,(n,p,ds) in sorted(taggr.items(), key=lambda x: x[1][0], reverse=True):
    print(s, 'trades', n, 'days', len(ds), 'avg/day', round(n/max(1,len(ds)),2), 'pnl', round(p,1))

# today only
rows2 = db.conn.execute("SELECT strategy, COUNT(*) n, SUM(realized_pnl) pnl FROM trade_history WHERE sell_date >= %s AND sell_date < %s GROUP BY strategy ORDER BY n DESC", ('2026-08-12 00:00:00','2026-08-13 00:00:00')).fetchall()
print('today_2026-08-12')
for r in rows2:
    print(r['strategy'], int(r['n'] or 0), float(r['pnl'] or 0.0))

# 13:00~15:30 window today
rows3 = db.conn.execute("SELECT strategy, COUNT(*) n FROM trade_history WHERE sell_date >= %s AND sell_date < %s GROUP BY strategy ORDER BY n DESC", ('2026-08-12 13:00:00','2026-08-12 15:30:00')).fetchall()
print('today_1300_1530_sells')
for r in rows3:
    print(r['strategy'], int(r['n'] or 0))
PY

Compute trade frequency by strategy from trade_history

python3 - <<'PY'
from database import TradeDB

db = TradeDB()
# target candidates today per strategy_id after 13:00
cols = [r['Field'] for r in db.conn.execute('SHOW COLUMNS FROM target_candidates_history').fetchall()]
print('tch_cols', cols)
rows = db.conn.execute('''
SELECT strategy_id, COUNT(*) n
FROM target_candidates_history
WHERE slot_key >= %s AND slot_key <= %s
GROUP BY strategy_id
ORDER BY n DESC
''', ('202608121300','202608121530')).fetchall()
print('candidates_1300_1530')
for r in rows:
    print((r['strategy_id'] or 'NULL'), int(r['n'] or 0))

# momentum / breakout / range_break specific counts
for sid in ['MOMENTUM','BREAKOUT','RANGE_BREAK','TAIL','SCALP']:
    c = db.conn.execute('SELECT COUNT(*) c FROM target_candidates_history WHERE strategy_id=%s AND slot_key >= %s AND slot_key <= %s', (sid,'202608121300','202608121530')).fetchone()['c']
    print('sid', sid, int(c or 0))
PY

Check candidate history counts by strategy_id


[REDACTED]

python3 - <<'PY'
from database import TradeDB

db = TradeDB()
rows = db.conn.execute('''
SELECT DATE(sell_date) d, strategy, ROUND(SUM(realized_pnl),0) pnl, COUNT(*) n
FROM trade_history
WHERE sell_date >= %s
GROUP BY DATE(sell_date), strategy
HAVING SUM(realized_pnl) > %s
ORDER BY pnl DESC
LIMIT 20
''', ('2026-07-01 00:00:00', 40000)).fetchall()
print('days_pnl_gt_40k')
for r in rows:
    print(r['d'], r['strategy'], int(r['pnl']), int(r['n']))
PY

Find recent >40k profit strategy-days


맞습니다. 지금 상태는 **“뭔가 잘못된 느낌”이 드는 게 정상**입니다.
데이터로 보면 원인이 꽤 명확합니다.

- 오늘 13:00~15:30 후보 수:
  - `MOMENTUM 3,848`
  - `BREAKOUT 464`
  - `SCALP 14,247`
  - **`RANGE_BREAK 0`**
- RANGE_BREAK 백테 6건은 있었는데, 이건 `universe_source=all` fallback(전 종목)에서 나온 값입니다.
  즉 **전략 전용 후보 이력 없이 임시로 전종목 훑어서 나온 거래**예요.

---

질문별로 바로 답하면:

## 1) “잘 도는 전략처럼 복원 + Optuna 추가” 시 조건식 따로 필요?

**필수는 아닙니다.**
먼저는 `BREAKOUT`처럼 **같은 HTS 풀(예: momentum 조건)**을 쓰되,
`strategy_id=RANGE_BREAK`로 후보 이력 저장/조회만 분리하면 됩니다.

- 조건식 새로 만드는 건 **2단계(선택)**:
  - 1차: 기존 풀 재사용 + TRIGGER(박스권 돌파)로 차별화
  - 2차: 후보가 너무 많거나 질이 나쁘면 HTS 조건식 별도 분리

즉, **Optuna 추가를 위해서 조건식 분리가 선행 필수는 아님**입니다.

---

## 2) 매매빈도: 다른 전략 대비 많아/적어?

최근(7/15~) 실매 체결 기준 평균:

- `BREAKOUT` 약 **11.9건/일**
- `MOMENTUM` 약 **10.5건/일**
- `SCALP` 약 **6.9건/일**
- `SHORT` 약 **3.9건/일**
- `US_MOMENTUM` 약 **3.1건/일**
- `RANGE_BREAK`는 현재 실매 OFF라 비교 불가(누적 0건)

일반적으로 RANGE_BREAK는 설계상 **BREAKOUT보다 적거나 비슷**,
시장/파라미터에 따라 SCALP보다 적은 쪽이 보통입니다.

---

## 3) “오늘 장이 좋았는데 3시간 0건이면 이상 아닌가?”

부분적으로 **맞는 의심**입니다. 다만 “시장 탓 100%”가 아니라 **구조 이슈가 큽니다**.

- 오늘 +5만 난 날은 `2026-08-06 SCALP +55,061` (17건)
- 오늘 13~15:30 실매 신규매수는 거의 없고(주로 기존 포지션 정리),
- RANGE_BREAK는 애초에 실매 OFF + 후보이력 0 + 백테 소스 정합 갭이 있어서
  **“0건이 진짜 전략 성능인지”를 아직 판단할 수 없는 상태**입니다.

즉, “3시간이면 1건은 나와야” 감각이 틀린 게 아니라,
현재는 **전략이 제대로 측정될 조건이 아직 안 맞아 있다**가 더 정확합니다.

---

원하면 다음 순서로 바로 복원 진행하겠습니다(안전 순서):

1. RANGE_BREAK 백테에 `bt_candle_source`/`candle_source,tick_source,ob_source` 정합 적용
2. 후보 이력 `strategy_id=RANGE_BREAK` 적재 경로 복구
3. Optuna 전략 추가(기존 4전략 방식 그대로)
4. 같은 기간으로 BREAKOUT vs RANGE_BREAK 빈도/손익 비교 리포트

진행 승인 주시면 1번부터 바로 들어가겠습니다.

[REDACTED]


<timestamp>Wednesday, Aug 12, 2026, 4:41 PM (UTC+9)</timestamp>
<user_query>
그럼 박스돌파는 잠시 두고 5만짜리 수익본날과 오늘의 코드를 비교해서 왜 옵투나에 한건도 안잡히는지 원인 찾아봐 근본적으로 뭔가 잘못된게 있을거야 분명
</user_query>


5만 수익 날(8/6)과 오늘(8/12)을 같은 조건으로 백테·Optuna·DB·유니버스·데이터 적재까지 나란히 비교해서, 0건이 전략 성능 문제인지 코드/설정 문제인지 근본 원인부터 찾겠습니다.

[REDACTED]

python3 - <<'PY'
from database import TradeDB
from collections import defaultdict

db = TradeDB()
for day in ['2026-08-06','2026-08-12']:
    print('='*20, day)
    rows = db.conn.execute('''
    SELECT strategy, COUNT(*) n, ROUND(SUM(realized_pnl),0) pnl,
           SUM(CASE WHEN sell_date >= %s AND sell_date < %s THEN 1 ELSE 0 END) aft13
    FROM trade_history
    WHERE sell_date >= %s AND sell_date < %s
    GROUP BY strategy ORDER BY n DESC
    ''', (f'{day} 13:00:00', f'{day} 15:30:00', f'{day} 00:00:00', f'{day} 23:59:59')).fetchall()
    for r in rows:
        print(dict(r))
    buys = db.conn.execute('''
    SELECT strategy, COUNT(*) n FROM trade_history
    WHERE buy_date >= %s AND buy_date < %s GROUP BY strategy ORDER BY n DESC
    ''', (f'{day} 00:00:00', f'{day} 23:59:59')).fetchall()
    print('buys:', {r['strategy']: int(r['n']) for r in buys})
PY

Compare live trades 8/6 vs 8/12 by strategy


[REDACTED]

python3 - <<'PY'
from database import TradeDB
db=TradeDB()
for day in ['20260806','20260812']:
    print('DAY', day)
    for tbl, src in [('ws_ticks','source'), ('ws_candles','source'), ('ws_orderbook','source')]:
        try:
            cols=[r['Field'] for r in db.conn.execute(f'SHOW COLUMNS FROM {tbl}').fetchall()]
            if 'source' not in cols:
                q=f"SELECT COUNT(*) c FROM {tbl} WHERE candle_time >= %s AND candle_time <= %s" if 'candle_time' in cols else f"SELECT COUNT(*) c FROM {tbl} WHERE tick_time >= %s AND tick_time <= %s"
                # generic
            else:
                if tbl=='ws_ticks':
                    q=f"SELECT source, COUNT(*) c FROM {tbl} WHERE tick_time >= %s AND tick_time <= %s GROUP BY source"
                    params=(day+'130000', day+'153000')
                elif tbl=='ws_candles':
                    q=f"SELECT source, COUNT(*) c FROM {tbl} WHERE timeframe=1 AND candle_time >= %s AND candle_time <= %s GROUP BY source"
                    params=(day+'130000', day+'153000')
                else:
                    q=f"SELECT source, COUNT(*) c FROM {tbl} WHERE ob_time >= %s AND ob_time <= %s GROUP BY source"
                    params=(day+'130000', day+'153000')
                rows=db.conn.execute(q, params).fetchall()
                print(' ', tbl, dict((r['source'], int(r['c'])) for r in rows))
        except Exception as e:
            print(' ', tbl, 'ERR', e)
    # scalp candidates
    c=db.conn.execute("SELECT COUNT(*) c FROM target_candidates_history WHERE strategy_id='SCALP' AND slot_key >= %s AND slot_key <= %s", (day+'1300', day+'1530')).fetchone()['c']
    print(' scalp candidates 13-1530', int(c))
PY

Compare tick/candle/ob data 13-1530 on 8/6 vs 8/12


[REDACTED]

for day in 2026-08-06 2026-08-12; do
  out="/home/hoon/kis_bot/logs/scalp_bt_${day//-/}_1300_kiwoom.json"
  curl -s -G "http://127.0.0.1:5050/api/backtest/scalping" \
    --data-urlencode "start=${day}" \
    --data-urlencode "end=${day}" \
    --data-urlencode "time_start=1300" \
    --data-urlencode "time_end=1530" \
    --data-urlencode "candle_source=kiwoom" \
    --data-urlencode "tick_source=kiwoom" \
    --data-urlencode "ob_source=kiwoom_0d" \
    -o "$out"
  python3 - <<PY
import json
from pathlib import Path
p=Path('$out')
d=json.loads(p.read_text())
sm=d.get('summary',{})
tr=d.get('trades') or []
print('$day','trades',sm.get('total_trades',len(tr)),'pnl',sm.get('total_pnl'),'wr',sm.get('win_rate'))
for t in tr[:5]:
 print(' ',t.get('buy_time'),t.get('code'),t.get('sell_reason') or t.get('reason'),t.get('pnl'))
PY
done

Run scalp backtest 13-1530 for 8/6 vs 8/12