""" kis_trader/engine/tail_env_keys.py — 꼬리잡기(TAIL) env 키 단일 정의 ==================================================================== 레거시(MIN_DROP_RATE, STOP_LOSS_PCT 등) 제거 — TAIL_* 접두사만 사용. config_short 테이블 저장 · tail_engine · 웹 · 파라서치 공통. """ from __future__ import annotations from typing import Any, Dict, Optional # 레거시 → TAIL (1회 마이그레이션용, 코드 런타임 폴백 없음) LEGACY_TO_TAIL: Dict[str, str] = { "MIN_DROP_RATE": "TAIL_MIN_DROP_RATE", "MIN_RECOVERY_RATIO_SHORT": "TAIL_MIN_RECOVERY_RATIO", "MAX_RECOVERY_RATIO_3M": "TAIL_MAX_RECOVERY_3M", "STOP_LOSS_PCT": "TAIL_STOP_LOSS_PCT", "TAKE_PROFIT_PCT": "TAIL_TAKE_PROFIT_PCT", "SHOULDER_MIN_HIGH_PCT": "TAIL_SHOULDER_MIN_HIGH_PCT", "SHOULDER_CUT_PCT": "TAIL_SHOULDER_CUT_PCT", "STOP_ATR_MULTIPLIER_TAIL": "TAIL_STOP_ATR_MULT", "TARGET_ATR_MULTIPLIER_TAIL": "TAIL_TARGET_ATR_MULT", "MIN_PRICE_TAIL": "TAIL_MIN_PRICE", "MAX_DAILY_CHANGE_PCT": "TAIL_MAX_DAILY_CHG", "MA20_MAX_ABOVE_PCT": "TAIL_MA20_MAX_ABOVE_PCT", "MAX_LOSS_PER_TRADE_KRW": "TAIL_MAX_LOSS_KRW", "MIN_DROP_PCT_FOR_LOSS_CUT": "TAIL_MIN_DROP_FOR_LOSS_CUT", "RSI_OVERHEAT_THRESHOLD": "TAIL_RSI_THRESHOLD", "RSI_PERIOD": "TAIL_RSI_PERIOD", "REENTRY_COOLDOWN_SEC": "TAIL_COOLDOWN_SEC", "MAX_DAILY_TAIL": "TAIL_MAX_DAILY", "HIGH_PRICE_CHASE_THRESHOLD": "TAIL_HIGH_CHASE_THR", "SHORT_TIME_START": "TAIL_TIME_START", "SHORT_TIME_END": "TAIL_TIME_END", # TIME_START/TIME_END 는 돌파·공통과 섞일 수 있어 마이그레이션 제외 "SHORT_MAX_STOCKS": "TAIL_MAX_STOCKS", "SHORT_TOTAL_BUDGET_KRW": "TAIL_TOTAL_BUDGET_KRW", "SHORT_MAX_BUY_AMOUNT": "TAIL_MAX_BUY_AMOUNT", "TAIL_MAX_BUY_AMOUNT": "TAIL_MAX_BUY_AMOUNT", "SHORT_ENTRY_MODE": "TAIL_ENTRY_MODE", "SLOT_MONEY_DEFAULT": "TAIL_SLOT_MONEY", "MAX_BUY_AMOUNT_PER_STOCK": "TAIL_SLOT_MONEY", } # config_short 전용 TAIL 키 (오케스트레이터 SHORT_* 제외) TAIL_CONFIG_KEYS = frozenset({ "TAIL_MIN_DROP_RATE", "TAIL_MIN_RECOVERY_RATIO", "TAIL_MAX_RECOVERY_3M", "TAIL_RATIO_MIN", "TAIL_PCT_MIN", "TAIL_STOP_LOSS_PCT", "TAIL_TAKE_PROFIT_PCT", "TAIL_SHOULDER_MIN_HIGH_PCT", "TAIL_SHOULDER_CUT_PCT", "TAIL_STOP_ATR_MULT", "TAIL_TARGET_ATR_MULT", "TAIL_ATR_SL_MIN_PCT", "TAIL_ATR_SL_MAX_PCT", "TAIL_ATR_TP_MIN_PCT", "TAIL_ATR_TP_MAX_PCT", "TAIL_MIN_PRICE", "TAIL_MAX_DAILY_CHG", "TAIL_MA20_MAX_ABOVE_PCT", "TAIL_MAX_LOSS_KRW", "TAIL_MIN_DROP_FOR_LOSS_CUT", "TAIL_RSI_THRESHOLD", "TAIL_RSI_PERIOD", "TAIL_COOLDOWN_SEC", "TAIL_MAX_DAILY", "TAIL_HIGH_CHASE_THR", "TAIL_TIME_START", "TAIL_TIME_END", "TAIL_MAX_STOCKS", "TAIL_TOTAL_BUDGET_KRW", "TAIL_MAX_BUY_AMOUNT", "TAIL_SLOT_MONEY", "TAIL_ENTRY_MODE", "TAIL_LIMIT_ATR_MULT", "TAIL_LIMIT_ANCHOR", "TAIL_LIMIT_VALID_BARS", "TAIL_LIMIT_FILL_SLIP_PCT", "TAIL_SKIP_HTS_SCAN_DUPES", "TAIL_USE_INTRADAY_DROP", "TAIL_USE_MA20_FILTER", "TAIL_USE_RSI_FILTER", "TAIL_USE_DAILY_RANGE_FILTER", "TAIL_USE_HIGH_CHASE_FILTER", "TAIL_BAR_CHG_MIN_PCT", "TAIL_BAR_CHG_MAX_PCT", "TAIL_VOL_MULT", "TAIL_VOL_WIN", "TAIL_MIN_INVEST_RATIO_OF_SLOT", "TAIL_CANDLE_LOOKBACK", "TAIL_PATTERN_HAMMER", "TAIL_PATTERN_PIN", "TAIL_PATTERN_ENGULFING", "TAIL_PATTERN_PIERCING", "TAIL_PATTERN_HARAMI", "TAIL_PATTERN_DOJI", "TAIL_PATTERN_MORNING_STAR", "TAIL_PIN_CLOSE_UPPER_RATIO", "TAIL_PIN_MAX_UPPER_TAIL_RATIO", "TAIL_ENGULF_MIN_BODY_RATIO", "TAIL_PIERCING_PENETRATE_RATIO", "TAIL_HARAMI_MAX_BODY_RATIO", "TAIL_DOJI_BODY_MAX_RATIO", "TAIL_MORNING_STAR_BODY_MAX_RATIO", }) def _row_val(row: Dict[str, Any], key: str, default: Any = None) -> Any: v = row.get(key) if v not in (None, "", "None"): return v return default def tail_env_float(row: Dict[str, Any], key: str, default: float) -> float: v = _row_val(row, key) if v is None: return float(default) try: return float(v) except (TypeError, ValueError): return float(default) def tail_env_int(row: Dict[str, Any], key: str, default: int) -> int: v = _row_val(row, key) if v is None: return int(default) try: return int(float(v)) except (TypeError, ValueError): return int(default) def tail_market_time_hm() -> tuple: """ 꼬리잡기(SHORT) 실매 장시간 HHMM — ``TAIL_TIME_START`` / ``TAIL_TIME_END`` 단일 소스. 글로벌 ``TIME_START`` / ``TIME_END``(스캘핑 레거시) 는 **폴백하지 않음**. 백테·tail_engine 기본값과 동일 (930~1500, DB에 값 있으면 DB 우선). """ from kis_trader.utils.env import get_env_int start = get_env_int("TAIL_TIME_START", 930) end = get_env_int("TAIL_TIME_END", 1500) return int(start), int(end) def tail_env_bool(row: Dict[str, Any], key: str, default: bool) -> bool: v = _row_val(row, key) if v is None: return default s = str(v).strip().lower() if s in ("1", "true", "t", "y", "yes", "on"): return True if s in ("0", "false", "f", "n", "no", "off", ""): return False return default def params_to_tail_env_patch(p: Dict[str, Any]) -> Dict[str, str]: """파라서치·웹 JSON params → TAIL env 패치 (문자열).""" patch: Dict[str, str] = {} if not p: return patch def _set(k: str, v: Any) -> None: if v is not None and v != "": patch[k] = str(v) if "min_drop_rate" in p: _set("TAIL_MIN_DROP_RATE", p["min_drop_rate"]) if "min_recovery_ratio" in p: _set("TAIL_MIN_RECOVERY_RATIO", p["min_recovery_ratio"]) if "max_rec_3m" in p: _set("TAIL_MAX_RECOVERY_3M", p["max_rec_3m"]) if "tail_ratio_min" in p: _set("TAIL_RATIO_MIN", p["tail_ratio_min"]) if "tail_pct_min" in p: _set("TAIL_PCT_MIN", p["tail_pct_min"]) if "sl_pct" in p: _set("TAIL_STOP_LOSS_PCT", -abs(float(p["sl_pct"]))) if "tp_pct" in p: _set("TAIL_TAKE_PROFIT_PCT", p["tp_pct"]) if "shoulder_cut_pct" in p: _set("TAIL_SHOULDER_CUT_PCT", p["shoulder_cut_pct"]) if "shoulder_min_high" in p: _set("TAIL_SHOULDER_MIN_HIGH_PCT", p["shoulder_min_high"]) if "trail_pct" in p: _set("TAIL_TRAIL_PCT", abs(float(p["trail_pct"]))) if "trail_arm_pct" in p: _set("TAIL_TRAIL_ARM_PCT", abs(float(p["trail_arm_pct"]))) if "ratchet_tiers" in p: patch["TAIL_RATCHET_TIERS"] = str(p.get("ratchet_tiers") or "").strip() if "max_hold_bars" in p and p["max_hold_bars"] is not None: _set("TAIL_MAX_HOLD_BARS", int(float(p["max_hold_bars"]))) for js_k, env_k in ( ("backtest_use_tick_db", "TAIL_BACKTEST_USE_TICK_DB"), ("backtest_tick_fallback_ohlc", "TAIL_BACKTEST_TICK_FALLBACK_OHLC"), ): if js_k in p: _set(env_k, str(p[js_k]).lower()) if "rsi_threshold" in p: _set("TAIL_RSI_THRESHOLD", p["rsi_threshold"]) if "rsi_period" in p: _set("TAIL_RSI_PERIOD", int(float(p["rsi_period"]))) if "ma20_max_above" in p: _set("TAIL_MA20_MAX_ABOVE_PCT", p["ma20_max_above"]) if "max_daily_change" in p: _set("TAIL_MAX_DAILY_CHG", p["max_daily_change"]) if "stop_atr_mult" in p: _set("TAIL_STOP_ATR_MULT", p["stop_atr_mult"]) if "target_atr_mult" in p: _set("TAIL_TARGET_ATR_MULT", p["target_atr_mult"]) for js_k, env_k in ( ("atr_sl_min_pct", "TAIL_ATR_SL_MIN_PCT"), ("atr_sl_max_pct", "TAIL_ATR_SL_MAX_PCT"), ("atr_tp_min_pct", "TAIL_ATR_TP_MIN_PCT"), ("atr_tp_max_pct", "TAIL_ATR_TP_MAX_PCT"), ): if js_k in p: _set(env_k, p[js_k]) if "max_loss_krw" in p: _set("TAIL_MAX_LOSS_KRW", int(float(p["max_loss_krw"]))) if "min_drop_pct_for_loss_cut" in p: v = float(p["min_drop_pct_for_loss_cut"]) _set("TAIL_MIN_DROP_FOR_LOSS_CUT", round(v * 100, 2) if v < 1 else round(v, 2)) if "high_chase_thr" in p and p["high_chase_thr"] is not None: _set("TAIL_HIGH_CHASE_THR", float(p["high_chase_thr"])) if "cooldown_min" in p and p["cooldown_min"] is not None: _set("TAIL_COOLDOWN_SEC", int(float(p["cooldown_min"]) * 60)) if "time_start_hm" in p and p["time_start_hm"] is not None: _set("TAIL_TIME_START", int(float(p["time_start_hm"]))) if "time_end_hm" in p and p["time_end_hm"] is not None: _set("TAIL_TIME_END", int(float(p["time_end_hm"]))) if "max_daily" in p and p["max_daily"] is not None: _set("TAIL_MAX_DAILY", int(float(p["max_daily"]))) if "min_price" in p and p["min_price"] is not None: _set("TAIL_MIN_PRICE", float(p["min_price"])) if "slot_money" in p and p["slot_money"] is not None: _set("TAIL_SLOT_MONEY", int(float(p["slot_money"]))) if "max_stocks" in p and p["max_stocks"] is not None: _set("TAIL_MAX_STOCKS", int(float(p["max_stocks"]))) if "total_budget_krw" in p and p["total_budget_krw"] is not None: _set("TAIL_TOTAL_BUDGET_KRW", int(float(p["total_budget_krw"]))) if "entry_mode" in p and p["entry_mode"] is not None: _set("TAIL_ENTRY_MODE", str(p["entry_mode"]).strip().lower()) for js_k, env_k in ( ("limit_atr_mult", "TAIL_LIMIT_ATR_MULT"), ("limit_anchor", "TAIL_LIMIT_ANCHOR"), ("limit_valid_bars", "TAIL_LIMIT_VALID_BARS"), ("limit_fill_slip_pct", "TAIL_LIMIT_FILL_SLIP_PCT"), ): if js_k in p and p[js_k] is not None: _set(env_k, p[js_k]) for js_k, env_k in ( ("skip_hts_scan_dupes", "TAIL_SKIP_HTS_SCAN_DUPES"), ("use_intraday_drop", "TAIL_USE_INTRADAY_DROP"), ("use_ma20_filter", "TAIL_USE_MA20_FILTER"), ("use_rsi_filter", "TAIL_USE_RSI_FILTER"), ("use_daily_range_filter", "TAIL_USE_DAILY_RANGE_FILTER"), ("use_high_chase_filter", "TAIL_USE_HIGH_CHASE_FILTER"), ): if js_k in p: _set(env_k, str(p[js_k]).lower()) if "bar_chg_min_pct" in p: _set("TAIL_BAR_CHG_MIN_PCT", p["bar_chg_min_pct"]) if "bar_chg_max_pct" in p: _set("TAIL_BAR_CHG_MAX_PCT", p["bar_chg_max_pct"]) if "tail_vol_mult" in p: _set("TAIL_VOL_MULT", p["tail_vol_mult"]) if "tail_vol_win" in p: _set("TAIL_VOL_WIN", int(float(p["tail_vol_win"]))) for js_k, env_k in ( ("pattern_hammer", "TAIL_PATTERN_HAMMER"), ("pattern_pin", "TAIL_PATTERN_PIN"), ("pattern_engulfing", "TAIL_PATTERN_ENGULFING"), ("pattern_piercing", "TAIL_PATTERN_PIERCING"), ("pattern_harami", "TAIL_PATTERN_HARAMI"), ("pattern_doji", "TAIL_PATTERN_DOJI"), ("pattern_morning_star", "TAIL_PATTERN_MORNING_STAR"), ): if js_k in p: _set(env_k, str(p[js_k]).lower()) for js_k, env_k in ( ("pin_close_upper_ratio", "TAIL_PIN_CLOSE_UPPER_RATIO"), ("pin_max_upper_tail_ratio", "TAIL_PIN_MAX_UPPER_TAIL_RATIO"), ("engulf_min_body_ratio", "TAIL_ENGULF_MIN_BODY_RATIO"), ("piercing_penetrate_ratio", "TAIL_PIERCING_PENETRATE_RATIO"), ("harami_max_body_ratio", "TAIL_HARAMI_MAX_BODY_RATIO"), ("doji_body_max_ratio", "TAIL_DOJI_BODY_MAX_RATIO"), ("morning_star_body_max_ratio", "TAIL_MORNING_STAR_BODY_MAX_RATIO"), ("candle_lookback", "TAIL_CANDLE_LOOKBACK"), ): if js_k in p and p[js_k] is not None: _set(env_k, p[js_k]) return patch def web_body_to_tail_env_patch(body: Dict[str, Any]) -> Dict[str, str]: """웹 saveTailConfig POST → TAIL env (퍼센트·분 단위 변환).""" if not isinstance(body, dict): return {} def _get(key: str) -> Any: v = body.get(key) if v is None or v == "": return None return v p: Dict[str, Any] = {} md = _get("min_drop_rate") if md is not None: p["min_drop_rate"] = float(md) / 100.0 mr = _get("min_recovery_ratio") if mr is not None: p["min_recovery_ratio"] = float(mr) / 100.0 tr = _get("tail_ratio_min") if tr is not None: p["tail_ratio_min"] = float(tr) tp = _get("tail_pct_min") if tp is not None: p["tail_pct_min"] = float(tp) / 100.0 m3 = _get("max_rec_3m") if m3 is not None: x = float(m3) p["max_rec_3m"] = x if 0 < x <= 1 else x / 100.0 sl = _get("sl_pct") if sl is not None: p["sl_pct"] = float(sl) / 100.0 tpp = _get("tp_pct") if tpp is not None: p["tp_pct"] = float(tpp) / 100.0 smh = _get("shoulder_min_high") if smh is not None: p["shoulder_min_high"] = float(smh) / 100.0 sc = _get("shoulder_cut_pct") if sc is not None: p["shoulder_cut_pct"] = float(sc) / 100.0 trp = _get("trail_pct") if trp is not None: p["trail_pct"] = float(trp) / 100.0 tra = _get("trail_arm_pct") if tra is not None: p["trail_arm_pct"] = float(tra) / 100.0 hc = _get("high_chase_thr") if hc is not None: x = float(hc) p["high_chase_thr"] = x if 0 < x <= 1 else x / 100.0 for k in ( "cooldown_min", "rsi_threshold", "rsi_period", "time_start", "time_end", "max_daily", "min_price", "max_daily_change", "ma20_max_above", "max_loss_krw", "stop_atr_mult", "target_atr_mult", "atr_sl_min_pct", "atr_sl_max_pct", "atr_tp_min_pct", "atr_tp_max_pct", "slot_money", "max_stocks", "total_budget_krw", "entry_mode", "limit_atr_mult", "limit_anchor", "limit_valid_bars", "limit_fill_slip_pct", "skip_hts_scan_dupes", "use_intraday_drop", "use_ma20_filter", "use_rsi_filter", "use_daily_range_filter", "use_high_chase_filter", "bar_chg_min_pct", "bar_chg_max_pct", "max_hold_bars", "tail_vol_mult", "tail_vol_win", "backtest_use_tick_db", "backtest_tick_fallback_ohlc", "pattern_hammer", "pattern_pin", "pattern_engulfing", "pattern_piercing", "pattern_harami", "pattern_doji", "pattern_morning_star", "pin_close_upper_ratio", "pin_max_upper_tail_ratio", "engulf_min_body_ratio", "piercing_penetrate_ratio", "harami_max_body_ratio", "doji_body_max_ratio", "morning_star_body_max_ratio", "candle_lookback", ): v = _get(k) if v is not None: p[k] = v if "ratchet_tiers" in body: p["ratchet_tiers"] = str(body.get("ratchet_tiers") or "").strip() mdloss = _get("min_drop_pct_for_loss_cut") if mdloss is not None: p["min_drop_pct_for_loss_cut"] = float(mdloss) if "time_start" in p: p["time_start_hm"] = int(float(p.pop("time_start"))) if "time_end" in p: p["time_end_hm"] = int(float(p.pop("time_end"))) return params_to_tail_env_patch(p) def migrate_config_short_legacy(db=None) -> Dict[str, str]: """config_short 최신 행: 레거시 값 → TAIL_* 복사 후 UPDATE.""" own_db = None applied: Dict[str, str] = {} try: if db is None: from database import TradeDB own_db = TradeDB() db = own_db row = db.conn.execute( "SELECT * FROM config_short ORDER BY id DESC LIMIT 1" ).fetchone() if not row: return applied r = dict(row) rid = r.get("id") updates: Dict[str, str] = {} for old_k, new_k in LEGACY_TO_TAIL.items(): if new_k in updates: continue ov = r.get(old_k) nv = r.get(new_k) if ov not in (None, "", "None") and (nv in (None, "", "None")): updates[new_k] = str(ov) if not updates or rid is None: return applied cols = ", ".join(f"{k}=%s" for k in updates) db.conn.execute( f"UPDATE config_short SET {cols} WHERE id=%s", list(updates.values()) + [rid], ) try: db.conn.commit() except Exception: pass applied = updates finally: if own_db is not None: try: own_db.close() except Exception: pass return applied