Files
kis_bot/kis_trader/ws/candle_series.py
Your Name 0780b2cdd0 feat: Enhance Optuna integration and logging for backtesting framework
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.
2026-08-21 19:05:23 +09:00

251 lines
8.5 KiB
Python

"""
ws_candles 시리즈 — source=증권사, channel=ws|rest|rollup.
저장은 섞지 않음: 키움 REST 갭보정은 항상 source=kiwoom, channel=rest.
읽기(실매=Optuna): 메인 WS → 2차 WS → LS WS → kiwoom+rest → rollup.
"""
from __future__ import annotations
import os
from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple
from kis_trader.utils.env import get_env_from_db
CHANNEL_WS = "ws"
CHANNEL_REST = "rest"
CHANNEL_ROLLUP = "rollup"
# 읽기 우선순위 쌍 (source, channel)
ReadPair = Tuple[str, str]
def normalize_source_channel(
source: str = "",
channel: str = "",
) -> Tuple[str, str]:
"""레거시 source=rest|kw_rest|rollup_1m 을 (증권사, channel)로 정규화."""
s = str(source or "").strip().lower()
ch = str(channel or "").strip().lower()
if s in ("rest", "kw_rest"):
return "kiwoom", CHANNEL_REST
if s in ("rollup_1m", "rollup"):
src = "kiwoom"
if ch in ("kis",):
src = "kis"
return src, CHANNEL_ROLLUP
if s in ("kis", "kis_us", "ws", ""):
return "kis", (ch or CHANNEL_WS)
if s == "kiwoom":
return "kiwoom", (ch or CHANNEL_WS)
if s == "ls":
return "ls", (ch or CHANNEL_WS)
return (s or "kis")[:16], (ch or CHANNEL_WS)[:10]
def _provider_and_override() -> Tuple[str, str]:
override = ""
raw = os.environ.get("CANDLE_SOURCE")
if raw is None:
try:
raw = get_env_from_db("CANDLE_SOURCE", "")
except Exception:
raw = ""
override = str(raw or "").strip().lower()
if override not in ("kis", "kiwoom"):
override = ""
try:
provider = (
get_env_from_db("LIVE_TICK_PROVIDER", "kiwoom") or "kiwoom"
).strip().lower()
except Exception:
provider = "kiwoom"
if provider not in ("kis", "kiwoom"):
provider = "kiwoom"
return provider, override
def live_read_pairs() -> Tuple[ReadPair, ...]:
"""실매·Optuna 기본 읽기 순서.
메인 WS → 2차 WS → LS WS(있으면) → 키움 REST 구멍 → 롤업.
CANDLE_SOURCE=kis|kiwoom 이면 그 메인을 씀. 빈값이면 LIVE_TICK_PROVIDER.
"""
provider, override = _provider_and_override()
main = override or provider
aux = "kis" if main == "kiwoom" else "kiwoom"
return (
(main, CHANNEL_WS),
(aux, CHANNEL_WS),
("ls", CHANNEL_WS),
("kiwoom", CHANNEL_REST),
("kiwoom", CHANNEL_ROLLUP),
)
def live_read_label() -> str:
provider, override = _provider_and_override()
main = override or provider
aux = "kis" if main == "kiwoom" else "kiwoom"
if override:
return f"{override.upper()}+{aux}+ls+kiwoom_rest"
return f"LIVE({main})+{aux}+ls+kiwoom_rest"
def _index_ticks_by_minute(ticks: Sequence[Dict[str, Any]]) -> Dict[Tuple[str, str], List[Dict[str, Any]]]:
"""(source, YYYYMMDDHHMM) → 틱. source 빈 틱은 ('*', 분) — 모든 증권사 쓰레기검사에 포함."""
idx: Dict[Tuple[str, str], List[Dict[str, Any]]] = {}
for t in ticks or []:
if not isinstance(t, dict):
continue
raw = str(t.get("tick_time_raw") or t.get("tick_time") or "").strip()
digits = raw.replace(":", "").replace("-", "").replace(" ", "")
tmin = digits[:12] if len(digits) >= 12 else ""
if len(tmin) < 12 or not tmin.isdigit():
continue
tsrc = str(t.get("source") or "").strip().lower()
key = (tsrc if tsrc else "*", tmin)
idx.setdefault(key, []).append(t)
return idx
def _ticks_in_bar_from_index(
idx: Optional[Dict[Tuple[str, str], List[Dict[str, Any]]]],
source: str,
candle_time: str,
tf_min: int,
) -> List[Dict[str, Any]]:
"""tick_in_bar_bucket 과 같은 분 구간만 — bar_is_garbage 전체 스캔 방지."""
from kis_trader.engine.candle_rollup import add_candle_minutes
if not idx:
return []
ct = str(candle_time or "").strip()[:12]
if len(ct) < 12:
return []
end = add_candle_minutes(ct, max(1, int(tf_min or 1)))
if not end:
return []
end12 = end[:12]
src = str(source or "").strip().lower()
out: List[Dict[str, Any]] = []
cur = ct
while cur and cur < end12:
if src:
out.extend(idx.get((src, cur), ()))
out.extend(idx.get(("*", cur), ()))
nxt = add_candle_minutes(cur, 1)
if not nxt or nxt[:12] <= cur:
break
cur = nxt[:12]
return out
def dedupe_by_read_pairs(
rows: Sequence[Dict[str, Any]],
pairs: Optional[Sequence[ReadPair]] = None,
*,
ticks: Optional[Sequence[Dict[str, Any]]] = None,
tf_min: int = 1,
missing_policy: str = "hole",
live_cover: bool = False,
stats: Optional[Dict[str, Any]] = None,
) -> List[Dict[str, Any]]:
"""candle_time 1행 — pairs 앞쪽이 이김. source/channel 은 결과에 남기지 않음.
ticks 가 있으면 WS 봉만 쓰레기(0건·봉끝 나이초과)일 때 다음 쌍. 한 봉 혼합 없음.
live_cover=True 이면 링이 그 분을 커버 못할 때 그 WS 봉을 유지.
stats 가 있으면 pick/garbage/hole 누적 (옵투나 로그용).
"""
from kis_trader.engine.feed_fallback import bar_is_garbage, live_bar_is_garbage
order = tuple(pairs or live_read_pairs())
by_ct: Dict[str, Dict[ReadPair, Dict[str, Any]]] = {}
for row in rows:
ct = str(row.get("candle_time") or "")[:12]
if not ct:
continue
src, ch = normalize_source_channel(
str(row.get("source") or ""),
str(row.get("channel") or ""),
)
by_ct.setdefault(ct, {})
pair = (src, ch)
if pair not in by_ct[ct]:
by_ct[ct][pair] = dict(row)
tf = max(1, int(tf_min or 1))
tick_idx = None
if ticks is not None:
tick_idx = _index_ticks_by_minute(ticks)
if stats is not None:
stats.setdefault("slots", 0)
stats.setdefault("picked", 0)
stats.setdefault("hole", 0)
stats.setdefault("garbage_skip", 0)
stats.setdefault("pick_by", {})
stats.setdefault("garbage_by", {})
stats.setdefault("raw_by", {})
for ct_map in by_ct.values():
for pair in ct_map:
key = "%s/%s" % pair
stats["raw_by"][key] = int(stats["raw_by"].get(key) or 0) + 1
out: List[Dict[str, Any]] = []
for ct in sorted(by_ct.keys()):
if stats is not None:
stats["slots"] = int(stats.get("slots") or 0) + 1
picked: Optional[Dict[str, Any]] = None
pick_key = ""
for pair in order:
bar = by_ct[ct].get(pair)
if bar is None:
continue
src, ch = pair
if ticks is not None and ch == CHANNEL_WS:
scoped = _ticks_in_bar_from_index(tick_idx, src, ct, tf)
if live_cover:
bad = live_bar_is_garbage(
ticks, candle_time=ct, tf_min=tf, source=src,
)
else:
bad = bar_is_garbage(
scoped, candle_time=ct, tf_min=tf, source=src,
missing_policy=missing_policy,
)
if bad:
if stats is not None:
stats["garbage_skip"] = int(stats.get("garbage_skip") or 0) + 1
gk = "%s/%s" % pair
stats["garbage_by"][gk] = int(stats["garbage_by"].get(gk) or 0) + 1
continue
picked = dict(bar)
pick_key = "%s/%s" % pair
break
if picked is None:
if stats is not None:
stats["hole"] = int(stats.get("hole") or 0) + 1
continue
if stats is not None:
stats["picked"] = int(stats.get("picked") or 0) + 1
stats["pick_by"][pick_key] = int(stats["pick_by"].get(pick_key) or 0) + 1
picked.pop("source", None)
picked.pop("channel", None)
out.append(picked)
return out
def pick_bar_by_read_pairs(
rows: Sequence[Dict[str, Any]],
pairs: Optional[Sequence[ReadPair]] = None,
*,
ticks: Optional[Sequence[Dict[str, Any]]] = None,
tf_min: int = 1,
missing_policy: str = "hole",
live_cover: bool = False,
) -> Optional[Dict[str, Any]]:
"""한 candle_time 묶음에서 승자 봉 1개. 없으면 None."""
out = dedupe_by_read_pairs(
rows, pairs, ticks=ticks, tf_min=tf_min,
missing_policy=missing_policy, live_cover=live_cover,
)
return out[0] if out else None