Files
kis_bot/kis_trader/strategies/updown_box_stock_cfg.py
Hwang 61c72a8a4c 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>
2026-07-06 01:27:00 +09:00

222 lines
8.1 KiB
Python

"""
kis_trader/strategies/updown_box_stock_cfg.py — UPDOWN 박스권 **종목별 파라미터 오버라이드**
=================================================================================================
[역할]
- 박스 전략은 기본 **글로벌**(env ``UPDOWN_BOX_*``)로 모든 watchlist 종목을 돌린다.
- 다만 특정 종목(예: 변동성이 다른 우량주)은 손으로 핀(pin)해 **종목별 파라미터**를 쓰고 싶을 수 있다.
→ 이 테이블(``updown_box_stock_cfg``)이 그 **선택적 오버라이드**를 담는다.
- 값이 NULL(미설정)인 항목은 **글로벌 값을 그대로 상속**한다 (부분 오버라이드 가능).
[설계 — 구 ``updow_stock_config`` 와 분리하는 이유]
- ``updow_stock_config`` 는 구 하락매수(updow_buy) 엔진 전용 컬럼 구조 + "행 추가(latest-row)" 방식이라,
박스 파라미터를 섞으면 의미·쓰기 충돌이 난다. 그래서 박스 전용 **코드 UNIQUE upsert** 테이블을 둔다.
[키]
- ``box_cfg_to_env_patch`` 의 키맵과 동일한 박스 파라미터를 종목별로 저장.
- ``ratchet_tiers`` 만 문자열('' = 래칫 OFF), 나머지는 DOUBLE.
모든 수치는 하드코딩 금지 — 글로벌 기본은 env(``UPDOWN_BOX_*``)에서 로드, 여기선 종목별 override 만.
"""
from __future__ import annotations
import logging
from datetime import datetime as dt
from typing import Any, Dict, List, Optional
logger = logging.getLogger("kis_trader.updown_box_stock_cfg")
# 종목별 오버라이드 가능한 박스 파라미터 (DOUBLE) — ratchet_tiers 는 문자열로 별도 처리
BOX_NUMERIC_KEYS = (
"near_low_pct",
"range_max_pct",
"bb_bw_max",
"ma_slope_tol_pct",
"body_drop_min_pct",
"breach_buffer_pct",
"panic_from_high_pct",
"stop_loss_pct",
"tp_pct",
"shoulder_min_high_pct",
"shoulder_cut_pct",
"max_hold_bars",
"lookback_bars",
"breakout_follow",
)
BOX_STR_KEYS = ("ratchet_tiers",)
BOX_ALL_KEYS = tuple(BOX_NUMERIC_KEYS) + tuple(BOX_STR_KEYS)
def _trade_db_core(db: Any):
"""TradeDBExt 이면 ``.raw``, 아니면 그대로 (다른 cfg 모듈과 동일 규약)."""
return getattr(db, "raw", db)
def _ddl() -> str:
cols = [
"id BIGINT AUTO_INCREMENT PRIMARY KEY",
"code VARCHAR(20) NOT NULL",
"name VARCHAR(50) NOT NULL DEFAULT ''",
]
for k in BOX_NUMERIC_KEYS:
cols.append(f"{k} DOUBLE NULL")
for k in BOX_STR_KEYS:
cols.append(f"{k} VARCHAR(64) NULL")
cols.append("updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP")
cols.append("UNIQUE KEY uq_updown_box_stock_code (code)")
body = ",\n ".join(cols)
return (
"CREATE TABLE IF NOT EXISTS updown_box_stock_cfg (\n "
+ body
+ "\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 "
"COMMENT='UPDOWN 박스권 종목별 파라미터 오버라이드(NULL=글로벌 상속)'"
)
def ensure_updown_box_stock_cfg_table(db: Any) -> None:
"""``updown_box_stock_cfg`` 테이블 생성 + 신규 키 컬럼 마이그레이션(있으면 무시)."""
raw = _trade_db_core(db)
try:
raw.conn.execute(_ddl())
except Exception as e:
logger.warning("updown_box_stock_cfg 생성 경고: %s", e)
# 구버전 테이블에 신규 박스 키가 빠져 있으면 추가
for k in BOX_NUMERIC_KEYS:
try:
raw.conn.execute(f"ALTER TABLE updown_box_stock_cfg ADD COLUMN {k} DOUBLE NULL")
except Exception:
pass
for k in BOX_STR_KEYS:
try:
raw.conn.execute(f"ALTER TABLE updown_box_stock_cfg ADD COLUMN {k} VARCHAR(64) NULL")
except Exception:
pass
try:
raw.conn.commit()
except Exception:
pass
def get_box_overrides(db: Any, code: str) -> Dict[str, Any]:
"""종목별 박스 오버라이드 — **설정된(NULL 아님) 키만** 반환. 없으면 {}."""
ensure_updown_box_stock_cfg_table(db)
code = str(code or "").strip()
if not code:
return {}
raw = _trade_db_core(db)
try:
row = raw.conn.execute(
"SELECT * FROM updown_box_stock_cfg WHERE code=%s LIMIT 1", (code,)
).fetchone()
except Exception as e:
logger.warning("get_box_overrides 조회 실패 %s: %s", code, e)
return {}
if not row:
return {}
d = dict(row)
out: Dict[str, Any] = {}
for k in BOX_NUMERIC_KEYS:
v = d.get(k)
if v is not None:
try:
out[k] = float(v)
except (TypeError, ValueError):
pass
for k in BOX_STR_KEYS:
v = d.get(k)
if v is not None: # 빈 문자열('')도 유효 — 래칫 OFF 의 명시적 표현
out[k] = str(v)
return out
def set_box_overrides(db: Any, code: str, name: str, overrides: Dict[str, Any]) -> bool:
"""종목별 박스 오버라이드 upsert.
- ``overrides`` 에 담긴 키만 갱신. 값이 None 또는 '' (숫자 키) 이면 해당 키는 **NULL(글로벌 상속)** 로 클리어.
- ``ratchet_tiers`` 는 '' 도 유효값(래칫 OFF)으로 저장하고, None 이면 NULL(글로벌 상속).
"""
ensure_updown_box_stock_cfg_table(db)
code = str(code or "").strip()
if not code:
return False
raw = _trade_db_core(db)
set_cols: List[str] = ["name"]
set_vals: List[Any] = [str(name or code)[:50]]
for k in BOX_NUMERIC_KEYS:
if k not in overrides:
continue
v = overrides.get(k)
if v is None or (isinstance(v, str) and not str(v).strip()):
set_cols.append(k)
set_vals.append(None) # NULL = 글로벌 상속
else:
try:
set_cols.append(k)
set_vals.append(float(v))
except (TypeError, ValueError):
set_cols.append(k)
set_vals.append(None)
for k in BOX_STR_KEYS:
if k not in overrides:
continue
v = overrides.get(k)
if v is None:
set_cols.append(k)
set_vals.append(None) # 미설정 = 글로벌 상속
else:
set_cols.append(k)
set_vals.append(str(v).strip()) # '' = 래칫 OFF (명시)
# INSERT ... ON DUPLICATE KEY UPDATE (code UNIQUE)
insert_cols = ["code"] + set_cols
insert_vals = [code] + set_vals
placeholders = ", ".join(["%s"] * len(insert_cols))
update_clause = ", ".join([f"{c}=VALUES({c})" for c in set_cols])
sql = (
f"INSERT INTO updown_box_stock_cfg ({', '.join(insert_cols)}) "
f"VALUES ({placeholders}) "
f"ON DUPLICATE KEY UPDATE {update_clause}, updated_at=NOW()"
)
try:
raw.conn.execute(sql, insert_vals)
raw.conn.commit()
logger.info("📌 updown_box_stock_cfg 저장: %s (%d키)", code, len(set_cols) - 1)
return True
except Exception as e:
logger.warning("set_box_overrides 저장 실패 %s: %s", code, e)
return False
def delete_box_overrides(db: Any, code: str) -> bool:
"""종목별 오버라이드 삭제 → 글로벌로 복귀."""
ensure_updown_box_stock_cfg_table(db)
code = str(code or "").strip()
if not code:
return False
raw = _trade_db_core(db)
try:
raw.conn.execute("DELETE FROM updown_box_stock_cfg WHERE code=%s", (code,))
raw.conn.commit()
return True
except Exception as e:
logger.warning("delete_box_overrides 실패 %s: %s", code, e)
return False
def list_box_stock_cfg(db: Any) -> List[Dict[str, Any]]:
"""오버라이드가 등록된 종목 목록 [{code, name, n_overrides}, ...]."""
ensure_updown_box_stock_cfg_table(db)
raw = _trade_db_core(db)
try:
rows = raw.conn.execute(
"SELECT * FROM updown_box_stock_cfg ORDER BY code"
).fetchall()
except Exception:
return []
out: List[Dict[str, Any]] = []
for r in (rows or []):
d = dict(r)
n = sum(1 for k in BOX_ALL_KEYS if d.get(k) is not None)
out.append({"code": str(d.get("code") or ""), "name": str(d.get("name") or ""), "n_overrides": n})
return out