379 lines
13 KiB
Python
379 lines
13 KiB
Python
"""
|
|
kis_trader/strategies/us_momentum_stock_cfg.py
|
|
==============================================
|
|
해외 모멘텀 종목별 파라미터 — ``us_momentum_stock_config`` (박스엔진/DBBAND 패턴).
|
|
|
|
- 유니버스는 계속 ``permanent_subscriptions`` (market=US).
|
|
- 종목 행이 있으면 TRIGGER/청산 축만 덮어씀. 없으면 전역 ``US_MOMENTUM_*``.
|
|
- 포트 한도(TOTAL_BUDGET/MAX_STOCKS)·세션 공통은 전역 유지.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
logger = logging.getLogger("kis_trader.us_momentum_stock_cfg")
|
|
|
|
# 엔진 params 키 ↔ 테이블 컬럼 (UI % 숫자로 저장하는 축은 *_pct)
|
|
STOCK_ENGINE_FLOAT_PCT = (
|
|
# DB에 퍼센트 숫자(1.5)로 저장 → 엔진 비율(0.015)
|
|
("sl_pct", "sl_pct"),
|
|
("tp_pct", "tp_pct"),
|
|
("tp_max_pct", "tp_max_pct"),
|
|
("shoulder_min_high", "shoulder_min_high_pct"),
|
|
("shoulder_cut_pct", "shoulder_cut_pct"),
|
|
("trail_pct", "trail_pct"),
|
|
("trail_arm_pct", "trail_arm_pct"),
|
|
)
|
|
STOCK_ENGINE_FLOAT = (
|
|
("mom_rsi_min", "rsi_min"),
|
|
("mom_rsi_max", "rsi_max"),
|
|
("mom_vol_mult", "vol_mult"),
|
|
("pullback_min_pct", "pullback_min_pct"),
|
|
("pullback_max_pct", "pullback_max_pct"),
|
|
("setup_vol_max_mult", "setup_vol_max_mult"),
|
|
("high_chase_thr", "high_chase_thr"),
|
|
("max_daily_chg", "max_daily_chg"),
|
|
("min_price", "min_price"),
|
|
)
|
|
STOCK_ENGINE_INT = (
|
|
("mom_vol_win", "vol_win"),
|
|
("chase_lookback_min", "chase_lookback_min"),
|
|
("pullback_lookback_min", "pullback_lookback_min"),
|
|
("setup_bear_bars_min", "setup_bear_bars_min"),
|
|
("max_hold_bars", "max_hold_bars"),
|
|
("max_daily", "max_daily"),
|
|
("ema_fast_period", "ema_fast_period"),
|
|
("ema_slow_period", "ema_slow_period"),
|
|
("slot_money", "slot_money"),
|
|
("cooldown_sec", "cooldown_sec"), # → cooldown_min
|
|
)
|
|
STOCK_ENGINE_BOOL = (
|
|
("use_defense_filters", "use_defense_filters"),
|
|
("use_high_chase_filter", "use_high_chase_filter"),
|
|
("use_daily_range_filter", "use_daily_range_filter"),
|
|
("use_ema_filter", "use_ema_filter"),
|
|
("use_rsi_max_filter", "use_rsi_max_filter"),
|
|
("pattern_breakout", "pattern_breakout"),
|
|
("pattern_pullback", "pattern_pullback"),
|
|
)
|
|
|
|
_DDL = """
|
|
CREATE TABLE IF NOT EXISTS us_momentum_stock_config (
|
|
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
|
code VARCHAR(32) NOT NULL,
|
|
exchange VARCHAR(16) NOT NULL DEFAULT 'NASD',
|
|
symbol VARCHAR(32) NOT NULL DEFAULT '',
|
|
name VARCHAR(64) DEFAULT '',
|
|
stock_group VARCHAR(16) NOT NULL DEFAULT 'STOCK',
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
sl_pct DOUBLE NULL,
|
|
tp_pct DOUBLE NULL,
|
|
tp_max_pct DOUBLE NULL,
|
|
shoulder_min_high_pct DOUBLE NULL,
|
|
shoulder_cut_pct DOUBLE NULL,
|
|
trail_pct DOUBLE NULL,
|
|
trail_arm_pct DOUBLE NULL,
|
|
ratchet_tiers VARCHAR(255) NULL,
|
|
max_hold_bars INT NULL,
|
|
cooldown_sec INT NULL,
|
|
max_daily INT NULL,
|
|
slot_money DOUBLE NULL,
|
|
rsi_min DOUBLE NULL,
|
|
rsi_max DOUBLE NULL,
|
|
vol_mult DOUBLE NULL,
|
|
vol_win INT NULL,
|
|
chase_lookback_min INT NULL,
|
|
pullback_lookback_min INT NULL,
|
|
pullback_min_pct DOUBLE NULL,
|
|
pullback_max_pct DOUBLE NULL,
|
|
setup_vol_max_mult DOUBLE NULL,
|
|
setup_bear_bars_min INT NULL,
|
|
high_chase_thr DOUBLE NULL,
|
|
max_daily_chg DOUBLE NULL,
|
|
min_price DOUBLE NULL,
|
|
ema_fast_period INT NULL,
|
|
ema_slow_period INT NULL,
|
|
use_defense_filters TINYINT NULL,
|
|
use_high_chase_filter TINYINT NULL,
|
|
use_daily_range_filter TINYINT NULL,
|
|
use_ema_filter TINYINT NULL,
|
|
use_rsi_max_filter TINYINT NULL,
|
|
pattern_breakout TINYINT NULL,
|
|
pattern_pullback TINYINT NULL,
|
|
UNIQUE KEY uk_usmom_code (code),
|
|
KEY idx_usmom_group (stock_group)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
|
COMMENT='US_MOMENTUM 종목별 TRIGGER/청산 (NULL=전역 US_MOMENTUM_* 폴백)'
|
|
"""
|
|
|
|
|
|
def _trade_db_core(db: Any):
|
|
return getattr(db, "raw", db)
|
|
|
|
|
|
def ensure_us_momentum_stock_config_table(db: Any) -> None:
|
|
raw = _trade_db_core(db)
|
|
raw.conn.execute(_DDL.strip())
|
|
try:
|
|
raw.conn.commit()
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def _pct_to_ratio(x: float) -> float:
|
|
"""UI/테이블 % (1.5) → 엔진 비율 (0.015). 이미 소수면 그대로."""
|
|
ax = abs(float(x))
|
|
if ax == 0:
|
|
return 0.0
|
|
return ax if ax < 0.5 else ax / 100.0
|
|
|
|
|
|
def get_us_momentum_stock_row(db: Any, code: str) -> Optional[Dict[str, Any]]:
|
|
ensure_us_momentum_stock_config_table(db)
|
|
cu = str(code or "").strip().upper()
|
|
if not cu:
|
|
return None
|
|
raw = _trade_db_core(db)
|
|
try:
|
|
row = raw.conn.execute(
|
|
"SELECT * FROM us_momentum_stock_config WHERE code=%s",
|
|
(cu,),
|
|
).fetchone()
|
|
return dict(row) if row else None
|
|
except Exception as e:
|
|
logger.debug("get_us_momentum_stock_row 실패 %s: %s", cu, e)
|
|
return None
|
|
|
|
|
|
def list_us_momentum_stock_rows(db: Any) -> List[Dict[str, Any]]:
|
|
ensure_us_momentum_stock_config_table(db)
|
|
raw = _trade_db_core(db)
|
|
try:
|
|
rows = raw.conn.execute(
|
|
"SELECT * FROM us_momentum_stock_config ORDER BY stock_group, code"
|
|
).fetchall()
|
|
return [dict(r) for r in (rows or [])]
|
|
except Exception as e:
|
|
logger.warning("list_us_momentum_stock_rows 실패: %s", e)
|
|
return []
|
|
|
|
|
|
def apply_us_momentum_stock_overlay(
|
|
db: Any,
|
|
code: str,
|
|
base_params: Dict[str, Any],
|
|
) -> Dict[str, Any]:
|
|
"""전역 엔진 params 위에 종목 행(NULL 아닌 컬럼만) 덮어씀."""
|
|
out = dict(base_params or {})
|
|
row = get_us_momentum_stock_row(db, code)
|
|
if not row:
|
|
return out
|
|
out["_us_stock_cfg"] = True
|
|
out["_us_stock_group"] = str(row.get("stock_group") or "STOCK")
|
|
if row.get("exchange"):
|
|
out["_us_exchange"] = str(row["exchange"]).strip().upper()
|
|
|
|
for eng, col in STOCK_ENGINE_FLOAT_PCT:
|
|
v = row.get(col)
|
|
if v is None or v == "":
|
|
continue
|
|
try:
|
|
out[eng] = _pct_to_ratio(float(v))
|
|
except (TypeError, ValueError):
|
|
pass
|
|
|
|
for eng, col in STOCK_ENGINE_FLOAT:
|
|
v = row.get(col)
|
|
if v is None or v == "":
|
|
continue
|
|
try:
|
|
out[eng] = float(v)
|
|
except (TypeError, ValueError):
|
|
pass
|
|
|
|
for eng, col in STOCK_ENGINE_INT:
|
|
v = row.get(col)
|
|
if v is None or v == "":
|
|
continue
|
|
try:
|
|
if eng == "cooldown_sec":
|
|
out["cooldown_min"] = float(v) / 60.0
|
|
else:
|
|
out[eng] = int(float(v))
|
|
except (TypeError, ValueError):
|
|
pass
|
|
|
|
for eng, col in STOCK_ENGINE_BOOL:
|
|
v = row.get(col)
|
|
if v is None or v == "":
|
|
continue
|
|
try:
|
|
out[eng] = bool(int(v))
|
|
except (TypeError, ValueError):
|
|
out[eng] = bool(v)
|
|
|
|
rt = row.get("ratchet_tiers")
|
|
if rt is not None and str(rt).strip() != "":
|
|
out["ratchet_tiers"] = str(rt).strip()
|
|
|
|
return out
|
|
|
|
|
|
def upsert_us_momentum_stock_config(
|
|
db: Any,
|
|
code: str,
|
|
*,
|
|
exchange: str = "NASD",
|
|
symbol: str = "",
|
|
name: str = "",
|
|
stock_group: str = "STOCK",
|
|
fields: Optional[Dict[str, Any]] = None,
|
|
) -> bool:
|
|
"""종목 행 UPSERT. fields 의 키=테이블 컬럼명, None 은 NULL(전역 폴백)."""
|
|
ensure_us_momentum_stock_config_table(db)
|
|
cu = str(code or "").strip().upper()
|
|
if not cu:
|
|
return False
|
|
sym = str(symbol or cu).strip().upper() or cu
|
|
exch = str(exchange or "NASD").strip().upper() or "NASD"
|
|
grp = str(stock_group or "STOCK").strip().upper() or "STOCK"
|
|
if grp not in ("ETF", "STOCK", "OTHER"):
|
|
grp = "STOCK"
|
|
fields = dict(fields or {})
|
|
raw = _trade_db_core(db)
|
|
# 기존 행 — Optuna 부분 apply 시 미전달 컬럼을 NULL 로 지우지 않음
|
|
existing = get_us_momentum_stock_row(db, cu) or {}
|
|
cols = [
|
|
"code", "exchange", "symbol", "name", "stock_group",
|
|
"sl_pct", "tp_pct", "tp_max_pct",
|
|
"shoulder_min_high_pct", "shoulder_cut_pct",
|
|
"trail_pct", "trail_arm_pct", "ratchet_tiers",
|
|
"max_hold_bars", "cooldown_sec", "max_daily", "slot_money",
|
|
"rsi_min", "rsi_max", "vol_mult", "vol_win",
|
|
"chase_lookback_min", "pullback_lookback_min",
|
|
"pullback_min_pct", "pullback_max_pct",
|
|
"setup_vol_max_mult", "setup_bear_bars_min",
|
|
"high_chase_thr", "max_daily_chg", "min_price",
|
|
"ema_fast_period", "ema_slow_period",
|
|
"use_defense_filters", "use_high_chase_filter",
|
|
"use_daily_range_filter", "use_ema_filter", "use_rsi_max_filter",
|
|
"pattern_breakout", "pattern_pullback",
|
|
]
|
|
vals: Dict[str, Any] = {
|
|
"code": cu,
|
|
"exchange": exch,
|
|
"symbol": sym,
|
|
"name": str(name or cu),
|
|
"stock_group": grp,
|
|
}
|
|
for c in cols:
|
|
if c in ("code", "exchange", "symbol", "name", "stock_group"):
|
|
continue
|
|
if c in fields:
|
|
vals[c] = fields[c]
|
|
elif c in existing:
|
|
vals[c] = existing.get(c)
|
|
else:
|
|
vals[c] = None
|
|
|
|
placeholders = ", ".join(["%s"] * len(cols))
|
|
col_sql = ", ".join(cols)
|
|
updates = ", ".join(
|
|
f"{c}=VALUES({c})" for c in cols if c != "code"
|
|
)
|
|
try:
|
|
raw.conn.execute(
|
|
f"INSERT INTO us_momentum_stock_config ({col_sql}) VALUES ({placeholders}) "
|
|
f"ON DUPLICATE KEY UPDATE {updates}",
|
|
tuple(vals.get(c) for c in cols),
|
|
)
|
|
raw.conn.commit()
|
|
return True
|
|
except Exception as e:
|
|
logger.error("upsert_us_momentum_stock_config 실패 %s: %s", cu, e)
|
|
return False
|
|
|
|
|
|
def delete_us_momentum_stock_config(db: Any, code: str) -> bool:
|
|
ensure_us_momentum_stock_config_table(db)
|
|
cu = str(code or "").strip().upper()
|
|
if not cu:
|
|
return False
|
|
raw = _trade_db_core(db)
|
|
try:
|
|
raw.conn.execute(
|
|
"DELETE FROM us_momentum_stock_config WHERE code=%s", (cu,),
|
|
)
|
|
raw.conn.commit()
|
|
return True
|
|
except Exception as e:
|
|
logger.warning("delete_us_momentum_stock_config 실패 %s: %s", cu, e)
|
|
return False
|
|
|
|
|
|
def seed_us_momentum_stock_from_permanent(db: Any) -> int:
|
|
"""permanent US 활성 종목 중 행 없는 것만 STOCK/ETF 추정으로 빈 행 추가. 반환=신규 수."""
|
|
ensure_us_momentum_stock_config_table(db)
|
|
try:
|
|
from permanent_subs import codes_by_market
|
|
us_list = codes_by_market(db, "US", enabled_only=True) or []
|
|
except Exception as e:
|
|
logger.debug("seed permanent 조회 실패: %s", e)
|
|
return 0
|
|
n = 0
|
|
etf_hint = {"QQQ", "QQQM", "SPY", "SPYM", "IVV", "VOO", "DIA", "IWM", "TQQQ", "SQQQ"}
|
|
for row in us_list:
|
|
code = str(row.get("code") or row.get("symbol") or "").strip().upper()
|
|
if not code:
|
|
continue
|
|
if get_us_momentum_stock_row(db, code):
|
|
continue
|
|
grp = "ETF" if code in etf_hint or code.endswith("M") and len(code) <= 5 else "STOCK"
|
|
# QQQM/SPYM 명시
|
|
if code in ("QQQM", "SPYM", "QQQ", "SPY"):
|
|
grp = "ETF"
|
|
ok = upsert_us_momentum_stock_config(
|
|
db,
|
|
code,
|
|
exchange=str(row.get("exchange") or "NASD"),
|
|
symbol=str(row.get("symbol") or code),
|
|
name=str(row.get("note") or row.get("name") or code),
|
|
stock_group=grp,
|
|
fields={}, # 전부 NULL → 전역 폴백
|
|
)
|
|
if ok:
|
|
n += 1
|
|
return n
|
|
|
|
|
|
def row_to_ui_dict(row: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""웹 폼용 — NULL 유지(빈칸=전역)."""
|
|
if not row:
|
|
return {}
|
|
out = {
|
|
"code": row.get("code"),
|
|
"exchange": row.get("exchange"),
|
|
"symbol": row.get("symbol"),
|
|
"name": row.get("name"),
|
|
"stock_group": row.get("stock_group") or "STOCK",
|
|
}
|
|
for k in (
|
|
"sl_pct", "tp_pct", "tp_max_pct",
|
|
"shoulder_min_high_pct", "shoulder_cut_pct",
|
|
"trail_pct", "trail_arm_pct", "ratchet_tiers",
|
|
"max_hold_bars", "cooldown_sec", "max_daily", "slot_money",
|
|
"rsi_min", "rsi_max", "vol_mult", "vol_win",
|
|
"chase_lookback_min", "pullback_lookback_min",
|
|
"pullback_min_pct", "pullback_max_pct",
|
|
"setup_vol_max_mult", "setup_bear_bars_min",
|
|
"high_chase_thr", "max_daily_chg", "min_price",
|
|
"ema_fast_period", "ema_slow_period",
|
|
"use_defense_filters", "use_high_chase_filter",
|
|
"use_daily_range_filter", "use_ema_filter", "use_rsi_max_filter",
|
|
"pattern_breakout", "pattern_pullback",
|
|
):
|
|
out[k] = row.get(k)
|
|
return out
|