297 lines
12 KiB
Python
297 lines
12 KiB
Python
"""
|
||
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),
|
||
}
|