한투 호가 = 2번째 앱키 전용 키 없거나 start 실패 시 메인에 H0STASP0 안 붙임. 운영설정 WS_ORDERBOOK_SAVE_KIS 빨간 danger. LS RAM 합집합 후보∪보유∪영구∪grace. sync_targets와 split reconcile 둘 다. 틱 DB 영구 게이트는 그대로. 분봉 쓰레기 → 다음 소스 봉 통째 그 분 틱 0건이거나 전부 봉끝 대비 LIVE_FEED_FALLBACK_MAX_AGE_SEC 초과면 구멍. 메인 WS → 2차 → LS → REST → rollup. CANDLE_GARBAGE_FALLBACK 기본 true. 파일: feed_fallback.py(신규), ws_manager.py, kis_ws.py, candle_series.py, bt_candle_source.py, live_config_schema.py, database.py, 스모크, MD 2개. 같은 ws_manager/database/kis_ws/live_config에는 직전 커밋 이후 쌓여 있던 시세 폴백·ENV 키 정리도 같이 들어갔습니다. 파일 단위로 나눌 수 없어서입니다.
346 lines
14 KiB
Python
346 lines
14 KiB
Python
"""
|
|
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:
|
|
from kis_trader.utils.legacy_root import ensure_legacy_root
|
|
ensure_legacy_root()
|
|
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),
|
|
}
|