Files
kis_bot/kis_trader/engine/tail_env_keys.py
2026-07-30 18:05:07 +09:00

472 lines
18 KiB
Python

"""
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_SYMBOL_DAILY_LOSS_LIMIT_KRW",
"TAIL_SYMBOL_DAILY_LOSS_LIMIT_PCT",
"TAIL_REENTRY_MIN_EDGE_KRW",
"TAIL_REENTRY_REQUIRE_NONNEG",
"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_use_tick_exit", "TAIL_BACKTEST_USE_TICK_EXIT"),
("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 "symbol_daily_loss_limit_krw" in p and p["symbol_daily_loss_limit_krw"] is not None:
_set("TAIL_SYMBOL_DAILY_LOSS_LIMIT_KRW", abs(float(p["symbol_daily_loss_limit_krw"])))
if "symbol_daily_loss_limit_pct" in p and p["symbol_daily_loss_limit_pct"] is not None:
_set("TAIL_SYMBOL_DAILY_LOSS_LIMIT_PCT", abs(float(p["symbol_daily_loss_limit_pct"])))
if "reentry_min_edge_krw" in p and p["reentry_min_edge_krw"] is not None:
_set("TAIL_REENTRY_MIN_EDGE_KRW", abs(float(p["reentry_min_edge_krw"])))
if "reentry_require_nonneg" in p:
_set("TAIL_REENTRY_REQUIRE_NONNEG", str(p["reentry_require_nonneg"]).lower())
if "eod_enabled" in p:
v = p.get("eod_enabled")
patch["TAIL_EOD_ENABLED"] = (
"1" if str(v).strip().lower() in ("1", "true", "t", "y", "yes", "on") else "0"
)
if "eod_hm" in p and p["eod_hm"] is not None:
eod_s = str(p["eod_hm"]).strip()
if eod_s and ":" not in eod_s and len(eod_s) == 4 and eod_s.isdigit():
eod_s = f"{eod_s[:2]}:{eod_s[2:]}"
patch["TAIL_EOD_HM"] = eod_s
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])
# 휩쏘 TRIGGER (Optuna TPE / apply-best) — 전략 전용 TAIL_WHIPSAW_*
if "whipsaw_enabled" in p:
_set("TAIL_WHIPSAW_FILTER_ENABLED", str(p["whipsaw_enabled"]).lower())
for js_k, env_k in (
("whipsaw_subbar_sec", "TAIL_WHIPSAW_SUBBAR_SEC"),
("whipsaw_lookback_sec", "TAIL_WHIPSAW_LOOKBACK_SEC"),
("whipsaw_dip_pct", "TAIL_WHIPSAW_DIP_PCT"),
("whipsaw_recovery_tol_pct", "TAIL_WHIPSAW_RECOVERY_TOL_PCT"),
("whipsaw_min_subbars", "TAIL_WHIPSAW_MIN_SUBBARS"),
):
if js_k in p and p[js_k] is not None and str(p[js_k]).strip() != "":
_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", "symbol_daily_loss_limit_krw", "symbol_daily_loss_limit_pct",
"reentry_min_edge_krw", "reentry_require_nonneg",
"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_use_tick_exit", "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()
if "eod_enabled" in body:
p["eod_enabled"] = body.get("eod_enabled")
eod_hm = _get("eod_hm")
if eod_hm is not None:
p["eod_hm"] = str(eod_hm).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