""" kis_trader/strategies/updow_holding_cfg.py ========================================== UPDOW(하락매수) 종목별 파라미터 — **``updow_stock_config`` 단일 테이블** (웹·백테·실매·JSON 적용 동일). - 종목 행이 있으면 그 값만 사용 (``env_config`` 의 ``UPDOW_*`` 로 덮지 않음). - 행이 없을 때만 env ``UPDOW_*`` 폴백 (신규 종목·그리드 끝값 등 전역 설정용). - ``holding_stock_config`` 의 ``updow_*`` 는 **최초 1회 마이그레이션** 용도만 (이후 미사용). 분봉(tf): ``updow_stock_config.tf_min`` 양수 → 해당 분봉, 아니면 env ``UPDOW_TF_MIN``. """ from __future__ import annotations import logging from typing import Any, Dict, Optional from ..engine.limit_entry_common import ENTRY_ALIGN, ENTRY_LIMIT_ATR from ..utils.env import get_env_int from .updow_buy import CFG_ENGINE_KEYS, DEFAULT_UPDOW_CONFIG logger = logging.getLogger("kis_trader.updow_holding_cfg") _UPDOW_STOCK_DDL = """ CREATE TABLE IF NOT EXISTS updow_stock_config ( id BIGINT AUTO_INCREMENT PRIMARY KEY, code VARCHAR(10) NOT NULL, market_type VARCHAR(8) NOT NULL DEFAULT 'KR', exchange VARCHAR(16) NOT NULL DEFAULT 'KRX', symbol VARCHAR(32) NOT NULL DEFAULT '', name VARCHAR(50) DEFAULT '', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, body_drop_min_pct DOUBLE NOT NULL, body_drop_max_pct DOUBLE NOT NULL DEFAULT 0, tp_pct DOUBLE NOT NULL, stop_loss_pct DOUBLE NOT NULL, max_hold_bars INT NOT NULL, exit_on_green DOUBLE NOT NULL DEFAULT 0, tf_min INT NOT NULL DEFAULT 60, slot_money DOUBLE NOT NULL DEFAULT 3000000, regime_ma_bars INT NOT NULL DEFAULT 0, regime_ma_ease_pct DOUBLE NOT NULL DEFAULT 0, atr_use_dynamic DOUBLE NOT NULL DEFAULT 0, atr_period INT NOT NULL DEFAULT 14, atr_sl_mult DOUBLE NOT NULL DEFAULT 2, atr_tp_mult DOUBLE NOT NULL DEFAULT 4, atr_sl_min_pct DOUBLE NOT NULL DEFAULT 0.8, atr_sl_max_pct DOUBLE NOT NULL DEFAULT 6, atr_tp_min_pct DOUBLE NOT NULL DEFAULT 1.5, atr_tp_max_pct DOUBLE NOT NULL DEFAULT 12, shoulder_min_high_pct DOUBLE NOT NULL DEFAULT 0.3, shoulder_cut_pct DOUBLE NOT NULL DEFAULT 0.2, limit_atr_mult DOUBLE NOT NULL DEFAULT 1.5, entry_mode VARCHAR(16) NOT NULL DEFAULT '', KEY idx_updow_stock_code (code) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='UPDOW 하락매수 종목별 파라미터' """ def _trade_db_core(db: Any): """TradeDBExt 이면 ``.raw``, 아니면 그대로.""" return getattr(db, "raw", db) def ensure_updow_stock_config_table(db: Any) -> None: """``updow_stock_config`` 테이블 생성.""" raw = _trade_db_core(db) raw.conn.execute(_UPDOW_STOCK_DDL.strip()) # 구버전 테이블(시장 컬럼 없음) 호환 마이그레이션 alters = ( "ADD COLUMN market_type VARCHAR(8) NOT NULL DEFAULT 'KR' AFTER code", "ADD COLUMN exchange VARCHAR(16) NOT NULL DEFAULT 'KRX' AFTER market_type", "ADD COLUMN symbol VARCHAR(32) NOT NULL DEFAULT '' AFTER exchange", "ADD COLUMN shoulder_min_high_pct DOUBLE NOT NULL DEFAULT 0.3 AFTER atr_tp_max_pct", "ADD COLUMN shoulder_cut_pct DOUBLE NOT NULL DEFAULT 0.2 AFTER shoulder_min_high_pct", "ADD COLUMN body_drop_max_pct DOUBLE NOT NULL DEFAULT 0 AFTER body_drop_min_pct", "ADD COLUMN limit_atr_mult DOUBLE NOT NULL DEFAULT 1.5 AFTER shoulder_cut_pct", # 종목별 진입모드(align/limit_atr) — '' 이면 글로벌 env UPDOW_ENTRY_MODE 사용 "ADD COLUMN entry_mode VARCHAR(16) NOT NULL DEFAULT '' AFTER limit_atr_mult", ) for frag in alters: try: raw.conn.execute(f"ALTER TABLE updow_stock_config {frag}") except Exception: pass try: raw.conn.execute( "UPDATE updow_stock_config " "SET symbol = code " "WHERE (symbol IS NULL OR symbol = '') AND code IS NOT NULL AND code != ''" ) except Exception: pass # 영문 티커(QQQM 등)가 KR/KRX 로 저장된 구버전 행 보정 try: raw.conn.execute( "UPDATE updow_stock_config " "SET market_type='US', exchange='NASD', symbol=UPPER(code) " "WHERE code REGEXP '^[A-Za-z]{1,8}$' " "AND (market_type='KR' OR exchange='KRX' OR exchange='')" ) except Exception: pass raw.conn.commit() def ensure_updow_backtest_tables(db: Any) -> None: """ UPDOW 백테·탐색·웹용 DB 준비. - ``holding_min_candles`` 등 분봉 저장 테이블 (``holding_bot.ensure_holding_tables``) - ``updow_stock_config`` 종목별 파라미터 (하락매수 전용, ``holding_stock_config`` 와 분리) """ try: import holding_bot as hb hb.ensure_holding_tables(db) except Exception as e: logger.warning("holding 분봉 테이블 확인 경고: %s", e) ensure_updow_stock_config_table(db) logger.info("✅ UPDOW DB 확인: holding_min_candles(분봉) + updow_stock_config(종목 파라미터)") def _engine_cfg_from_row(row: Dict[str, Any]) -> Dict[str, Any]: out: Dict[str, Any] = {} for k in CFG_ENGINE_KEYS: if k in row and row[k] is not None: out[k] = float(row[k]) else: # 구버전 행·신규 추가 키 누락 시 엔진 기본값으로 보강 (KeyError 방지) out[k] = float(DEFAULT_UPDOW_CONFIG.get(k, 0.0)) # 종목별 진입모드(문자열) — 유효값일 때만 주입. # 빈 문자열을 out["entry_mode"]에 넣으면 updow_entry_mode()가 align 으로 오인하므로 # 반드시 valid 일 때만 키를 세팅하고, 아니면 키 자체를 넣지 않아 글로벌 env 로 폴백한다. em = str(row.get("entry_mode") or "").strip().lower() if em in (ENTRY_ALIGN, ENTRY_LIMIT_ATR): out["entry_mode"] = em return out def _normalize_market_meta( market_type: Optional[str], exchange: Optional[str], symbol: Optional[str], code: str, ) -> tuple[str, str, str]: mt = str(market_type or "KR").strip().upper() if mt not in ("KR", "US"): mt = "KR" ex = str(exchange or ("KRX" if mt == "KR" else "NASD")).strip().upper() sym = str(symbol or code or "").strip().upper() if not sym: sym = str(code or "").strip().upper() if not ex: ex = "KRX" if mt == "KR" else "NASD" return mt, ex, sym def resolve_market_meta( code: str, market_type: Optional[str] = None, exchange: Optional[str] = None, symbol: Optional[str] = None, ) -> tuple[str, str, str]: """ 시장 메타 정규화 + 티커 형태 보정. DB에 QQQM 등이 market_type=KR 로 잘못 들어가도, 6자리 숫자가 아닌 영문 티커는 해외(US)로 간주한다. """ c = str(code or symbol or "").strip().upper() mt, ex, sym = _normalize_market_meta(market_type, exchange, symbol, c) if not c: return mt, ex, sym if c.isdigit() and len(c) == 6: return "KR", ex if ex not in ("NASD", "NAS", "NYSE", "NYS", "AMEX", "AMS") else "KRX", sym or c if c.isalpha() and 1 <= len(c) <= 8: if mt == "KR" or ex in ("", "KRX"): return "US", "NASD", sym or c return mt, ex, sym def get_updow_stock_config_row(db: Any, code: str) -> Optional[Dict[str, Any]]: """``updow_stock_config`` 최신 1행 → 엔진 cfg dict (없으면 None).""" ensure_updow_stock_config_table(db) code = str(code or "").strip() if not code: return None raw = _trade_db_core(db) row = raw.conn.execute( "SELECT * FROM updow_stock_config " "WHERE code=%s OR symbol=%s " "ORDER BY id DESC LIMIT 1", [code, code], ).fetchone() if not row: return None d = dict(row) if not isinstance(row, dict) else dict(row) return _engine_cfg_from_row(d) def get_updow_stock_meta(db: Any, code: str) -> Optional[Dict[str, Any]]: """종목명·tf 포함 메타 (최신 행).""" ensure_updow_stock_config_table(db) code = str(code or "").strip() if not code: return None raw = _trade_db_core(db) row = raw.conn.execute( "SELECT code, market_type, exchange, symbol, name, tf_min, created_at " "FROM updow_stock_config " "WHERE code=%s OR symbol=%s ORDER BY id DESC LIMIT 1", [code, code], ).fetchone() if not row: return None return dict(row) if not isinstance(row, dict) else dict(row) def set_updow_stock_config( db: Any, code: str, name: str, engine_cfg: Dict[str, Any], tf_min: Optional[int] = None, market_type: Optional[str] = None, exchange: Optional[str] = None, symbol: Optional[str] = None, ) -> None: """UPDOW 종목 파라미터 INSERT (최신 row 방식).""" ensure_updow_stock_config_table(db) code = str(code or symbol or "").strip().upper() if not code: raise ValueError("code 필수") mt, ex, sym = _normalize_market_meta(market_type, exchange, symbol, code) base = dict(DEFAULT_UPDOW_CONFIG) for k in CFG_ENGINE_KEYS: if k in engine_cfg and engine_cfg[k] is not None: base[k] = float(engine_cfg[k]) try: tfv = int(float(tf_min if tf_min is not None else engine_cfg.get("tf_min", base.get("tf_min", 60)))) except (TypeError, ValueError): tfv = int(get_env_int("UPDOW_TF_MIN", 60)) if tfv < 1: tfv = int(get_env_int("UPDOW_TF_MIN", 60)) # 진입모드(문자열) — align/limit_atr 만 허용, 그 외는 '' (글로벌 env 폴백) em = str(engine_cfg.get("entry_mode") or "").strip().lower() if em not in (ENTRY_ALIGN, ENTRY_LIMIT_ATR): em = "" cols = ["code", "market_type", "exchange", "symbol", "name", "tf_min"] + list(CFG_ENGINE_KEYS) + ["entry_mode"] vals = [code, mt, ex, sym, str(name or "").strip() or code, tfv] for k in CFG_ENGINE_KEYS: vals.append(base[k]) vals.append(em) placeholders = ", ".join(["%s"] * len(cols)) col_sql = ", ".join(cols) raw = _trade_db_core(db) raw.conn.execute( f"INSERT INTO updow_stock_config ({col_sql}) VALUES ({placeholders})", vals, ) raw.conn.commit() def load_updow_engine_cfg(db: Any, code: str, env_fallback: Dict[str, Any]) -> Dict[str, Any]: """ UPDOW 종목 cfg — ``updow_stock_config`` 우선, 없으면 ``env_fallback`` 만. (holding_stock_config·env 이중 병합 없음) """ row_cfg = get_updow_stock_config_row(db, code) if row_cfg: return row_cfg return dict(env_fallback) def fetch_latest_updow_stock_config_by_code(db: Any) -> Dict[str, Dict[str, Any]]: """종목코드별 ``updow_stock_config`` 최신 1행 (실매 유니버스·웹 목록).""" ensure_updow_stock_config_table(db) raw = _trade_db_core(db) sql = """ SELECT u.* FROM updow_stock_config u INNER JOIN ( SELECT code, MAX(id) AS mx FROM updow_stock_config GROUP BY code ) t ON u.code = t.code AND u.id = t.mx ORDER BY u.code """ cur = raw.conn.execute(sql) rows = cur.fetchall() if cur else [] out: Dict[str, Dict[str, Any]] = {} for row in rows: d = dict(row) if not isinstance(row, dict) else dict(row) code = str(d.get("code", "")).strip() if not code: continue eng = _engine_cfg_from_row(d) eng["name"] = str(d.get("name") or code).strip() or code eng["tf_min"] = int(float(d.get("tf_min") or get_env_int("UPDOW_TF_MIN", 60))) eng["market_type"] = str(d.get("market_type") or "KR").strip().upper() or "KR" eng["exchange"] = str(d.get("exchange") or ("KRX" if eng["market_type"] == "KR" else "NASD")).strip().upper() eng["symbol"] = str(d.get("symbol") or code).strip().upper() or code out[code] = eng return out def list_updow_stock_codes(db: Any) -> list: """웹 종목 셀렉트용 [{code, name}, ...].""" by_code = fetch_latest_updow_stock_config_by_code(db) out = [] for c in sorted(by_code.keys()): mt, ex, sym = resolve_market_meta( c, by_code[c].get("market_type"), by_code[c].get("exchange"), by_code[c].get("symbol"), ) out.append({ "code": c, "name": by_code[c].get("name", c), "market_type": mt, "exchange": ex, "symbol": sym, }) return out def migrate_updow_from_holding(db: Any, code: str, env_fallback: Dict[str, Any]) -> bool: """ ``holding_stock_config`` → ``updow_stock_config`` 1회 복사 (이미 있으면 False). 구버전 holding ``updow_*`` + 익절/손절 병합값을 그대로 옮김. """ if get_updow_stock_config_row(db, code): return False try: import holding_bot as hb except ImportError: return False hold_row = hb.get_stock_config_row(db, code) if not hold_row: return False merged = merge_holding_row_into_updow_cfg(hold_row, env_fallback) tf = effective_updow_tf_min(hold_row, int(env_fallback.get("tf_min", get_env_int("UPDOW_TF_MIN", 60)))) name = str(hold_row.get("name") or code).strip() or code set_updow_stock_config(db, code, name, merged, tf_min=tf) logger.info("updow_stock_config 마이그레이션: %s ← holding_stock_config", code) return True def migrate_all_updow_from_holding(db: Any, env_fallback: Dict[str, Any]) -> int: """holding 에 등록된 모든 코드를 updow_stock_config 로 (없는 것만).""" try: import holding_bot as hb except ImportError: return 0 n = 0 try: items = hb.load_watchlist() except Exception: items = [] codes = {str(it.get("code", "")).strip() for it in items if it.get("code")} raw = _trade_db_core(db) cur = raw.conn.execute("SELECT DISTINCT code FROM holding_stock_config") for row in (cur.fetchall() if cur else []): c = str(dict(row).get("code", "")).strip() if c: codes.add(c) for code in sorted(codes): if migrate_updow_from_holding(db, code, env_fallback): n += 1 return n def ensure_holding_updow_columns(db: Any) -> None: """holding_stock_config 에 UPDOW 전용 선택 컬럼 추가(이미 있으면 무시).""" raw = _trade_db_core(db) alters = ( "ADD COLUMN updow_body_drop_min_pct DOUBLE NULL " "COMMENT 'UPDOW 직전봉 몸통하락%% 하한, NULL=env UPDOW_BODY_DROP_MIN_PCT'", "ADD COLUMN updow_max_hold_bars INT NULL " "COMMENT 'UPDOW 최대 보유 봉 수, NULL=env UPDOW_MAX_HOLD_BARS'", "ADD COLUMN updow_exit_on_green DOUBLE NULL " "COMMENT 'UPDOW 양봉청산 1=ON 0=OFF, NULL=env UPDOW_EXIT_ON_GREEN'", "ADD COLUMN updow_tf_min INT NULL " "COMMENT 'UPDOW 분봉 tf, NULL 또는 1미만=env UPDOW_TF_MIN'", "ADD COLUMN updow_regime_ma_bars INT NULL " "COMMENT 'UPDOW KOSPI프록시 1분 MA봉수 0=OFF NULL=env UPDOW_REGIME_MA_BARS'", "ADD COLUMN updow_atr_use_dynamic DOUBLE NULL " "COMMENT 'UPDOW ATR 동적 청산 1=ON 0=OFF NULL=env UPDOW_ATR_USE_DYNAMIC'", "ADD COLUMN updow_atr_period INT NULL " "COMMENT 'UPDOW ATR 기간(봉), NULL=env UPDOW_ATR_PERIOD'", "ADD COLUMN updow_atr_sl_mult DOUBLE NULL " "COMMENT 'UPDOW ATR 손절 배수, NULL=env UPDOW_ATR_SL_MULT'", "ADD COLUMN updow_atr_tp_mult DOUBLE NULL " "COMMENT 'UPDOW ATR 익절 배수, NULL=env UPDOW_ATR_TP_MULT'", "ADD COLUMN updow_atr_sl_min_pct DOUBLE NULL " "COMMENT 'UPDOW ATR 손절 하한(%) NULL=env UPDOW_ATR_SL_MIN_PCT'", "ADD COLUMN updow_atr_sl_max_pct DOUBLE NULL " "COMMENT 'UPDOW ATR 손절 상한(%) NULL=env UPDOW_ATR_SL_MAX_PCT'", "ADD COLUMN updow_atr_tp_min_pct DOUBLE NULL " "COMMENT 'UPDOW ATR 익절 하한(%) NULL=env UPDOW_ATR_TP_MIN_PCT'", "ADD COLUMN updow_atr_tp_max_pct DOUBLE NULL " "COMMENT 'UPDOW ATR 익절 상한(%) NULL=env UPDOW_ATR_TP_MAX_PCT'", "ADD COLUMN updow_shoulder_min_high_pct DOUBLE NULL " "COMMENT 'UPDOW 어깨 발동(%%) NULL=env UPDOW_SHOULDER_MIN_HIGH_PCT'", "ADD COLUMN updow_shoulder_cut_pct DOUBLE NULL " "COMMENT 'UPDOW 어깨 폭(%%) NULL=env UPDOW_SHOULDER_CUT_PCT'", ) for frag in alters: try: raw.conn.execute(f"ALTER TABLE holding_stock_config {frag}") raw.conn.commit() except Exception: pass def fetch_latest_holding_stock_config_by_code(db: Any) -> Dict[str, Dict[str, Any]]: """종목코드별 ``holding_stock_config`` 최신 1행 (id DESC).""" raw = _trade_db_core(db) sql = """ SELECT h.* FROM holding_stock_config h INNER JOIN ( SELECT code, MAX(id) AS mx FROM holding_stock_config GROUP BY code ) t ON h.code = t.code AND h.id = t.mx ORDER BY h.code """ cur = raw.conn.execute(sql) rows = cur.fetchall() if cur else [] out: Dict[str, Dict[str, Any]] = {} for row in rows: d = dict(row) if not isinstance(row, dict) else dict(row) code = str(d.get("code", "")).strip() if not code: continue d.pop("id", None) d.pop("created_at", None) out[code] = d return out def merge_holding_row_into_updow_cfg( row: Optional[Dict[str, Any]], env_fallback: Dict[str, Any], ) -> Dict[str, Any]: """ ``holding_stock_config`` 한 행을 ``updow_buy`` 엔진 cfg 형식으로 병합. - ``take_profit_pct`` → ``tp_pct`` - ``stop_loss_pct`` → ``stop_loss_pct`` (%% 양수, 백테와 동일) - ``slot_money`` → ``slot_money`` - ``updow_body_drop_min_pct`` / ``updow_max_hold_bars`` / ``updow_exit_on_green`` / ``updow_regime_ma_bars`` 가 NULL 이 아니고 유효하면 env 를 덮어씀. """ out: Dict[str, Any] = dict(env_fallback) if not row: return out def _f(key: str) -> Optional[float]: v = row.get(key) if v is None or (isinstance(v, str) and not str(v).strip()): return None try: return float(v) except (TypeError, ValueError): return None try: tp = _f("take_profit_pct") if tp is not None and tp > 0: out["tp_pct"] = tp sl = _f("stop_loss_pct") if sl is not None and sl > 0: out["stop_loss_pct"] = sl sm = _f("slot_money") if sm is not None and sm > 0: out["slot_money"] = sm bd = _f("updow_body_drop_min_pct") if bd is not None and bd >= 0.0: out["body_drop_min_pct"] = bd mh = _f("updow_max_hold_bars") if mh is not None and mh >= 1.0: out["max_hold_bars"] = float(int(mh)) eg = _f("updow_exit_on_green") # -1 = 미설정(장기홀딩 기본값) → env 유지. 0~1 은 UPDOW 양봉청산 on/off. if eg is not None and eg >= 0.0: out["exit_on_green"] = 1.0 if eg >= 0.5 else 0.0 rm = _f("updow_regime_ma_bars") if rm is not None and rm >= 0.0: out["regime_ma_bars"] = float(int(rm)) aud = _f("updow_atr_use_dynamic") if aud is not None and aud >= 0.0: out["atr_use_dynamic"] = 1.0 if aud >= 0.5 else 0.0 ap = _f("updow_atr_period") if ap is not None and ap >= 1.0: out["atr_period"] = float(int(ap)) asm = _f("updow_atr_sl_mult") if asm is not None and asm > 0.0: out["atr_sl_mult"] = asm atm = _f("updow_atr_tp_mult") if atm is not None and atm > 0.0: out["atr_tp_mult"] = atm asmin = _f("updow_atr_sl_min_pct") if asmin is not None and asmin > 0.0: out["atr_sl_min_pct"] = asmin asmax = _f("updow_atr_sl_max_pct") if asmax is not None and asmax > 0.0: out["atr_sl_max_pct"] = asmax atmin = _f("updow_atr_tp_min_pct") if atmin is not None and atmin > 0.0: out["atr_tp_min_pct"] = atmin atmax = _f("updow_atr_tp_max_pct") if atmax is not None and atmax > 0.0: out["atr_tp_max_pct"] = atmax usmh = _f("updow_shoulder_min_high_pct") if usmh is not None and usmh > 0.0: out["shoulder_min_high_pct"] = usmh usc = _f("updow_shoulder_cut_pct") if usc is not None and usc > 0.0: out["shoulder_cut_pct"] = usc except Exception as e: logger.debug("merge_holding_row_into_updow_cfg 경고: %s", e) return out def updow_param_sources_for_code( db: Any, code: str, env_fallback: Dict[str, Any], ) -> Dict[str, Dict[str, Any]]: """웹 표시: ``updow_stock_config`` 행 있으면 전부 종목, 없으면 env 폴백.""" stock_cfg = get_updow_stock_config_row(db, code) applied = load_updow_engine_cfg(db, code, env_fallback) src = "updow_stock" if stock_cfg else "env" out: Dict[str, Dict[str, Any]] = {} for k in CFG_ENGINE_KEYS: out[k] = { "from": src, "applied": applied.get(k), "updow_stock": stock_cfg.get(k) if stock_cfg else None, "env": env_fallback.get(k), } return out def effective_updow_tf_for_code(db: Any, code: str, env_tf: int) -> int: """``updow_stock_config.tf_min`` 우선.""" meta = get_updow_stock_meta(db, code) if meta: try: iv = int(float(meta.get("tf_min", 0))) if iv >= 1: return iv except (TypeError, ValueError): pass try: et = int(env_tf) except (TypeError, ValueError): et = 0 if et < 1: et = get_env_int("UPDOW_TF_MIN", 60) return et def effective_updow_tf_min(row: Optional[Dict[str, Any]], env_tf: int) -> int: """ 종목별 ``holding_stock_config.updow_tf_min`` — 양의 정수면 해당 분봉, NULL·1미만·변환 실패 시 ``env_tf`` (보통 env ``UPDOW_TF_MIN`` 스냅샷 값). """ try: et = int(env_tf) except (TypeError, ValueError): et = 0 if et < 1: et = get_env_int("UPDOW_TF_MIN", 60) if not row: return et raw = row.get("updow_tf_min") if raw is None: return et try: iv = int(float(raw)) except (TypeError, ValueError): return et if iv < 1: return et return iv