ls증권 히스토리 구독 넣음
This commit is contained in:
@@ -4,9 +4,10 @@ kis_trader/backtest/breakout_tick_loader.py — ws_ticks 로드·분봉 인덱
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from typing import Any, Dict, List, Optional, Set, Tuple
|
||||
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
|
||||
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")
|
||||
@@ -36,32 +37,77 @@ def _prefer_kiwoom_minute_ticks(
|
||||
return kw if kw else ticks
|
||||
|
||||
|
||||
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]:
|
||||
"""
|
||||
기간 내 ``ws_ticks`` 를 종목·분봉(YYYYMMDDHHMM) 단위로 로드.
|
||||
|
||||
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)
|
||||
prefer_kw = get_env_bool("WS_TICK_PREFER_KIWOOM", True)
|
||||
|
||||
def _ws_ticks_table(market: str) -> str:
|
||||
"""국내 ws_ticks / 해외 ws_ticks_us."""
|
||||
m = str(market or "KR").strip().upper()
|
||||
try:
|
||||
if hasattr(db, "ensure_ws_ticks_table"):
|
||||
db.ensure_ws_ticks_table()
|
||||
from database import TradeDB
|
||||
return TradeDB.ws_ticks_table_for_market(m)
|
||||
except Exception:
|
||||
pass
|
||||
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, tt_start, tt_end]
|
||||
params: List[Any] = [mkt, chunk_s, chunk_e]
|
||||
if codes:
|
||||
placeholders = ",".join(["%s"] * len(codes))
|
||||
code_filter = f" AND code IN ({placeholders})"
|
||||
@@ -69,21 +115,44 @@ def load_breakout_ticks_by_code(
|
||||
|
||||
sql = f"""
|
||||
SELECT code, tick_time, price, volume, source
|
||||
FROM ws_ticks
|
||||
FROM {table}
|
||||
WHERE market = %s
|
||||
AND tick_time >= %s
|
||||
AND tick_time <= %s
|
||||
{code_filter}
|
||||
ORDER BY code, tick_time
|
||||
"""
|
||||
try:
|
||||
rows = db.conn.execute(sql, tuple(params)).fetchall()
|
||||
except Exception as e:
|
||||
logger.warning("ws_ticks 조회 실패 — OHLC 폴백만 사용: %s", e)
|
||||
return {}, 0
|
||||
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 []
|
||||
|
||||
out: Dict[str, Dict[str, List[Dict[str, Any]]]] = defaultdict(dict)
|
||||
total = 0
|
||||
|
||||
def _ingest_tick_rows(
|
||||
rows: List[Dict[str, Any]],
|
||||
out: Dict[str, Dict[str, List[Dict[str, Any]]]],
|
||||
) -> int:
|
||||
"""SELECT 행 → 종목·분봉 버킷. 반환=적재 건수."""
|
||||
n = 0
|
||||
for r in rows:
|
||||
code = str(r["code"]).strip()
|
||||
tt = str(r["tick_time"])[:14]
|
||||
@@ -99,7 +168,91 @@ def load_breakout_ticks_by_code(
|
||||
}
|
||||
bucket = out[code].setdefault(minute_key, [])
|
||||
bucket.append(tick)
|
||||
total += 1
|
||||
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)
|
||||
# 해외는 키움 체결 없음(kis_us) — 키움 우선 필터 끄기
|
||||
prefer_kw = bool(get_env_bool("WS_TICK_PREFER_KIWOOM", True)) and mkt != "US"
|
||||
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 없음 → 분봉 버킷 시간순 정렬 (틱 재생 정합)
|
||||
for _code, minutes in out.items():
|
||||
for _mk, ticks in minutes.items():
|
||||
ticks.sort(key=lambda t: str(t.get("tick_time") or ""))
|
||||
|
||||
# 실매(키움 구독) 정합: 분봉 단위 키움 우선 (모멘텀/꼬리 로더가 이 함수 재사용)
|
||||
if prefer_kw and out:
|
||||
|
||||
Reference in New Issue
Block a user