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

297 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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),
}