feat: 새로운 안전 규칙 및 최적화 적용을 통한 트레이딩 시스템 개선
변경 사항 (Changes): 구문 오류(Syntax error) 및 토큰 낭비를 방지하기 위해 에이전트 쉘(Agent shell)과 파이썬 코드 스니펫에 다수의 신규 안전 규칙(Safety rules)을 추가함. 스키마 검증 및 적절한 SQL 포맷팅을 보장하기 위해 임시(Ad-hoc) 데이터베이스 쿼리 작성 가이드라인을 도입함. 코드 수정 후 UI 기능이 정상 작동하는지 확인하기 위해, 백테스트 웹 서비스 재시작 및 브라우저 검증에 대한 새로운 규칙을 구현함. 시스템 전반의 무결성(Integrity)을 유지하기 위해 실전 매매(Live trading), 웹 백테스팅, 파라미터 탐색(Parameter searches) 간의 일관성 검사(Consistency checks) 체계를 확립함. 기대 효과 (Impact): 이러한 개선 사항들은 트레이딩 시스템의 견고성(Robustness)과 신뢰성을 향상시키며, 에러 발생을 최소화하고 다양한 시스템 컴포넌트 간의 원활한 상호작용을 보장함.
This commit is contained in:
@@ -8,7 +8,8 @@
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional, Set, Tuple
|
||||
|
||||
from kis_trader.backtest.backtest_portfolio_common import (
|
||||
attach_scalp_trade_pnl,
|
||||
@@ -49,20 +50,16 @@ def momentum_universe_exit_debounce_sec() -> int:
|
||||
"""
|
||||
백테 EXIT 디바운스(초).
|
||||
|
||||
실매 ``CONDITION_EXIT_GRACE_SEC``(기본 120)가 이미 스냅샷에 반영되어 있으면
|
||||
이중 디바운스를 쓰지 않음 — env ``MOMENTUM_UNIVERSE_EXIT_DEBOUNCE_SEC`` 로만 강제 가능.
|
||||
실매 ``CONDITION_EXIT_GRACE_SEC`` 와 동일하게 스냅샷 축소 시 N초 유지
|
||||
(overnight→장초 wipe / 단발 EXIT 노이즈 정합).
|
||||
``MOMENTUM_UNIVERSE_EXIT_DEBOUNCE_SEC`` 가 있으면 그 값 우선(0=OFF).
|
||||
"""
|
||||
from kis_trader.utils.env import get_env_from_db, get_env_int
|
||||
raw = get_env_from_db("MOMENTUM_UNIVERSE_EXIT_DEBOUNCE_SEC", None)
|
||||
if raw not in (None, ""):
|
||||
try:
|
||||
return max(0, int(float(raw)))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
grace = int(get_env_int("CONDITION_EXIT_GRACE_SEC", 0))
|
||||
if grace > 0:
|
||||
return 0
|
||||
return max(0, int(get_env_int("MOMENTUM_UNIVERSE_EXIT_DEBOUNCE_SEC", 30)))
|
||||
from kis_trader.backtest.universe_timeline import resolve_universe_exit_debounce_sec
|
||||
|
||||
return resolve_universe_exit_debounce_sec(
|
||||
strategy_env_key="MOMENTUM_UNIVERSE_EXIT_DEBOUNCE_SEC",
|
||||
default_when_no_grace=30,
|
||||
)
|
||||
|
||||
|
||||
def momentum_backtest_candle_warmup_bars() -> int:
|
||||
@@ -124,6 +121,177 @@ def prepend_momentum_candle_warmup(
|
||||
return total_prepended
|
||||
|
||||
|
||||
# 종목×기간일 단위 REST 웜업 캐시 (프로세스 메모리만 — DB 미기록)
|
||||
_REST_WARMUP_PREFIX_CACHE: Dict[Tuple[str, str], List[Dict[str, Any]]] = {}
|
||||
|
||||
|
||||
def _momentum_rows_have_prev_day(rows: List[Dict], period_day: str) -> bool:
|
||||
"""기간 시작일 이전 분봉이 있으면 HTS E(전일시가) 해석 가능."""
|
||||
pd = str(period_day or "")[:8]
|
||||
if not pd:
|
||||
return True
|
||||
for r in rows or []:
|
||||
ct = str(r.get("candle_time") or "")
|
||||
if len(ct) >= 8 and ct[:8] < pd:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _kiwoom_gap_credentials() -> Tuple[str, str, bool]:
|
||||
"""실매 갭보정과 동일 — REAL 우선, 없으면 LEGACY."""
|
||||
from kis_trader.utils.env import get_env_from_db, get_env_bool
|
||||
|
||||
key = (get_env_from_db("KIWOOM_APP_KEY_REAL", "") or "").strip()
|
||||
secret = (get_env_from_db("KIWOOM_APP_SECRET_REAL", "") or "").strip()
|
||||
if not key or not secret:
|
||||
key = (get_env_from_db("KIWOOM_APP_KEY", "") or "").strip()
|
||||
secret = (get_env_from_db("KIWOOM_APP_SECRET", "") or "").strip()
|
||||
is_mock = get_env_bool("MOMENTUM_BACKTEST_REST_KIWOOM_MOCK", False)
|
||||
return key, secret, is_mock
|
||||
|
||||
|
||||
def inject_momentum_rest_warmup_memory(
|
||||
candles_by_code: Dict[str, List[Dict]],
|
||||
period_start_key: str,
|
||||
*,
|
||||
universe_by_slot: Optional[Dict[str, List[str]]] = None,
|
||||
) -> Dict[str, int]:
|
||||
"""
|
||||
DB 전일봉이 없을 때 키움 ka10080 REST를 종목당 1회 호출해 **메모리에만** prepend.
|
||||
|
||||
- 실매: 유니버스 편입 → 갭보정 RAM (DB 구데이터 미사용)
|
||||
- 백테/Optuna: DB warmup 실패 시 동일 REST 1회로 E·지표 워밍업
|
||||
- DB INSERT 없음. 프로세스 캐시로 웹 재호출·Optuna trial 재조회 방지.
|
||||
"""
|
||||
from kis_trader.utils.env import get_env_bool, get_env_float, get_env_int
|
||||
from kis_trader.utils.logger import get_logger
|
||||
|
||||
log = get_logger("kis_trader.momentum_backtest")
|
||||
stats = {"need": 0, "ok": 0, "fail": 0, "cache_hit": 0, "bars": 0, "skipped": 0}
|
||||
if not get_env_bool("MOMENTUM_BACKTEST_REST_WARMUP", True):
|
||||
stats["skipped"] = 1
|
||||
return stats
|
||||
ps = str(period_start_key or "")[:12]
|
||||
if len(ps) < 8 or not candles_by_code:
|
||||
return stats
|
||||
period_day = ps[:8]
|
||||
|
||||
target: Set[str]
|
||||
if universe_by_slot:
|
||||
target = set()
|
||||
for codes in universe_by_slot.values():
|
||||
for c in codes or []:
|
||||
if c:
|
||||
target.add(str(c).strip())
|
||||
target &= set(candles_by_code.keys())
|
||||
else:
|
||||
target = set(candles_by_code.keys())
|
||||
|
||||
need_codes = [
|
||||
c for c in sorted(target)
|
||||
if not _momentum_rows_have_prev_day(candles_by_code.get(c) or [], period_day)
|
||||
]
|
||||
stats["need"] = len(need_codes)
|
||||
if not need_codes:
|
||||
return stats
|
||||
|
||||
max_codes = int(get_env_int("MOMENTUM_BACKTEST_REST_MAX_CODES", 0))
|
||||
if max_codes > 0:
|
||||
need_codes = need_codes[:max_codes]
|
||||
|
||||
n_bars = max(
|
||||
50,
|
||||
int(get_env_int("MOMENTUM_BACKTEST_REST_WARMUP_BARS", 700)),
|
||||
)
|
||||
sleep_sec = float(get_env_float("MOMENTUM_BACKTEST_REST_SLEEP_SEC", 0.25))
|
||||
kw_key, kw_secret, is_mock = _kiwoom_gap_credentials()
|
||||
if not kw_key or not kw_secret:
|
||||
log.warning("⚠️ 모멘텀 REST 웜업 스킵 — 키움 앱키/시크릿 없음")
|
||||
stats["fail"] = len(need_codes)
|
||||
return stats
|
||||
|
||||
from kis_trader.ws.kis_ws import get_kiwoom_candles_df
|
||||
|
||||
log.info(
|
||||
"📡 모멘텀 REST 웜업(메모리): 전일봉 부족 %d종목 · 종목당 1회 ka10080 n=%d (DB 미기록)",
|
||||
len(need_codes), n_bars,
|
||||
)
|
||||
for i, code in enumerate(need_codes):
|
||||
rows = candles_by_code.get(code) or []
|
||||
if not rows:
|
||||
stats["fail"] += 1
|
||||
continue
|
||||
cache_key = (code, period_day)
|
||||
cached = _REST_WARMUP_PREFIX_CACHE.get(cache_key)
|
||||
if cached is not None:
|
||||
stats["cache_hit"] += 1
|
||||
prefix = [dict(r) for r in cached]
|
||||
else:
|
||||
try:
|
||||
df = get_kiwoom_candles_df(
|
||||
code, 1, kw_key, kw_secret, is_mock=is_mock, n=n_bars,
|
||||
)
|
||||
except Exception as e:
|
||||
log.warning("⚠️ REST 웜업 실패 %s: %s", code, e)
|
||||
stats["fail"] += 1
|
||||
continue
|
||||
if df is None or getattr(df, "empty", True):
|
||||
stats["fail"] += 1
|
||||
continue
|
||||
first_ct = ""
|
||||
for r in rows:
|
||||
ct = str(r.get("candle_time") or "")
|
||||
if ct >= ps:
|
||||
first_ct = ct[:12]
|
||||
break
|
||||
if not first_ct:
|
||||
first_ct = ps
|
||||
existing = {str(r.get("candle_time") or "")[:12] for r in rows}
|
||||
prefix = []
|
||||
try:
|
||||
for _, rec in df.iterrows():
|
||||
t = str(rec.get("time") or "")[:12]
|
||||
if len(t) < 12 or t >= first_ct or t in existing:
|
||||
continue
|
||||
op = float(rec.get("open") or 0)
|
||||
if op <= 0:
|
||||
continue
|
||||
prefix.append({
|
||||
"candle_time": t,
|
||||
"open": op,
|
||||
"high": float(rec.get("high") or op),
|
||||
"low": float(rec.get("low") or op),
|
||||
"close": float(rec.get("close") or op),
|
||||
"volume": int(float(rec.get("volume") or 0)),
|
||||
"is_confirmed": 1,
|
||||
"_rest_warmup": 1,
|
||||
})
|
||||
except Exception as e:
|
||||
log.warning("⚠️ REST 웜업 파싱 실패 %s: %s", code, e)
|
||||
stats["fail"] += 1
|
||||
continue
|
||||
prefix.sort(key=lambda x: str(x.get("candle_time") or ""))
|
||||
_REST_WARMUP_PREFIX_CACHE[cache_key] = [dict(r) for r in prefix]
|
||||
if sleep_sec > 0 and i + 1 < len(need_codes):
|
||||
time.sleep(sleep_sec)
|
||||
|
||||
if not prefix:
|
||||
stats["fail"] += 1
|
||||
continue
|
||||
if not _momentum_rows_have_prev_day(prefix, period_day):
|
||||
stats["fail"] += 1
|
||||
continue
|
||||
candles_by_code[code] = [dict(r) for r in prefix] + [dict(r) for r in rows]
|
||||
stats["ok"] += 1
|
||||
stats["bars"] += len(prefix)
|
||||
|
||||
log.info(
|
||||
"✅ 모멘텀 REST 웜업 완료: ok=%d fail=%d cache=%d bars=%d",
|
||||
stats["ok"], stats["fail"], stats["cache_hit"], stats["bars"],
|
||||
)
|
||||
return stats
|
||||
|
||||
|
||||
def date_keys(start: str, end: str) -> Tuple[str, str, str, str]:
|
||||
start_key = start.replace("-", "") + "0000"
|
||||
end_key = end.replace("-", "") + "2359"
|
||||
@@ -236,6 +404,7 @@ def run_momentum_backtest_web_aligned(
|
||||
) -> List[Dict]:
|
||||
p = dict(params)
|
||||
warmup_prepended = 0
|
||||
rest_warmup_stats: Dict[str, int] = {}
|
||||
period_start_key = ""
|
||||
if meta_out is not None:
|
||||
start_key_meta = str(meta_out.get("start_key") or "")
|
||||
@@ -247,6 +416,14 @@ def run_momentum_backtest_web_aligned(
|
||||
warmup_prepended = prepend_momentum_candle_warmup(
|
||||
db_meta, candles_by_code, period_start_key,
|
||||
)
|
||||
if not period_start_key:
|
||||
period_start_key = str(p.get("_backtest_period_start_key") or "")[:12]
|
||||
if len(period_start_key) >= 8:
|
||||
rest_warmup_stats = inject_momentum_rest_warmup_memory(
|
||||
candles_by_code,
|
||||
period_start_key,
|
||||
universe_by_slot=universe_by_slot,
|
||||
)
|
||||
p["slot_money"] = float(slot_money)
|
||||
p["fee_rate"] = float(fee_rate)
|
||||
p["sell_tax"] = float(sell_tax)
|
||||
@@ -331,6 +508,8 @@ def run_momentum_backtest_web_aligned(
|
||||
strategy_id=MOMENTUM_STRATEGY_ID,
|
||||
use_saved_history=universe_by_slot is not None,
|
||||
)
|
||||
from kis_trader.backtest.backtest_env_timeline import attach_backtest_env_timeline_to_params
|
||||
attach_backtest_env_timeline_to_params(p, meta_out, MOMENTUM_STRATEGY_ID)
|
||||
trades = run_momentum_backtest(
|
||||
candles_by_code, p, universe_by_slot=universe_by_slot,
|
||||
ticks_by_code=loaded_ticks or ticks_by_code,
|
||||
@@ -347,6 +526,8 @@ def run_momentum_backtest_web_aligned(
|
||||
if warmup_prepended > 0 or momentum_backtest_candle_warmup_bars() > 0:
|
||||
meta_out["skip_stats"]["candle_warmup_bars"] = momentum_backtest_candle_warmup_bars()
|
||||
meta_out["skip_stats"]["candle_warmup_prepended"] = warmup_prepended
|
||||
if rest_warmup_stats:
|
||||
meta_out["skip_stats"]["rest_warmup"] = rest_warmup_stats
|
||||
meta_out["universe_timing"] = (
|
||||
"strict" if momentum_backtest_universe_strict_enabled() else "minute"
|
||||
)
|
||||
@@ -355,6 +536,10 @@ def run_momentum_backtest_web_aligned(
|
||||
if snap_meta:
|
||||
meta_out["trigger_snapshot_backtest"] = snap_meta
|
||||
if tick_meta:
|
||||
from kis_trader.backtest.breakout_tick_loader import enrich_tick_meta_with_traded_codes
|
||||
tick_meta = enrich_tick_meta_with_traded_codes(
|
||||
tick_meta, candles_by_code, loaded_ticks, trades,
|
||||
)
|
||||
meta_out["tick_backtest"] = tick_meta
|
||||
return trades
|
||||
|
||||
|
||||
Reference in New Issue
Block a user