Files
kis_bot/kis_trader/engine/indicator_cache.py

413 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
kis_trader/engine/indicator_cache.py — RSI/EMA materialized 캐시 (파라서치 가속)
================================================================================
- 종목별 IndicatorCache: RSI·EMA period 별 시리즈 1회 계산 → 조합 루프 재사용
- ws_candles DB 컬럼 materialize (RSI·EMA period 별 rsi_N / ema_N)
[ws_candles 컬럼]
- 실시간 WS 확정봉: rsi_2, rsi_3, rsi_5 (CandleAggregator 가 INSERT)
- materialize 배치: rsi_7, rsi_14, rsi_21 + ema_5,9,12,15,21,34 (env 로 period 확장)
파라서치 ④ 구간(조합×RSI/EMA 재계산) 가속용 — 실매 TRIGGER 로직 변경 없음.
"""
from __future__ import annotations
from typing import Any, Dict, List, Optional, Sequence, Tuple
from kis_trader.engine.ema_trend_filter import compute_ema_series
from kis_trader.utils.env import get_env_bool, get_env_float, get_env_from_db, get_env_int
def _parse_period_list(env_key: str, fallback: str) -> List[int]:
raw = str(get_env_from_db(env_key, fallback) or fallback).strip()
out: List[int] = []
for part in raw.split(","):
part = part.strip()
if not part:
continue
try:
p = int(float(part))
if p > 0:
out.append(p)
except (TypeError, ValueError):
continue
return sorted(set(out)) if out else [int(x) for x in fallback.split(",") if x.strip()]
def default_rsi_periods() -> List[int]:
return _parse_period_list("PARAM_SEARCH_RSI_PERIODS", "2,3,5,7,14,21")
def default_ema_periods() -> List[int]:
return _parse_period_list("PARAM_SEARCH_EMA_PERIODS", "5,9,12,15,21,34")
def ws_candles_materialize_rsi_periods() -> List[int]:
"""DB materialize 대상 RSI period — env ``WS_CANDLE_MATERIALIZE_RSI_PERIODS`` 우선."""
raw = _parse_period_list(
"WS_CANDLE_MATERIALIZE_RSI_PERIODS",
",".join(str(p) for p in default_rsi_periods() if p not in (2, 3, 5)),
)
# rsi_2/3/5 는 CandleAggregator 가 실시간 기록 — 배치는 7+ 위주
return sorted(set(raw))
def ws_candles_materialize_ema_periods() -> List[int]:
"""DB materialize 대상 EMA period."""
return _parse_period_list(
"WS_CANDLE_MATERIALIZE_EMA_PERIODS",
",".join(str(p) for p in default_ema_periods()),
)
def ws_candles_all_rsi_column_names() -> List[str]:
"""ws_candles SELECT/마이그레이션용 RSI 컬럼명 (rsi_2 … env period)."""
periods = sorted(set(default_rsi_periods()) | {2, 3, 5})
return [f"rsi_{p}" for p in periods]
def ws_candles_all_ema_column_names() -> List[str]:
periods = ws_candles_materialize_ema_periods()
return [f"ema_{p}" for p in periods]
def ws_candles_indicator_column_migrations() -> List[Tuple[str, str]]:
"""(컬럼명, ALTER DDL suffix) — TradeDB._migrate_add_columns 에서 사용.
※ TradeDB 기동 중 호출되므로 get_env_from_db(DB) 를 쓰지 않는다.
(env 조회 → TradeDB() 재진입 → _tables_lock 데드락 → journal 로그 0줄 hang)
"""
out: List[Tuple[str, str]] = []
# env 기본값과 동일한 정적 period — 런타임 확장 period 는 ensure 시 information_schema 로 ADD
migration_rsi = (7, 14, 21)
migration_ema = (5, 9, 12, 15, 21, 34)
for period in migration_rsi:
out.append((
f"rsi_{period}",
f"DOUBLE NULL COMMENT 'RSI({period}) materialized'",
))
for period in migration_ema:
out.append((
f"ema_{period}",
f"DOUBLE NULL COMMENT 'EMA({period}) materialized'",
))
return out
def ensure_ws_candles_indicator_columns(db) -> int:
"""ws_candles 에 누락된 rsi_N / ema_N 컬럼 ADD (1회 마이그레이션). Returns 추가된 컬럼 수."""
added = 0
try:
cols = set(db.conn.get_columns("ws_candles"))
except Exception:
return 0
for col, ddl in ws_candles_indicator_column_migrations():
if col in cols:
continue
try:
db.conn.execute(f"ALTER TABLE ws_candles ADD COLUMN {col} {ddl}")
cols.add(col)
added += 1
except Exception:
pass
return added
def indicator_cache_enabled() -> bool:
return get_env_bool("PARAM_SEARCH_INDICATOR_CACHE_ENABLED", True)
def materialize_db_on_load() -> bool:
return get_env_bool("WS_CANDLE_MATERIALIZE_ON_LOAD", False)
class IndicatorCache:
"""단일 종목 캔들에 대한 RSI/EMA 시리즈 메모이제이션."""
# _rsi_final / _ema_final: 해당 period 시리즈를 (DB seed + 1회 계산 병합 후)
# '확정'했음을 표시. 확정된 period 는 재계산 없이 캐시를 그대로 반환한다.
# (기존엔 rsi_at/ema_at 호출마다 compute_*_series 를 다시 돌려 O(N²) 였음 — 결과는
# 동일했으나 파라서치 단일 백테가 봉당 전체 재계산으로 수십 초 소요되던 병목)
__slots__ = ("closes", "_rsi", "_ema", "_rsi_final", "_ema_final")
def __init__(self, candles: List[Dict[str, Any]]) -> None:
self.closes: List[float] = [float(c.get("close") or 0) for c in candles]
self._rsi: Dict[int, List[Optional[float]]] = {}
self._ema: Dict[int, List[Optional[float]]] = {}
self._rsi_final: set = set()
self._ema_final: set = set()
self._seed_from_candle_fields(candles)
def _seed_from_candle_fields(self, candles: List[Dict[str, Any]]) -> None:
"""DB materialized 컬럼(rsi_14, ema_9 등)이 있으면 해당 period 시리즈에 반영."""
for i, c in enumerate(candles):
for period in default_rsi_periods():
key = f"rsi_{period}"
if key in c and c[key] is not None:
ser = self._rsi.setdefault(period, [None] * len(candles))
if i < len(ser):
try:
ser[i] = float(c[key])
except (TypeError, ValueError):
pass
legacy = {2: "rsi_2", 3: "rsi_3", 5: "rsi_5"}.get(period)
if legacy and legacy in c and c[legacy] is not None:
ser = self._rsi.setdefault(period, [None] * len(candles))
if i < len(ser) and ser[i] is None:
try:
ser[i] = float(c[legacy])
except (TypeError, ValueError):
pass
for period in default_ema_periods():
key = f"ema_{period}"
if key in c and c[key] is not None:
ser = self._ema.setdefault(period, [None] * len(candles))
if i < len(ser):
try:
ser[i] = float(c[key])
except (TypeError, ValueError):
pass
def rsi_series(self, period: int) -> List[Optional[float]]:
p = max(1, int(period))
# 이미 확정된 period 면 재계산 없이 캐시 반환 (핫패스 가속, 결과 불변)
if p in self._rsi_final:
return self._rsi[p]
from kis_trader.engine.momentum_engine import compute_rsi_series
computed = compute_rsi_series(self.closes, p)
seeded = self._rsi.get(p)
if seeded is None:
self._rsi[p] = computed
self._rsi_final.add(p)
return computed
if len(seeded) != len(computed):
seeded = (list(seeded) + [None] * len(computed))[: len(computed)]
# warm-up prepend 등으로 DB rsi_N 이 앞구간만 있으면 뒤 구간은 계산값으로 보완
merged = [
s if s is not None else b
for s, b in zip(seeded, computed)
]
self._rsi[p] = merged
self._rsi_final.add(p)
return merged
def rsi_at(self, i: int, period: int) -> Optional[float]:
ser = self.rsi_series(period)
if 0 <= i < len(ser):
return ser[i]
return None
def ema_series(self, period: int) -> List[Optional[float]]:
p = max(1, int(period))
# 이미 확정된 period 면 재계산 없이 캐시 반환 (핫패스 가속, 결과 불변)
if p in self._ema_final:
return self._ema[p]
computed = compute_ema_series(self.closes, p)
seeded = self._ema.get(p)
if seeded is None:
self._ema[p] = computed
self._ema_final.add(p)
return computed
if len(seeded) != len(computed):
seeded = (list(seeded) + [None] * len(computed))[: len(computed)]
merged = [
s if s is not None else b
for s, b in zip(seeded, computed)
]
self._ema[p] = merged
self._ema_final.add(p)
return merged
def ema_at(self, i: int, period: int) -> Optional[float]:
ser = self.ema_series(period)
if 0 <= i < len(ser):
return ser[i]
return None
def build_indicator_cache(candles: List[Dict[str, Any]]) -> IndicatorCache:
return IndicatorCache(candles)
def build_indicator_cache_by_code(
codes_candles: Dict[str, List[Dict[str, Any]]],
*,
rsi_periods: Optional[Sequence[int]] = None,
ema_periods: Optional[Sequence[int]] = None,
) -> Dict[str, IndicatorCache]:
"""종목별 캐시 — rsi/ema period 는 warm-up 용 (첫 접근 시 lazy 계산)."""
if not indicator_cache_enabled():
return {}
out: Dict[str, IndicatorCache] = {}
rsi_p = list(rsi_periods or default_rsi_periods())
ema_p = list(ema_periods or default_ema_periods())
for code, rows in (codes_candles or {}).items():
if not rows:
continue
ic = IndicatorCache([dict(r) for r in rows])
for p in rsi_p:
ic.rsi_series(p)
for p in ema_p:
ic.ema_series(p)
out[str(code)] = ic
return out
def attach_indicator_caches_to_params(
params: Dict[str, Any],
codes_candles: Dict[str, List[Dict[str, Any]]],
) -> None:
"""params['_indicator_cache_by_code'] 에 종목별 캐시 주입 (in-place)."""
if not indicator_cache_enabled():
return
existing = params.get("_indicator_cache_by_code")
if isinstance(existing, dict) and existing:
return
params["_indicator_cache_by_code"] = build_indicator_cache_by_code(codes_candles)
def get_indicator_cache_from_params(
params: Dict[str, Any],
code: str,
) -> Optional[IndicatorCache]:
by_code = params.get("_indicator_cache_by_code")
if isinstance(by_code, dict):
return by_code.get(str(code))
ic = params.get("_indicator_cache")
return ic if isinstance(ic, IndicatorCache) else None
def enrich_candles_with_materialized_fields(
candles: List[Dict[str, Any]],
ic: IndicatorCache,
*,
rsi_periods: Optional[Sequence[int]] = None,
ema_periods: Optional[Sequence[int]] = None,
) -> None:
"""캔들 dict 에 rsi_N / ema_N 필드 부착 (DB 저장·재로드용)."""
rsi_p = list(rsi_periods or default_rsi_periods())
ema_p = list(ema_periods or default_ema_periods())
for i, c in enumerate(candles):
for p in rsi_p:
v = ic.rsi_at(i, p)
if v is not None:
c[f"rsi_{p}"] = round(v, 4)
for p in ema_p:
v = ic.ema_at(i, p)
if v is not None:
c[f"ema_{p}"] = round(v, 4)
def materialize_ws_candles_batch(
db,
candles_by_code: Dict[str, List[Dict[str, Any]]],
timeframe: int,
) -> int:
"""
ws_candles 행에 rsi_N·ema_N UPDATE (배치).
env ``WS_CANDLE_MATERIALIZE_ON_LOAD=true`` 일 때만 실행.
Returns: 업데이트 시도 행 수.
"""
if not materialize_db_on_load() or db is None:
return 0
ensure_ws_candles_indicator_columns(db)
try:
db_cols = set(db.conn.get_columns("ws_candles"))
except Exception:
return 0
rsi_p = [p for p in ws_candles_materialize_rsi_periods() if f"rsi_{p}" in db_cols]
ema_p = [p for p in ws_candles_materialize_ema_periods() if f"ema_{p}" in db_cols]
if not rsi_p and not ema_p:
return 0
set_parts = []
for p in rsi_p:
set_parts.append(f"rsi_{p} = ?")
for p in ema_p:
set_parts.append(f"ema_{p} = ?")
set_sql = ", ".join(set_parts)
if not set_sql:
return 0
batch: List[Tuple[Any, ...]] = []
for code, rows in (candles_by_code or {}).items():
if not rows:
continue
ic = build_indicator_cache([dict(r) for r in rows])
enrich_candles_with_materialized_fields(
rows, ic,
rsi_periods=rsi_p or None,
ema_periods=ema_p or None,
)
for c in rows:
ct = str(c.get("candle_time") or "")
if not ct:
continue
vals: List[Any] = []
for p in rsi_p:
vals.append(c.get(f"rsi_{p}"))
for p in ema_p:
vals.append(c.get(f"ema_{p}"))
vals.extend([str(code), int(timeframe), ct])
batch.append(tuple(vals))
if not batch:
return 0
where = "WHERE code = ? AND timeframe = ? AND candle_time = ? AND source = 'kis'"
sql_sqlite = f"""
UPDATE ws_candles
SET {set_sql}, updated_at = datetime('now','localtime')
{where}
"""
set_mysql = ", ".join(
[f"rsi_{p} = %s" for p in rsi_p]
+ [f"ema_{p} = %s" for p in ema_p]
)
sql_mysql = f"""
UPDATE ws_candles
SET {set_mysql}
WHERE code = %s AND timeframe = %s AND candle_time = %s AND source = 'kis'
"""
try:
with db.conn:
db.conn.executemany(sql_sqlite, batch)
except Exception:
try:
db.conn.executemany(sql_mysql, batch)
except Exception:
return 0
return len(batch)
def ws_candles_select_indicator_cols(db=None) -> str:
"""SELECT 절 추가 컬럼 — DB 에 실제 존재하는 rsi_N / ema_N 만."""
names: List[str] = []
if db is not None:
try:
cols = set(db.conn.get_columns("ws_candles"))
for n in ws_candles_all_rsi_column_names() + ws_candles_all_ema_column_names():
if n in cols:
names.append(n)
except Exception:
pass
if not names:
names = ws_candles_all_rsi_column_names() + ws_candles_all_ema_column_names()
return (", " + ", ".join(names)) if names else ""
def ws_candles_has_materialized_cols(db) -> bool:
"""materialize 컬럼(ema_9 또는 ema_34 등)이 1개 이상 있으면 True."""
try:
cols = set(db.conn.get_columns("ws_candles"))
for n in ws_candles_all_ema_column_names():
if n in cols:
return True
for n in ("rsi_7", "rsi_14", "rsi_21"):
if n in cols:
return True
return False
except Exception:
return False