Changes: - Added new API endpoints for continuing and confirming Optuna jobs, allowing for better management of ongoing studies. - Introduced detailed logging for tick feed tracking and order book processing, improving traceability of vendor performance during backtests. - Updated database schema to include new fields for managing Optuna study results, enhancing the ability to track study progress and outcomes. - Refactored existing functions to utilize the new logging and tracking features, ensuring consistency across the backtesting framework. Impact: - These enhancements improve the robustness and transparency of the Optuna backtesting process, facilitating better analysis and optimization of trading strategies.
575 lines
20 KiB
Python
575 lines
20 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_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:
|
|
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_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>2s면 보조)",
|
|
total, kept,
|
|
)
|
|
raw_before_merge = total
|
|
total = kept
|
|
|
|
# 옵투나/백테: 틱 벤더(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
|
|
|