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:
@@ -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))
|
||||
|
||||
|
||||
260
kis_trader/utils/live_portfolio_common.py
Normal file
260
kis_trader/utils/live_portfolio_common.py
Normal 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
|
||||
@@ -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 메시지 전송.
|
||||
|
||||
41
kis_trader/utils/position_sizing.py
Normal file
41
kis_trader/utils/position_sizing.py
Normal 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
|
||||
@@ -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,
|
||||
|
||||
118
kis_trader/utils/strategy_ids.py
Normal file
118
kis_trader/utils/strategy_ids.py
Normal 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
|
||||
54
kis_trader/utils/universe_source.py
Normal file
54
kis_trader/utils/universe_source.py
Normal 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
|
||||
Reference in New Issue
Block a user