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:
2026-07-06 01:27:00 +09:00
parent d8ba01afa4
commit 61c72a8a4c
171 changed files with 176914 additions and 7329 deletions

View File

@@ -1,20 +1,25 @@
"""
kis_trader/utils/env.py — DB(env_config) 우선 + os.environ 폴백 환경변수 헬퍼
kis_trader/utils/env.py — DB(config_* + env_config 병합) 우선 + os.environ 폴백 환경변수 헬퍼
===========================================================================
기존 kis_scalping_ver2 / kis_short_ver3 에 흩어져 있던 `get_env_*` 를 통합.
- DB(env_config 최신 row) → os.environ → default 순으로 조회.
- DB get_merged_env_snapshot / get_strategy_env_dict → os.environ → default 순으로 조회.
- 하드코딩 금지 원칙에 맞춰 전 모듈에서 이 함수들만 사용하도록 한다.
"""
from __future__ import annotations
import logging
import os
from typing import Any
from typing import Any, Dict, Optional
logger = logging.getLogger("kis_trader.env")
# 순환 import 방지용 레이지 TradeDB 참조
_db_instance = None
# get_merged_env_snapshot() — 프로세스당 1회 (백테·파라서치 env 키 반복 조회 가속)
_merged_env_cache: Optional[Dict[str, str]] = None
# env 캐시 세대 카운터 — invalidate 시 +1. 파생 캐시(예: whipsaw 파라미터)가
# 이 값으로 무효화를 감지해 안전하게 재계산한다(값은 그대로, 재계산 시점만 동일).
_env_generation: int = 0
def _get_db():
@@ -36,6 +41,22 @@ def set_db(db_obj) -> None:
_db_instance = db_obj
def invalidate_merged_env_cache() -> None:
"""env/config 저장 후 스냅샷 캐시 무효화 (insert_env_snapshot 등)."""
global _merged_env_cache, _env_generation
_merged_env_cache = None
_env_generation += 1
def env_cache_generation() -> int:
"""현재 env 캐시 세대. invalidate 될 때마다 증가.
파생 캐시가 이 값을 키에 포함하면, 설정 저장(무효화) 시 자동으로 재계산되고
그 전까지는 동일 값을 재사용한다(get_env_from_db 의 병합캐시와 동일 수명).
"""
return _env_generation
def _strip_comment(val: Any) -> Any:
"""DB 컬럼에 `#` 이후 주석이 들어 있을 때 제거. 기존 규칙 유지."""
if isinstance(val, str) and "#" in val:
@@ -43,19 +64,57 @@ def _strip_comment(val: Any) -> Any:
return val
def get_env_from_db(key: str, default: str = "") -> str:
"""env_config 최신 row → 키 값을 문자열로 반환. 없으면 os.environ → default."""
def get_merged_env_dict() -> dict:
"""env_config + config_scalp/short/momentum/… 최신 행 병합 flat dict."""
global _merged_env_cache
if _merged_env_cache is not None:
return _merged_env_cache
db = _get_db()
if db is not None:
try:
if db is None:
return {}
try:
if hasattr(db, "get_merged_env_snapshot"):
merged = db.get_merged_env_snapshot()
else:
row = db.get_latest_env()
if row and row.get("snapshot"):
v = row["snapshot"].get(key)
v = _strip_comment(v)
if v not in (None, ""):
return str(v)
except Exception as e:
logger.debug("env_config 조회 실패 (%s): %s", key, e)
merged = dict((row or {}).get("snapshot") or {})
_merged_env_cache = dict(merged or {})
return _merged_env_cache
except Exception as e:
logger.debug("merged env 조회 실패: %s", e)
return {}
def get_strategy_env_dict(strategy_id: str) -> dict:
"""전략 config_* 테이블 + 공통 env 병합 (전략 키 우선).
SCALP → config_scalp, MOMENTUM → config_momentum, SHORT → config_short …
실매(get_env_from_db) · 웹 · 파라서치가 동일 소스를 쓰도록 한다.
"""
merged = get_merged_env_dict()
db = _get_db()
if db is None:
return merged
try:
if hasattr(db, "get_strategy_config_snapshot"):
strat = db.get_strategy_config_snapshot(strategy_id)
if strat:
out = dict(merged)
out.update(strat)
return out
except Exception as e:
logger.debug("strategy env 조회 실패 (%s): %s", strategy_id, e)
return merged
def get_env_from_db(key: str, default: str = "") -> str:
"""config_* + env_config 병합 스냅샷 → 키 값. 없으면 os.environ → default."""
merged = get_merged_env_dict()
if merged:
v = _strip_comment(merged.get(key))
if v not in (None, ""):
return str(v)
# os.environ 폴백 (기존 호환)
return os.environ.get(key, str(default))

View File

@@ -0,0 +1,260 @@
"""
실매 ↔ 포트폴리오 백테 정합 — 유니버스 슬롯·총한도·매수금액.
백테 ``breakout_portfolio_backtest`` / ``backtest_portfolio_common`` 과 동일 분모.
"""
from __future__ import annotations
from datetime import datetime as dt
from typing import Any, Dict, List, Optional, Set, Tuple
from ..backtest.backtest_portfolio_common import (
STRATEGY_PORTFOLIO_KEYS,
min_invest_ratio_of_slot,
portfolio_exposure_krw,
resolve_portfolio_params,
target_qty_and_cost,
)
from ..utils.env import get_env_bool, get_env_from_db, get_env_int
from ..utils.position_sizing import invest_qty_for_price
# 자체 유니버스 — 기본 슬롯 필터 OFF (env 로 켤 수 있음)
_UNIVERSE_SLOT_SKIP_DEFAULT = frozenset({"UPDOW", "DBBAND"})
# 총한도·일1회 base 가드 스킵 (전략 자체 구현)
_PORTFOLIO_BUDGET_SKIP_DEFAULT = frozenset({"UPDOW"})
def portfolio_strategy_key(strategy_id: str) -> str:
sid = (strategy_id or "").upper()
return "TAIL" if sid == "SHORT" else sid
def _env_flag(key: str, default: bool) -> bool:
raw = get_env_from_db(key, "")
if raw in (None, "", "None"):
return default
return str(raw).strip().lower() in ("1", "true", "t", "y", "yes", "on")
def live_universe_slot_align_enabled(strategy_id: str) -> bool:
sid = (strategy_id or "").upper()
per_key = f"{sid}_LIVE_UNIVERSE_SLOT_ALIGN"
if get_env_from_db(per_key, "") not in (None, "", "None"):
return _env_flag(per_key, True)
if sid in _UNIVERSE_SLOT_SKIP_DEFAULT:
return False
return _env_flag("LIVE_UNIVERSE_SLOT_ALIGN", True)
def live_portfolio_budget_align_enabled(strategy_id: str) -> bool:
sid = (strategy_id or "").upper()
per_key = f"{sid}_LIVE_PORTFOLIO_BUDGET_ALIGN"
if get_env_from_db(per_key, "") not in (None, "", "None"):
return _env_flag(per_key, True)
if sid in _PORTFOLIO_BUDGET_SKIP_DEFAULT:
return False
return _env_flag("LIVE_PORTFOLIO_BUDGET_ALIGN", True)
def resolve_live_scan_interval_min(strategy_id: str) -> int:
sid = (strategy_id or "").upper()
v = get_env_int(f"{sid}_SCAN_INTERVAL_MIN", 0)
if v > 0:
return max(1, int(v))
return max(1, get_env_int("SCAN_INTERVAL_MIN", 1))
def slot_key_from_dt(when: dt, scan_interval_min: int = 1) -> str:
"""백테 ``_bt_slot_key`` / ``_slot_key`` 와 동일."""
iv = max(1, int(scan_interval_min))
date = when.strftime("%Y%m%d")
hm = when.hour * 100 + when.minute
total_min = (hm // 100) * 60 + (hm % 100)
slot_min = (total_min // iv) * iv
slot_hm = (slot_min // 60) * 100 + (slot_min % 60)
return date + str(slot_hm).zfill(4)
def resolve_live_total_budget_krw(
strategy_id: str,
max_stocks: int,
slot_money: float,
) -> float:
pkey = portfolio_strategy_key(strategy_id)
keys = STRATEGY_PORTFOLIO_KEYS.get(pkey, STRATEGY_PORTFOLIO_KEYS["SCALP"])
env_row: Dict[str, Any] = {}
for k in keys.get("total_budget", ()):
v = get_env_int(k, 0)
if v > 0:
env_row[k] = v
break
for k in keys.get("slot", ()):
v = get_env_int(k, 0)
if v > 0:
env_row[k] = v
for k in keys.get("max_stocks", ()):
v = get_env_int(k, 0)
if v > 0:
env_row[k] = v
pf = resolve_portfolio_params(
env_row if env_row else None,
strategy=pkey,
slot_money=float(slot_money),
max_stocks=int(max_stocks),
)
return float(pf["total_budget_krw"])
def resolve_live_min_invest_ratio(strategy_id: str) -> float:
return min_invest_ratio_of_slot({}, strategy=portfolio_strategy_key(strategy_id))
def history_universe_codes_at(
db: Any,
strategy_id: str,
when: Optional[dt] = None,
) -> Optional[Set[str]]:
"""
``target_candidates_history`` — ``at_time`` 이전 최신 스냅샷 종목 집합.
스냅샷 없으면 ``None`` (필터 생략 = 실시간 후보 유지).
"""
when = when or dt.now()
at_time = when.strftime("%Y-%m-%d %H:%M:%S")
sid = (strategy_id or "").upper()
getter = getattr(db, "get_universe_at", None)
if getter is None:
return None
try:
rows = getter(strategy_id=sid, at_time=at_time) or []
except Exception:
return None
if not rows:
return None
codes: Set[str] = set()
for r in rows:
code = str(r.get("code") or "").strip()
if code:
codes.add(code)
return codes if codes else None
def filter_candidates_by_history_universe(
candidates: List[Dict],
db: Any,
strategy_id: str,
*,
when: Optional[dt] = None,
) -> Tuple[List[Dict], int]:
"""실시간 후보 ∩ history 스냅샷. (filtered, dropped_count)."""
if not candidates:
return [], 0
allowed = history_universe_codes_at(db, strategy_id, when)
if allowed is None:
return candidates, 0
out: List[Dict] = []
for c in candidates:
code = (c.get("code") or c.get("stk_cd") or "").strip()
if code and code in allowed:
out.append(c)
return out, len(candidates) - len(out)
def live_max_daily(strategy_id: str) -> int:
sid = (strategy_id or "").upper()
v = get_env_int(f"{sid}_MAX_DAILY", 0)
if v > 0:
return int(v)
if sid == "SHORT":
return get_env_int("TAIL_MAX_DAILY", 0)
return 0
def live_daily_entry_count(
db: Any,
code: str,
today: str,
strategy_id: str,
) -> int:
try:
trades = db.get_trades_by_date(today) or []
sid = (strategy_id or "").upper()
return len([
t for t in trades
if t.get("code") == code
and str(t.get("strategy", "")).upper().startswith(sid)
])
except Exception:
return 0
def resolve_live_buy_qty(
curr_price: float,
holdings: Dict[str, Dict],
strategy_id: str,
slot_money: float,
*,
max_stocks: int,
invest_cap: Optional[float] = None,
) -> Tuple[int, float, Optional[str]]:
"""
포트폴리오 백테 ``target_qty_and_cost`` + ``min_invest_ratio`` + ``total_budget`` 정합.
Returns: (qty, invest_krw, reject_reason)
"""
if curr_price <= 0:
return 0, 0.0, "가격0"
cap = float(invest_cap if invest_cap is not None else slot_money)
if cap <= 0:
return 0, 0.0, "투자캡0"
exposure = portfolio_exposure_krw(holdings)
total_budget = resolve_live_total_budget_krw(strategy_id, max_stocks, slot_money)
remaining = max(0.0, total_budget - exposure)
ratio = resolve_live_min_invest_ratio(strategy_id)
target_qty, target_cost = target_qty_and_cost(curr_price, cap)
min_required = target_cost * ratio
if target_qty < 1 or remaining < min_required:
return 0, 0.0, "소액/잔여부족"
invest = min(cap, remaining, target_cost)
qty = invest_qty_for_price(curr_price, invest)
if qty < 1:
return 0, 0.0, "수량0"
cost = qty * curr_price
if cost < min_required:
return 0, 0.0, "소액"
return qty, invest, None
def live_portfolio_entry_reject(
db: Any,
holdings: Dict[str, Dict],
strategy_id: str,
code: str,
slot_money: float,
max_stocks: int,
) -> Optional[str]:
"""일일한도·총한도 — ``None`` 이면 통과."""
if not live_portfolio_budget_align_enabled(strategy_id):
return None
md = live_max_daily(strategy_id)
if md > 0:
today = dt.now().strftime("%Y%m%d")
cnt = live_daily_entry_count(db, code, today, strategy_id)
if cnt >= md:
return f"탈락-일일한도 daily={cnt}/{md}"
total = resolve_live_total_budget_krw(strategy_id, max_stocks, slot_money)
if total > 0 and portfolio_exposure_krw(holdings) >= total - 1e-6:
return "탈락-총한도"
return None
def live_portfolio_budget_full(
holdings: Dict[str, Dict],
strategy_id: str,
slot_money: float,
max_stocks: int,
) -> bool:
if not live_portfolio_budget_align_enabled(strategy_id):
return False
total = resolve_live_total_budget_krw(strategy_id, max_stocks, slot_money)
if total <= 0:
return False
return portfolio_exposure_krw(holdings) >= total - 1e-6

View File

@@ -15,11 +15,11 @@ import tempfile
import threading
import time
from pathlib import Path
from typing import Any, Optional
from typing import Any, Iterable, Optional
import requests
from .env import get_env_from_db
from .env import get_env_bool, get_env_from_db
# ── 로깅 ─────────────────────────────────────────────────────────────
# 루트 로거 한 번만 설정 (여러 모듈에서 import되어도 중복 핸들러 안 생기게)
@@ -117,6 +117,51 @@ def _load_mm_channel_id(channel_alias: str) -> Optional[str]:
return None
def msg_mm_multi(
text: str,
channel_aliases: Iterable[str],
*,
jitter: bool = True,
) -> bool:
"""
동일 메시지를 여러 MM alias 로 발송 (중복 alias 는 1회만).
jitter=True 이면 첫 채널에만 슬립 — 체결 알림은 jitter=False 권장.
"""
seen: set[str] = set()
any_ok = False
first = True
for raw in channel_aliases:
alias = str(raw or "").strip()
if not alias or alias in seen:
continue
seen.add(alias)
if msg_mm(text, channel_alias=alias, jitter=jitter and first):
any_ok = True
first = False
return any_ok
def msg_mm_strategy(
text: str,
strategy_channel_alias: str,
*,
jitter: bool = False,
) -> bool:
"""
전략 채널 + (옵션) 통합 채널 이중 발송.
MM_DUAL_CHANNEL_ENABLED=true 일 때 MATTERMOST_CHANNEL 도 함께 전송 (alias 같으면 1회).
"""
strat_ch = str(strategy_channel_alias or "").strip() or str(
get_env_from_db("MATTERMOST_CHANNEL", "stock")
)
aliases = [strat_ch]
if get_env_bool("MM_DUAL_CHANNEL_ENABLED", False):
unified = str(get_env_from_db("MATTERMOST_CHANNEL", "stock") or "stock").strip()
if unified and unified != strat_ch:
aliases.append(unified)
return msg_mm_multi(text, aliases, jitter=jitter)
def msg_mm(text: str, channel_alias: Optional[str] = None, jitter: bool = True) -> bool:
"""
Mattermost 메시지 전송.

View File

@@ -0,0 +1,41 @@
"""
kis_trader.utils.position_sizing — 전략 공통 1회 매수 금액(원)
============================================================
``SLOT_MONEY_DEFAULT``(및 전략별 *_SLOT_MONEY) 를 그대로 쓰고,
과거 ``max_loss / sl_pct`` 로 포지션을 줄이던 방식은 폐기한다.
(금액 손실컷 ``MAX_LOSS_PER_TRADE_KRW`` 는 청산 엔진에서만 사용.)
"""
from __future__ import annotations
from ..utils.env import get_env_int
def invest_qty_for_price(entry_price: float, invest_krw: float) -> int:
"""
1회 투자금으로 살 수 있는 정수 주수.
주가가 투자금보다 비싸면 0 (최소 1주 강제 매수 없음).
"""
if entry_price <= 0 or invest_krw <= 0:
return 0
qty = int(float(invest_krw) / entry_price)
return qty if qty >= 1 else 0
def resolve_invest_amount_krw(
slot_money: int,
*,
max_loss_krw: int = 0,
sl_pct: float = 0.0,
extra_cap: int = 0,
) -> int:
"""
1회 매수에 쓸 금액(원). 기본 = ``slot_money``.
``extra_cap`` > 0 이면 min(slot, extra_cap) — UPDOW 총한도 잔여 등.
``max_loss_krw`` / ``sl_pct`` 는 하위 호환용으로만 받고 투자금 산출에는 미사용.
"""
base = int(slot_money or 0)
if base <= 0:
base = get_env_int("SLOT_MONEY_DEFAULT", 3_000_000)
if extra_cap > 0:
return int(min(base, extra_cap))
return base

View File

@@ -50,6 +50,52 @@ class SafeRequest:
self.timeout_sec = float(timeout_sec)
self._last_call_ts: float = 0.0
self._throttle_lock = threading.Lock()
# ── 계측: 초당 호출 수 / 큐 대기시간 (피크 실측용) ───────────────
self._calls_in_window: int = 0
self._window_start: float = time.time()
self._peak_calls_per_sec: int = 0
self._total_calls: int = 0
self._total_wait_sec: float = 0.0
self._peak_wait_sec: float = 0.0
self._rate_limit_hits: int = 0
def _current_min_interval(self) -> float:
"""호출 직전 min_interval 재해석 훅 (서브클래스가 env 핫리로드용으로 오버라이드).
기본은 고정 ``self.min_interval_sec`` 반환. 오버라이드 시 재시작 없이 반영.
"""
return self.min_interval_sec
def _record_call(self, waited: float) -> None:
"""계측 누적 — _throttle 내부에서 호출 (lock 보유 상태)."""
now = time.time()
self._total_calls += 1
self._total_wait_sec += waited
if waited > self._peak_wait_sec:
self._peak_wait_sec = waited
# 1초 윈도우 카운트
if now - self._window_start >= 1.0:
self._window_start = now
self._calls_in_window = 1
else:
self._calls_in_window += 1
if self._calls_in_window > self._peak_calls_per_sec:
self._peak_calls_per_sec = self._calls_in_window
def get_throttle_stats(self) -> dict:
"""계측 스냅샷 (모니터링·검증용)."""
avg_wait = (
self._total_wait_sec / self._total_calls
if self._total_calls else 0.0
)
return {
"total_calls": self._total_calls,
"peak_calls_per_sec": self._peak_calls_per_sec,
"avg_wait_sec": round(avg_wait, 4),
"peak_wait_sec": round(self._peak_wait_sec, 4),
"rate_limit_hits": self._rate_limit_hits,
"min_interval_sec": self._current_min_interval(),
}
# ------------------------------------------------------------------
# 내부 유틸
@@ -57,10 +103,14 @@ class SafeRequest:
def _throttle(self) -> None:
"""호출 간 최소 간격 보장 (클라이언트 측 429 방지)."""
with self._throttle_lock:
interval = self._current_min_interval()
elapsed = time.time() - self._last_call_ts
if elapsed < self.min_interval_sec:
time.sleep(self.min_interval_sec - elapsed)
waited = 0.0
if elapsed < interval:
waited = interval - elapsed
time.sleep(waited)
self._last_call_ts = time.time()
self._record_call(waited)
def _sleep_backoff(self, attempt: int) -> None:
"""지수 백오프 + 지터. attempt=1,2,3..."""
@@ -111,6 +161,8 @@ class SafeRequest:
# [1] 재시도 대상 HTTP 상태
if resp.status_code in self.RETRYABLE_STATUSES:
if resp.status_code == 429:
self._rate_limit_hits += 1
logger.warning(
"HTTP %d on %s %s (%d/%d) → 백오프 후 재시도",
resp.status_code, method.upper(), url, attempt, self.max_retries,
@@ -125,6 +177,7 @@ class SafeRequest:
except Exception:
body = None
if body and self._is_kis_rate_limited(body):
self._rate_limit_hits += 1
logger.warning(
"KIS rate-limit %s on %s (%d/%d) → 백오프 후 재시도",
body.get("msg_cd"), url, attempt, self.max_retries,

View File

@@ -0,0 +1,118 @@
"""
kis_trader 전략 ID 단일 정의 — active_trades / trade_history / 웹 실거래·보유탭 공통.
- ``main.py`` 전략 클래스의 ``strategy_id`` (SCALP, SHORT, MOMENTUM, UPDOW, BREAKOUT, HOLDING)
- ``trade_history`` 신규 기록은 ``canonical_strategy_id()`` 로 위 ID 만 저장 → 실거래 분석 탭과 일치
- 구식명(SCALP_RSI_REVERSAL, SHORT_ANT_SHAKING)은 조회 시 접두어(LIKE)로 묶고, 신규 저장은 canonical
"""
from __future__ import annotations
from typing import List, Optional
# kis_trader/main.py 에 등록되는 전략 ID (실거래 분석·보유탭 공통)
KIS_TRADER_STRATEGY_IDS: List[str] = [
"SCALP",
"SHORT",
"MOMENTUM",
"UPDOW",
"BREAKOUT",
"RANGE_BREAK",
"DBBAND",
"HOLDING",
]
# 사용자 지정 — 웹·계산에서 완전히 숨길 전략 (비활성·중복·저성능).
# DB 에 기존 거래기록이 남아 있어도 실거래 분석·대시보드·보유탭·운영설정에서 표시·집계하지 않는다.
# SCALP : MOMENTUM 과 1분봉 슬롯 중복 (반등 vs 추세) → MOMENTUM 만 사용
# RANGE_BREAK : BREAKOUT 과 돌파 컨셉 중복 + 미검증 (파라서치 거래 0건)
# DBBAND : 표본 부족·미성숙 (백테 2~4건)
HIDDEN_STRATEGY_IDS: List[str] = [
"SCALP",
"RANGE_BREAK",
"DBBAND",
]
# 웹·실거래 집계에서 제외 (DB에 남아 있어도 표시·집계 안 함)
EXCLUDED_STRATEGY_IDS: List[str] = [
"MANUAL",
"SCALP_RSI_REVERSAL",
"SHORT_ANT_SHAKING",
"SCALP_TEST",
] + HIDDEN_STRATEGY_IDS
# 보유·매도 탭 전용 — 홀딩봇(HOLDING)은 조회만, 여기서 매도 대상 아님
PORTFOLIO_EXCLUDED_STRATEGY_IDS: List[str] = EXCLUDED_STRATEGY_IDS + [
"HOLDING",
]
def canonical_strategy_id(strategy: Optional[str]) -> str:
"""
DB 저장·trade_history 기록용 canonical ID.
MANUAL 은 그대로, 구식명은 SCALP/SHORT 등으로 접힘.
"""
s = (strategy or "").strip().upper()
if not s:
return "MANUAL"
if s == "MANUAL":
return "MANUAL"
if s.startswith("SCALP"):
return "SCALP"
if s.startswith("SHORT") or s.startswith("TAIL"):
return "SHORT"
if s.startswith("MOMENTUM"):
return "MOMENTUM"
if s.startswith("UPDOW"):
return "UPDOW"
if s.startswith("BREAKOUT"):
return "BREAKOUT"
if s.startswith("RANGE_BREAK"):
return "RANGE_BREAK"
if s.startswith("DBBAND") or s.startswith("BBBAND"):
return "DBBAND"
if s.startswith("HOLDING"):
return "HOLDING"
return s
def strategy_prefix_for_filter(strategy: Optional[str]) -> str:
"""
trade_history / active_trades LIKE 필터용 접두어.
실거래 분석 탭·보유탭이 동일 규칙 사용.
"""
s_upper = (strategy or "").upper().strip()
if not s_upper or s_upper == "ALL":
return ""
if s_upper == "MANUAL":
return "MANUAL"
if s_upper.startswith("SCALP"):
return "SCALP"
if s_upper.startswith("SHORT") or s_upper.startswith("TAIL"):
return "SHORT"
if s_upper.startswith("MOMENTUM"):
return "MOMENTUM"
if s_upper.startswith("UPDOW"):
return "UPDOW"
if s_upper.startswith("BREAKOUT"):
return "BREAKOUT"
if s_upper.startswith("RANGE_BREAK"):
return "RANGE_BREAK"
if s_upper.startswith("DBBAND") or s_upper.startswith("BBBAND"):
return "DBBAND"
if s_upper.startswith("HOLDING"):
return "HOLDING"
return s_upper
def strategy_like_pattern(strategy: Optional[str]) -> Optional[str]:
"""None 이면 전체, 아니면 ``PREFIX%``."""
prefix = strategy_prefix_for_filter(strategy)
if not prefix:
return None
return prefix + "%"
def is_bot_strategy(strategy: Optional[str]) -> bool:
"""kis_trader 봇 전략 row 인지 (MANUAL 제외)."""
c = canonical_strategy_id(strategy)
return c in KIS_TRADER_STRATEGY_IDS

View File

@@ -0,0 +1,54 @@
"""
kis_trader/utils/universe_source.py — {SID}_UNIVERSE_SOURCE 단일 해석
======================================================================
매니저 폴링·history·전략 후보 로드가 같은 규칙을 쓰도록 공통 helper.
운영 스위치는 env **하나**:
``{STRATEGY_ID}_UNIVERSE_SOURCE`` → ``ranking`` | ``condition`` | ``kiwoom_condition``
비활성 전략(``STRATEGY_{SID}_ENABLED=false``)은 main 이 매니저에 등록하지 않으므로
별도 UNIVERSE_SOURCE 설정 불필요.
"""
from __future__ import annotations
from typing import FrozenSet
from .env import get_env_from_db
VALID_UNIVERSE_SOURCES: FrozenSet[str] = frozenset(
{"ranking", "condition", "kiwoom_condition"}
)
# BaseStrategy.DEFAULT_UNIVERSE_SOURCES · main._DEFAULT_SOURCE 와 동기화
_DEFAULT_SOURCES: dict[str, str] = {
"SCALP": "condition",
"SHORT": "kiwoom_condition",
"BREAKOUT": "kiwoom_condition",
"MOMENTUM": "kiwoom_condition",
"UPDOW": "condition",
"RANGE_BREAK": "condition",
"DBBAND": "condition",
}
def resolve_universe_source(strategy_id: str, *, default: str | None = None) -> str:
"""DB/env 에서 전략 유니버스 소스 해석. 잘못된 값이면 default 로 폴백."""
sid = (strategy_id or "").strip().upper()
if not sid:
return "ranking"
fb = default if default is not None else _DEFAULT_SOURCES.get(sid, "ranking")
if fb not in VALID_UNIVERSE_SOURCES:
fb = "ranking"
key = f"{sid}_UNIVERSE_SOURCE"
src = (get_env_from_db(key, fb) or fb).strip().lower()
if src not in VALID_UNIVERSE_SOURCES:
return fb
return src
def universe_source_active(strategy_id: str, want: str) -> bool:
"""현재 active UNIVERSE_SOURCE 가 want 와 같을 때만 True (REST/WS 반영 gate)."""
want_norm = (want or "").strip().lower()
if want_norm not in VALID_UNIVERSE_SOURCES:
return False
return resolve_universe_source(strategy_id) == want_norm