feat(tests): 신규 키움 웹소켓 조건검색 및 실시간 조건검색 테스트 추가
변경 사항 ---- - _test_kiwoom_condition_list.py: 키움 웹소켓 조건검색 '목록조회' 기능을 단독으로 테스트하는 스크립트 추가 - _test_kiwoom_condition_realtime.py: 'momentum' 조건식을 실시간으로 등록하고 초기 매칭 종목 리스트 및 실시간 편입/이탈을 수신하는 테스트 스크립트 추가 - _verify_columnar_bitid.py, _verify_shared_e2e_breakout.py, _verify_shared_e2e.py: 공유 메모리 및 dict 간의 데이터 일관성을 검증하는 테스트 추가 영향 ---- - 신규 테스트 스크립트 추가로 키움 웹소켓 API의 기능 검증 및 안정성을 높임 - 기존 기능에 대한 영향 없음 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
343
kis_trader/strategies/dbband_stock_cfg.py
Normal file
343
kis_trader/strategies/dbband_stock_cfg.py
Normal file
@@ -0,0 +1,343 @@
|
||||
"""
|
||||
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),
|
||||
}
|
||||
Reference in New Issue
Block a user