""" kis_trader/strategies/dbband_stock_cfg.py ========================================= 더블 볼린저(DBBAND) 종목별 파라미터 — ``dbband_stock_config`` 단일 테이블. - 종목 행이 있으면 그 값만 사용 (env ``DBBAND_*`` 로 덮지 않음). - 행이 없을 때만 env 폴백 (그리드 끝값·전역 기본). - QQQM(나스닥100)·069500(KOSPI) 등 **종목마다 BB/MA/손익비를 따로** 저장·탐색. 분봉(tf): ``dbband_stock_config.tf_min`` 양수 → 해당 분봉, 아니면 env ``DBBAND_TIMEFRAME``. """ from __future__ import annotations import logging from typing import Any, Dict, List, Optional, Tuple from ..engine.dbband_engine import CFG_ENGINE_KEYS, DEFAULT_DBBAND_CONFIG from ..utils.env import get_env_int logger = logging.getLogger("kis_trader.dbband_stock_cfg") _STR_COLS = ("side_mode", "entry_mode", "stop_mode", "tp_mode", "exit_mode") _DBBAND_STOCK_DDL = """ CREATE TABLE IF NOT EXISTS dbband_stock_config ( id BIGINT AUTO_INCREMENT PRIMARY KEY, code VARCHAR(32) 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, tf_min INT NOT NULL DEFAULT 15, bb_period INT NOT NULL DEFAULT 20, bb_inner_std DOUBLE NOT NULL DEFAULT 2, bb_outer_std DOUBLE NOT NULL DEFAULT 3, trend_ma_period INT NOT NULL DEFAULT 200, use_trend_filter DOUBLE NOT NULL DEFAULT 1, side_mode VARCHAR(16) NOT NULL DEFAULT 'long_only', entry_valid_bars INT NOT NULL DEFAULT 3, entry_mode VARCHAR(16) NOT NULL DEFAULT 'break_high', stop_mode VARCHAR(16) NOT NULL DEFAULT 'signal_low', stop_buffer_pct DOUBLE NOT NULL DEFAULT 0.1, stop_loss_pct DOUBLE NOT NULL DEFAULT 2, tp_mode VARCHAR(16) NOT NULL DEFAULT 'opposite_band', take_profit_pct DOUBLE NOT NULL DEFAULT 3, rr_ratio DOUBLE NOT NULL DEFAULT 2, exit_mode VARCHAR(16) NOT NULL DEFAULT 'classic', shoulder_min_high_pct DOUBLE NOT NULL DEFAULT 0.3, shoulder_cut_pct DOUBLE NOT NULL DEFAULT 0.2, trail_pct DOUBLE NOT NULL DEFAULT 0, trail_arm_pct DOUBLE NOT NULL DEFAULT 0, max_hold_bars INT NOT NULL DEFAULT 0, slot_money DOUBLE NOT NULL DEFAULT 3000000, cooldown_min DOUBLE NOT NULL DEFAULT 15, max_daily INT NOT NULL DEFAULT 3, KEY idx_dbband_stock_code (code) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='DBBAND 더블볼린저 종목별 파라미터' """ def _trade_db_core(db: Any): return getattr(db, "raw", db) def ensure_dbband_stock_config_table(db: Any) -> None: raw = _trade_db_core(db) raw.conn.execute(_DBBAND_STOCK_DDL.strip()) try: raw.conn.execute( "ALTER TABLE dbband_stock_config " "ADD COLUMN exit_mode VARCHAR(16) NOT NULL DEFAULT 'classic'" ) raw.conn.commit() except Exception: pass try: raw.conn.execute( "UPDATE dbband_stock_config SET symbol = code " "WHERE (symbol IS NULL OR symbol = '') AND code IS NOT NULL AND code != ''" ) raw.conn.commit() except Exception: pass def ensure_dbband_backtest_tables(db: Any) -> None: try: import holding_bot as hb hb.ensure_holding_tables(db) except Exception as e: logger.warning("holding 분봉 테이블 확인 경고: %s", e) ensure_dbband_stock_config_table(db) logger.info("✅ DBBAND DB 확인: holding_min_candles + dbband_stock_config") def _pct_to_frac(v: float) -> float: """DB 퍼센트(2.0) → 엔진 비율(0.02). 1 미만이면 이미 비율.""" try: x = float(v) except (TypeError, ValueError): return 0.0 return x / 100.0 if x >= 1.0 else x def _engine_cfg_from_row(row: Dict[str, Any]) -> Dict[str, Any]: out: Dict[str, Any] = {} for k in _STR_COLS: out[k] = str(row.get(k) or DEFAULT_DBBAND_CONFIG.get(k, "")).strip().lower() out["bb_period"] = int(float(row.get("bb_period") or 20)) out["bb_inner_std"] = float(row.get("bb_inner_std") or 2.0) out["bb_outer_std"] = float(row.get("bb_outer_std") or 3.0) out["trend_ma_period"] = int(float(row.get("trend_ma_period") or 200)) utf = row.get("use_trend_filter", 1) out["use_trend_filter"] = bool(utf) if isinstance(utf, bool) else float(utf) >= 0.5 out["entry_valid_bars"] = int(float(row.get("entry_valid_bars") or 3)) out["sl_pct"] = _pct_to_frac(row.get("stop_loss_pct", 2.0)) out["tp_pct"] = _pct_to_frac(row.get("take_profit_pct", 3.0)) out["stop_buffer_pct"] = _pct_to_frac(row.get("stop_buffer_pct", 0.1)) out["rr_ratio"] = float(row.get("rr_ratio") or 2.0) out["exit_mode"] = str(row.get("exit_mode") or "classic").strip().lower() out["shoulder_min_high"] = _pct_to_frac(row.get("shoulder_min_high_pct", 0.3)) out["shoulder_cut_pct"] = _pct_to_frac(row.get("shoulder_cut_pct", 0.2)) out["trail_pct"] = _pct_to_frac(row.get("trail_pct", 0.0)) out["trail_arm_pct"] = _pct_to_frac(row.get("trail_arm_pct", 0.0)) out["max_hold_bars"] = int(float(row.get("max_hold_bars") or 0)) out["slot_money"] = float(row.get("slot_money") or 3_000_000) out["cooldown_min"] = float(row.get("cooldown_min") or 15.0) out["max_daily"] = int(float(row.get("max_daily") or 3)) out["tf_min"] = int(float(row.get("tf_min") or 15)) out["timeframe"] = out["tf_min"] return out def resolve_market_meta( code: str, market_type: Optional[str] = None, exchange: Optional[str] = None, symbol: Optional[str] = None, ) -> Tuple[str, str, str]: c = str(code or symbol or "").strip().upper() 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 c).strip().upper() if c.isdigit() and len(c) == 6: return "KR", ex if ex not in ("NASD", "NAS", "NYSE", "NYS") else "KRX", sym or c if c.isalpha() and 1 <= len(c) <= 8 and (mt == "KR" or ex in ("", "KRX")): return "US", "NASD", sym or c return mt, ex or ("KRX" if mt == "KR" else "NASD"), sym or c def get_dbband_stock_config_row(db: Any, code: str) -> Optional[Dict[str, Any]]: ensure_dbband_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 dbband_stock_config WHERE code=%s OR symbol=%s ORDER BY id DESC LIMIT 1", [code, code], ).fetchone() if not row: return None return _engine_cfg_from_row(dict(row)) def get_dbband_stock_meta(db: Any, code: str) -> Optional[Dict[str, Any]]: ensure_dbband_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 dbband_stock_config WHERE code=%s OR symbol=%s ORDER BY id DESC LIMIT 1", [code, code], ).fetchone() return dict(row) if row else None def set_dbband_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: ensure_dbband_stock_config_table(db) code = str(code or symbol or "").strip().upper() if not code: raise ValueError("code 필수") mt, ex, sym = resolve_market_meta(code, market_type, exchange, symbol) base = dict(DEFAULT_DBBAND_CONFIG) for k in CFG_ENGINE_KEYS: if k in engine_cfg and engine_cfg[k] is not None: base[k] = engine_cfg[k] try: tfv = int(float(tf_min if tf_min is not None else engine_cfg.get("tf_min", base.get("timeframe", 15)))) except (TypeError, ValueError): tfv = int(get_env_int("DBBAND_TIMEFRAME", 15)) if tfv < 1: tfv = int(get_env_int("DBBAND_TIMEFRAME", 15)) num_cols = [ "bb_period", "bb_inner_std", "bb_outer_std", "trend_ma_period", "use_trend_filter", "entry_valid_bars", "stop_buffer_pct", "stop_loss_pct", "take_profit_pct", "rr_ratio", "shoulder_min_high_pct", "shoulder_cut_pct", "trail_pct", "trail_arm_pct", "max_hold_bars", "slot_money", "cooldown_min", "max_daily", ] str_cols = list(_STR_COLS) cols = ["code", "market_type", "exchange", "symbol", "name", "tf_min"] + num_cols + str_cols vals: List[Any] = [code, mt, ex, sym, str(name or "").strip() or code, tfv] row_map = { "stop_loss_pct": abs(float(engine_cfg.get("stop_loss_pct") or engine_cfg.get("sl_pct") or base.get("stop_loss_pct", 2))), "take_profit_pct": abs(float(engine_cfg.get("take_profit_pct") or engine_cfg.get("tp_pct") or base.get("take_profit_pct", 3))), "shoulder_min_high_pct": float(engine_cfg.get("shoulder_min_high_pct") or engine_cfg.get("shoulder_min_high") or 0.3), "shoulder_cut_pct": float(engine_cfg.get("shoulder_cut_pct") or 0.2), "use_trend_filter": 1.0 if engine_cfg.get("use_trend_filter", True) in (True, 1, "1", "true") else 0.0, } for nc in num_cols: if nc in row_map: vals.append(row_map[nc]) elif nc in engine_cfg: vals.append(float(engine_cfg[nc])) else: vals.append(float(base.get(nc, DEFAULT_DBBAND_CONFIG.get(nc, 0)))) for sc in str_cols: vals.append(str(engine_cfg.get(sc) or base.get(sc) or DEFAULT_DBBAND_CONFIG.get(sc, "")).strip().lower()) placeholders = ", ".join(["%s"] * len(cols)) col_sql = ", ".join(cols) raw = _trade_db_core(db) raw.conn.execute( f"INSERT INTO dbband_stock_config ({col_sql}) VALUES ({placeholders})", vals, ) raw.conn.commit() def load_dbband_engine_cfg(db: Any, code: str, env_fallback: Dict[str, Any]) -> Dict[str, Any]: row_cfg = get_dbband_stock_config_row(db, code) if row_cfg: return row_cfg return dict(env_fallback) def fetch_latest_dbband_stock_config_by_code(db: Any) -> Dict[str, Dict[str, Any]]: ensure_dbband_stock_config_table(db) raw = _trade_db_core(db) sql = """ SELECT d.* FROM dbband_stock_config d INNER JOIN ( SELECT code, MAX(id) AS mx FROM dbband_stock_config GROUP BY code ) t ON d.code = t.code AND d.id = t.mx ORDER BY d.code """ rows = raw.conn.execute(sql).fetchall() or [] out: Dict[str, Dict[str, Any]] = {} for row in rows: d = 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("DBBAND_TIMEFRAME", 15))) mt, ex, sym = resolve_market_meta( code, d.get("market_type"), d.get("exchange"), d.get("symbol"), ) eng["market_type"] = mt eng["exchange"] = ex eng["symbol"] = sym out[code] = eng return out def list_dbband_stock_codes(db: Any) -> List[Dict[str, Any]]: by_code = fetch_latest_dbband_stock_config_by_code(db) out = [] for c in sorted(by_code.keys()): eng = by_code[c] out.append({ "code": c, "name": eng.get("name", c), "market_type": eng.get("market_type", "KR"), "exchange": eng.get("exchange", "KRX"), "symbol": eng.get("symbol", c), }) return out def effective_dbband_tf_for_code(db: Any, code: str, env_tf: int) -> int: meta = get_dbband_stock_meta(db, code) if meta and meta.get("tf_min"): try: t = int(float(meta["tf_min"])) if t >= 1: return t except (TypeError, ValueError): pass return int(env_tf) def engine_cfg_to_ui(cfg: Dict[str, Any]) -> Dict[str, Any]: """엔진 cfg → 웹 입력란 (퍼센트 표시).""" def pct(v: Any, default: float = 0.0) -> float: try: x = float(v) return round(x * 100, 3) if 0 < x < 1 else round(x, 3) except (TypeError, ValueError): return default return { "bb_period": int(cfg.get("bb_period") or 20), "bb_inner_std": float(cfg.get("bb_inner_std") or 2.0), "bb_outer_std": float(cfg.get("bb_outer_std") or 3.0), "trend_ma_period": int(cfg.get("trend_ma_period") or 200), "use_trend_filter": bool(cfg.get("use_trend_filter", True)), "side_mode": str(cfg.get("side_mode") or "long_only"), "entry_valid_bars": int(cfg.get("entry_valid_bars") or 3), "entry_mode": str(cfg.get("entry_mode") or "break_high"), "stop_mode": str(cfg.get("stop_mode") or "signal_low"), "stop_buffer_pct": pct(cfg.get("stop_buffer_pct"), 0.1), "sl_pct": pct(cfg.get("sl_pct") or cfg.get("stop_loss_pct"), 2.0), "tp_mode": str(cfg.get("tp_mode") or "opposite_band"), "tp_pct": pct(cfg.get("tp_pct") or cfg.get("take_profit_pct"), 3.0), "rr_ratio": float(cfg.get("rr_ratio") or 2.0), "exit_mode": str(cfg.get("exit_mode") or "classic"), "shoulder_min_high": pct(cfg.get("shoulder_min_high"), 0.3), "shoulder_cut_pct": pct(cfg.get("shoulder_cut_pct"), 0.2), "trail_pct": pct(cfg.get("trail_pct"), 0.0), "trail_arm_pct": pct(cfg.get("trail_arm_pct"), 0.0), "max_hold_bars": int(cfg.get("max_hold_bars") or 0), "slot_money": int(float(cfg.get("slot_money") or 3_000_000)), "cooldown_min": float(cfg.get("cooldown_min") or 15.0), "max_daily": int(cfg.get("max_daily") or 3), "timeframe": int(cfg.get("tf_min") or cfg.get("timeframe") or 15), }