이번에 들어간 내용

한투 호가 = 2번째 앱키 전용
키 없거나 start 실패 시 메인에 H0STASP0 안 붙임. 운영설정 WS_ORDERBOOK_SAVE_KIS 빨간 danger.

LS RAM 합집합
후보∪보유∪영구∪grace. sync_targets와 split reconcile 둘 다. 틱 DB 영구 게이트는 그대로.

분봉 쓰레기 → 다음 소스 봉 통째
그 분 틱 0건이거나 전부 봉끝 대비 LIVE_FEED_FALLBACK_MAX_AGE_SEC 초과면 구멍. 메인 WS → 2차 → LS → REST → rollup. CANDLE_GARBAGE_FALLBACK 기본 true.

파일: feed_fallback.py(신규), ws_manager.py, kis_ws.py, candle_series.py, bt_candle_source.py, live_config_schema.py, database.py, 스모크, MD 2개.

같은 ws_manager/database/kis_ws/live_config에는 직전 커밋 이후 쌓여 있던 시세 폴백·ENV 키 정리도 같이 들어갔습니다. 파일 단위로 나눌 수 없어서입니다.
This commit is contained in:
Your Name
2026-08-19 22:11:31 +09:00
parent d1dc274f0e
commit 0ecac7cb95
229 changed files with 8487 additions and 1647 deletions

View File

@@ -13,6 +13,7 @@ kis_trader/strategies/base.py — 전략 공통 기반 클래스
"""
from __future__ import annotations
import queue
import random
import threading
import time
@@ -164,6 +165,11 @@ class BaseStrategy(ABC, threading.Thread):
self._sell_lock = threading.Lock()
self._tick_sell_last_ts: Dict[str, float] = {}
self._tick_sell_listener_on = False
# 종목당 매도 1장 (틱 큐·루프 공유). REST/place 는 워커·루프만.
self._sell_inflight: set = set()
self._tick_sell_q: Optional[queue.Queue] = None
self._tick_sell_worker: Optional[threading.Thread] = None
self._tick_sell_worker_lock = threading.Lock()
# 루프 숙제별 ms 계측 (LOOP_PROFILE_ENABLED)
self._loop_prof_i = 0
self._tick_sell_lock_miss = 0
@@ -188,6 +194,7 @@ class BaseStrategy(ABC, threading.Thread):
def stop_loop(self) -> None:
"""쓰레드 정지 요청 (daemon 이지만 정상 종료 시 호출)."""
self._running = False
self._stop_tick_sell_worker()
self._unregister_tick_sell_listener()
def _tick_sell_enabled(self) -> bool:
@@ -199,6 +206,83 @@ class BaseStrategy(ABC, threading.Thread):
return bool(get_env_bool(sid_key, False))
return bool(get_env_bool("TICK_SELL_ENABLED", False))
def _tick_sell_async_place(self) -> bool:
"""틱 콜백에서 place()/잔고 REST 금지. 기본 ON."""
return bool(get_env_bool("TICK_SELL_ASYNC_PLACE", True))
def _ensure_tick_sell_worker(self) -> None:
with self._tick_sell_worker_lock:
if self._tick_sell_worker is not None and self._tick_sell_worker.is_alive():
return
if self._tick_sell_q is None:
self._tick_sell_q = queue.Queue()
th = threading.Thread(
target=self._tick_sell_worker_loop,
name="tick-sell-%s" % (self.strategy_id or "BASE"),
daemon=True,
)
self._tick_sell_worker = th
th.start()
def _stop_tick_sell_worker(self) -> None:
q = self._tick_sell_q
if q is not None:
try:
q.put_nowait(None)
except Exception:
pass
def _tick_sell_worker_loop(self) -> None:
q = self._tick_sell_q
if q is None:
return
while True:
try:
item = q.get(timeout=0.3)
except queue.Empty:
if not self._running:
break
continue
if item is None:
break
sig = item if isinstance(item, dict) else {}
code = str(sig.get("code") or "").strip()
try:
self._sell_lock.acquire(blocking=True)
try:
if self._running:
if get_env_bool("REAL_BALANCE_VERIFY_BEFORE_SELL", True):
try:
self.order_mgr.prefetch_broker_holdings()
except Exception:
pass
self._submit_sell(sig)
finally:
if code:
self._sell_inflight.discard(code)
self._sell_lock.release()
except Exception as ex:
self.logger.warning("틱매도 워커 예외 %s: %s", code or "-", ex)
if code:
try:
self._sell_lock.acquire(blocking=True)
try:
self._sell_inflight.discard(code)
finally:
self._sell_lock.release()
except Exception:
self._sell_inflight.discard(code)
def _mark_sell_inflight(self, code: str) -> bool:
"""호출자 _sell_lock 보유. True=이 종목 매도 슬롯을 가져감."""
code = (code or "").strip()
if not code:
return False
if code in self._sell_inflight:
return False
self._sell_inflight.add(code)
return True
def _register_tick_sell_listener(self) -> None:
if self._tick_sell_listener_on:
return
@@ -209,9 +293,11 @@ class BaseStrategy(ABC, threading.Thread):
ws.register_price_listener(self._on_ws_price_tick)
self._tick_sell_listener_on = True
if self._tick_sell_enabled():
self._ensure_tick_sell_worker()
self.logger.info(
"📡 [틱매도] 리스너 등록 ON (%s_TICK_SELL / TICK_SELL)",
"📡 [틱매도] 리스너 등록 ON (%s_TICK_SELL / TICK_SELL) async_place=%s",
self.strategy_id,
self._tick_sell_async_place(),
)
else:
self.logger.debug(
@@ -235,6 +321,8 @@ class BaseStrategy(ABC, threading.Thread):
"""WS 현재가 갱신 → 보유 중이면 기존 check_sell_signals 경로로 매도 검사.
매도 규칙은 루프 매도와 동일 함수. 바뀌는 것은 호출 시점(틱)뿐.
이 함수는 한투·키움·LS **수신 스레드**에서 돈다.
``TICK_SELL_ASYNC_PLACE``(기본 true) 이면 신호만 큐에 넣고 REST/place 는 워커.
"""
if not self._running or not self._tick_sell_enabled():
return
@@ -257,15 +345,29 @@ class BaseStrategy(ABC, threading.Thread):
if code not in self.holdings:
return
sell_signals = self.check_sell_signals() or []
if sell_signals and get_env_bool("REAL_BALANCE_VERIFY_BEFORE_SELL", True):
try:
self.order_mgr.prefetch_broker_holdings()
except Exception:
pass
for sig in sell_signals:
if (sig.get("code") or "") == code:
self._submit_sell(sig)
if (sig.get("code") or "") != code:
continue
if not self._mark_sell_inflight(code):
break
if self._tick_sell_async_place():
self._ensure_tick_sell_worker()
try:
self._tick_sell_q.put_nowait(sig)
except Exception as qex:
self._sell_inflight.discard(code)
self.logger.warning("틱매도 큐 실패 %s: %s", code, qex)
break
try:
if get_env_bool("REAL_BALANCE_VERIFY_BEFORE_SELL", True):
try:
self.order_mgr.prefetch_broker_holdings()
except Exception:
pass
self._submit_sell(sig)
finally:
self._sell_inflight.discard(code)
break
except Exception as ex:
self.logger.debug("틱매도 예외 %s: %s", code, ex)
finally:
@@ -472,13 +574,52 @@ class BaseStrategy(ABC, threading.Thread):
out["prefetch_ms"] = (time.perf_counter() - t1) * 1000.0
t2 = time.perf_counter()
for sig in sell_signals:
self._submit_sell(sig)
sc = str(sig.get("code") or "").strip()
if not sc:
continue
if not self._mark_sell_inflight(sc):
continue
try:
self._submit_sell(sig)
finally:
self._sell_inflight.discard(sc)
out["submit_sell_ms"] = (time.perf_counter() - t2) * 1000.0
finally:
out["lock_hold_ms"] = (time.perf_counter() - t_hold0) * 1000.0
self._sell_lock.release()
return out
def _ws_last_quote(self, code: str) -> Optional[dict]:
"""실매 매수 현재가. 생략=2초 체인(메인 실패→2차→3차). REST 없음."""
ws = self.ws
getp = getattr(ws, "get_price", None)
if not callable(getp):
return None
try:
return getp(code)
except TypeError:
try:
return getp(code, max_age_sec=0)
except Exception:
return None
except Exception:
return None
def _resolve_sell_price(self, code: str, *, is_eod: bool, buy_price: float) -> float:
"""실매 매도 현재가 — 마지막 WS를 TTL로 버리지 않음. EOD는 매수가 폴백."""
from kis_trader.engine.live_sell_price import resolve_live_sell_price
inquire = getattr(self.client, "inquire_price", None)
px, _src = resolve_live_sell_price(
self.ws,
inquire,
code,
is_eod=bool(is_eod),
fallback_price=float(buy_price or 0.0),
logger=self.logger,
)
return float(px or 0.0)
# ------------------------------------------------------------------
# 스캔 루프 sleep (env 핫리로드 — 재시작 없이 반영)
# ------------------------------------------------------------------
@@ -530,6 +671,7 @@ class BaseStrategy(ABC, threading.Thread):
except Exception as e:
self.logger.exception("전략 루프 예외: %s", e)
finally:
self._stop_tick_sell_worker()
self._unregister_tick_sell_listener()
self.logger.info("⏹ 전략 쓰레드 종료 [%s]", self.strategy_id)
@@ -657,7 +799,7 @@ class BaseStrategy(ABC, threading.Thread):
row["cand_n"] = int(getattr(self, "_prof_cand_n", 0) or 0)
cand_codes = [c.get("code") for c in candidates if c.get("code")]
hold_codes = list(self.holdings.keys())
# KIS 최소 구독 모드: 후보=키움 WS, KIS=영구+보유 (WSManager.sync_targets_split)
# WS_SUBSCRIBE_KIS_MINIMAL: 후보=키움, 한투=보유만, 영구KR=LS (sync_targets_split)
# US_MOMENTUM 등은 _sync_ws_for_loop 오버라이드로 해외 WS 만 사용
t0 = time.perf_counter() if do_prof else 0.0
self._sync_ws_for_loop(cand_codes, hold_codes)