- Optuna web jobs/TPE/apply snapshot·틱로더 정합, jobs limit·감사로그 - 백테 UI 호가모드·후보 적용 흐름, feed_collect_stats API/탭 - 가설검증·교차검증 룰, 4전략 스모크·OB slot41 진단 스크립트 Co-authored-by: Cursor <cursoragent@cursor.com>
628 lines
22 KiB
Python
628 lines
22 KiB
Python
"""
|
|
kis_trader/backtest/breakout_tick_loader.py — ws_ticks 로드·분봉 인덱싱 (B안 백테)
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
from collections import defaultdict
|
|
from datetime import datetime, timedelta
|
|
from typing import Any, Dict, Iterator, List, Optional, Set, Tuple
|
|
|
|
from ..utils.env import get_env_bool, get_env_from_db, get_env_int
|
|
from ..utils.logger import get_logger
|
|
|
|
logger = get_logger("kis_trader.breakout_tick_loader")
|
|
|
|
|
|
def _candle_keys_to_tick_range(start_key: str, end_key: str) -> Tuple[str, str]:
|
|
"""ws_candles 키(12자리) → ws_ticks tick_time(14자리) 범위."""
|
|
s = (start_key or "")[:12]
|
|
e = (end_key or "")[:12]
|
|
return s + "00", e + "59"
|
|
|
|
|
|
def _prefer_kiwoom_minute_ticks(
|
|
ticks: List[Dict[str, Any]],
|
|
) -> List[Dict[str, Any]]:
|
|
"""
|
|
실매 후보 경로는 키움 체결량(FID15) 기준.
|
|
같은 분에 kis(과거 BIDP1 오염 volume)가 섞이면 거래량 필터가 가짜 통과하므로
|
|
키움 틱이 1건이라도 있으면 키움만 사용. 키움 없으면 kis/기타 유지.
|
|
"""
|
|
if not ticks:
|
|
return ticks
|
|
kw = [
|
|
t for t in ticks
|
|
if str(t.get("source") or "").strip().lower() == "kiwoom"
|
|
]
|
|
return kw if kw else ticks
|
|
|
|
|
|
def _ws_ticks_table(market: str) -> str:
|
|
"""국내 ws_ticks / 해외 ws_ticks_us."""
|
|
m = str(market or "KR").strip().upper()
|
|
try:
|
|
from database import TradeDB
|
|
return TradeDB.ws_ticks_table_for_market(m)
|
|
except Exception:
|
|
return "ws_ticks_us" if m == "US" else "ws_ticks"
|
|
|
|
|
|
def _iter_tick_day_chunks(tt_start: str, tt_end: str) -> Iterator[Tuple[str, str]]:
|
|
"""
|
|
틱 구간을 달력일 단위로 자른다.
|
|
4일치·수백만 행을 한 방 SELECT 하면 TradeDB 기본 read_timeout(30s)에 걸린다.
|
|
"""
|
|
s = str(tt_start or "").strip().ljust(14, "0")[:14]
|
|
e = str(tt_end or "").strip().ljust(14, "0")[:14]
|
|
if len(s) < 8 or len(e) < 8 or s > e:
|
|
return
|
|
d0 = datetime.strptime(s[:8], "%Y%m%d")
|
|
d1 = datetime.strptime(e[:8], "%Y%m%d")
|
|
cur = d0
|
|
while cur <= d1:
|
|
day = cur.strftime("%Y%m%d")
|
|
chunk_s = max(s, day + "000000")
|
|
chunk_e = min(e, day + "235959")
|
|
if chunk_s <= chunk_e:
|
|
yield chunk_s, chunk_e
|
|
cur += timedelta(days=1)
|
|
|
|
|
|
def _open_tick_load_conn(read_timeout: int):
|
|
"""벌크 틱 조회 전용 연결 (기본 TradeDB 30s read_timeout 우회)."""
|
|
import pymysql
|
|
import pymysql.cursors
|
|
from database import _DB_HOST, _DB_NAME, _DB_PASS, _DB_PORT, _DB_USER
|
|
|
|
# 읽기 타임아웃(초) — 하루치 ~100만행 SELECT 대비. DB/env: WS_TICK_LOAD_READ_TIMEOUT
|
|
to = max(30, int(read_timeout))
|
|
return pymysql.connect(
|
|
host=_DB_HOST,
|
|
port=int(_DB_PORT),
|
|
user=_DB_USER,
|
|
password=_DB_PASS,
|
|
database=_DB_NAME,
|
|
charset="utf8mb4",
|
|
autocommit=True,
|
|
cursorclass=pymysql.cursors.DictCursor,
|
|
connect_timeout=10,
|
|
read_timeout=to,
|
|
write_timeout=30,
|
|
)
|
|
|
|
|
|
def _fetch_ws_ticks_day_rows(
|
|
table: str,
|
|
mkt: str,
|
|
chunk_s: str,
|
|
chunk_e: str,
|
|
codes: Optional[Set[str]],
|
|
) -> List[Dict[str, Any]]:
|
|
"""
|
|
하루(또는 부분일) 틱 SELECT.
|
|
ORDER BY 는 MySQL 정렬 비용이 커서 빼고, 호출 측에서 분봉 버킷 정렬.
|
|
"""
|
|
# 읽기 타임아웃(초) / 재시도 횟수 — 하드코딩 금지, DB·env
|
|
read_timeout = get_env_int("WS_TICK_LOAD_READ_TIMEOUT", 180)
|
|
max_retries = max(1, get_env_int("WS_TICK_LOAD_MAX_RETRIES", 2))
|
|
|
|
code_filter = ""
|
|
params: List[Any] = [mkt, chunk_s, chunk_e]
|
|
if codes:
|
|
placeholders = ",".join(["%s"] * len(codes))
|
|
code_filter = f" AND code IN ({placeholders})"
|
|
params.extend(sorted(codes))
|
|
|
|
tick_source = os.environ.get("TICK_SOURCE", "").strip()
|
|
source_filter = ""
|
|
if tick_source:
|
|
source_filter = " AND source = %s"
|
|
params.append(tick_source)
|
|
|
|
sql = f"""
|
|
SELECT code, tick_time, price, volume, source, recv_ts, tick_time_raw
|
|
FROM {table}
|
|
WHERE market = %s
|
|
AND tick_time >= %s
|
|
AND tick_time <= %s
|
|
{code_filter}
|
|
{source_filter}
|
|
"""
|
|
last_err: Optional[BaseException] = None
|
|
for attempt in range(1, max_retries + 1):
|
|
conn = None
|
|
try:
|
|
conn = _open_tick_load_conn(read_timeout)
|
|
with conn.cursor() as cur:
|
|
cur.execute(sql, tuple(params))
|
|
rows = cur.fetchall() or []
|
|
return list(rows)
|
|
except Exception as e:
|
|
last_err = e
|
|
logger.warning(
|
|
"%s 일별 조회 실패 (day=%s~%s attempt=%s/%s): %s",
|
|
table, chunk_s[:8], chunk_e[:8], attempt, max_retries, e,
|
|
)
|
|
finally:
|
|
if conn is not None:
|
|
try:
|
|
conn.close()
|
|
except Exception:
|
|
pass
|
|
if last_err is not None:
|
|
raise last_err
|
|
return []
|
|
|
|
|
|
def _tick_row_lag_seconds(row: Dict[str, Any]) -> Optional[int]:
|
|
"""ws_ticks 1행 recv_ts vs 체결시각(raw 우선) 지연(초). 계산 불가 시 None."""
|
|
from kis_trader.engine.feed_fallback import packet_lag_seconds
|
|
|
|
recv_ts = str(row.get("recv_ts") or "").strip()
|
|
raw = str(row.get("tick_time_raw") or "").strip()
|
|
tt = str(row.get("tick_time") or "").strip()
|
|
try:
|
|
recv_dt = datetime.strptime(recv_ts[:19], "%Y-%m-%d %H:%M:%S")
|
|
except Exception:
|
|
return None
|
|
lag = packet_lag_seconds(raw or tt, now_dt=recv_dt)
|
|
if lag is None:
|
|
return None
|
|
return int(lag)
|
|
|
|
|
|
def _ingest_tick_rows(
|
|
rows: List[Dict[str, Any]],
|
|
out: Dict[str, Dict[str, List[Dict[str, Any]]]],
|
|
) -> int:
|
|
"""SELECT 행 → 종목·분봉 버킷. 반환=적재 건수.
|
|
|
|
공책은 버리지 않음. lag 는 읽기 폴백(2초)용 메타만 붙인다.
|
|
"""
|
|
n = 0
|
|
for r in rows:
|
|
kw_lag = _tick_row_lag_seconds(r)
|
|
code = str(r["code"]).strip()
|
|
tt = str(r["tick_time"])[:14]
|
|
if len(tt) < 12:
|
|
continue
|
|
minute_key = tt[:12]
|
|
tick = {
|
|
"code": code,
|
|
"tick_time": tt,
|
|
"price": float(r["price"] or 0),
|
|
"volume": int(r.get("volume") or 0),
|
|
"source": r.get("source") or "",
|
|
"_lag_sec": kw_lag,
|
|
}
|
|
bucket = out[code].setdefault(minute_key, [])
|
|
bucket.append(tick)
|
|
n += 1
|
|
return n
|
|
|
|
|
|
def load_breakout_ticks_by_code(
|
|
db,
|
|
start_key: str,
|
|
end_key: str,
|
|
codes: Optional[Set[str]] = None,
|
|
*,
|
|
market: Optional[str] = None,
|
|
) -> Tuple[Dict[str, Dict[str, List[Dict[str, Any]]]], int]:
|
|
"""
|
|
기간 내 체결 틱을 종목·분봉(YYYYMMDDHHMM) 단위로 로드.
|
|
- KR → ``ws_ticks``
|
|
- US → ``ws_ticks_us`` (해외 전용 InnoDB)
|
|
- 다일은 **일별 청크** + 긴 read_timeout (4일 한 방 조회 타임아웃 방지)
|
|
|
|
Returns:
|
|
(``{code: {minute_key: [tick, ...]}}``, total_tick_rows)
|
|
"""
|
|
mkt = (market or get_env_from_db("WS_TICK_DEFAULT_MARKET", "KR") or "KR").strip().upper()
|
|
tt_start, tt_end = _candle_keys_to_tick_range(start_key, end_key)
|
|
# 분봉에 메인 1건이면 보조 전부 버리기는 하지 않음. 같은 초만 메인 우선.
|
|
table = _ws_ticks_table(mkt)
|
|
|
|
try:
|
|
if mkt == "US" and hasattr(db, "ensure_ws_ticks_us_table"):
|
|
db.ensure_ws_ticks_us_table()
|
|
elif hasattr(db, "ensure_ws_ticks_table"):
|
|
db.ensure_ws_ticks_table()
|
|
except Exception:
|
|
pass
|
|
|
|
chunks = list(_iter_tick_day_chunks(tt_start, tt_end))
|
|
if not chunks:
|
|
logger.warning("%s 조회 범위 없음: %s ~ %s", table, tt_start, tt_end)
|
|
return {}, 0
|
|
|
|
out: Dict[str, Dict[str, List[Dict[str, Any]]]] = defaultdict(dict)
|
|
total = 0
|
|
failed_days = 0
|
|
read_timeout = get_env_int("WS_TICK_LOAD_READ_TIMEOUT", 180)
|
|
logger.info(
|
|
"📥 %s 일별 로드 시작 | days=%s | read_timeout=%ss | codes=%s",
|
|
table,
|
|
len(chunks),
|
|
read_timeout,
|
|
len(codes) if codes else "ALL",
|
|
)
|
|
|
|
for chunk_s, chunk_e in chunks:
|
|
try:
|
|
rows = _fetch_ws_ticks_day_rows(table, mkt, chunk_s, chunk_e, codes)
|
|
n = _ingest_tick_rows(rows, out)
|
|
total += n
|
|
logger.info(
|
|
"✅ %s day=%s rows=%s (누적=%s)",
|
|
table, chunk_s[:8], n, total,
|
|
)
|
|
except Exception as e:
|
|
failed_days += 1
|
|
logger.warning(
|
|
"%s day=%s 조회 실패 — 해당일 스킵: %s",
|
|
table, chunk_s[:8], e,
|
|
)
|
|
|
|
if total <= 0:
|
|
# ws_ticks 비어도 LS 3차만으로 재생 가능 (1·2차 공백일)
|
|
if mkt != "US" and get_env_bool("BT_TICK_LS_THIRD_FALLBACK", True):
|
|
try:
|
|
from kis_trader.backtest.ls_history_loaders import load_ls_ticks_by_code
|
|
from kis_trader.engine.feed_fallback import merge_ls_ticks_third_fallback
|
|
|
|
ls_map, ls_raw = load_ls_ticks_by_code(db, start_key, end_key, codes)
|
|
if ls_raw > 0:
|
|
added = merge_ls_ticks_third_fallback(out, ls_map)
|
|
if added:
|
|
logger.info(
|
|
"📌 틱 3차 LS만 로드: ls_raw=%s → kept=%s (ws_ticks 0)",
|
|
f"{ls_raw:,}",
|
|
f"{added:,}",
|
|
)
|
|
try:
|
|
from kis_trader.backtest.optuna_feed_trace import log_bt_tick_feed_trace
|
|
from kis_trader.engine.feed_fallback import live_tick_primary
|
|
|
|
log_bt_tick_feed_trace(
|
|
dict(out),
|
|
table="ls_ws_ticks",
|
|
main_src=live_tick_primary(),
|
|
raw_total=ls_raw,
|
|
context="틱로드(LS만)",
|
|
)
|
|
except Exception:
|
|
pass
|
|
return dict(out), int(added)
|
|
except Exception as e:
|
|
logger.debug("틱 LS-only 로드 스킵: %s", e)
|
|
logger.warning(
|
|
"%s 조회 실패 — OHLC 폴백만 사용: 0건 (failed_days=%s/%s)",
|
|
table, failed_days, len(chunks),
|
|
)
|
|
return {}, 0
|
|
|
|
if failed_days > 0:
|
|
logger.warning(
|
|
"⚠️ %s 부분 로드: failed_days=%s/%s · loaded=%s",
|
|
table, failed_days, len(chunks), total,
|
|
)
|
|
|
|
# 일별 SELECT 에 ORDER BY 없음 → 분봉 버킷 시간순 정렬 (틱 재생 정합)
|
|
from kis_trader.engine.feed_fallback import (
|
|
live_tick_primary,
|
|
merge_ls_ticks_third_fallback,
|
|
merge_ticks_time_axis_fallback,
|
|
)
|
|
|
|
main_src = live_tick_primary() if mkt != "US" else "kis"
|
|
kept = 0
|
|
for _code, minutes in out.items():
|
|
for _mk, ticks in list(minutes.items()):
|
|
ticks.sort(key=lambda t: str(t.get("tick_time") or ""))
|
|
if mkt != "US":
|
|
filtered = merge_ticks_time_axis_fallback(ticks, main_src=main_src)
|
|
minutes[_mk] = filtered
|
|
kept += len(filtered)
|
|
else:
|
|
kept += len(ticks)
|
|
if kept != total:
|
|
logger.info(
|
|
"📌 틱 시간축 폴백: raw=%d → kept=%d (같은 초 메인 우선, 메인 lag>나이면 보조)",
|
|
total, kept,
|
|
)
|
|
raw_before_merge = total
|
|
total = kept
|
|
|
|
# 실매 3차 LS: 같은 초에 1·2차 없으면 ls_ws_ticks (나이=LIVE_FEED_FALLBACK, 기본 3초)
|
|
if mkt != "US" and get_env_bool("BT_TICK_LS_THIRD_FALLBACK", True):
|
|
try:
|
|
from kis_trader.backtest.ls_history_loaders import load_ls_ticks_by_code
|
|
|
|
ls_map, ls_raw = load_ls_ticks_by_code(db, start_key, end_key, codes)
|
|
if ls_raw > 0:
|
|
added = merge_ls_ticks_third_fallback(out, ls_map)
|
|
total += int(added or 0)
|
|
if added:
|
|
logger.info(
|
|
"📌 틱 3차 LS 폴백: ls_raw=%s → +%s초 (메인·2차 빈 초만)",
|
|
f"{ls_raw:,}",
|
|
f"{added:,}",
|
|
)
|
|
except Exception as e:
|
|
logger.debug("틱 3차 LS 폴백 스킵: %s", e)
|
|
|
|
# 옵투나/백테: 틱 벤더(kis|kiwoom|ls) 비율 — 실매 트리거 피드 추적과 동일 축
|
|
try:
|
|
from kis_trader.backtest.optuna_feed_trace import log_bt_tick_feed_trace
|
|
|
|
log_bt_tick_feed_trace(
|
|
dict(out),
|
|
table=table,
|
|
main_src=main_src,
|
|
raw_total=raw_before_merge,
|
|
context="틱로드",
|
|
)
|
|
except Exception as e:
|
|
logger.debug("틱 피드추적 로그 스킵: %s", e)
|
|
|
|
return dict(out), total
|
|
|
|
|
|
def _infer_bar_tf_min(candles: List[Dict], default: int = 1) -> int:
|
|
"""연속 candle_time 간격의 중앙값으로 봉주기(분) 추정. 실패 시 default."""
|
|
from kis_trader.engine.candle_rollup import minute_diff
|
|
|
|
times = sorted({
|
|
str(c.get("candle_time") or "")[:12]
|
|
for c in (candles or [])
|
|
if len(str(c.get("candle_time") or "")[:12]) >= 12
|
|
})
|
|
if len(times) < 2:
|
|
return max(1, int(default))
|
|
diffs = []
|
|
for i in range(1, min(len(times), 80)):
|
|
d = minute_diff(times[i - 1], times[i])
|
|
if d is not None and d > 0:
|
|
diffs.append(int(d))
|
|
if not diffs:
|
|
return max(1, int(default))
|
|
diffs.sort()
|
|
return max(1, int(diffs[len(diffs) // 2]))
|
|
|
|
|
|
def _bar_has_ticks(
|
|
candle_time: str,
|
|
minute_map: Dict[str, List],
|
|
tf_min: int,
|
|
) -> bool:
|
|
"""
|
|
N분봉이면 시작~시작+tf 구간의 **어느 1분이라도** 틱이 있으면 커버.
|
|
(옛 로직은 시작분만 봐 → 꼬리 3M에서 커버가 과소 집계됨)
|
|
"""
|
|
from kis_trader.engine.candle_rollup import add_candle_minutes
|
|
|
|
ct = str(candle_time or "")[:12]
|
|
if len(ct) < 12 or not minute_map:
|
|
return False
|
|
tf = max(1, int(tf_min))
|
|
for i in range(tf):
|
|
mk = ct if i == 0 else add_candle_minutes(ct, i)
|
|
if mk and minute_map.get(mk):
|
|
return True
|
|
return False
|
|
|
|
|
|
def tick_coverage_stats(
|
|
codes_candles: Dict[str, List[Dict]],
|
|
ticks_by_code: Dict[str, Dict[str, List[Dict]]],
|
|
*,
|
|
bar_tf_min: Optional[int] = None,
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
분봉 대비 틱 보유 비율 (백테 메타용).
|
|
|
|
왜 '전체 %'가 낮아 보이는가
|
|
─────────────────────────
|
|
분봉(ws_candles)은 REST 갭보정으로 **구독 전·틱 없는 구간**까지 채워지고,
|
|
틱(ws_ticks)은 **구독 중·체결 있는 분**만 쌓인다. 분모에 REST 봉을 넣으면
|
|
커버가 낮게 나온다 (기록 누락이 아님).
|
|
|
|
반환 키
|
|
───────
|
|
- tick_bar_coverage_pct: **구독구간**(종목별 첫틱~끝틱) 커버 — UI 주표시
|
|
- tick_bar_coverage_pct_all: 전체 봉(REST 웜업 포함)
|
|
- tick_bar_coverage_pct_friend: 틱이 1건이라도 있는 종목만
|
|
"""
|
|
total_bars = 0
|
|
covered_all = 0
|
|
covered_exact_legacy = 0
|
|
friend_bars = 0
|
|
friend_covered = 0
|
|
sub_bars = 0
|
|
sub_covered = 0
|
|
codes_with_any = 0
|
|
|
|
for code, candles in (codes_candles or {}).items():
|
|
minute_map = ticks_by_code.get(code) or {}
|
|
has_ticks = bool(minute_map)
|
|
if has_ticks:
|
|
codes_with_any += 1
|
|
tf = int(bar_tf_min) if bar_tf_min and int(bar_tf_min) > 0 else _infer_bar_tf_min(
|
|
candles, default=1,
|
|
)
|
|
tick_keys = [str(k)[:12] for k in minute_map.keys() if k]
|
|
first_m = min(tick_keys) if tick_keys else ""
|
|
last_m = max(tick_keys) if tick_keys else ""
|
|
|
|
for c in candles or []:
|
|
ct = str(c.get("candle_time") or "")[:12]
|
|
if len(ct) < 12:
|
|
continue
|
|
total_bars += 1
|
|
hit = _bar_has_ticks(ct, minute_map, tf)
|
|
if minute_map.get(ct):
|
|
covered_exact_legacy += 1
|
|
if hit:
|
|
covered_all += 1
|
|
if has_ticks:
|
|
friend_bars += 1
|
|
if hit:
|
|
friend_covered += 1
|
|
# 구독구간: 첫 틱 분 ~ 마지막 틱 분 (봉 시작 시각 기준)
|
|
if first_m and last_m and first_m <= ct <= last_m:
|
|
sub_bars += 1
|
|
if hit:
|
|
sub_covered += 1
|
|
|
|
pct_all = (covered_all / total_bars * 100.0) if total_bars else 0.0
|
|
pct_friend = (friend_covered / friend_bars * 100.0) if friend_bars else 0.0
|
|
pct_sub = (sub_covered / sub_bars * 100.0) if sub_bars else 0.0
|
|
pct_legacy = (covered_exact_legacy / total_bars * 100.0) if total_bars else 0.0
|
|
|
|
return {
|
|
# UI·로그 주표시 = 구독구간 (실매 '보면서 기록'에 가장 가깝다)
|
|
"tick_bar_coverage_pct": round(pct_sub, 2),
|
|
"tick_bar_coverage_pct_subscribed": round(pct_sub, 2),
|
|
"tick_bars_subscribed_total": sub_bars,
|
|
"tick_bars_subscribed_covered": sub_covered,
|
|
"tick_bar_coverage_pct_all": round(pct_all, 2),
|
|
"tick_bar_coverage_pct_friend": round(pct_friend, 2),
|
|
"tick_bar_coverage_pct_legacy_exact": round(pct_legacy, 2),
|
|
"tick_bars_total": total_bars,
|
|
"tick_bars_covered": covered_all,
|
|
"tick_bars_friend_total": friend_bars,
|
|
"tick_bars_friend_covered": friend_covered,
|
|
"tick_codes_with_data": codes_with_any,
|
|
"tick_codes_total": len(codes_candles or {}),
|
|
}
|
|
|
|
|
|
def enrich_tick_meta_with_traded_codes(
|
|
tick_meta: Optional[Dict[str, Any]],
|
|
candles_by_code: Dict[str, List[Dict]],
|
|
ticks_by_code: Optional[Dict[str, Dict[str, List[Dict]]]],
|
|
trades: Optional[List[Dict]],
|
|
*,
|
|
bar_tf_min: Optional[int] = None,
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
백테 **체결(거래) 종목만** 구독구간 커버를 tick_meta 에 보강.
|
|
UI: ``거래종목 NN%`` — 산 친구 칸만 센 값.
|
|
"""
|
|
out: Dict[str, Any] = dict(tick_meta or {})
|
|
codes = sorted({
|
|
str(t.get("code") or "").strip()
|
|
for t in (trades or [])
|
|
if str(t.get("code") or "").strip()
|
|
})
|
|
out["tick_codes_traded"] = len(codes)
|
|
if not codes:
|
|
return out
|
|
sub_candles = {
|
|
c: candles_by_code[c]
|
|
for c in codes
|
|
if c in (candles_by_code or {}) and candles_by_code.get(c)
|
|
}
|
|
if not sub_candles:
|
|
return out
|
|
ticks = ticks_by_code or {}
|
|
sub_ticks = {c: ticks.get(c) or {} for c in sub_candles}
|
|
sub = tick_coverage_stats(sub_candles, sub_ticks, bar_tf_min=bar_tf_min)
|
|
out["tick_bar_coverage_pct_traded"] = sub.get("tick_bar_coverage_pct")
|
|
out["tick_bars_traded_subscribed_total"] = sub.get("tick_bars_subscribed_total")
|
|
out["tick_bars_traded_subscribed_covered"] = sub.get("tick_bars_subscribed_covered")
|
|
return out
|
|
|
|
|
|
def build_tick_coverage_meta_for_day(
|
|
db,
|
|
codes: Set[str],
|
|
start_ymd: str,
|
|
end_ymd: str,
|
|
*,
|
|
timeframe: int = 1,
|
|
trades: Optional[List[Dict]] = None,
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
실매·운영 탭용: 당일(기간) ws_candles + ws_ticks 로 분봉커버 메타 생성.
|
|
UI 통일 — 백테 ``tick_backtest`` 와 동일 키.
|
|
"""
|
|
codes_clean = sorted({str(c).strip() for c in (codes or set()) if str(c).strip()})
|
|
if not codes_clean or not db:
|
|
return {}
|
|
start_key = str(start_ymd or "").replace("-", "")[:8] + "0900"
|
|
end_key = str(end_ymd or start_ymd or "").replace("-", "")[:8] + "1530"
|
|
if len(start_key) < 12 or len(end_key) < 12:
|
|
return {}
|
|
day_like = start_key[:8] + "%"
|
|
tf = max(1, int(timeframe or 1))
|
|
placeholders = ",".join(["%s"] * len(codes_clean))
|
|
try:
|
|
rows = db.conn.execute(
|
|
f"SELECT code, candle_time, open, high, low, close, volume "
|
|
f"FROM ws_candles WHERE timeframe=%s AND candle_time LIKE %s "
|
|
f"AND code IN ({placeholders})",
|
|
tuple([tf, day_like] + codes_clean),
|
|
).fetchall()
|
|
except Exception as e:
|
|
logger.warning("build_tick_coverage_meta_for_day candles: %s", e)
|
|
return {}
|
|
candles_by_code: Dict[str, List[Dict]] = defaultdict(list)
|
|
for r in rows or []:
|
|
code = str(r.get("code") or "").strip()
|
|
ct = str(r.get("candle_time") or "")[:12]
|
|
if not code or len(ct) < 12:
|
|
continue
|
|
candles_by_code[code].append({
|
|
"candle_time": ct,
|
|
"open": r.get("open"),
|
|
"high": r.get("high"),
|
|
"low": r.get("low"),
|
|
"close": r.get("close"),
|
|
"volume": r.get("volume"),
|
|
})
|
|
if not candles_by_code:
|
|
return {"tick_codes_traded": len(codes_clean), "tick_bars_total": 0}
|
|
ticks_by_code, tick_rows = load_breakout_ticks_by_code(
|
|
db, start_key, end_key, set(candles_by_code.keys()),
|
|
)
|
|
meta = tick_coverage_stats(dict(candles_by_code), ticks_by_code, bar_tf_min=tf)
|
|
meta["ws_tick_rows_loaded"] = tick_rows
|
|
trade_list = trades if trades is not None else [{"code": c} for c in codes_clean]
|
|
meta = enrich_tick_meta_with_traded_codes(
|
|
meta, dict(candles_by_code), ticks_by_code, trade_list, bar_tf_min=tf,
|
|
)
|
|
return meta
|
|
|
|
|
|
def first_tick_minute_key(
|
|
ticks_by_code: Optional[Dict[str, Dict[str, List[Dict]]]],
|
|
code: str,
|
|
) -> Optional[str]:
|
|
"""종목 ws_ticks 중 가장 이른 분봉 키(YYYYMMDDHHMM). 없으면 None."""
|
|
if not ticks_by_code or not code:
|
|
return None
|
|
minute_map = ticks_by_code.get(code) or {}
|
|
if not minute_map:
|
|
return None
|
|
keys = [str(k)[:12] for k in minute_map.keys() if k]
|
|
return min(keys) if keys else None
|
|
|
|
|
|
def entry_before_first_tick(
|
|
ticks_by_code: Optional[Dict[str, Dict[str, List[Dict]]]],
|
|
code: str,
|
|
entry_bar_time: str,
|
|
) -> bool:
|
|
"""정합용: 진입봉이 첫 틱 분 이전이면 True (매수 스킵 대상)."""
|
|
first = first_tick_minute_key(ticks_by_code, code)
|
|
if not first:
|
|
return False
|
|
et = str(entry_bar_time or "")[:12]
|
|
if not et:
|
|
return False
|
|
return et < first
|
|
|