변경 사항 ---- - _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>
155 lines
5.7 KiB
Python
155 lines
5.7 KiB
Python
"""
|
|
kis_trader/strategies/updown_scan.py — UPDOWN 박스권 SCAN 배선
|
|
================================================================================
|
|
[목적 — SCAN vs TRIGGER 분리 원칙]
|
|
조건검색(HTS) 결과를 받아 **박스권 필터**만 통과시켜 ``updown_watchlist`` 에
|
|
충전(membership 등록)한다. 무거운 매수 직전 검사(호가·실시간가)는 여기서 하지 않고,
|
|
"이 종목이 며칠짜리 박스권인가?"라는 **느리게 변하는 멤버십**만 판정한다.
|
|
|
|
[흐름]
|
|
조건검색 후보(code,name)
|
|
→ 종목별 15분봉(기본) 조회
|
|
→ ``evaluate_box`` (좁은범위·밴드스퀴즈·추세없음)
|
|
→ is_box 통과분만 box_low/high/score 와 함께 watchlist upsert(soft)
|
|
→ 만료/정리(expire_stale)
|
|
|
|
[설계 메모]
|
|
- 박스 판별은 15분봉 lookback 기준 → 5분 스캔마다 종목당 분봉 1회 조회.
|
|
조건검색 후보가 많아도 watchlist cap(30) 안에서만 유지되므로 호출량 제한적.
|
|
- ``get_candles_fn(code, tf, n)`` 를 주입받아 데이터 소스(WS/REST)와 분리(테스트 용이).
|
|
- 모든 임계값은 ``updown_box.get_box_cfg_from_env`` 단일 소스(하드코딩 0).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import random
|
|
import time
|
|
from typing import Any, Callable, Dict, List, Optional
|
|
|
|
from ..engine.updown_box import evaluate_box, get_box_cfg_from_env
|
|
from ..utils.env import get_env_int
|
|
from .updown_watchlist import (
|
|
ensure_updown_watchlist_table,
|
|
expire_stale,
|
|
upsert_from_scan,
|
|
)
|
|
|
|
logger = logging.getLogger("kis_trader.updown_scan")
|
|
|
|
# 분봉 조회 콜백 시그니처: (code, tf_min, n) -> List[candle dict]
|
|
GetCandlesFn = Callable[[str, int, int], List[Dict[str, Any]]]
|
|
|
|
|
|
def scan_tf_min() -> int:
|
|
"""SCAN 박스 판별에 쓸 분봉(분). 기본 15분 (수집·백테와 동일)."""
|
|
return get_env_int("UPDOWN_SCAN_TF_MIN", 15)
|
|
|
|
|
|
def scan_fetch_n() -> int:
|
|
"""박스 판별용 분봉 조회 개수. lookback + 여유."""
|
|
cfg = get_box_cfg_from_env()
|
|
base = int(cfg.get("lookback_bars", 20)) + int(cfg.get("bb_period", 20)) + 5
|
|
return get_env_int("UPDOWN_SCAN_FETCH_N", max(base, 45))
|
|
|
|
|
|
def box_filter_candidates(
|
|
candidates: List[Dict[str, Any]],
|
|
get_candles_fn: GetCandlesFn,
|
|
*,
|
|
cfg: Optional[Dict[str, float]] = None,
|
|
tf_min: Optional[int] = None,
|
|
fetch_n: Optional[int] = None,
|
|
sleep_between: bool = True,
|
|
) -> List[Dict[str, Any]]:
|
|
"""조건검색 후보 → 박스권 통과분만 (box_low/high/score 부착) 반환.
|
|
|
|
Args:
|
|
candidates : [{"code":.., "name":..}, ...] (조건검색 결과)
|
|
get_candles_fn : (code, tf_min, n) -> 분봉 리스트(시간 오름차순)
|
|
sleep_between : REST 부하 방지용 종목 간 random.sleep (실매 루프 기본 True)
|
|
|
|
Returns: [{"code","name","box_low","box_high","box_score"}, ...]
|
|
"""
|
|
c = cfg or get_box_cfg_from_env()
|
|
tf = int(tf_min if tf_min is not None else scan_tf_min())
|
|
n = int(fetch_n if fetch_n is not None else scan_fetch_n())
|
|
passed: List[Dict[str, Any]] = []
|
|
|
|
for cand in candidates or []:
|
|
code = str(cand.get("code") or "").strip()
|
|
if not code:
|
|
continue
|
|
name = (cand.get("name") or code or "").strip() or code
|
|
try:
|
|
raw = get_candles_fn(code, tf, n) or []
|
|
except Exception as e:
|
|
logger.debug("[UPDOWN SCAN] %s 분봉조회 실패: %s", code, e)
|
|
raw = []
|
|
# 종목 간 소량 슬립 (서버 부하/429 방지) — 실시간 매매 루프 아님
|
|
if sleep_between:
|
|
time.sleep(random.uniform(0.05, 0.15))
|
|
if len(raw) < int(c.get("min_bars", 20)):
|
|
continue
|
|
box = evaluate_box(raw, c)
|
|
if not box.get("is_box"):
|
|
continue
|
|
passed.append({
|
|
"code": code,
|
|
"name": name,
|
|
"box_low": float(box.get("box_low", 0) or 0),
|
|
"box_high": float(box.get("box_high", 0) or 0),
|
|
"box_score": float(box.get("box_score", 0) or 0),
|
|
})
|
|
return passed
|
|
|
|
|
|
def run_updown_scan(
|
|
db,
|
|
condition_candidates: List[Dict[str, Any]],
|
|
get_candles_fn: GetCandlesFn,
|
|
*,
|
|
source: str = "condition",
|
|
cfg: Optional[Dict[str, float]] = None,
|
|
sleep_between: bool = True,
|
|
) -> Dict[str, Any]:
|
|
"""SCAN 1회 실행: 조건검색 후보 → 박스필터 → watchlist 충전 + 만료정리.
|
|
|
|
Returns: {"scanned":N, "box_passed":M, "upserted":K, "expired":E}
|
|
"""
|
|
ensure_updown_watchlist_table(db)
|
|
scanned = len(condition_candidates or [])
|
|
|
|
box_cands = box_filter_candidates(
|
|
condition_candidates, get_candles_fn,
|
|
cfg=cfg, sleep_between=sleep_between,
|
|
)
|
|
|
|
stats: Dict[str, int] = {"added": 0, "reactivated": 0, "updated": 0, "skipped_full": 0}
|
|
if box_cands:
|
|
try:
|
|
stats = upsert_from_scan(db, box_cands, source=source)
|
|
except Exception as e:
|
|
logger.error("[UPDOWN SCAN] watchlist upsert 실패: %s", e)
|
|
|
|
expired = 0
|
|
try:
|
|
expired = expire_stale(db)
|
|
except Exception as e:
|
|
logger.debug("[UPDOWN SCAN] expire_stale 실패: %s", e)
|
|
|
|
upserted = int(stats.get("added", 0)) + int(stats.get("reactivated", 0))
|
|
logger.info(
|
|
"🔎 [UPDOWN SCAN] 조건후보=%d → 박스통과=%d → 신규+재진입=%d 갱신=%d 만석스킵=%d (만료=%d)",
|
|
scanned, len(box_cands), upserted,
|
|
int(stats.get("updated", 0)), int(stats.get("skipped_full", 0)), expired,
|
|
)
|
|
return {
|
|
"scanned": scanned,
|
|
"box_passed": len(box_cands),
|
|
"added": int(stats.get("added", 0)),
|
|
"reactivated": int(stats.get("reactivated", 0)),
|
|
"updated": int(stats.get("updated", 0)),
|
|
"skipped_full": int(stats.get("skipped_full", 0)),
|
|
"expired": expired,
|
|
}
|