""" kis_trader/engine/us_momentum_env_keys.py — 해외 모멘텀(US_MOMENTUM) env 키 ========================================================================== config_us_momentum · UsMomentumStrategy · 웹 · Optuna apply 공통. 국내 MOMENTUM_* / config_momentum 과 분리 (오염 금지). """ from __future__ import annotations from typing import Any, Dict, Optional # ENV_CONFIG_KEYS / classify → config_us_momentum 과 동기화 US_MOMENTUM_CONFIG_KEYS = frozenset({ "STRATEGY_US_MOMENTUM_ENABLED", "US_MOMENTUM_TIME_START", "US_MOMENTUM_TIME_END", "US_MOMENTUM_SESSION_START", "US_MOMENTUM_BUY_WAIT_LOG_SEC", "US_MOMENTUM_SELL_TIME_END", "US_MOMENTUM_DRY_RUN", "US_MOMENTUM_MAX_STOCKS", "US_MOMENTUM_SLOT_MONEY", "US_MOMENTUM_FEE_RATE", "US_MOMENTUM_SELL_TAX", "US_MOMENTUM_FX_FEE_RATE", "US_MOMENTUM_MIN_PRICE", "US_MOMENTUM_LIVE_MIN_CANDLES", "US_MOMENTUM_EOD_ENABLED", "US_MOMENTUM_EOD_HM", "US_MOMENTUM_MAX_BUY_AMOUNT", "US_MOMENTUM_TOTAL_BUDGET", "US_MOMENTUM_MAX_ORDER_QTY", "US_MOMENTUM_FORCE_BUY_QTY", "US_MOMENTUM_RELOAD_INTERVAL_SEC", "US_MOMENTUM_PSAMOUNT_CACHE_SEC", "US_MOMENTUM_TRADES_CACHE_SEC", "US_MOMENTUM_LOOP_SLEEP_MIN", "US_MOMENTUM_LOOP_SLEEP_MAX", "US_MOMENTUM_SCAN_REJECT_SLEEP_MIN", "US_MOMENTUM_SCAN_REJECT_SLEEP_MAX", "US_MOMENTUM_RSI_MIN", "US_MOMENTUM_RSI_MAX", "US_MOMENTUM_VOL_MULT", "US_MOMENTUM_VOL_WIN", "US_MOMENTUM_MAX_FROM_OPEN_PCT", "US_MOMENTUM_MIN_FROM_OPEN_PCT", "US_MOMENTUM_STOP_LOSS_PCT", "US_MOMENTUM_TAKE_PROFIT_PCT", "US_MOMENTUM_TP_MAX_PCT", "US_MOMENTUM_SHOULDER_MIN_HIGH_PCT", "US_MOMENTUM_SHOULDER_CUT_PCT", "US_MOMENTUM_RATCHET_TIERS", "US_MOMENTUM_TRAIL_PCT", "US_MOMENTUM_TRAIL_ARM_PCT", "US_MOMENTUM_MAX_HOLD_BARS", "US_MOMENTUM_MAX_DAILY", "US_MOMENTUM_MAX_DAILY_CHG", "US_MOMENTUM_HIGH_CHASE_THR", "US_MOMENTUM_USE_DEFENSE_FILTERS", "US_MOMENTUM_USE_HIGH_CHASE_FILTER", "US_MOMENTUM_USE_DAILY_RANGE_FILTER", "US_MOMENTUM_USE_EMA_FILTER", "US_MOMENTUM_EMA_FAST_PERIOD", "US_MOMENTUM_EMA_SLOW_PERIOD", "US_MOMENTUM_USE_RSI_MAX_FILTER", "US_MOMENTUM_PATTERN_BREAKOUT", "US_MOMENTUM_PATTERN_PULLBACK", "US_MOMENTUM_CHASE_LOOKBACK_MIN", "US_MOMENTUM_PULLBACK_LOOKBACK_MIN", "US_MOMENTUM_PULLBACK_MIN_PCT", "US_MOMENTUM_PULLBACK_MAX_PCT", "US_MOMENTUM_SETUP_VOL_MAX_MULT", "US_MOMENTUM_SETUP_BEAR_BARS_MIN", "US_MOMENTUM_MAX_LOSS_PER_TRADE", "US_MOMENTUM_MIN_PROFIT_PCT", "US_MOMENTUM_COOLDOWN_SEC", "US_MOMENTUM_DAILY_PROFIT_TARGET_ENABLED", "US_MOMENTUM_DAILY_PROFIT_TRAIL_TIERS", "US_MOMENTUM_DAILY_PROFIT_MODE", "KIS_US_MOMENTUM_MM_CHANNEL", }) def _env_bool_10(v: Any) -> str: if v is True or str(v).strip().lower() in ("1", "true", "t", "y", "yes", "on"): return "1" return "0" def params_to_us_momentum_env_patch(p: Dict[str, Any]) -> Dict[str, str]: """파라서치·웹 merged(UI %) → US_MOMENTUM_* 패치 (호가/프로그램 제외).""" if not p: return {} patch: Dict[str, str] = {} def gi(key: str, default: int = 0) -> int: v = p.get(key) return int(float(v)) if v not in (None, "") else default def gf(key: str, default: float = 0.0) -> float: v = p.get(key) return float(v) if v not in (None, "") else default def _set(k: str, v: Any) -> None: if v is not None and v != "": patch[k] = str(v) if "mom_rsi_min" in p: _set("US_MOMENTUM_RSI_MIN", gi("mom_rsi_min", 50)) if "mom_rsi_max" in p: _set("US_MOMENTUM_RSI_MAX", gi("mom_rsi_max", 80)) if "mom_vol_mult" in p: _set("US_MOMENTUM_VOL_MULT", gf("mom_vol_mult", 1.5)) if "mom_vol_win" in p: _set("US_MOMENTUM_VOL_WIN", gi("mom_vol_win", 5)) # TIME_* — session_env_patch 기본 OFF. 여기선 명시 키만(웹 저장용). if "time_start" in p or "time_start_hm" in p: ts = p.get("time_start_hm", p.get("time_start")) if ts not in (None, ""): _set("US_MOMENTUM_TIME_START", int(float(ts))) if "time_end" in p or "time_end_hm" in p or "mom_time_end_hm" in p: te = p.get("mom_time_end_hm", p.get("time_end_hm", p.get("time_end"))) if te not in (None, ""): _set("US_MOMENTUM_TIME_END", int(float(te))) if "sell_time_end" in p and p.get("sell_time_end") not in (None, ""): _set("US_MOMENTUM_SELL_TIME_END", int(float(p["sell_time_end"]))) if "sl_pct" in p: _set("US_MOMENTUM_STOP_LOSS_PCT", abs(gf("sl_pct", 1.5)) / 100.0) if "tp_pct" in p: _set("US_MOMENTUM_TAKE_PROFIT_PCT", abs(gf("tp_pct", 2.5)) / 100.0) if "tp_max_pct" in p and p.get("tp_max_pct") not in (None, ""): _set("US_MOMENTUM_TP_MAX_PCT", abs(float(p["tp_max_pct"])) / 100.0) if "cooldown_min" in p and p.get("cooldown_min") is not None: _set("US_MOMENTUM_COOLDOWN_SEC", int(float(p["cooldown_min"]) * 60)) if "max_daily" in p: _set("US_MOMENTUM_MAX_DAILY", gi("max_daily", 5)) sm = p.get("slot_money") if sm not in (None, ""): sms = str(int(float(sm))) patch["US_MOMENTUM_SLOT_MONEY"] = sms patch["US_MOMENTUM_MAX_BUY_AMOUNT"] = sms if "slots" in p or "max_stocks" in p: ms = p.get("max_stocks", p.get("slots")) if ms not in (None, ""): _set("US_MOMENTUM_MAX_STOCKS", int(float(ms))) if "total_budget_krw" in p and p.get("total_budget_krw") not in (None, ""): _set("US_MOMENTUM_TOTAL_BUDGET", int(float(p["total_budget_krw"]))) if "high_chase_thr" in p and p.get("high_chase_thr") is not None: hc = float(p["high_chase_thr"]) _set("US_MOMENTUM_HIGH_CHASE_THR", hc if 0 < hc <= 1 else hc / 100.0) if "max_daily_chg" in p: _set("US_MOMENTUM_MAX_DAILY_CHG", gf("max_daily_chg", 20.0)) if "min_price" in p: _set("US_MOMENTUM_MIN_PRICE", gf("min_price", 1.0)) if "max_loss_krw" in p and p.get("max_loss_krw") not in (None, ""): _set("US_MOMENTUM_MAX_LOSS_PER_TRADE", int(float(p["max_loss_krw"]))) if "min_margin" in p: _set("US_MOMENTUM_MIN_PROFIT_PCT", gf("min_margin", 0.2)) if "mom_max_from_open_pct" in p and p.get("mom_max_from_open_pct") not in (None, ""): _set("US_MOMENTUM_MAX_FROM_OPEN_PCT", float(p["mom_max_from_open_pct"])) if "mom_min_from_open_pct" in p and p.get("mom_min_from_open_pct") not in (None, ""): _set("US_MOMENTUM_MIN_FROM_OPEN_PCT", float(p["mom_min_from_open_pct"])) if "shoulder_min_high" in p and p.get("shoulder_min_high") not in (None, ""): _set("US_MOMENTUM_SHOULDER_MIN_HIGH_PCT", abs(float(p["shoulder_min_high"])) / 100.0) if "shoulder_cut_pct" in p and p.get("shoulder_cut_pct") not in (None, ""): _set("US_MOMENTUM_SHOULDER_CUT_PCT", abs(float(p["shoulder_cut_pct"])) / 100.0) has_trail_pct = "trail_pct" in p and p.get("trail_pct") not in (None, "") has_trail_arm = "trail_arm_pct" in p and p.get("trail_arm_pct") not in (None, "") has_legacy = ( ("trail_trigger" in p and p.get("trail_trigger") not in (None, "")) or ("trail_stop" in p and p.get("trail_stop") not in (None, "")) ) if has_trail_pct: _set("US_MOMENTUM_TRAIL_PCT", abs(float(p["trail_pct"])) / 100.0) elif has_legacy and not has_trail_arm: patch["US_MOMENTUM_TRAIL_PCT"] = "0" if has_trail_arm: _set("US_MOMENTUM_TRAIL_ARM_PCT", abs(float(p["trail_arm_pct"])) / 100.0) elif has_legacy and not has_trail_pct: patch["US_MOMENTUM_TRAIL_ARM_PCT"] = "0" if "ratchet_tiers" in p: patch["US_MOMENTUM_RATCHET_TIERS"] = str(p.get("ratchet_tiers") or "").strip() if "max_hold_bars" in p and p.get("max_hold_bars") not in (None, ""): _set("US_MOMENTUM_MAX_HOLD_BARS", int(float(p["max_hold_bars"]))) for js_k, env_k in ( ("use_defense_filters", "US_MOMENTUM_USE_DEFENSE_FILTERS"), ("use_high_chase_filter", "US_MOMENTUM_USE_HIGH_CHASE_FILTER"), ("use_daily_range_filter", "US_MOMENTUM_USE_DAILY_RANGE_FILTER"), ("use_ema_filter", "US_MOMENTUM_USE_EMA_FILTER"), ("use_rsi_max_filter", "US_MOMENTUM_USE_RSI_MAX_FILTER"), ("pattern_breakout", "US_MOMENTUM_PATTERN_BREAKOUT"), ("pattern_pullback", "US_MOMENTUM_PATTERN_PULLBACK"), ): if js_k in p: patch[env_k] = _env_bool_10(p.get(js_k)) if "chase_lookback_min" in p and p.get("chase_lookback_min") not in (None, ""): _set("US_MOMENTUM_CHASE_LOOKBACK_MIN", int(float(p["chase_lookback_min"]))) if "pullback_lookback_min" in p and p.get("pullback_lookback_min") not in (None, ""): _set("US_MOMENTUM_PULLBACK_LOOKBACK_MIN", int(float(p["pullback_lookback_min"]))) if "pullback_min_pct" in p and p.get("pullback_min_pct") not in (None, ""): _set("US_MOMENTUM_PULLBACK_MIN_PCT", float(p["pullback_min_pct"])) if "pullback_max_pct" in p and p.get("pullback_max_pct") not in (None, ""): _set("US_MOMENTUM_PULLBACK_MAX_PCT", float(p["pullback_max_pct"])) if "setup_vol_max_mult" in p and p.get("setup_vol_max_mult") not in (None, ""): _set("US_MOMENTUM_SETUP_VOL_MAX_MULT", float(p["setup_vol_max_mult"])) if "setup_bear_bars_min" in p and p.get("setup_bear_bars_min") not in (None, ""): _set("US_MOMENTUM_SETUP_BEAR_BARS_MIN", int(float(p["setup_bear_bars_min"]))) if "ema_fast_period" in p and p.get("ema_fast_period") not in (None, ""): _set("US_MOMENTUM_EMA_FAST_PERIOD", int(float(p["ema_fast_period"]))) if "ema_slow_period" in p and p.get("ema_slow_period") not in (None, ""): _set("US_MOMENTUM_EMA_SLOW_PERIOD", int(float(p["ema_slow_period"]))) if "eod_enabled" in p: patch["US_MOMENTUM_EOD_ENABLED"] = _env_bool_10(p.get("eod_enabled")) if "eod_hm" in p and p.get("eod_hm") not in (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["US_MOMENTUM_EOD_HM"] = eod_s if "daily_profit_enabled" in p: patch["US_MOMENTUM_DAILY_PROFIT_TARGET_ENABLED"] = ( "true" if _env_bool_10(p.get("daily_profit_enabled")) == "1" else "false" ) if "daily_trail_tiers" in p: tiers = str(p.get("daily_trail_tiers") or "").strip() patch["US_MOMENTUM_DAILY_PROFIT_TRAIL_TIERS"] = "" if tiers.lower() == "off" else tiers if tiers and tiers.lower() != "off": patch["US_MOMENTUM_DAILY_PROFIT_MODE"] = ( str(p.get("daily_profit_mode") or "trailing").strip().lower() or "trailing" ) elif "daily_profit_mode" in p: patch["US_MOMENTUM_DAILY_PROFIT_MODE"] = ( str(p.get("daily_profit_mode") or "trailing").strip().lower() or "trailing" ) # 수수료·SEC·환전 — UI% 이면 /100, 이미 비율(≤1)이면 그대로 def _ratio_from_ui_or_rate(key: str) -> Optional[float]: if key not in p or p.get(key) in (None, ""): return None try: x = abs(float(p[key])) except (TypeError, ValueError): return None if x > 1.0: return x / 100.0 return x for ui_key, env_key in ( ("fee_rate", "US_MOMENTUM_FEE_RATE"), ("sell_tax", "US_MOMENTUM_SELL_TAX"), ("fx_fee_rate", "US_MOMENTUM_FX_FEE_RATE"), ): r = _ratio_from_ui_or_rate(ui_key) if r is not None: _set(env_key, r) return patch # 한투 미국주식 온라인 기준 기본값 (비율). DB 비어 있을 때 코드 폴백. # 수수료 0.25% 편도 · SEC Fee 매도 0.00206% · 환전 스프레드 편도 0.05%(우대 95%≈1%×5%) US_MOMENTUM_FEE_RATE_DEFAULT = 0.0025 US_MOMENTUM_SELL_TAX_DEFAULT = 0.0000206 US_MOMENTUM_FX_FEE_RATE_DEFAULT = 0.0005 def us_momentum_trading_cost_rates() -> Dict[str, float]: """실매·백테·Optuna 공통 — (수수료, SEC/매도세, 환전편도) 비율.""" from kis_trader.utils.env import get_env_float fee = float(get_env_float("US_MOMENTUM_FEE_RATE", US_MOMENTUM_FEE_RATE_DEFAULT)) tax = float(get_env_float("US_MOMENTUM_SELL_TAX", US_MOMENTUM_SELL_TAX_DEFAULT)) fx = float(get_env_float("US_MOMENTUM_FX_FEE_RATE", US_MOMENTUM_FX_FEE_RATE_DEFAULT)) # 실수로 % 숫자(0.25)로 저장된 경우 → 비율로 정규화 if fee > 1.0: fee = fee / 100.0 if tax > 1.0: tax = tax / 100.0 if fx > 1.0: fx = fx / 100.0 return { "fee_rate": max(0.0, fee), "sell_tax": max(0.0, tax), "fx_fee_rate": max(0.0, fx), }