이번에 들어간 내용
한투 호가 = 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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user