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:
221
kis_trader/strategies/updown_box_stock_cfg.py
Normal file
221
kis_trader/strategies/updown_box_stock_cfg.py
Normal file
@@ -0,0 +1,221 @@
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user