ls증권 히스토리 구독 넣음

This commit is contained in:
Your Name
2026-07-30 18:05:07 +09:00
parent 61bec4bd1d
commit 67eab24603
1593 changed files with 135733 additions and 1232 deletions

View File

@@ -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: