feat: Enhance trading system with new permanent subscription features and order book management
Changes: - Added a new API endpoint for managing permanent subscriptions, allowing users to enable or disable subscriptions dynamically. - Implemented a function to fill candle data from Kiwoom, ensuring that only relevant data is inserted into the database. - Introduced a mechanism to handle master subscription states, improving the management of subscription statuses. - Updated the database schema to include new fields for managing subscription states and order book filtering. Impact: - These enhancements improve the flexibility and reliability of the trading system, allowing for better management of subscriptions and order book data, while reducing the risk of data inconsistencies. 히스토리 align 제거 븅신같은 초기설계 아예 제거 진입모드에 구멍메움 호가진입을 켜도 호가가 안들어올때 호가 안보고 그냥 사버림
This commit is contained in:
@@ -4,10 +4,9 @@
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime as dt
|
||||
from typing import Any, Dict, List, Optional, Set, Tuple
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from ..backtest.backtest_portfolio_common import (
|
||||
STRATEGY_PORTFOLIO_KEYS,
|
||||
@@ -16,60 +15,12 @@ from ..backtest.backtest_portfolio_common import (
|
||||
resolve_portfolio_params,
|
||||
target_qty_and_cost,
|
||||
)
|
||||
from ..utils.env import get_env_bool, get_env_float, get_env_from_db, get_env_int
|
||||
from ..utils.env import 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"})
|
||||
|
||||
# 유니버스 슬롯 history — RAM TTL (정합 ON 유지 · 루프마다 SELECT 금지)
|
||||
_SLOT_UNIV_LOCK = threading.Lock()
|
||||
# key=(sid, src, slot_key) → (codes|None, ts). codes=None → 스냅샷 없음(필터 생략)
|
||||
_SLOT_UNIV_CACHE: Dict[Tuple[str, str, str], Tuple[Optional[Set[str]], float]] = {}
|
||||
# 같은 키 TTL 만료 시 DB SELECT 1회만 (대기자는 Event 공유) — 분슬롯 freeze 아님
|
||||
_SLOT_UNIV_FLIGHT: Dict[Tuple[str, str, str], threading.Event] = {}
|
||||
|
||||
|
||||
def _slot_univ_ttl_sec() -> float:
|
||||
try:
|
||||
return float(get_env_float("UNIVERSE_SLOT_CACHE_TTL_SEC", 1.0) or 1.0)
|
||||
except Exception:
|
||||
return 1.0
|
||||
|
||||
|
||||
def _slot_univ_flight_wait_sec() -> float:
|
||||
"""single-flight 대기 상한(초). 리더 실패/타임아웃 후 재시도용."""
|
||||
try:
|
||||
return float(get_env_float("UNIVERSE_SLOT_FLIGHT_WAIT_SEC", 5.0) or 5.0)
|
||||
except Exception:
|
||||
return 5.0
|
||||
|
||||
|
||||
def invalidate_universe_slot_cache(strategy_id: Optional[str] = None) -> None:
|
||||
"""슬롯 캐시 무효화. strategy_id 없으면 전체. in-flight 대기자도 깨움."""
|
||||
sid = (strategy_id or "").upper()
|
||||
wake: List[threading.Event] = []
|
||||
with _SLOT_UNIV_LOCK:
|
||||
if not sid:
|
||||
_SLOT_UNIV_CACHE.clear()
|
||||
wake = list(_SLOT_UNIV_FLIGHT.values())
|
||||
_SLOT_UNIV_FLIGHT.clear()
|
||||
else:
|
||||
for k in list(_SLOT_UNIV_CACHE.keys()):
|
||||
if k[0] == sid:
|
||||
del _SLOT_UNIV_CACHE[k]
|
||||
for k in list(_SLOT_UNIV_FLIGHT.keys()):
|
||||
if k[0] == sid:
|
||||
wake.append(_SLOT_UNIV_FLIGHT.pop(k))
|
||||
for ev in wake:
|
||||
try:
|
||||
ev.set()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _prof_add(prof: Optional[Dict[str, float]], key: str, t0: float) -> None:
|
||||
"""LOOP_PROF 세분용 — 매매 로직 불변, ms 누적만."""
|
||||
@@ -93,31 +44,15 @@ def _env_flag(key: str, default: bool) -> bool:
|
||||
return str(raw).strip().lower() in ("1", "true", "t", "y", "yes", "on")
|
||||
|
||||
|
||||
def live_universe_slot_align_enabled(strategy_id: str) -> bool:
|
||||
"""실매 후보 ∩ history 슬롯 필터.
|
||||
|
||||
기본 **False** — history 지연/축소 시 전량탈락(universe_wipe) 방지.
|
||||
백테 슬롯 정합이 필요하면 ``LIVE_UNIVERSE_SLOT_ALIGN=true`` 또는 전략별 키로 ON.
|
||||
"""
|
||||
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, False)
|
||||
if sid in _UNIVERSE_SLOT_SKIP_DEFAULT:
|
||||
return False
|
||||
return _env_flag("LIVE_UNIVERSE_SLOT_ALIGN", False)
|
||||
|
||||
|
||||
def resolve_live_universe_history_source(
|
||||
strategy_id: str,
|
||||
*,
|
||||
universe_source: Optional[str] = None,
|
||||
) -> str:
|
||||
"""실매 슬롯 정합이 읽을 이력 테이블.
|
||||
"""백테/조회가 읽을 이력 테이블.
|
||||
|
||||
- ``ls_condition`` 유니버스 → ``ls_candidates_history``
|
||||
- 그 외 → ``target_candidates_history`` (키움/KIS)
|
||||
키움 이력과 LS 실후보를 교집합하면 전원 탈락(20→0) 한다.
|
||||
"""
|
||||
sid = (strategy_id or "").upper()
|
||||
# 전략·전역 명시 오버라이드
|
||||
@@ -206,288 +141,6 @@ def resolve_live_min_invest_ratio(strategy_id: str) -> float:
|
||||
return min_invest_ratio_of_slot({}, strategy=portfolio_strategy_key(strategy_id))
|
||||
|
||||
|
||||
def _history_snapshot_event_time(
|
||||
db: Any,
|
||||
strategy_id: str,
|
||||
at_time: str,
|
||||
history_source: str,
|
||||
) -> Optional[Any]:
|
||||
"""``at_time`` 이전 최신 ``event_time`` (없으면 None)."""
|
||||
conn = getattr(db, "conn", None)
|
||||
if conn is None:
|
||||
return None
|
||||
table_fn = getattr(db, "_universe_history_table", None)
|
||||
if callable(table_fn):
|
||||
try:
|
||||
table = table_fn(history_source)
|
||||
except Exception:
|
||||
table = None
|
||||
else:
|
||||
table = None
|
||||
if not table:
|
||||
from kis_trader.backtest.universe_history_source import history_table_for_source
|
||||
table = history_table_for_source(history_source)
|
||||
try:
|
||||
row = conn.execute(
|
||||
f"""
|
||||
SELECT MAX(event_time) AS et
|
||||
FROM {table}
|
||||
WHERE strategy_id=%s AND event_time <= %s
|
||||
""",
|
||||
(strategy_id, at_time),
|
||||
).fetchone()
|
||||
return (row or {}).get("et") if row else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _event_time_ymd(et: Any) -> str:
|
||||
if et is None:
|
||||
return ""
|
||||
if hasattr(et, "strftime"):
|
||||
try:
|
||||
return et.strftime("%Y-%m-%d")
|
||||
except Exception:
|
||||
pass
|
||||
s = str(et).strip()
|
||||
if len(s) >= 10 and s[4] == "-" and s[7] == "-":
|
||||
return s[:10]
|
||||
# YYYYMMDDHHMM / YYYYMMDD...
|
||||
digits = "".join(ch for ch in s if ch.isdigit())
|
||||
if len(digits) >= 8:
|
||||
return f"{digits[:4]}-{digits[4:6]}-{digits[6:8]}"
|
||||
return ""
|
||||
|
||||
|
||||
def history_universe_codes_at(
|
||||
db: Any,
|
||||
strategy_id: str,
|
||||
when: Optional[dt] = None,
|
||||
*,
|
||||
history_source: str = "kiwoom",
|
||||
universe_source: Optional[str] = None,
|
||||
prof: Optional[Dict[str, float]] = None,
|
||||
) -> Optional[Set[str]]:
|
||||
"""
|
||||
이력 스냅샷 — ``at_time`` 이전 최신 event_time 종목 집합.
|
||||
- kiwoom → ``target_candidates_history``
|
||||
- ls → ``ls_candidates_history``
|
||||
스냅샷 없으면 ``None`` (필터 생략 = 실시간 후보 유지).
|
||||
|
||||
**당일 스냅샷만** 사용한다. 주말 재시작·키움 매니저 다운 뒤 남은
|
||||
며칠 전 history 와 sticky 후보를 교집합하면 전원 탈락(11→0) 한다.
|
||||
|
||||
RAM TTL(``UNIVERSE_SLOT_CACHE_TTL_SEC`` 기본 1초): 같은 슬롯키면 DB 재조회 생략.
|
||||
TTL 만료 시 **single-flight**: 같은 ``(sid,src,slot_key)`` 는 DB SELECT 1회만,
|
||||
동시 호출은 Event 대기 후 동일 RAM 결과 (분 단위 freeze / at_time 고정 아님).
|
||||
"""
|
||||
when = when or dt.now()
|
||||
at_time = when.strftime("%Y-%m-%d %H:%M:%S")
|
||||
sid = (strategy_id or "").upper()
|
||||
src = str(history_source or "").strip().lower()
|
||||
if universe_source is not None:
|
||||
src = resolve_live_universe_history_source(
|
||||
sid, universe_source=universe_source,
|
||||
)
|
||||
elif src in ("", "auto"):
|
||||
src = resolve_live_universe_history_source(sid, universe_source=None)
|
||||
if src not in ("ls", "kiwoom"):
|
||||
src = "kiwoom"
|
||||
|
||||
iv = resolve_live_scan_interval_min(sid)
|
||||
slot_key = slot_key_from_dt(when, iv)
|
||||
cache_key = (sid, src, slot_key)
|
||||
ttl = _slot_univ_ttl_sec()
|
||||
day_ymd = when.strftime("%Y-%m-%d")
|
||||
|
||||
def _from_cache_locked(
|
||||
*, as_hit: bool, from_flight: bool = False,
|
||||
) -> Tuple[bool, Optional[Set[str]]]:
|
||||
"""(hit, codes). hit=False 이면 미스. codes=None 은 스냅샷 없음(필터 생략)."""
|
||||
hit = _SLOT_UNIV_CACHE.get(cache_key)
|
||||
if hit is None:
|
||||
return False, None
|
||||
codes_cached, ts = hit
|
||||
now_ts = time.time()
|
||||
# TTL 0 이하 = 캐시 끔(스키마·ENV 설명과 동일) → 항상 미스
|
||||
if ttl <= 0 or (now_ts - ts) >= ttl:
|
||||
return False, None
|
||||
if as_hit and prof is not None:
|
||||
prof["slot_cache_hit"] = float(prof.get("slot_cache_hit", 0) or 0) + 1.0
|
||||
if from_flight:
|
||||
prof["slot_flight_wait"] = float(
|
||||
prof.get("slot_flight_wait", 0) or 0
|
||||
) + 1.0
|
||||
if codes_cached is None:
|
||||
if prof is not None:
|
||||
prof["slot_miss"] = float(prof.get("slot_miss", 0) or 0) + 1.0
|
||||
return True, None
|
||||
if prof is not None:
|
||||
prof["slot_ok"] = float(prof.get("slot_ok", 0) or 0) + 1.0
|
||||
prof["slot_codes"] = float(len(codes_cached))
|
||||
return True, set(codes_cached)
|
||||
|
||||
def _store_locked(store: Optional[Set[str]]) -> None:
|
||||
_SLOT_UNIV_CACHE[cache_key] = (
|
||||
set(store) if store else None,
|
||||
time.time(),
|
||||
)
|
||||
|
||||
def _fetch_db() -> Optional[Set[str]]:
|
||||
# 당일(캘린더) 스냅샷만 슬롯정합에 쓴다 — 낡은 history 전멸 방지
|
||||
t_et = time.perf_counter()
|
||||
et = _history_snapshot_event_time(db, sid, at_time, src)
|
||||
_prof_add(prof, "slot_et_ms", t_et)
|
||||
if not et or _event_time_ymd(et) != day_ymd:
|
||||
with _SLOT_UNIV_LOCK:
|
||||
_store_locked(None)
|
||||
if prof is not None:
|
||||
prof["slot_miss"] = float(prof.get("slot_miss", 0) or 0) + 1.0
|
||||
prof["slot_cache_miss"] = float(
|
||||
prof.get("slot_cache_miss", 0) or 0
|
||||
) + 1.0
|
||||
return None
|
||||
getter = getattr(db, "get_universe_at", None)
|
||||
if getter is None:
|
||||
with _SLOT_UNIV_LOCK:
|
||||
_store_locked(None)
|
||||
if prof is not None:
|
||||
prof["slot_miss"] = float(prof.get("slot_miss", 0) or 0) + 1.0
|
||||
prof["slot_cache_miss"] = float(
|
||||
prof.get("slot_cache_miss", 0) or 0
|
||||
) + 1.0
|
||||
return None
|
||||
try:
|
||||
t_get = time.perf_counter()
|
||||
# TradeDBExt 는 history_source 지원. FakeDB 등 구시그니처는 무시.
|
||||
try:
|
||||
rows = getter(
|
||||
strategy_id=sid, at_time=at_time, history_source=src,
|
||||
) or []
|
||||
except TypeError:
|
||||
rows = getter(strategy_id=sid, at_time=at_time) or []
|
||||
_prof_add(prof, "slot_get_ms", t_get)
|
||||
except Exception:
|
||||
if prof is not None:
|
||||
prof["slot_miss"] = float(prof.get("slot_miss", 0) or 0) + 1.0
|
||||
prof["slot_cache_miss"] = float(
|
||||
prof.get("slot_cache_miss", 0) or 0
|
||||
) + 1.0
|
||||
return None
|
||||
codes: Set[str] = set()
|
||||
for r in rows or []:
|
||||
code = str(r.get("code") or "").strip()
|
||||
if code:
|
||||
codes.add(code)
|
||||
store: Optional[Set[str]] = set(codes) if codes else None
|
||||
with _SLOT_UNIV_LOCK:
|
||||
_store_locked(store)
|
||||
if prof is not None:
|
||||
prof["slot_cache_miss"] = float(
|
||||
prof.get("slot_cache_miss", 0) or 0
|
||||
) + 1.0
|
||||
if codes:
|
||||
prof["slot_ok"] = float(prof.get("slot_ok", 0) or 0) + 1.0
|
||||
prof["slot_codes"] = float(len(codes))
|
||||
else:
|
||||
prof["slot_miss"] = float(prof.get("slot_miss", 0) or 0) + 1.0
|
||||
return codes if codes else None
|
||||
|
||||
# 1) TTL hit / 2) flight 대기 / 3) 리더 SELECT
|
||||
leader = False
|
||||
flight_ev: Optional[threading.Event] = None
|
||||
with _SLOT_UNIV_LOCK:
|
||||
ok, cached = _from_cache_locked(as_hit=True)
|
||||
if ok:
|
||||
return cached
|
||||
flight_ev = _SLOT_UNIV_FLIGHT.get(cache_key)
|
||||
if flight_ev is None:
|
||||
flight_ev = threading.Event()
|
||||
_SLOT_UNIV_FLIGHT[cache_key] = flight_ev
|
||||
leader = True
|
||||
|
||||
if not leader and flight_ev is not None:
|
||||
flight_ev.wait(timeout=max(0.05, _slot_univ_flight_wait_sec()))
|
||||
with _SLOT_UNIV_LOCK:
|
||||
ok, cached = _from_cache_locked(as_hit=True, from_flight=True)
|
||||
if ok:
|
||||
return cached
|
||||
# 리더 실패·타임아웃 → 내가 재비행
|
||||
if cache_key not in _SLOT_UNIV_FLIGHT:
|
||||
flight_ev = threading.Event()
|
||||
_SLOT_UNIV_FLIGHT[cache_key] = flight_ev
|
||||
leader = True
|
||||
else:
|
||||
flight_ev = _SLOT_UNIV_FLIGHT[cache_key]
|
||||
if not leader and flight_ev is not None:
|
||||
flight_ev.wait(timeout=max(0.05, _slot_univ_flight_wait_sec()))
|
||||
with _SLOT_UNIV_LOCK:
|
||||
ok, cached = _from_cache_locked(as_hit=True, from_flight=True)
|
||||
if ok:
|
||||
return cached
|
||||
if cache_key not in _SLOT_UNIV_FLIGHT:
|
||||
flight_ev = threading.Event()
|
||||
_SLOT_UNIV_FLIGHT[cache_key] = flight_ev
|
||||
leader = True
|
||||
else:
|
||||
# 여전히 비행 중이면 중복 SELECT 대신 스냅샷 없음과 동일(필터 생략)
|
||||
if prof is not None:
|
||||
prof["slot_miss"] = float(
|
||||
prof.get("slot_miss", 0) or 0
|
||||
) + 1.0
|
||||
return None
|
||||
|
||||
if not leader:
|
||||
return None
|
||||
|
||||
assert flight_ev is not None
|
||||
try:
|
||||
return _fetch_db()
|
||||
finally:
|
||||
with _SLOT_UNIV_LOCK:
|
||||
_SLOT_UNIV_FLIGHT.pop(cache_key, None)
|
||||
try:
|
||||
flight_ev.set()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def filter_candidates_by_history_universe(
|
||||
candidates: List[Dict],
|
||||
db: Any,
|
||||
strategy_id: str,
|
||||
*,
|
||||
when: Optional[dt] = None,
|
||||
history_source: Optional[str] = None,
|
||||
universe_source: Optional[str] = None,
|
||||
prof: Optional[Dict[str, float]] = None,
|
||||
) -> Tuple[List[Dict], int]:
|
||||
"""실시간 후보 ∩ history 스냅샷. (filtered, dropped_count)."""
|
||||
if not candidates:
|
||||
return [], 0
|
||||
hs = history_source
|
||||
if hs is None:
|
||||
hs = resolve_live_universe_history_source(
|
||||
strategy_id, universe_source=universe_source,
|
||||
)
|
||||
allowed = history_universe_codes_at(
|
||||
db,
|
||||
strategy_id,
|
||||
when,
|
||||
history_source=hs or "kiwoom",
|
||||
universe_source=None,
|
||||
prof=prof,
|
||||
)
|
||||
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)
|
||||
|
||||
@@ -29,7 +29,6 @@ _SESSION_CODES = frozenset({
|
||||
"ws_ls_down",
|
||||
"ws_tick_silence",
|
||||
"universe_zero",
|
||||
"universe_wipe",
|
||||
"history_stale",
|
||||
"kwcond_off",
|
||||
"order_buy_reject",
|
||||
|
||||
Reference in New Issue
Block a user