""" 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