Files
kis_bot/kis_trader/strategies/dbband_stock_cfg.py
Your Name 0ecac7cb95 이번에 들어간 내용
한투 호가 = 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 키 정리도 같이 들어갔습니다. 파일 단위로 나눌 수 없어서입니다.
2026-08-19 22:11:31 +09:00

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),
}