ls증권 히스토리 구독 넣음
This commit is contained in:
@@ -76,6 +76,7 @@ class BaseStrategy(ABC, threading.Thread):
|
||||
condition_mgr=None,
|
||||
ranking_mgr=None,
|
||||
kiwoom_condition_mgr=None,
|
||||
ls_condition_mgr=None,
|
||||
market_guard=None,
|
||||
):
|
||||
super().__init__(daemon=True, name=f"Strat-{self.strategy_id}")
|
||||
@@ -87,19 +88,23 @@ class BaseStrategy(ABC, threading.Thread):
|
||||
self.ranking_mgr = ranking_mgr # VolumeRankManager (선택)
|
||||
# KiwoomConditionSearchManager (키움 WS 실시간 조건검색, 선택). KIS 와 별개 소스.
|
||||
self.kiwoom_condition_mgr = kiwoom_condition_mgr
|
||||
# LsConditionSearchManager (LS AFR 조건검색, 선택). 시세는 키움/한투 유지.
|
||||
self.ls_condition_mgr = ls_condition_mgr
|
||||
self.market_guard = market_guard # MarketGuard (선택, None 이면 가드 없음)
|
||||
self.logger = get_logger(f"kis_trader.strategy.{self.strategy_id}")
|
||||
# MarketGuard PANIC 차단 로그 스팸 방지용 (분당 1회)
|
||||
self._panic_log_ts: float = 0.0
|
||||
|
||||
# 유니버스 소스: "ranking" | "condition"(KIS) | "kiwoom_condition"(키움 WS)
|
||||
# 유니버스 소스: ranking | condition(KIS) | kiwoom_condition | ls_condition
|
||||
# env: {STRATEGY_ID}_UNIVERSE_SOURCE — 런타임에 스위치 가능.
|
||||
key = f"{self.strategy_id}_UNIVERSE_SOURCE"
|
||||
default = self.DEFAULT_UNIVERSE_SOURCES.get(self.strategy_id, "ranking")
|
||||
self.universe_source = (
|
||||
(get_env_from_db(key, default) or default).strip().lower()
|
||||
)
|
||||
if self.universe_source not in ("ranking", "condition", "kiwoom_condition"):
|
||||
if self.universe_source not in (
|
||||
"ranking", "condition", "kiwoom_condition", "ls_condition",
|
||||
):
|
||||
self.logger.warning(
|
||||
"알 수 없는 UNIVERSE_SOURCE=%s → 기본값 %s 사용",
|
||||
self.universe_source, default,
|
||||
@@ -113,12 +118,19 @@ class BaseStrategy(ABC, threading.Thread):
|
||||
self._runtime: Dict[str, dict] = {}
|
||||
# 최근 매도 쿨다운 (종목별 마지막 매도 타임스탬프)
|
||||
self.recently_sold: Dict[str, float] = {}
|
||||
# 후보 ENTER 시각 (datetime) — 중분 편입 시 해당 봉 시가 매수 보류용
|
||||
self._cand_enter_dt: Dict[str, Any] = {}
|
||||
# 당일 매매불가 종목 (다음 후보로 넘어감)
|
||||
self.untradable_skip: set = set()
|
||||
|
||||
# 일일 익절 목표 가드 (Orchestrator 주입, 없으면 OFF)
|
||||
self.daily_profit_halt: Any = None
|
||||
|
||||
# 틱매도 — WS 현재가 갱신 시 매도 검사 (기본 OFF · env 로 ON)
|
||||
self._sell_lock = threading.Lock()
|
||||
self._tick_sell_last_ts: Dict[str, float] = {}
|
||||
self._tick_sell_listener_on = False
|
||||
|
||||
self._sync_holdings_from_db(log_restore=True)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@@ -127,6 +139,87 @@ class BaseStrategy(ABC, threading.Thread):
|
||||
def stop_loop(self) -> None:
|
||||
"""쓰레드 정지 요청 (daemon 이지만 정상 종료 시 호출)."""
|
||||
self._running = False
|
||||
self._unregister_tick_sell_listener()
|
||||
|
||||
def _tick_sell_enabled(self) -> bool:
|
||||
"""공통 TICK_SELL_ENABLED 또는 {SID}_TICK_SELL_ENABLED (전략키 비어있지 않으면 우선)."""
|
||||
sid = (self.strategy_id or "BASE").upper()
|
||||
sid_key = f"{sid}_TICK_SELL_ENABLED"
|
||||
raw_sid = str(get_env_from_db(sid_key, "") or "").strip()
|
||||
if raw_sid:
|
||||
return bool(get_env_bool(sid_key, False))
|
||||
return bool(get_env_bool("TICK_SELL_ENABLED", False))
|
||||
|
||||
def _register_tick_sell_listener(self) -> None:
|
||||
if self._tick_sell_listener_on:
|
||||
return
|
||||
ws = getattr(self, "ws", None)
|
||||
if ws is None or not hasattr(ws, "register_price_listener"):
|
||||
return
|
||||
try:
|
||||
ws.register_price_listener(self._on_ws_price_tick)
|
||||
self._tick_sell_listener_on = True
|
||||
if self._tick_sell_enabled():
|
||||
self.logger.info(
|
||||
"📡 [틱매도] 리스너 등록 ON (%s_TICK_SELL / TICK_SELL)",
|
||||
self.strategy_id,
|
||||
)
|
||||
else:
|
||||
self.logger.debug(
|
||||
"틱매도 리스너 등록(대기) — env OFF 시 콜백 no-op",
|
||||
)
|
||||
except Exception as ex:
|
||||
self.logger.warning("틱매도 리스너 등록 실패: %s", ex)
|
||||
|
||||
def _unregister_tick_sell_listener(self) -> None:
|
||||
if not self._tick_sell_listener_on:
|
||||
return
|
||||
ws = getattr(self, "ws", None)
|
||||
if ws is not None and hasattr(ws, "unregister_price_listener"):
|
||||
try:
|
||||
ws.unregister_price_listener(self._on_ws_price_tick)
|
||||
except Exception:
|
||||
pass
|
||||
self._tick_sell_listener_on = False
|
||||
|
||||
def _on_ws_price_tick(self, code: str, price: float, raw: Any = None) -> None:
|
||||
"""WS 현재가 갱신 → 보유 중이면 기존 check_sell_signals 경로로 매도 검사.
|
||||
|
||||
매도 규칙은 루프 매도와 동일 함수. 바뀌는 것은 호출 시점(틱)뿐.
|
||||
"""
|
||||
if not self._running or not self._tick_sell_enabled():
|
||||
return
|
||||
code = (code or "").strip()
|
||||
if not code or code not in self.holdings:
|
||||
return
|
||||
try:
|
||||
min_ms = max(0, int(get_env_int("TICK_SELL_MIN_INTERVAL_MS", 50) or 0))
|
||||
except Exception:
|
||||
min_ms = 50
|
||||
now = time.time()
|
||||
last = float(self._tick_sell_last_ts.get(code, 0.0) or 0.0)
|
||||
if min_ms > 0 and (now - last) * 1000.0 < float(min_ms):
|
||||
return
|
||||
self._tick_sell_last_ts[code] = now
|
||||
if not self._sell_lock.acquire(blocking=False):
|
||||
return
|
||||
try:
|
||||
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)
|
||||
break
|
||||
except Exception as ex:
|
||||
self.logger.debug("틱매도 예외 %s: %s", code, ex)
|
||||
finally:
|
||||
self._sell_lock.release()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 스캔 루프 sleep (env 핫리로드 — 재시작 없이 반영)
|
||||
@@ -173,11 +266,13 @@ class BaseStrategy(ABC, threading.Thread):
|
||||
"""threading.Thread.run() 오버라이드 — 전략 메인 루프."""
|
||||
self._running = True
|
||||
self.logger.info("🚀 전략 쓰레드 시작 [%s]", self.strategy_id)
|
||||
self._register_tick_sell_listener()
|
||||
try:
|
||||
self._run_loop()
|
||||
except Exception as e:
|
||||
self.logger.exception("전략 루프 예외: %s", e)
|
||||
finally:
|
||||
self._unregister_tick_sell_listener()
|
||||
self.logger.info("⏹ 전략 쓰레드 종료 [%s]", self.strategy_id)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@@ -221,19 +316,31 @@ class BaseStrategy(ABC, threading.Thread):
|
||||
# 보유 목록 = DB 진실 + _runtime 오버레이 (poll 체결·재시작 정합)
|
||||
self._sync_holdings_from_db()
|
||||
|
||||
# 일일익절 B안 — 매수루프 비어도 hit 후 보유 리스크 정리
|
||||
guard = getattr(self, "daily_profit_halt", None)
|
||||
if guard is not None:
|
||||
try:
|
||||
guard.maybe_trim_open_risk(self.strategy_id)
|
||||
except Exception as ex:
|
||||
self.logger.debug("일일익절 리스크버짓 예외: %s", ex)
|
||||
|
||||
# ── [1] 매도 먼저 ────────────────────────────────
|
||||
sell_signals = self.check_sell_signals()
|
||||
if sell_signals and get_env_bool("REAL_BALANCE_VERIFY_BEFORE_SELL", True):
|
||||
self.order_mgr.prefetch_broker_holdings()
|
||||
for sig in sell_signals:
|
||||
self._submit_sell(sig)
|
||||
with self._sell_lock:
|
||||
sell_signals = self.check_sell_signals()
|
||||
if sell_signals and get_env_bool("REAL_BALANCE_VERIFY_BEFORE_SELL", True):
|
||||
self.order_mgr.prefetch_broker_holdings()
|
||||
for sig in sell_signals:
|
||||
self._submit_sell(sig)
|
||||
|
||||
# ── [2] 후보 구독 동기화 (공유 WS) ────────────────
|
||||
candidates = self._load_candidates()
|
||||
# 중분 편입 시가 애매 가드용 — 후보 ENTER 시각(초) 기록
|
||||
self._note_candidate_enters(candidates)
|
||||
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)
|
||||
self.ws.sync_targets_split(self.strategy_id, cand_codes, hold_codes)
|
||||
# US_MOMENTUM 등은 _sync_ws_for_loop 오버라이드로 해외 WS 만 사용
|
||||
self._sync_ws_for_loop(cand_codes, hold_codes)
|
||||
|
||||
# ── [2b] 미체결 지정가 만료 취소 ───────────────────
|
||||
self.manage_pending_orders()
|
||||
@@ -243,6 +350,17 @@ class BaseStrategy(ABC, threading.Thread):
|
||||
active_cnt = len(self.holdings)
|
||||
if candidates and active_cnt < max_stocks and self.check_buy_allowed():
|
||||
self._scan_and_buy(candidates, max_stocks, active_cnt)
|
||||
elif candidates and active_cnt >= max_stocks:
|
||||
# 보유만석이면 _scan_and_buy 미진입 → 매수체크 로그가 안 나와 "멈춘 것"처럼 보임
|
||||
now_m = time.time()
|
||||
last_m = float(getattr(self, "_last_full_skip_log_ts", 0) or 0)
|
||||
if now_m - last_m >= 60.0:
|
||||
self._last_full_skip_log_ts = now_m
|
||||
self.logger.info(
|
||||
"🔍 [매수체크 스킵] 보유만석 %d/%d codes=%s",
|
||||
active_cnt, max_stocks,
|
||||
",".join(list(self.holdings.keys())[:12]),
|
||||
)
|
||||
|
||||
# 고점·세션저점 등 런타임 오버레이 저장 (다음 루프 DB sync 시 max merge)
|
||||
self._capture_runtime_overlay()
|
||||
@@ -256,6 +374,18 @@ class BaseStrategy(ABC, threading.Thread):
|
||||
self.logger.error("루프 예외: %s", e)
|
||||
time.sleep(5)
|
||||
|
||||
def _sync_ws_for_loop(self, cand_codes: List[str], hold_codes: List[str]) -> None:
|
||||
"""후보·보유 WS 구독 동기화 — 해외 전략은 오버라이드.
|
||||
|
||||
``ls_condition``:
|
||||
- LS US3 = 틱·현재가 (히스토리와 한 묶음)
|
||||
- 키움 = 갭보정·분봉 (기존 잘 되는 경로)
|
||||
"""
|
||||
ls_feed = str(getattr(self, "universe_source", "") or "") == "ls_condition"
|
||||
self.ws.sync_targets_split(
|
||||
self.strategy_id, cand_codes, hold_codes, ls_feed=ls_feed,
|
||||
)
|
||||
|
||||
def manage_pending_orders(self) -> None:
|
||||
"""미체결 ATR 지정가 만료 시 취소 — 서브클래스에서 구현."""
|
||||
return None
|
||||
@@ -387,10 +517,22 @@ class BaseStrategy(ABC, threading.Thread):
|
||||
except Exception as ex:
|
||||
self.logger.debug("일일익절 가드 예외(매수 계속): %s", ex)
|
||||
|
||||
self.logger.info(
|
||||
"🔍 [매수체크] 후보 %d (보유 %d/%d)",
|
||||
len(candidates), active_cnt, max_stocks,
|
||||
)
|
||||
_codes = [
|
||||
str(c.get("code") or c.get("stk_cd") or "").strip()
|
||||
for c in (candidates or [])
|
||||
]
|
||||
_codes = [c for c in _codes if c]
|
||||
if _codes and str(getattr(self, "strategy_id", "")).upper().startswith("US_"):
|
||||
self.logger.info(
|
||||
"🔍 [매수체크] 후보 %d (보유 %d/%d) codes=%s",
|
||||
len(candidates), active_cnt, max_stocks,
|
||||
",".join(_codes[:12]),
|
||||
)
|
||||
else:
|
||||
self.logger.info(
|
||||
"🔍 [매수체크] 후보 %d (보유 %d/%d)",
|
||||
len(candidates), active_cnt, max_stocks,
|
||||
)
|
||||
for c in candidates:
|
||||
if not self._running:
|
||||
return
|
||||
@@ -430,6 +572,45 @@ class BaseStrategy(ABC, threading.Thread):
|
||||
return # 1루프당 1매수 (포지션 과집중 방지)
|
||||
time.sleep(self._scan_sleep("buy_fail"))
|
||||
|
||||
def _note_candidate_enters(self, candidates: List[Dict]) -> None:
|
||||
"""후보 집합 변화 → ENTER/EXIT 시각 갱신 (중분 시가 애매 가드)."""
|
||||
from datetime import datetime
|
||||
|
||||
now = datetime.now()
|
||||
codes = set()
|
||||
for c in candidates or []:
|
||||
code = str(c.get("code") or c.get("stk_cd") or "").strip()
|
||||
if code:
|
||||
codes.add(code)
|
||||
if not hasattr(self, "_cand_enter_dt") or self._cand_enter_dt is None:
|
||||
self._cand_enter_dt = {}
|
||||
for gone in list(self._cand_enter_dt.keys()):
|
||||
if gone not in codes:
|
||||
self._cand_enter_dt.pop(gone, None)
|
||||
for code in codes:
|
||||
if code not in self._cand_enter_dt:
|
||||
self._cand_enter_dt[code] = now
|
||||
|
||||
def _defer_mid_enroll_entry(
|
||||
self,
|
||||
code: str,
|
||||
entry_bar_key: Any,
|
||||
tf_min: int = 1,
|
||||
params: Optional[Dict] = None,
|
||||
) -> Optional[str]:
|
||||
"""중분 편입 + 같은 진입봉이면 사유 문자열, 아니면 None."""
|
||||
from kis_trader.engine.mid_enroll_entry_gate import gate_reason_mid_enroll
|
||||
|
||||
enroll = None
|
||||
if hasattr(self, "_cand_enter_dt"):
|
||||
enroll = (self._cand_enter_dt or {}).get(str(code).strip())
|
||||
return gate_reason_mid_enroll(
|
||||
str(entry_bar_key or ""),
|
||||
enroll,
|
||||
tf_min=int(tf_min or 1),
|
||||
params=params,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# OrderManager 호출 래퍼
|
||||
# ------------------------------------------------------------------
|
||||
@@ -553,7 +734,13 @@ class BaseStrategy(ABC, threading.Thread):
|
||||
return start <= hhmm_now <= end
|
||||
|
||||
def check_buy_allowed(self) -> bool:
|
||||
"""신규 매수 허용 시간 — ``{SID}_TIME_END`` / ``TIME_END`` 기준 (매수 종료)."""
|
||||
"""신규 매수 허용 시간 — ``{SID}_TIME_END`` / ``TIME_END`` 기준 (매수 종료).
|
||||
|
||||
EOD(``{SID}_EOD_HM``) 시각 이후에는 신규매수 금지.
|
||||
벽시계 비교라 익일 장중(EOD 전)에는 자동으로 다시 허용된다.
|
||||
전략에 ``eod_enabled`` 가 없거나 false 면 EOD 매수차단 없음
|
||||
(예: 해외모멘텀 기본 ``US_MOMENTUM_EOD_ENABLED=false``).
|
||||
"""
|
||||
if get_env_bool("FORCE_MARKET_OPEN", False):
|
||||
return True
|
||||
if not self.check_market_status():
|
||||
@@ -561,7 +748,25 @@ class BaseStrategy(ABC, threading.Thread):
|
||||
now = dt.now()
|
||||
hhmm_now = now.hour * 100 + now.minute
|
||||
start, buy_end = self._buy_time_bounds()
|
||||
return start <= hhmm_now <= buy_end
|
||||
if not (start <= hhmm_now <= buy_end):
|
||||
return False
|
||||
# EOD 이후 신규매수 차단 — SCALP 15:25 청산 직후 재매수(003470) 재발 방지
|
||||
# 해외모멘텀은 check_buy_allowed 오버라이드 + eod 기본 OFF → 국장 EOD에 안 걸림
|
||||
if is_live_eod_now(
|
||||
bool(getattr(self, "eod_enabled", False)),
|
||||
str(getattr(self, "eod_hm", "15:20") or "15:20"),
|
||||
now,
|
||||
default_hm="15:20",
|
||||
):
|
||||
return False
|
||||
# LS 복구 중 신규매수 게이트 (기본 OFF — LS_WS_BLOCK_BUY_WHILE_RECOVERING)
|
||||
try:
|
||||
from kis_trader.engine.ls_feed_gate import ls_feed_blocks_new_buy
|
||||
if ls_feed_blocks_new_buy():
|
||||
return False
|
||||
except Exception:
|
||||
pass
|
||||
return True
|
||||
|
||||
def _max_stocks(self) -> int:
|
||||
"""전략별 동시 보유 한도.
|
||||
@@ -747,17 +952,21 @@ class BaseStrategy(ABC, threading.Thread):
|
||||
1) universe_source == "ranking" → VolumeRankManager
|
||||
2) universe_source == "condition" → ConditionSearchManager (KIS REST)
|
||||
3) universe_source == "kiwoom_condition" → KiwoomConditionSearchManager (키움 WS)
|
||||
4) universe_source == "ls_condition" → LsConditionSearchManager (LS AFR)
|
||||
|
||||
⚡ 운영 스위치는 **{SID}_UNIVERSE_SOURCE 하나** 만 바꾸면 됨.
|
||||
- condition + CONDITION_{SID}_NAME/SEQ → KIS 조건 (REST 폴링)
|
||||
- kiwoom_condition + CONDITION_{SID}_NAME → 키움 조건 (WS 실시간, seq 자동)
|
||||
- ls_condition + CONDITION_{SID}_NAME/LS_NAME → LS 동명 조건 (AFR, 시세는 키움/한투)
|
||||
- ranking → 거래량/거래대금 순위
|
||||
(키움 seq 를 고정하려면 CONDITION_{SID}_KIWOOM_SEQ 선택 설정)
|
||||
"""
|
||||
key = f"{self.strategy_id}_UNIVERSE_SOURCE"
|
||||
default = self.DEFAULT_UNIVERSE_SOURCES.get(self.strategy_id, "ranking")
|
||||
current_src = (get_env_from_db(key, default) or default).strip().lower()
|
||||
if current_src not in ("ranking", "condition", "kiwoom_condition"):
|
||||
if current_src not in (
|
||||
"ranking", "condition", "kiwoom_condition", "ls_condition",
|
||||
):
|
||||
current_src = default
|
||||
if current_src != self.universe_source:
|
||||
self.logger.info(
|
||||
@@ -767,15 +976,20 @@ class BaseStrategy(ABC, threading.Thread):
|
||||
self.universe_source = current_src
|
||||
|
||||
# 소스명 → 매니저 매핑. primary(선택 소스) 먼저, 나머지는 폴백 순.
|
||||
# ※ ls_condition 선택 시 키움/KIS 로 자동 폴백하지 않음 (의도 스위치 존중).
|
||||
mgr_by_src = {
|
||||
"ranking": self.ranking_mgr,
|
||||
"condition": self.condition_mgr,
|
||||
"kiwoom_condition": self.kiwoom_condition_mgr,
|
||||
"ls_condition": self.ls_condition_mgr,
|
||||
}
|
||||
order = [self.universe_source] + [
|
||||
s for s in ("condition", "kiwoom_condition", "ranking")
|
||||
if s != self.universe_source
|
||||
]
|
||||
if self.universe_source == "ls_condition":
|
||||
order = ["ls_condition"]
|
||||
else:
|
||||
order = [self.universe_source] + [
|
||||
s for s in ("condition", "kiwoom_condition", "ranking")
|
||||
if s != self.universe_source
|
||||
]
|
||||
|
||||
candidates: List[Dict] = []
|
||||
for tag in order:
|
||||
@@ -796,7 +1010,10 @@ class BaseStrategy(ABC, threading.Thread):
|
||||
except Exception as e:
|
||||
self.logger.debug("%s 유니버스 로드 실패: %s", tag, e)
|
||||
|
||||
# 두 매니저 모두 본 전략 설정 없음 → 레거시 DB 경로
|
||||
# 매니저 모두 본 전략 설정 없음 → 레거시 DB 경로
|
||||
# (ls_condition 은 DB 폴백도 하지 않음 — 빈 후보)
|
||||
if self.universe_source == "ls_condition":
|
||||
return self._post_filter_candidates([])
|
||||
try:
|
||||
candidates = self.db.get_target_candidates() or []
|
||||
except Exception as e:
|
||||
@@ -840,15 +1057,25 @@ class BaseStrategy(ABC, threading.Thread):
|
||||
)
|
||||
candidates = candidates[:cap]
|
||||
|
||||
# 3) 백테 유니버스 슬롯 정합 — target_candidates_history 스냅샷 ∩ 실시간 후보
|
||||
# 3) 백테 유니버스 슬롯 정합 — history 스냅샷 ∩ 실시간 후보
|
||||
# ls_condition → ls_candidates_history / 그 외 → target_candidates_history
|
||||
from ..utils.live_portfolio_common import (
|
||||
filter_candidates_by_history_universe,
|
||||
live_universe_slot_align_enabled,
|
||||
resolve_live_universe_history_source,
|
||||
)
|
||||
if live_universe_slot_align_enabled(sid):
|
||||
before = len(candidates)
|
||||
univ_src = str(getattr(self, "universe_source", "") or "")
|
||||
hist_src = resolve_live_universe_history_source(
|
||||
sid, universe_source=univ_src,
|
||||
)
|
||||
candidates, dropped = filter_candidates_by_history_universe(
|
||||
candidates, self.db, sid,
|
||||
candidates,
|
||||
self.db,
|
||||
sid,
|
||||
universe_source=univ_src,
|
||||
history_source=hist_src,
|
||||
)
|
||||
if dropped > 0:
|
||||
now_ts = time.time()
|
||||
@@ -856,8 +1083,9 @@ class BaseStrategy(ABC, threading.Thread):
|
||||
if now_ts - last >= 120:
|
||||
self._universe_slot_log_ts = now_ts
|
||||
self.logger.info(
|
||||
"🔍 [유니버스슬롯] %s 후보 %d→%d (history 교집합, 제외 %d)",
|
||||
sid, before, len(candidates), dropped,
|
||||
"🔍 [유니버스슬롯] %s 후보 %d→%d "
|
||||
"(history=%s 교집합, 제외 %d)",
|
||||
sid, before, len(candidates), hist_src, dropped,
|
||||
)
|
||||
|
||||
return candidates
|
||||
|
||||
Reference in New Issue
Block a user