늘림목까지 완성성
This commit is contained in:
@@ -42,7 +42,7 @@ LOG_GREEN = "\033[92m" # 통과
|
||||
LOG_CYAN = "\033[96m" # 강조
|
||||
LOG_RESET = "\033[0m"
|
||||
|
||||
# DB 초기화
|
||||
# DB 초기화 (스크립트所在 디렉터리 기준 경로)
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
db = TradeDB(db_path=str(SCRIPT_DIR / "quant_bot.db"))
|
||||
|
||||
@@ -1162,12 +1162,11 @@ class KISClient:
|
||||
exclude_spec_etn_leverage: bool,
|
||||
) -> list:
|
||||
"""
|
||||
거래량순위 API 연속 조회 (tr_cont)로 limit건까지 수집.
|
||||
API가 한 번에 20~30건만 주므로, tr_cont='M'이면 다음 페이지 요청 반복.
|
||||
거래량순위 API 1회 조회 (한투 volume-rank API는 다음 페이지 tr_cont 미지원 → 1회만 호출).
|
||||
"""
|
||||
path = "/uapi/domestic-stock/v1/quotations/volume-rank"
|
||||
tr_id = "FHPST01710000"
|
||||
base = {
|
||||
params = {
|
||||
"FID_COND_MRKT_DIV_CODE": market,
|
||||
"FID_COND_SCR_DIV_CODE": "20171",
|
||||
"FID_INPUT_ISCD": "0000",
|
||||
@@ -1180,68 +1179,22 @@ class KISClient:
|
||||
"FID_VOL_CNT": "0",
|
||||
"FID_INPUT_DATE_1": "",
|
||||
}
|
||||
accumulated = []
|
||||
tr_cont = ""
|
||||
max_pages = 20 # 20페이지 이상이면 중단 (과부하 방지)
|
||||
page = 0
|
||||
try:
|
||||
while len(accumulated) < limit and page < max_pages:
|
||||
params = {**base}
|
||||
time.sleep(0.5)
|
||||
# 연속 조회 시 tr_cont는 요청 헤더로 전달 (한투 문서: Request Header tr_cont=N)
|
||||
r = self._get(path, tr_id, params, tr_cont=tr_cont if tr_cont else None)
|
||||
if r.status_code != 200:
|
||||
break
|
||||
j = r.json()
|
||||
if j.get("rt_cd") != "0":
|
||||
break
|
||||
output = j.get("output", [])
|
||||
if exclude_spec_etn_leverage:
|
||||
output = self._filter_rank_by_valid_stock(output)
|
||||
# 2차 이상 수신 시: 이번 output이 이미 누적된 종목과 완전 동일하면 서버가 같은 페이지를 반복 준 것 → 중복 누적·추가 요청 중단
|
||||
def _codes_from_list(lst):
|
||||
s = set()
|
||||
for item in lst:
|
||||
c = (item.get("stk_cd") or item.get("mksc_shrn_iscd") or item.get("code") or "").strip()
|
||||
if c:
|
||||
s.add(c)
|
||||
return s
|
||||
if page >= 1 and output:
|
||||
already = _codes_from_list(accumulated)
|
||||
this_codes = _codes_from_list(output)
|
||||
if this_codes and this_codes <= already:
|
||||
logger.info(f" 📡 [순위API] 2차 수신 {len(output)}건은 1차와 동일(중복) → 연속조회 중단 (API가 다음 페이지 미지원 또는 동일 데이터 반환)")
|
||||
break
|
||||
accumulated.extend(output)
|
||||
# 연속 조회: tr_cont는 HTTP 응답 헤더에 있음 (한투 문서). 소문자/대문자 모두 확인
|
||||
tr_cont_resp = ""
|
||||
for k, v in (r.headers or {}).items():
|
||||
if k.strip().lower() == "tr_cont" and v:
|
||||
tr_cont_resp = v.strip() if isinstance(v, str) else str(v)
|
||||
break
|
||||
if not tr_cont_resp:
|
||||
tr_cont_resp = (r.headers.get("tr_cont") or r.headers.get("TR_CONT") or "").strip()
|
||||
if isinstance(tr_cont_resp, str):
|
||||
tr_cont_resp = tr_cont_resp.strip()
|
||||
# 페이지네이션 동작 확인용 INFO 로그 (기본 로그 레벨에서 보이도록)
|
||||
header_keys = list((r.headers or {}).keys())
|
||||
logger.info(f" 📡 [순위API] 1차 수신 {len(output)}건, 누적 {len(accumulated)}건 | 응답 tr_cont='{tr_cont_resp}' | 헤더키: {header_keys}")
|
||||
# tr_cont=M이면 다음 페이지 있음. 또는 첫 페이지에서 누적이 limit 미만이면 한 번 더 시도 (서버가 tr_cont 없이도 다음 페이지 지원하는 경우 대비)
|
||||
if tr_cont_resp == "M":
|
||||
tr_cont = "N"
|
||||
page += 1
|
||||
logger.info(f" 📡 [연속조회] tr_cont=M → tr_cont=N으로 다음 페이지 요청 (페이지 {page})")
|
||||
elif page == 0 and len(output) > 0 and len(accumulated) < limit:
|
||||
tr_cont = "N"
|
||||
page += 1
|
||||
logger.info(f" 📡 [연속조회] 누적 {len(accumulated)}건 < {limit}건 → tr_cont=N으로 다음 페이지 1회 시도")
|
||||
else:
|
||||
break
|
||||
time.sleep(random.uniform(0.8, 1.5))
|
||||
return accumulated[:limit]
|
||||
time.sleep(0.5)
|
||||
r = self._get(path, tr_id, params, tr_cont=None)
|
||||
if r.status_code != 200:
|
||||
return []
|
||||
j = r.json()
|
||||
if j.get("rt_cd") != "0":
|
||||
return []
|
||||
output = j.get("output", [])
|
||||
if exclude_spec_etn_leverage:
|
||||
output = self._filter_rank_by_valid_stock(output)
|
||||
logger.info(f" 📡 [순위API] 수신 {len(output)}건 (다음페이지 미지원 → 1회만 호출)")
|
||||
return output[:limit]
|
||||
except Exception as e:
|
||||
logger.debug(f"거래량순위 연속 조회 실패: {e}")
|
||||
return accumulated[:limit]
|
||||
logger.debug(f"거래량순위 조회 실패: {e}")
|
||||
return []
|
||||
|
||||
def get_volume_rank(
|
||||
self,
|
||||
@@ -1250,7 +1203,7 @@ class KISClient:
|
||||
exclude_spec_etn_leverage: bool = True,
|
||||
):
|
||||
"""
|
||||
거래량순위 조회 [v1_국내주식-047] (연속 조회로 limit건까지 수집)
|
||||
거래량순위 조회 [v1_국내주식-047] (1회 호출, API가 반환한 건수만큼 수집)
|
||||
"""
|
||||
try:
|
||||
output = self._fetch_volume_rank_paged(
|
||||
@@ -1530,9 +1483,9 @@ class ShortTradingBot:
|
||||
self.d2_excc_amt = 0 # D+2 예수금 (output2 prvs_rcdl_excc_amt)
|
||||
self.total_deposit = get_env_float("TOTAL_DEPOSIT", 0)
|
||||
|
||||
# DB에서 활성 트레이드 로드
|
||||
# DB에서 활성 트레이드 로드 (단타만: SHORT_% - 늘림목과 섞이지 않도록)
|
||||
self.holdings = {}
|
||||
active_trades = self.db.get_active_trades()
|
||||
active_trades = self.db.get_active_trades(strategy_prefix="SHORT")
|
||||
for code, trade in active_trades.items():
|
||||
self.holdings[code] = {
|
||||
"buy_price": trade.get("avg_buy_price", 0),
|
||||
@@ -1681,6 +1634,37 @@ class ShortTradingBot:
|
||||
except Exception as e:
|
||||
logger.warning(f" ⚠️ [거래대금순위] 수집 실패: {e}")
|
||||
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
# 4-2. 테마/인기 종목 (단타 묘미 - 테마별로 빵 뜨는 종목 보너스)
|
||||
# 상승률·거래증가율 상위 = 테마성 수요 대리 지표 (추후 테마 API 연동 시 교체 가능)
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
try:
|
||||
theme_hot = self.client.get_price_change_rank(market="J", sort_type="1", limit=20)
|
||||
if theme_hot:
|
||||
for idx, item in enumerate(theme_hot):
|
||||
code = item.get("stk_cd", "").strip() or item.get("code", "").strip()
|
||||
if not code or len(code) != 6:
|
||||
continue
|
||||
bonus = (20 - idx) / 20.0 * 0.15 # 순위별 보너스 최대 +0.15
|
||||
if code in all_candidates:
|
||||
all_candidates[code]["bonus_score"] += bonus
|
||||
else:
|
||||
price_data = self.client.inquire_price(code)
|
||||
if price_data:
|
||||
current_price = abs(float(price_data.get("stck_prpr", 0) or 0))
|
||||
if current_price > 0 and (not slot_money or current_price <= slot_money):
|
||||
all_candidates[code] = {
|
||||
"code": code,
|
||||
"name": item.get("stk_nm", code),
|
||||
"price": current_price,
|
||||
"base_score": 0.0,
|
||||
"bonus_score": bonus,
|
||||
"from_ant": False,
|
||||
}
|
||||
logger.info(f" ✅ [테마/인기] 상승률 상위 20개 보너스 반영 (테마성 수요 대리)")
|
||||
except Exception as e:
|
||||
logger.warning(f" ⚠️ [테마/인기] 수집 실패: {e}")
|
||||
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
# 5. 외국인/기관 순매수 - 보너스 +0.3 (투자자 동향 기반)
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
@@ -2491,6 +2475,13 @@ class ShortTradingBot:
|
||||
logger.warning(" ⚠️ [개미털기] 스캔 대상 0개 → 스캔 생략 (API에서 종목 리스트를 받지 못함)")
|
||||
return []
|
||||
|
||||
# 후보 등록 방식: RELAXED면 낙폭+회복만 통과한 상위 N명만 DB 등록 (피뢰침/RSI/MA20은 매수 시점에 적용)
|
||||
# 기본 True → 후보 풀 확대(6개 수준), False면 기존처럼 전 필터 통과한 종목만 등록
|
||||
relaxed = get_env_bool("RELAXED_CANDIDATE_SCAN", False)
|
||||
top_n = get_env_int("CANDIDATE_LIST_TOP_N", 6)
|
||||
if relaxed:
|
||||
logger.info(f" 📌 [RELAXED 모드] 낙폭+회복 통과만으로 후보 수집 → 상위 {top_n}명만 DB 등록 (피뢰침/RSI/MA20은 매수 시점 적용)")
|
||||
|
||||
# 스캔 대상 리스트를 거래량/낙폭 체크 전에 한 번 출력 (종목명 · 코드)
|
||||
# 참고: 위 [스캔유니버스] 6소스(거래량·거래대금·회전율·등락률상승·등락률하락·거래증가율) 합산 → 동일 개미털기 필터 적용
|
||||
logger.info(f" 📋 [개미털기 스캔 대상] {len(scan_list)}개 (6소스 합산, 종목명 · 코드)")
|
||||
@@ -2503,7 +2494,7 @@ class ShortTradingBot:
|
||||
time.sleep(random.uniform(0.3, 0.6))
|
||||
execution_strength_map = self.client.get_execution_strength_map(market="J", limit=200)
|
||||
if execution_strength_map:
|
||||
logger.info(f" 📡 [체결강도] 상위 {len(execution_strength_map)}종목 로드 (통과 시 100+ → +10점, 120+ → +20점)")
|
||||
logger.info(f" 📡 [체결강도] 상위 {len(execution_strength_map)}종목 로드 (통과 시 100+ → +1점, 120+ → +2점)")
|
||||
except Exception as e:
|
||||
logger.debug(f"체결강도 맵 로드 스킵: {e}")
|
||||
|
||||
@@ -2598,52 +2589,54 @@ class ShortTradingBot:
|
||||
logger.warning(f" ⚠️ SK증권(001510) 회복률부족으로 탈락: 회복률={recovery_pos*100:.1f}%, 기준={self.min_recovery_ratio*100:.0f}%")
|
||||
continue
|
||||
|
||||
# [필터 3] 피뢰침 방지 - 고점 추격 매수 방지
|
||||
high_chase_threshold = get_env_float("HIGH_PRICE_CHASE_THRESHOLD", 0.96)
|
||||
if current_price >= high_price * high_chase_threshold:
|
||||
filter_counts["피뢰침(고점근접)"] += 1
|
||||
drop_from_high = (high_price - current_price) / high_price * 100
|
||||
logger.info(f"{LOG_YELLOW}🔍 [탈락-피뢰침] {name} {code}: 고점 대비 {drop_from_high:.1f}% 조정 부족 (최소 4% 필요){LOG_RESET}")
|
||||
continue
|
||||
|
||||
# [필터 4] 피뢰침 방지 - 급등주 제외
|
||||
if low_price > 0:
|
||||
daily_change_pct = (high_price - low_price) / low_price * 100
|
||||
else:
|
||||
daily_change_pct = 0
|
||||
max_daily_change = get_env_float("MAX_DAILY_CHANGE_PCT", 20.0)
|
||||
if daily_change_pct > max_daily_change:
|
||||
filter_counts["피뢰침(급등주)"] += 1
|
||||
logger.info(f"{LOG_YELLOW}🔍 [탈락-피뢰침] {name} {code}: 당일 변동폭 {daily_change_pct:.1f}% 과도 (최대 {max_daily_change}%){LOG_RESET}")
|
||||
continue
|
||||
|
||||
# [필터 5] RSI 과열 체크 (분봉 데이터 필요)
|
||||
try:
|
||||
df = self.client.get_minute_chart(code, period="3", limit=20)
|
||||
if not df.empty and len(df) >= 14 and "RSI" in df.columns:
|
||||
rsi = float(df["RSI"].iloc[-1])
|
||||
rsi_threshold = get_env_float("RSI_OVERHEAT_THRESHOLD", 78.0)
|
||||
if rsi >= rsi_threshold:
|
||||
filter_counts["RSI과열"] += 1
|
||||
logger.info(f"{LOG_YELLOW}🔍 [탈락-RSI] {name} {code}: RSI 과열 ({rsi:.1f} >= {rsi_threshold}){LOG_RESET}")
|
||||
continue
|
||||
|
||||
# [필터 6] MA20 체크
|
||||
if "MA20" in df.columns and len(df) >= 20:
|
||||
ma20 = float(df["MA20"].iloc[-1])
|
||||
if current_price < ma20:
|
||||
filter_counts["MA20"] += 1
|
||||
logger.info(f"{LOG_YELLOW}🔍 [탈락-MA20] {name} {code}: 현재가({current_price:.0f}) < MA20({ma20:.2f}){LOG_RESET}")
|
||||
# [필터 3~6] RELAXED 모드가 아닐 때만 적용 (후보 풀 확대 시 등록은 넓게, 매수 시점에 엄격 적용)
|
||||
if not relaxed:
|
||||
# [필터 3] 피뢰침 방지 - 고점 추격 매수 방지
|
||||
high_chase_threshold = get_env_float("HIGH_PRICE_CHASE_THRESHOLD", 0.96)
|
||||
if current_price >= high_price * high_chase_threshold:
|
||||
filter_counts["피뢰침(고점근접)"] += 1
|
||||
drop_from_high = (high_price - current_price) / high_price * 100
|
||||
logger.info(f"{LOG_YELLOW}🔍 [탈락-피뢰침] {name} {code}: 고점 대비 {drop_from_high:.1f}% 조정 부족 (최소 4% 필요){LOG_RESET}")
|
||||
continue
|
||||
|
||||
# [필터 4] 피뢰침 방지 - 급등주 제외
|
||||
if low_price > 0:
|
||||
daily_change_pct = (high_price - low_price) / low_price * 100
|
||||
else:
|
||||
daily_change_pct = 0
|
||||
max_daily_change = get_env_float("MAX_DAILY_CHANGE_PCT", 20.0)
|
||||
if daily_change_pct > max_daily_change:
|
||||
filter_counts["피뢰침(급등주)"] += 1
|
||||
logger.info(f"{LOG_YELLOW}🔍 [탈락-피뢰침] {name} {code}: 당일 변동폭 {daily_change_pct:.1f}% 과도 (최대 {max_daily_change}%){LOG_RESET}")
|
||||
continue
|
||||
|
||||
# [필터 5] RSI 과열 체크 (분봉 데이터 필요)
|
||||
try:
|
||||
df = self.client.get_minute_chart(code, period="3", limit=20)
|
||||
if not df.empty and len(df) >= 14 and "RSI" in df.columns:
|
||||
rsi = float(df["RSI"].iloc[-1])
|
||||
rsi_threshold = get_env_float("RSI_OVERHEAT_THRESHOLD", 78.0)
|
||||
if rsi >= rsi_threshold:
|
||||
filter_counts["RSI과열"] += 1
|
||||
logger.info(f"{LOG_YELLOW}🔍 [탈락-RSI] {name} {code}: RSI 과열 ({rsi:.1f} >= {rsi_threshold}){LOG_RESET}")
|
||||
continue
|
||||
|
||||
ma20_cap_pct = get_env_float("MA20_MAX_ABOVE_PCT", 3.0)
|
||||
if ma20 > 0 and current_price > ma20 * (1 + ma20_cap_pct / 100):
|
||||
filter_counts["MA20"] += 1
|
||||
gap_pct = (current_price - ma20) / ma20 * 100
|
||||
logger.info(f"{LOG_YELLOW}🔍 [탈락-MA20과열] {name} {code}: 20선 대비 {gap_pct:.1f}% 위 (최대 {ma20_cap_pct}%){LOG_RESET}")
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.debug(f"RSI/MA20 체크 실패({code}): {e}")
|
||||
# [필터 6] MA20 체크
|
||||
if "MA20" in df.columns and len(df) >= 20:
|
||||
ma20 = float(df["MA20"].iloc[-1])
|
||||
if current_price < ma20:
|
||||
filter_counts["MA20"] += 1
|
||||
logger.info(f"{LOG_YELLOW}🔍 [탈락-MA20] {name} {code}: 현재가({current_price:.0f}) < MA20({ma20:.2f}){LOG_RESET}")
|
||||
continue
|
||||
|
||||
ma20_cap_pct = get_env_float("MA20_MAX_ABOVE_PCT", 3.0)
|
||||
if ma20 > 0 and current_price > ma20 * (1 + ma20_cap_pct / 100):
|
||||
filter_counts["MA20"] += 1
|
||||
gap_pct = (current_price - ma20) / ma20 * 100
|
||||
logger.info(f"{LOG_YELLOW}🔍 [탈락-MA20과열] {name} {code}: 20선 대비 {gap_pct:.1f}% 위 (최대 {ma20_cap_pct}%){LOG_RESET}")
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.debug(f"RSI/MA20 체크 실패({code}): {e}")
|
||||
|
||||
# 외국인/기관 동향 확인
|
||||
investor_trend = self.client.get_investor_trend(code, days=3)
|
||||
@@ -2684,18 +2677,21 @@ class ShortTradingBot:
|
||||
except Exception as e:
|
||||
logger.debug(f"ML 예측 실패({code}): {e}")
|
||||
|
||||
# 점수 계산: 낙폭 + 회복률 + 거래량 + 수급 + ML 승률
|
||||
score = (drop_rate * 100) + (recovery_pos * 50) + investor_score
|
||||
if volume > 1000000: # 거래량 100만주 이상 가산점
|
||||
score += 10
|
||||
# 강도(점수) 계산: 스케일 0~15 전후 (10=높은 편, 5 전후=평범, 한투 체결강도/수급은 소폭 가산)
|
||||
# 기존 (drop*100 + rec*50)은 30~50대라 평범한 구간이 없었음 → 10 단위로 조정
|
||||
score = (drop_rate * 10) + (recovery_pos * 10) # 낙폭·회복 기여 (각 0~10 수준)
|
||||
if investor_score >= 10: # 수급 보너스 (0 / 1 / 2점)
|
||||
score += 2 if investor_score >= 20 else 1
|
||||
if volume > 1000000: # 거래량 100만주 이상 +1점
|
||||
score += 1
|
||||
if ml_prob is not None:
|
||||
score += (ml_prob - 0.5) * 100 # ML 승률 가산점
|
||||
# 체결강도 상위 API(FHPST01710000) 보너스: 100 이상 +10점, 120 이상 +20점
|
||||
score += (ml_prob - 0.5) * 10 # ML 승률 -5~+5점
|
||||
# 체결강도 보너스: 100 이상 +1점, 120 이상 +2점 (과도한 가산 방지)
|
||||
execution_strength = execution_strength_map.get(code, 0)
|
||||
if execution_strength >= 120:
|
||||
score += 20
|
||||
score += 2
|
||||
elif execution_strength >= 100:
|
||||
score += 10
|
||||
score += 1
|
||||
candidate = {
|
||||
"code": code,
|
||||
"name": name,
|
||||
@@ -2713,20 +2709,21 @@ class ShortTradingBot:
|
||||
candidates.append(candidate)
|
||||
passed_filters += 1
|
||||
|
||||
# 통과 즉시 DB 저장 (매매 루프가 스캔 완료를 기다리지 않고 실시간으로 후보 읽기 위함)
|
||||
# 통과 즉시 DB 저장 (RELAXED가 아닐 때만; RELAXED면 루프 끝나고 상위 N명만 일괄 등록)
|
||||
# 같은 종목 중복 시 ON CONFLICT(code) DO UPDATE 로 최신 점수/가격으로 갱신됨
|
||||
try:
|
||||
self.db.add_target_candidate({
|
||||
"code": code,
|
||||
"name": name,
|
||||
"score": score,
|
||||
"price": current_price,
|
||||
})
|
||||
except Exception as e:
|
||||
logger.debug(f"후보 즉시 저장 실패({code}): {e}")
|
||||
if not relaxed:
|
||||
try:
|
||||
self.db.add_target_candidate({
|
||||
"code": code,
|
||||
"name": name,
|
||||
"score": score,
|
||||
"price": current_price,
|
||||
})
|
||||
except Exception as e:
|
||||
logger.debug(f"후보 즉시 저장 실패({code}): {e}")
|
||||
|
||||
ml_info = f" | ML {ml_prob:.1%}" if ml_prob is not None else ""
|
||||
strength_info = f" | 체결강도 {execution_strength:.0f}(+{'20' if execution_strength >= 120 else '10'}점)" if execution_strength >= 100 else ""
|
||||
strength_info = f" | 체결강도 {execution_strength:.0f}(+{'2' if execution_strength >= 120 else '1'}점)" if execution_strength >= 100 else ""
|
||||
logger.info(
|
||||
f"{LOG_GREEN}✅ [통과] {name} {code}: 낙폭 {drop_rate*100:.1f}% | 회복 {recovery_pos*100:.0f}% | 강도 {score:.1f}{strength_info}{ml_info}{LOG_RESET}"
|
||||
)
|
||||
@@ -2741,6 +2738,21 @@ class ShortTradingBot:
|
||||
|
||||
candidates.sort(key=lambda x: x["score"], reverse=True)
|
||||
|
||||
# RELAXED 모드: 낙폭+회복만 통과한 풀에서 상위 N명만 DB 등록 (후보 풀 확대)
|
||||
if relaxed and candidates:
|
||||
n_register = min(top_n, len(candidates))
|
||||
for c in candidates[:n_register]:
|
||||
try:
|
||||
self.db.add_target_candidate({
|
||||
"code": c["code"],
|
||||
"name": c.get("name", c["code"]),
|
||||
"score": c["score"],
|
||||
"price": c.get("price", 0),
|
||||
})
|
||||
except Exception as e:
|
||||
logger.debug(f"후보 등록 실패({c.get('code')}): {e}")
|
||||
logger.info(f" 📌 [RELAXED] 상위 {n_register}명 DB 등록 (후보 풀 {len(candidates)}개 중 점수순)")
|
||||
|
||||
# 필터별 탈락/통과 요약 (색상: 탈락=노랑, 통과=초록)
|
||||
summary = ", ".join(f"{k}={v}" for k, v in filter_counts.items() if v > 0)
|
||||
logger.info(f" 📊 [필터 요약] 스캔 {total_scanned}개 중 {LOG_YELLOW}탈락: {summary or '없음'}{LOG_RESET} | {LOG_GREEN}통과: {len(candidates)}개{LOG_RESET}")
|
||||
|
||||
Reference in New Issue
Block a user