## 근본원인 bt_candle_source._load_ticks_by_code_bulk / _ticks_for_bar_garbage 가 자체 SELECT 로 recv_ts/tick_time_raw 미포함 → bar_is_garbage 가 _lag_sec/recv_ts 없이 봉끝 폴백 → WS 봉의 76% 대량 컷. 엔진 로더(breakout_tick_loader.load_common_ticks_by_code) 는 이미 recv_ts + _lag_sec 완비였지만 두 로더가 발산 상태 → 이번에 사고 촉발. ## 수정 (C안: 로더 통일) - _load_ticks_by_code_bulk: breakout_tick_loader._fetch_ws_ticks_day_rows 위임. flat 리턴은 _ingest_flat_tick_rows (신설) 로 어댑팅. recv_ts + _lag_sec 모두 포함 → 엔진 로더와 100% 동일 SELECT/계산. - _ticks_for_bar_garbage: 단일 종목용으로 _load_ticks_by_code_bulk (codes_filter=[code]) 재사용. 별도 SELECT 제거. ## 검증 Optuna scalp TPE 10 trials · 2026-09-04 · Python 엔진: - Before: 슬롯 63671 · 쓰레기스킵 14495 (22.8%) · hole 7.4% · kis/ws pick 11% - After: 슬롯 63671 · 쓰레기스킵 3324 ( 5.2%) · hole 3.7% · kis/ws pick 20% 실매 스모크 (scripts/test_live_execution_validation.py): 최종 통과 · 완결 로그: logs/test_live_execution_validation_20260906_201535.log ## 문서 (rust_engine_parity_port_plan.md) - 부록 A: A6~A11 사전 정지 작업 추가 (wall-clock 정합·로더 통일·파케이·게이팅·뱃지) - 부록 C (신설): D안 로드맵 — Rust 세션이 현재 봉만 받고 틱/호가/프로그램매매 미전달. Python 정합 개선을 받아도 Rust 는 여전히 데이터 절반짜리 상태. P0 최우선 6단계. ## 룰 - CRITICAL §0 (봉 freeze · OHLC 폴백 금지) 준수 - 룰 19 (근본원인 딥다이브 · 3안 선보고) — B/C/D 안 제시 후 사용자 C안 선택 - 룰 25-1 (교차검증) — 두 로더 발산 재발 방지 Co-authored-by: Cursor <cursoragent@cursor.com>
495 lines
16 KiB
Python
495 lines
16 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
백테·Optuna ws_candles 소스 선택 — 실매 get_candles 와 동일.
|
|
|
|
읽기: 틱 메인 WS 한 칸, 없으면 kiwoom+rest 구멍, 그다음 kiwoom+rollup.
|
|
CANDLE_SOURCE=kis|kiwoom 이면 그 메인(+구멍). 빈값이면 LIVE_TICK_PROVIDER.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import time
|
|
from collections import defaultdict
|
|
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
|
|
|
from kis_trader.ws.candle_series import (
|
|
dedupe_by_read_pairs,
|
|
live_read_label,
|
|
live_read_pairs,
|
|
normalize_source_channel,
|
|
)
|
|
|
|
ReadPair = Tuple[str, str]
|
|
|
|
logger = logging.getLogger("bt_candle_source")
|
|
|
|
# 레거시 호환 이름 (실제 필터는 live_read_pairs)
|
|
BT_WS_CANDLE_SOURCES: Tuple[str, ...] = ("kiwoom", "kis", "rest", "rollup_1m")
|
|
|
|
|
|
def resolve_bt_candle_source_override() -> str:
|
|
"""'' = LIVE 병합, 'kis'|'kiwoom' = 그 메인(+키움 REST 구멍)."""
|
|
from kis_trader.ws.candle_series import _provider_and_override
|
|
_provider, override = _provider_and_override()
|
|
return override
|
|
|
|
|
|
def live_candle_source_order() -> Tuple[str, ...]:
|
|
"""호환: source 문자열만. 실제 조회는 resolve_bt_read_pairs."""
|
|
return tuple(dict.fromkeys(p[0] for p in live_read_pairs()))
|
|
|
|
|
|
def resolve_bt_read_pairs() -> Tuple[ReadPair, ...]:
|
|
return live_read_pairs()
|
|
|
|
|
|
def resolve_bt_candle_source_order() -> Tuple[str, ...]:
|
|
"""호환 래퍼 — 신규 코드는 resolve_bt_read_pairs 사용."""
|
|
return live_candle_source_order()
|
|
|
|
|
|
def resolve_bt_candle_source_label() -> str:
|
|
return live_read_label()
|
|
|
|
|
|
def dedupe_candle_rows(
|
|
rows: Sequence[Dict[str, Any]],
|
|
source_order: Optional[Sequence[Any]] = None,
|
|
) -> List[Dict[str, Any]]:
|
|
"""candle_time 1행. source_order 가 쌍이면 그대로, 아니면 live_read_pairs."""
|
|
pairs: Optional[Sequence[ReadPair]] = None
|
|
if source_order:
|
|
first = source_order[0]
|
|
if isinstance(first, (tuple, list)) and len(first) >= 2:
|
|
pairs = tuple((str(a), str(b)) for a, b in source_order) # type: ignore[misc]
|
|
else:
|
|
# 레거시 source 문자열 → 정규화 후 순위
|
|
mapped: List[ReadPair] = []
|
|
for s in source_order:
|
|
mapped.append(normalize_source_channel(str(s), ""))
|
|
pairs = tuple(mapped)
|
|
return dedupe_by_read_pairs(rows, pairs)
|
|
|
|
|
|
def _pairs_in_sql(pairs: Sequence[ReadPair]) -> Tuple[str, List[str]]:
|
|
parts: List[str] = []
|
|
params: List[str] = []
|
|
for src, ch in pairs:
|
|
parts.append("(source=%s AND channel=%s)")
|
|
params.extend([src, ch])
|
|
return " AND (" + " OR ".join(parts) + ")", params
|
|
|
|
|
|
def _tick_time_bounds(start_key: str, end_key: str) -> Tuple[str, str]:
|
|
sk = str(start_key or "").strip()
|
|
ek = str(end_key or "").strip()
|
|
if len(sk) == 12:
|
|
sk = sk + "00"
|
|
if len(ek) == 12:
|
|
ek = ek + "59"
|
|
return sk, ek
|
|
|
|
|
|
def _ws_ticks_table(market: Optional[str]) -> str:
|
|
mk = (market or "").strip().upper()
|
|
if mk == "US":
|
|
return "ws_ticks_us"
|
|
return "ws_ticks"
|
|
|
|
|
|
def _ingest_flat_tick_rows(
|
|
rows: List[Dict[str, Any]],
|
|
target: Dict[str, List[Dict[str, Any]]],
|
|
) -> int:
|
|
"""SELECT 행 → 종목별 flat 리스트 (봉 pick 쓰레기검사용).
|
|
|
|
``breakout_tick_loader._ingest_tick_rows`` 는 minute_key 버킷 딕트를 만들지만,
|
|
bt_candle_source 소비자 (``dedupe_by_read_pairs``) 는 flat 리스트를 원한다.
|
|
→ 같은 컬럼 (``recv_ts`` + ``_lag_sec``) 을 유지하되 리턴 형식만 다르게.
|
|
"""
|
|
from kis_trader.backtest.breakout_tick_loader import _tick_row_lag_seconds
|
|
|
|
n = 0
|
|
for r in rows or []:
|
|
code = str(r.get("code") or "").strip()
|
|
if not code:
|
|
continue
|
|
tt = str(r.get("tick_time") or "")[:14]
|
|
if len(tt) < 12:
|
|
continue
|
|
kw_lag = _tick_row_lag_seconds(r)
|
|
# recv_ts: bar_is_garbage 가 wall-clock 정합 판정에 사용 (docs/정합성.md §9)
|
|
# 문자열로 보존. None 이면 봉끝 폴백.
|
|
_recv_raw = r.get("recv_ts")
|
|
_recv_str = ""
|
|
if _recv_raw is not None:
|
|
try:
|
|
_recv_str = (
|
|
_recv_raw.strftime("%Y-%m-%d %H:%M:%S")
|
|
if hasattr(_recv_raw, "strftime")
|
|
else str(_recv_raw)
|
|
)
|
|
except Exception:
|
|
_recv_str = str(_recv_raw)
|
|
target[code].append({
|
|
"code": code,
|
|
"tick_time": tt,
|
|
"source": r.get("source") or "",
|
|
"tick_time_raw": str(r.get("tick_time_raw") or ""),
|
|
"_lag_sec": kw_lag,
|
|
"recv_ts": _recv_str,
|
|
})
|
|
n += 1
|
|
return n
|
|
|
|
|
|
def _load_ticks_by_code_bulk(
|
|
db,
|
|
start_key: str,
|
|
end_key: str,
|
|
*,
|
|
market: Optional[str] = None,
|
|
codes_filter: Optional[Sequence[str]] = None,
|
|
) -> Optional[Dict[str, List[Dict[str, Any]]]]:
|
|
"""쓰레기 검사용 틱 — 로더 통일 (2026-09-06 C안).
|
|
|
|
이전(자체 SELECT): ``tick_time, source, tick_time_raw`` 만 → recv_ts/_lag_sec 미포함
|
|
→ ``bar_is_garbage`` 가 봉끝 폴백 → **22.8% 대량 컷** (실매와 다른 기준).
|
|
|
|
현재: ``breakout_tick_loader._fetch_ws_ticks_day_rows`` 위임 →
|
|
엔진 로더와 **동일 SELECT + 동일 lag 계산 + 동일 recv_ts** →
|
|
쓰레기 판정도 wall-clock 정합.
|
|
|
|
- KR → ``ws_ticks`` / US → ``ws_ticks_us``
|
|
- OFF 면 None (봉만 dedupe)
|
|
- 리턴 형식은 flat ``Dict[code, List[tick]]`` (bt_candle_source 소비자 호환)
|
|
"""
|
|
from kis_trader.engine.feed_fallback import candle_garbage_fallback_enabled
|
|
|
|
if not candle_garbage_fallback_enabled():
|
|
return None
|
|
|
|
from kis_trader.backtest.breakout_tick_loader import (
|
|
_candle_keys_to_tick_range,
|
|
_fetch_ws_ticks_day_rows,
|
|
_iter_tick_day_chunks,
|
|
_ws_ticks_table as _bl_ws_ticks_table,
|
|
)
|
|
|
|
mk = (market or "").strip().upper()
|
|
tt_start, tt_end = _candle_keys_to_tick_range(start_key, end_key)
|
|
|
|
want: Optional[set] = None
|
|
if codes_filter:
|
|
want = {str(c).strip() for c in codes_filter if str(c).strip()}
|
|
|
|
out: Dict[str, List[Dict[str, Any]]] = defaultdict(list)
|
|
|
|
def _pull(table: str, mkt_str: str) -> int:
|
|
total = 0
|
|
for chunk_s, chunk_e in _iter_tick_day_chunks(tt_start, tt_end):
|
|
try:
|
|
rows = _fetch_ws_ticks_day_rows(table, mkt_str, chunk_s, chunk_e, want)
|
|
except Exception as e:
|
|
logger.warning(
|
|
"쓰레기검사 틱 로드 실패 (table=%s day=%s): %s",
|
|
table, chunk_s[:8], e,
|
|
)
|
|
continue
|
|
total += _ingest_flat_tick_rows(rows, out)
|
|
return total
|
|
|
|
try:
|
|
n_kr = n_us = 0
|
|
if mk == "US":
|
|
n_us = _pull(_bl_ws_ticks_table("US"), "US")
|
|
elif mk == "KR":
|
|
n_kr = _pull(_bl_ws_ticks_table("KR"), "KR")
|
|
else:
|
|
n_kr = _pull(_bl_ws_ticks_table("KR"), "KR")
|
|
try:
|
|
n_us = _pull(_bl_ws_ticks_table("US"), "US")
|
|
except Exception:
|
|
n_us = 0
|
|
logger.info("📥 틱 bulk 쓰레기검사용: KR=%s US=%s 종목=%s", n_kr, n_us, len(out))
|
|
return dict(out)
|
|
except Exception as exc:
|
|
logger.warning("틱 bulk 스킵(봉만): %s", exc)
|
|
return None
|
|
|
|
|
|
def _ticks_for_bar_garbage(
|
|
db,
|
|
code: str,
|
|
start_key: str,
|
|
end_key: str,
|
|
*,
|
|
market: Optional[str] = None,
|
|
) -> Optional[List[Dict[str, Any]]]:
|
|
"""단일 종목 쓰레기 검사용 틱 — 로더 통일 (bulk 재사용, 2026-09-06 C안).
|
|
|
|
이전엔 별도 SELECT (``tick_time, source, tick_time_raw`` 만) → recv_ts/_lag_sec 없음.
|
|
이제 ``_load_ticks_by_code_bulk`` (breakout_tick_loader 위임) 를 코드 1개로 호출 →
|
|
동일 정합.
|
|
"""
|
|
from kis_trader.engine.feed_fallback import candle_garbage_fallback_enabled
|
|
|
|
if not candle_garbage_fallback_enabled():
|
|
return None
|
|
if not str(code or "").strip():
|
|
return None
|
|
result = _load_ticks_by_code_bulk(
|
|
db, start_key, end_key, market=market, codes_filter=[str(code).strip()],
|
|
)
|
|
if result is None:
|
|
return None
|
|
return result.get(str(code).strip(), [])
|
|
|
|
|
|
def list_ws_candle_codes(
|
|
db,
|
|
timeframe: int,
|
|
start_key: str,
|
|
end_key: str,
|
|
*,
|
|
market: Optional[str] = None,
|
|
) -> List[str]:
|
|
"""기간 내 종목 코드 — 선택된 (source,channel) 기준 DISTINCT."""
|
|
pairs = resolve_bt_read_pairs()
|
|
src_sql, src_params = _pairs_in_sql(pairs)
|
|
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]]:
|
|
"""단일 종목·기간 봉 로드 — 메인 WS 우선, 구멍 kiwoom+rest(+rollup)."""
|
|
pairs = resolve_bt_read_pairs()
|
|
confirmed_sql = " AND is_confirmed=1" if confirmed_only else ""
|
|
mk = (market or "").strip().upper()
|
|
src_sql, src_params = _pairs_in_sql(pairs)
|
|
cols = (
|
|
f"candle_time, open, high, low, close, volume, source, channel"
|
|
f"{peak_sel}{extra_select}"
|
|
)
|
|
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()
|
|
ticks = _ticks_for_bar_garbage(
|
|
db, str(code), start_key, end_key, market=mk or None,
|
|
)
|
|
return dedupe_by_read_pairs(
|
|
[dict(r) for r in rows], pairs, ticks=ticks, tf_min=int(timeframe),
|
|
missing_policy="hole", live_cover=False,
|
|
)
|
|
|
|
|
|
def fetch_ws_candles_by_code_bulk(
|
|
db,
|
|
timeframe: int,
|
|
start_key: str,
|
|
end_key: str,
|
|
*,
|
|
extra_select: str = "",
|
|
peak_sel: str = "",
|
|
market: Optional[str] = None,
|
|
confirmed_only: bool = True,
|
|
codes_filter: Optional[Sequence[str]] = None,
|
|
) -> Dict[str, List[Dict[str, Any]]]:
|
|
"""기간 전체 봉 1쿼리 + 틱 1~2쿼리 후 종목별 기존 쓰레기 검사.
|
|
|
|
웜업용 ``fetch_ws_candles_for_code`` / ``fetch_ws_candles_warmup_before`` 는 그대로.
|
|
"""
|
|
t0 = time.perf_counter()
|
|
pairs = resolve_bt_read_pairs()
|
|
confirmed_sql = " AND is_confirmed=1" if confirmed_only else ""
|
|
mk = (market or "").strip().upper()
|
|
src_sql, src_params = _pairs_in_sql(pairs)
|
|
cols = (
|
|
f"code, candle_time, open, high, low, close, volume, source, channel"
|
|
f"{peak_sel}{extra_select}"
|
|
)
|
|
want: List[str] = []
|
|
if codes_filter:
|
|
want = [
|
|
str(c).strip()
|
|
for c in codes_filter
|
|
if str(c).strip()
|
|
]
|
|
in_sql = ""
|
|
in_params: List[str] = []
|
|
if want:
|
|
in_sql = " AND code IN (" + ",".join(["%s"] * len(want)) + ")"
|
|
in_params = want
|
|
|
|
params: List[Any] = [int(timeframe)]
|
|
mk_sql = ""
|
|
if mk:
|
|
mk_sql = " AND market=%s"
|
|
params.append(mk)
|
|
params.extend([start_key, end_key, *src_params, *in_params])
|
|
|
|
rows = db.conn.execute(
|
|
f"SELECT {cols} FROM ws_candles "
|
|
"WHERE timeframe=%s"
|
|
+ mk_sql
|
|
+ " AND candle_time >= %s AND candle_time <= %s"
|
|
+ confirmed_sql
|
|
+ src_sql
|
|
+ in_sql
|
|
+ " ORDER BY code ASC, candle_time ASC",
|
|
params,
|
|
).fetchall()
|
|
|
|
by_code: Dict[str, List[Dict[str, Any]]] = defaultdict(list)
|
|
for r in rows or []:
|
|
code = str(r.get("code") or "").strip()
|
|
if not code:
|
|
continue
|
|
by_code[code].append(dict(r))
|
|
|
|
ticks_map = _load_ticks_by_code_bulk(
|
|
db, start_key, end_key, market=mk or None, codes_filter=want or None,
|
|
)
|
|
out: Dict[str, List[Dict[str, Any]]] = {}
|
|
stats: Dict[str, Any] = {
|
|
"slots": 0, "picked": 0, "hole": 0, "garbage_skip": 0,
|
|
"pick_by": {}, "garbage_by": {}, "raw_by": {},
|
|
}
|
|
for code, raw in by_code.items():
|
|
ticks = None if ticks_map is None else ticks_map.get(code, [])
|
|
out[code] = dedupe_by_read_pairs(
|
|
raw, pairs, ticks=ticks, tf_min=int(timeframe),
|
|
missing_policy="hole", live_cover=False, stats=stats,
|
|
)
|
|
elapsed = time.perf_counter() - t0
|
|
n_tick_rows = 0
|
|
if ticks_map:
|
|
n_tick_rows = sum(len(v) for v in ticks_map.values())
|
|
slots = int(stats.get("slots") or 0) or 1
|
|
pick_by = stats.get("pick_by") or {}
|
|
garb_by = stats.get("garbage_by") or {}
|
|
raw_by = stats.get("raw_by") or {}
|
|
pick_txt = " ".join(
|
|
"%s=%s(%.0f%%)" % (k, v, 100.0 * int(v) / max(1, int(stats.get("picked") or 1)))
|
|
for k, v in sorted(pick_by.items(), key=lambda x: -int(x[1]))
|
|
) or "—"
|
|
garb_txt = " ".join(
|
|
"%s=%s" % (k, v) for k, v in sorted(garb_by.items(), key=lambda x: -int(x[1]))
|
|
) or "0"
|
|
raw_txt = " ".join(
|
|
"%s=%s" % (k, v) for k, v in sorted(raw_by.items(), key=lambda x: -int(x[1]))
|
|
) or "—"
|
|
logger.info(
|
|
"📂 캔들 bulk: 봉 %s행 · 틱 %s행 · 종목 %s · %.2fs (tf=%s market=%s)",
|
|
len(rows or []),
|
|
n_tick_rows,
|
|
len(out),
|
|
elapsed,
|
|
int(timeframe),
|
|
mk or "ALL",
|
|
)
|
|
logger.info(
|
|
"📊 봉 pick 비율(1차→2차→REST): 슬롯 %s · pick %s · hole %s(%.1f%%) · "
|
|
"쓰레기스킵 %s(슬롯대비 %.1f%%) | raw[%s] | pick[%s] | garbage[%s]",
|
|
slots,
|
|
stats.get("picked"),
|
|
stats.get("hole"),
|
|
100.0 * int(stats.get("hole") or 0) / slots,
|
|
stats.get("garbage_skip"),
|
|
100.0 * int(stats.get("garbage_skip") or 0) / slots,
|
|
raw_txt,
|
|
pick_txt,
|
|
garb_txt,
|
|
)
|
|
return out
|
|
|
|
|
|
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 []
|
|
pairs = resolve_bt_read_pairs()
|
|
confirmed_sql = " AND is_confirmed=1" if confirmed_only else ""
|
|
fetch_limit = max(int(limit) * max(len(pairs), 1), int(limit) + 50)
|
|
src_sql, src_params = _pairs_in_sql(pairs)
|
|
cols = (
|
|
f"candle_time, open, high, low, close, volume, source, channel"
|
|
f"{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
|
|
+ src_sql
|
|
+ " ORDER BY candle_time DESC LIMIT %s",
|
|
[int(timeframe), code, before_candle_time, *src_params, fetch_limit],
|
|
).fetchall()
|
|
raw_rows = [dict(r) for r in reversed(rows)]
|
|
t0 = str(raw_rows[0].get("candle_time") or "")[:12] if raw_rows else ""
|
|
ticks = (
|
|
_ticks_for_bar_garbage(db, str(code), t0, str(before_candle_time))
|
|
if t0 else None
|
|
)
|
|
bars = dedupe_by_read_pairs(
|
|
raw_rows, pairs,
|
|
ticks=ticks,
|
|
tf_min=int(timeframe),
|
|
missing_policy="hole",
|
|
live_cover=False,
|
|
)
|
|
return bars[-limit:] if len(bars) > limit else bars
|