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:
Your Name
2026-07-17 01:09:09 +09:00
parent a4626e0351
commit fc27e726f9
151 changed files with 20718 additions and 6450 deletions

View File

@@ -31,13 +31,187 @@ from kis_trader.engine.indicator_cache import (
from kis_trader.share.stock_share import attach_share_denoms_to_params
from kis_trader.strategies.breakout import (
breakout_backtest_wants_tick_replay,
breakout_invest_amount_krw,
breakout_min_bars_required,
normalize_breakout_max_loss_krw,
resolve_breakout_skip_hts_scan_dupes,
run_breakout_backtest,
)
BREAKOUT_STRATEGY_ID = "BREAKOUT"
def _bo_golden_end_to_hm(s: str) -> int:
"""'10:30' 또는 HHMM → 1030 (실패 시 1030)."""
try:
raw = str(s or "").strip()
if ":" in raw:
hh, mm = raw.split(":", 1)
return int(hh) * 100 + int(mm)
if raw.isdigit():
return int(raw[:4]) if len(raw) >= 4 else int(raw)
except Exception:
pass
return 1030
def _breakout_trigger_filter_enabled(
env: Dict[str, Any],
*,
prefix: str,
kind: str,
global_key: str,
) -> bool:
"""전략별 TRIGGER 필터 ON/OFF. ORDERBOOK=전략키만(없으면 OFF). PROGRAM=전략→글로벌."""
sk = f"{prefix}_{kind}_FILTER_ENABLED"
raw = env.get(sk)
if raw not in (None, "", "None"):
return str(raw).strip().lower() in ("1", "true", "t", "y", "yes", "on")
if kind == "ORDERBOOK":
return False # 글로벌 ORDERBOOK_FILTER_ENABLED 폐기
raw_g = env.get(global_key)
if raw_g not in (None, "", "None"):
return str(raw_g).strip().lower() in ("1", "true", "t", "y", "yes", "on")
return True
def get_breakout_defaults_from_env_row(env: Dict[str, Any]) -> Dict[str, Any]:
"""돌파 엔진 params — env_row 스냅샷만 사용 (웹 백테 env 타임라인)."""
fee_rate, sell_tax, _slot = fee_and_slot_from_env_row(env, strategy="BREAKOUT")
def pick(keys: Tuple[str, ...], default: Any, cast=float):
for k in keys:
v = env.get(k)
if v not in (None, "", "None"):
try:
return cast(v)
except (ValueError, TypeError):
continue
return default
sl_r = pick(("BREAKOUT_STOP_LOSS_PCT",), -0.02, float)
tp_r = pick(("BREAKOUT_TAKE_PROFIT_PCT",), 0.05, float)
tr_r = pick(("BREAKOUT_TRAIL_PCT",), 0.015, float)
tra_r = pick(("BREAKOUT_TRAIL_ARM_PCT",), 0.0, float)
smh_r = pick(("BREAKOUT_SHOULDER_MIN_HIGH_PCT",), 0.02, float)
sc_r = pick(("BREAKOUT_SHOULDER_CUT_PCT",), 0.01, float)
def pct_ui(ratio: float) -> float:
av = abs(float(ratio))
if av == 0:
return 0.0
return round(av * 100, 3) if av < 0.5 else round(av, 3)
time_end_raw = env.get("BREAKOUT_TIME_END")
if time_end_raw not in (None, "", "None"):
try:
time_end_hm = int(float(time_end_raw))
except (ValueError, TypeError):
time_end_hm = _bo_golden_end_to_hm(str(env.get("BREAKOUT_GOLDEN_END_HM", "10:30")))
else:
time_end_hm = _bo_golden_end_to_hm(str(env.get("BREAKOUT_GOLDEN_END_HM", "10:30")))
cd_sec = pick(("BREAKOUT_COOLDOWN_SEC",), 0.0, float)
if cd_sec and cd_sec > 0:
cooldown_min = int(cd_sec / 60) if cd_sec > 120 else int(cd_sec)
else:
re_sec = pick(("REENTRY_COOLDOWN_SEC",), 1800.0, float)
cooldown_min = int(re_sec / 60) if re_sec > 120 else int(re_sec)
sl_pct_ui = pct_ui(sl_r)
max_loss_raw = pick(
("BREAKOUT_MAX_LOSS_PER_TRADE_KRW", "MAX_LOSS_PER_TRADE_KRW"),
200_000,
lambda v: int(float(v)),
)
max_loss_krw = normalize_breakout_max_loss_krw(max_loss_raw)
slot_cap = pick(
("BREAKOUT_SLOT_MONEY", "SLOT_MONEY_DEFAULT"),
2_000_000,
lambda v: int(float(v)),
)
slot_money = int(breakout_invest_amount_krw(max_loss_krw, sl_pct_ui, slot_cap))
portfolio = resolve_breakout_portfolio_params(
env, None, slot_money=float(slot_money),
)
max_stocks_v = int(portfolio["max_stocks"])
total_budget_v = int(float(portfolio["total_budget_krw"]))
eod_raw = str(env.get("BREAKOUT_EOD_HM") or "15:15").strip()
if eod_raw in ("", "None"):
eod_hm = "15:15"
elif ":" in eod_raw:
eod_hm = eod_raw
elif len(eod_raw) == 4 and eod_raw.isdigit():
eod_hm = f"{eod_raw[:2]}:{eod_raw[2:]}"
else:
eod_hm = eod_raw
eod_enabled_raw = env.get("BREAKOUT_EOD_ENABLED")
if eod_enabled_raw in (None, "", "None"):
eod_enabled = True
else:
eod_enabled = str(eod_enabled_raw).strip().lower() in ("1", "true", "t", "y", "yes", "on")
return {
"lookback_min": pick(("BREAKOUT_LOOKBACK_MIN",), 1, lambda v: int(float(v))),
"vol_window": pick(("BREAKOUT_VOL_WIN",), 1, lambda v: int(float(v))),
"vol_mult": pick(("BREAKOUT_VOL_MULT",), 0.0, float),
"min_turnover_1m_pct": pick(("BREAKOUT_MIN_TURNOVER_1M_PCT",), 0.05, float),
"prev_chg_min": pick(("BREAKOUT_PREV_CHG_MIN",), 1.0, float),
"prev_chg_max": pick(("BREAKOUT_PREV_CHG_MAX",), 10.0, float),
"sl_pct": sl_pct_ui,
"tp_pct": pct_ui(tp_r),
"trail_pct": pct_ui(tr_r),
"trail_arm_pct": pct_ui(tra_r),
"shoulder_min_high_pct": pct_ui(smh_r),
"shoulder_cut_pct": pct_ui(sc_r),
"sl_mode": str(env.get("BREAKOUT_SL_MODE") or "fixed").strip().lower() or "fixed",
"atr_period": pick(("BREAKOUT_ATR_PERIOD",), 14, lambda v: int(float(v))),
"atr_sl_mult": pick(("BREAKOUT_ATR_SL_MULT",), 2.0, float),
"atr_sl_min_pct": pick(("BREAKOUT_ATR_SL_MIN_PCT",), 0.8, float),
"atr_sl_max_pct": pick(("BREAKOUT_ATR_SL_MAX_PCT",), 6.0, float),
"max_hold_bars": pick(("BREAKOUT_MAX_HOLD_BARS",), 0, lambda v: int(float(v))),
"ratchet_tiers": str(env.get("BREAKOUT_RATCHET_TIERS") or ""),
"time_start_hm": pick(("BREAKOUT_TIME_START",), 900, lambda v: int(float(v))),
"time_end_hm": time_end_hm,
"eod_enabled": eod_enabled,
"eod_hm": eod_hm,
"max_daily": pick(("BREAKOUT_MAX_DAILY",), 1, lambda v: int(float(v))),
"cooldown_min": cooldown_min,
"max_daily_chg": pick(("BREAKOUT_MAX_DAILY_CHG",), 15.0, float),
"min_price": pick(("BREAKOUT_MIN_PRICE", "MIN_STOCK_PRICE"), 1000.0, float),
"confirm_margin_pct": pick(("BREAKOUT_CONFIRM_MARGIN_PCT",), 0.0, float),
"body_min_pct": pick(("BREAKOUT_BODY_MIN_PCT",), 0.0, float),
"max_loss_krw": max_loss_krw,
"slot_money": slot_money,
"max_stocks": max_stocks_v,
"total_budget_krw": total_budget_v,
"fee_rate_pct": fee_rate * 100.0 if fee_rate < 1 else fee_rate,
"sell_tax_pct": sell_tax * 100.0 if sell_tax < 1 else sell_tax,
"entry_mode": str(env.get("BREAKOUT_ENTRY_MODE") or "intrabar").strip().lower(),
"intrabar_slippage_pct": float(pick(("BREAKOUT_INTRABAR_SLIPPAGE_PCT",), 0.0, float)),
"use_ema_filter": (
str(env.get("BREAKOUT_USE_EMA_FILTER")).strip().lower()
in ("1", "true", "t", "y", "yes", "on")
if env.get("BREAKOUT_USE_EMA_FILTER") not in (None, "", "None")
else False
),
"ema_fast_period": pick(("BREAKOUT_EMA_FAST_PERIOD",), 9, lambda v: int(float(v))),
"ema_slow_period": pick(("BREAKOUT_EMA_SLOW_PERIOD",), 21, lambda v: int(float(v))),
"skip_hts_scan_dupes": resolve_breakout_skip_hts_scan_dupes(env),
"ob_filter_enabled": _breakout_trigger_filter_enabled(
env, prefix="BREAKOUT", kind="ORDERBOOK", global_key="ORDERBOOK_FILTER_ENABLED",
),
"pg_filter_enabled": _breakout_trigger_filter_enabled(
env, prefix="BREAKOUT", kind="PROGRAM", global_key="PROGRAM_FILTER_ENABLED",
),
"max_spread_pct": pick(
("BREAKOUT_ORDERBOOK_MAX_SPREAD_PCT",), 0.45, float,
),
}
def breakout_backtest_universe_scan_at_enabled(params: Optional[Dict[str, Any]] = None) -> bool:
"""백테 유니버스: 1분 슬롯 대신 초단위 스캔시각 타임라인 (기본 ON, 실매 정합).
@@ -56,6 +230,76 @@ def breakout_backtest_universe_scan_at_enabled(params: Optional[Dict[str, Any]]
return get_env_bool("BREAKOUT_BACKTEST_UNIVERSE_SCAN_AT", True)
def breakout_universe_exit_debounce_sec() -> int:
"""실매 ``CONDITION_EXIT_GRACE_SEC`` 정합 — 스냅샷 축소 시 N초 유지."""
from kis_trader.backtest.universe_timeline import resolve_universe_exit_debounce_sec
return resolve_universe_exit_debounce_sec(
strategy_env_key="BREAKOUT_UNIVERSE_EXIT_DEBOUNCE_SEC",
default_when_no_grace=0,
)
def breakout_backtest_candle_warmup_bars() -> int:
"""백테 지표·lookback warm-up — 실매 WS 전일봉 버퍼와 동일하게 전일 봉 선행."""
from kis_trader.utils.env import get_env_int
return max(0, int(get_env_int("BREAKOUT_BACKTEST_CANDLE_WARMUP_BARS", 50)))
def prepend_breakout_candle_warmup(
db,
candles_by_code: Dict[str, List[Dict]],
period_start_key: str,
*,
warmup_bars: Optional[int] = None,
) -> int:
"""
``period_start_key``(YYYYMMDDHHMM) 이전 N봉(1분)을 종목별로 prepend.
lookback/vol_window 판별용 — 루프 시각(all_times)에는 기간일만 포함.
"""
wb = (
breakout_backtest_candle_warmup_bars()
if warmup_bars is None
else max(0, int(warmup_bars))
)
if wb <= 0 or db is None or not period_start_key:
return 0
ps = str(period_start_key)[:12]
ind_cols = ws_candles_select_indicator_cols(db)
total_prepended = 0
for code, rows in list(candles_by_code.items()):
if not rows:
continue
first_period_idx = None
for i, r in enumerate(rows):
ct = str(r.get("candle_time") or "")
if ct >= ps:
first_period_idx = i
break
if first_period_idx is None:
continue
if first_period_idx > 0:
continue
first_ct = str(rows[first_period_idx].get("candle_time") or "")
if not first_ct:
continue
warm_rows = db.conn.execute(
f"SELECT candle_time, open, high, low, close, volume, is_confirmed{ind_cols} "
"FROM ws_candles WHERE timeframe=1 AND code=%s "
"AND candle_time < %s ORDER BY candle_time DESC LIMIT %s",
[code, first_ct, wb],
).fetchall()
if not warm_rows:
continue
prefix = [dict(r) for r in reversed(warm_rows)]
candles_by_code[code] = prefix + [dict(r) for r in rows]
total_prepended += len(prefix)
if total_prepended > 0:
materialize_ws_candles_batch(db, candles_by_code, 1)
return total_prepended
def date_keys(start: str, end: str) -> Tuple[str, str, str, str]:
"""YYYY-MM-DD → candle_time 키 및 ymd."""
start_key = start.replace("-", "") + "0000"
@@ -156,9 +400,21 @@ def run_breakout_backtest_web_aligned(
engine_params.setdefault("scan_interval_min", 1)
engine_params.setdefault("portfolio_mode", True)
# ── 전일 봉 웜업 (실매 WS 버퍼 정합) ─────────────────────────────────
warmup_prepended = 0
_sk_w = str((meta_out or {}).get("start_key") or "")
if meta_out is not None and _sk_w:
engine_params["_backtest_period_start_key"] = str(_sk_w)[:12]
_db_w = meta_out.get("db")
if _db_w is not None:
warmup_prepended = prepend_breakout_candle_warmup(
_db_w, candles_by_code, str(_sk_w)[:12],
)
# ── 초단위 유니버스 타임라인 (실매 get_universe_at 정합) ──────────────
# 1분 슬롯(strict lag)의 "편입 +최대 1분 지연" 을 제거. 봉 마감(HH:MM:59) 직전
# 최신 조건검색 스냅샷을 그대로 조회해 실매와 동일 시점 유니버스로 매수 판정.
# EXIT 디바운스 = CONDITION_EXIT_GRACE_SEC (실매 sticky/grace 정합).
if universe_by_slot is not None and breakout_backtest_universe_scan_at_enabled(engine_params):
_sk = str((meta_out or {}).get("start_key") or "")
_ek = str((meta_out or {}).get("end_key") or "")
@@ -173,16 +429,18 @@ def run_breakout_backtest_web_aligned(
_sk, _ek = min(_days), max(_days)
if len(_sk) >= 8 and len(_ek) >= 8:
from kis_trader.backtest.universe_timeline import build_universe_timeline
_deb = breakout_universe_exit_debounce_sec()
_tl = build_universe_timeline(
strategy_id=BREAKOUT_STRATEGY_ID,
start_ymd=_sk[:8], end_ymd=_ek[:8],
debounce_sec=0, strict=False, strict_lag_minutes=0,
debounce_sec=_deb, strict=False, strict_lag_minutes=0,
)
if _tl is not None:
engine_params["_universe_timeline"] = _tl
if meta_out is not None:
meta_out["universe_timing"] = "scan_at"
meta_out["universe_timeline_snapshots"] = _tl.snapshot_count
meta_out["universe_exit_debounce_sec"] = _deb
db_for_share = (meta_out or {}).get("db")
if db_for_share and "share_denom_by_code" not in engine_params:
@@ -222,6 +480,9 @@ def run_breakout_backtest_web_aligned(
if snap_meta.get("log_verdict_by_code"):
engine_params["_backtest_log_verdict_by_code"] = snap_meta["log_verdict_by_code"]
from kis_trader.backtest.backtest_env_timeline import attach_backtest_env_timeline_to_params
attach_backtest_env_timeline_to_params(engine_params, meta_out, BREAKOUT_STRATEGY_ID)
trades = run_breakout_backtest(
candles_by_code,
engine_params,
@@ -237,8 +498,15 @@ def run_breakout_backtest_web_aligned(
if meta_out is not None:
skip_stats = engine_params.get("_portfolio_skip_stats") or {}
meta_out["skip_stats"] = dict(skip_stats)
if warmup_prepended > 0 or breakout_backtest_candle_warmup_bars() > 0:
meta_out["skip_stats"]["candle_warmup_bars"] = breakout_backtest_candle_warmup_bars()
meta_out["skip_stats"]["candle_warmup_prepended"] = warmup_prepended
meta_out["engine_params"] = engine_params
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
mode = engine_params.get("entry_mode", "intrabar")
if tick_meta.get("ws_tick_rows_loaded", 0) > 0: