추천로직
This commit is contained in:
@@ -31,9 +31,11 @@ sys.path.insert(0, str(Path(__file__).parent))
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.database import AsyncSessionLocal # 🔧 공통 DB 모듈 사용
|
||||
from app.services.korea_investment_api import KoreaInvestmentAPI, korea_investment_api
|
||||
import aiohttp
|
||||
from sqlalchemy import select as sa_select
|
||||
from app.models.models import StockDailyPrice
|
||||
from app.services.analysis_db_service import bulk_upsert_analysis, passes_price_filter, run_db_scanner # 🔧 단일화
|
||||
from app.api.endpoints.recommendations import analyze_stock_for_batch, StockRecommendation
|
||||
from app.services.market_analyzer import analyze_stock_for_batch_db, StockRecommendation
|
||||
import logging
|
||||
|
||||
# 🔧 [fix] logging.basicConfig는 __main__ 블록에서만 호출
|
||||
@@ -55,33 +57,57 @@ MIN_STOCK_COUNT = 50
|
||||
# 1. 시장 레짐 판별 (KOSPI 기준)
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
async def _fetch_db_chart(stock_code: str, limit: int = 240) -> list:
|
||||
"""stock_daily_prices DB에서 KIS 형식 차트 데이터 조회 (newest first).
|
||||
|
||||
KIS API 호출 없이 collect_krx_data.py가 사전 수집한 데이터를 사용.
|
||||
"""
|
||||
async with AsyncSessionLocal() as db:
|
||||
result = await db.execute(
|
||||
sa_select(StockDailyPrice)
|
||||
.where(StockDailyPrice.stock_code == stock_code)
|
||||
.order_by(StockDailyPrice.trade_date.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
rows = result.scalars().all()
|
||||
return [
|
||||
{
|
||||
"stck_bsop_date": row.trade_date.strftime("%Y%m%d"),
|
||||
"stck_clpr": str(row.close_price or 0),
|
||||
"stck_oprc": str(row.open_price or 0),
|
||||
"stck_hgpr": str(row.high_price or 0),
|
||||
"stck_lwpr": str(row.low_price or 0),
|
||||
"acml_vol": str(row.volume or 0),
|
||||
"hts_kor_isnm": row.stock_name or "",
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
async def detect_and_log_market_regime() -> str:
|
||||
"""배치 시작 시 KOSPI 200일 데이터로 시장 레짐 판별.
|
||||
"""배치 시작 시 KOSPI200 레짐 판별 — DB에서 KODEX200 데이터 조회.
|
||||
|
||||
BULL / BEAR / SIDEWAYS 중 하나를 리턴하며 배치 전체에 로깅된다.
|
||||
BULL / BEAR 중 하나를 리턴하며 배치 전체에 로깅된다.
|
||||
실패 시 UNKNOWN을 리턴하여 배치를 차단하지 않는다.
|
||||
|
||||
pykrx 대신 KIS API 사용 — KODEX 200(069500)으로 KOSPI 레짐 판별.
|
||||
KODEX 200은 KOSPI 200을 추종하므로 지수 대용으로 적합하다.
|
||||
"""
|
||||
try:
|
||||
from app.services.mean_reversion_strategy import detect_market_regime
|
||||
|
||||
# KODEX 200(069500) 일봉 240개 = 약 1년치 데이터
|
||||
data = await korea_investment_api.get_daily_chart("069500", period="D", limit=240)
|
||||
output = data.get("output2") or []
|
||||
|
||||
if not isinstance(output, list) or len(output) < 205:
|
||||
logger.warning("⚠️ KODEX200 데이터 부족(%d행) — 레짐 판별 스킵", len(output) if output else 0)
|
||||
chart_data = await _fetch_db_chart("069500", limit=240)
|
||||
if not chart_data:
|
||||
logger.warning("⚠️ KOSPI200(KODEX200) DB 데이터 없음 — 레짐 판별 스킵")
|
||||
return "UNKNOWN"
|
||||
|
||||
# KIS API는 최신순 반환 → 오래된 순(시계열 순)으로 역정렬
|
||||
closes = [float(item["stck_clpr"]) for item in reversed(output) if item.get("stck_clpr")]
|
||||
regime = detect_market_regime(closes)
|
||||
# DB 데이터는 newest first → oldest first 변환 후 detect_market_regime 호출
|
||||
closes = list(reversed([float(item["stck_clpr"]) for item in chart_data]))
|
||||
|
||||
regime_emoji = {"상승장": "📈 BULL", "하락장": "📉 BEAR",
|
||||
"bull": "📈 BULL", "bear": "📉 BEAR"}.get(regime.value, "➡️ " + regime.value.upper())
|
||||
logger.info("🌏 시장 레짐: %s (KODEX200 %d일 기준)", regime_emoji, len(closes))
|
||||
if len(closes) < 205:
|
||||
logger.warning("⚠️ KOSPI200 데이터 부족(%d행) — 레짐 판별 스킵", len(closes))
|
||||
return "UNKNOWN"
|
||||
|
||||
regime = detect_market_regime(closes)
|
||||
regime_emoji = {"bull": "📈 BULL", "bear": "📉 BEAR"}.get(regime.value, "➡️ " + regime.value.upper())
|
||||
logger.info("🌏 시장 레짐: %s (KODEX200 %d일 기준, DB)", regime_emoji, len(closes))
|
||||
return regime.value.upper()
|
||||
|
||||
except Exception as exc:
|
||||
@@ -93,87 +119,146 @@ async def detect_and_log_market_regime() -> str:
|
||||
# 2. 종목 목록 조회
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
async def get_all_tradable_stocks(kis_api: KoreaInvestmentAPI, limit: int = 500) -> list[tuple[str, str]]:
|
||||
"""거래량 상위 종목 조회 (KIS API 사용)
|
||||
async def get_all_tradable_stocks(limit: int = 500) -> list[tuple[str, str]]:
|
||||
"""거래 가능 종목 조회 (네이버 금융 sise 사용)
|
||||
|
||||
pykrx 의존성 제거 — KIS 거래량 순위 API(FHPST01710000) 사용.
|
||||
시가총액 기준 → 거래량 기준으로 변경 (유동성 높은 종목 우선).
|
||||
네이버 금융 전체 시세 페이지에서 KOSPI+KOSDAQ 종목 수집.
|
||||
(data.krx.co.kr IP 차단 우회 — 네이버 서버는 별도)
|
||||
ETF / 레버리지 / 스팩 등 특수종목 자동 제외.
|
||||
KOSPI + KOSDAQ 합산 거래량 상위 limit개 반환.
|
||||
|
||||
Raises:
|
||||
RuntimeError: 조회 결과가 MIN_STOCK_COUNT(50) 미만인 경우 배치 중단
|
||||
"""
|
||||
logger.info("Fetching top %d stocks by volume from KIS API...", limit)
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
SKIP_KEYWORDS = [
|
||||
"ETF", "레버리지", "인버스", "KODEX", "TIGER", "ARIRANG",
|
||||
"KBSTAR", "HANARO", "SOL", "ACE", "KOSEF", "KINDEX",
|
||||
"KBSTAR", "HANARO", "SOL", "ACE", "KOSEF", "KINDEX", "RISE",
|
||||
"선물", "스팩", "리츠", "SPAC",
|
||||
]
|
||||
HEADERS = {"User-Agent": "Mozilla/5.0 (compatible; autostock-batch/1.0)"}
|
||||
|
||||
# 모의투자 모드에서 실계좌 시세 API를 사용할 수 있으면 준비
|
||||
real_market_api: Optional[KoreaInvestmentAPI] = None
|
||||
if settings.KIS_MODE == "virtual" and settings.KIS_REAL_APP_KEY:
|
||||
try:
|
||||
real_market_api = KoreaInvestmentAPI.for_real_market_data()
|
||||
logger.info("📡 KOSDAQ 조회: 실계좌 시세 API 사용 (모의투자 서버 제한 우회)")
|
||||
except Exception as exc:
|
||||
logger.warning("⚠️ 실계좌 시세 API 초기화 실패, 모의투자 API 사용: %s", exc)
|
||||
logger.info("📡 네이버 금융 종목 조회 (KOSPI+KOSDAQ)")
|
||||
|
||||
stocks_with_vol = []
|
||||
for market_code, market_name in [("J", "KOSPI"), ("Q", "KOSDAQ")]:
|
||||
# KOSDAQ은 실계좌 시세 API 우선 사용 (모의투자 서버는 KOSDAQ 미지원)
|
||||
api_to_use = real_market_api if (market_code == "Q" and real_market_api) else kis_api
|
||||
try:
|
||||
result = await api_to_use.get_volume_rank(market_code)
|
||||
items = result.get("output") or []
|
||||
async def _fetch_market_naver(
|
||||
session: aiohttp.ClientSession, sosok: int, market_name: str
|
||||
) -> list[tuple[str, str, str]]:
|
||||
"""sosok=0: KOSPI, sosok=1: KOSDAQ"""
|
||||
base_url = "https://finance.naver.com/sise/sise_market_sum.naver"
|
||||
results: list[tuple[str, str, str]] = []
|
||||
|
||||
if not isinstance(items, list):
|
||||
logger.warning(" %s: output이 리스트 아님 — 건너뜀", market_name)
|
||||
continue
|
||||
# 첫 페이지로 총 페이지 수 파악
|
||||
async with session.get(
|
||||
base_url, params={"sosok": sosok, "page": 1}, headers=HEADERS,
|
||||
timeout=aiohttp.ClientTimeout(total=30)
|
||||
) as resp:
|
||||
if resp.status != 200:
|
||||
logger.warning(" 네이버 %s HTTP %s", market_name, resp.status)
|
||||
return []
|
||||
html = await resp.text(encoding="euc-kr", errors="replace")
|
||||
|
||||
count = 0
|
||||
for item in items:
|
||||
ticker = item.get("mksc_shrn_iscd") or item.get("stck_shrn_iscd") or ""
|
||||
name = item.get("hts_kor_isnm") or item.get("stck_kor_isnm") or ""
|
||||
volume = int(item.get("acml_vol") or 0)
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
# 총 페이지 수 추출
|
||||
pager = soup.select("td.pgRR a")
|
||||
if pager:
|
||||
import re
|
||||
m = re.search(r"page=(\d+)", pager[-1].get("href", ""))
|
||||
total_pages = int(m.group(1)) if m else 1
|
||||
else:
|
||||
total_pages = 1
|
||||
total_pages = min(total_pages, 30) # 최대 30페이지 (약 1,500종목)
|
||||
|
||||
if not ticker or not name:
|
||||
# 첫 페이지 파싱
|
||||
def _parse_page(s: BeautifulSoup) -> list[tuple[str, str, str]]:
|
||||
rows = s.select("table.type_2 tr")
|
||||
items = []
|
||||
for row in rows:
|
||||
a_tag = row.select_one("a.tltle")
|
||||
if not a_tag:
|
||||
continue
|
||||
if any(k in name for k in SKIP_KEYWORDS):
|
||||
name = a_tag.get_text(strip=True)
|
||||
href = a_tag.get("href", "")
|
||||
import re as _re
|
||||
m2 = _re.search(r"code=(\d+)", href)
|
||||
if not m2:
|
||||
continue
|
||||
code = m2.group(1)
|
||||
if any(kw in name for kw in SKIP_KEYWORDS):
|
||||
continue
|
||||
items.append((code, name, market_name))
|
||||
return items
|
||||
|
||||
stocks_with_vol.append((ticker, name, volume, market_name))
|
||||
count += 1
|
||||
results.extend(_parse_page(soup))
|
||||
|
||||
logger.info(" %s: %d개 종목 조회", market_name, count)
|
||||
# 나머지 페이지 병렬 수집
|
||||
sem = asyncio.Semaphore(5)
|
||||
|
||||
except Exception as exc:
|
||||
logger.error("❌ %s 종목 조회 실패: %s", market_name, exc)
|
||||
async def _fetch_page(page: int):
|
||||
async with sem:
|
||||
try:
|
||||
async with session.get(
|
||||
base_url, params={"sosok": sosok, "page": page},
|
||||
headers=HEADERS, timeout=aiohttp.ClientTimeout(total=30)
|
||||
) as r:
|
||||
if r.status != 200:
|
||||
return []
|
||||
h = await r.text(encoding="euc-kr", errors="replace")
|
||||
return _parse_page(BeautifulSoup(h, "html.parser"))
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
stocks_with_vol.sort(key=lambda x: x[2], reverse=True)
|
||||
top_with_mkt = stocks_with_vol[:limit]
|
||||
top_stocks = [(code, name) for code, name, _, _mkt in top_with_mkt]
|
||||
tasks = [_fetch_page(p) for p in range(2, total_pages + 1)]
|
||||
pages_data = await asyncio.gather(*tasks)
|
||||
for page_items in pages_data:
|
||||
results.extend(page_items)
|
||||
|
||||
# 중복 제거
|
||||
seen: set[str] = set()
|
||||
unique = []
|
||||
for item in results:
|
||||
if item[0] not in seen:
|
||||
seen.add(item[0])
|
||||
unique.append(item)
|
||||
|
||||
logger.info(" %s: %d개 선정 (총 %d페이지)", market_name, len(unique), total_pages)
|
||||
return unique
|
||||
|
||||
kospi_stocks: list[tuple[str, str, str]] = []
|
||||
kosdaq_stocks: list[tuple[str, str, str]] = []
|
||||
async with aiohttp.ClientSession() as session:
|
||||
kospi_stocks = await _fetch_market_naver(session, 0, "KOSPI")
|
||||
kosdaq_stocks = await _fetch_market_naver(session, 1, "KOSDAQ")
|
||||
|
||||
# 네이버 스크래핑 실패 시 KIS API로 폴백
|
||||
naver_total = len(kospi_stocks) + len(kosdaq_stocks)
|
||||
if naver_total < MIN_STOCK_COUNT:
|
||||
logger.warning(
|
||||
"⚠️ 네이버 금융 조회 부족(%d개) — KIS API로 폴백", naver_total
|
||||
)
|
||||
try:
|
||||
from app.services.market_analyzer import get_all_tradable_stocks as _kis_stocks
|
||||
kis_result = await _kis_stocks()
|
||||
if len(kis_result) >= MIN_STOCK_COUNT:
|
||||
logger.info("✅ KIS API 폴백 성공: %d개", len(kis_result))
|
||||
return kis_result[:limit]
|
||||
except Exception as _exc:
|
||||
logger.error("❌ KIS API 폴백도 실패: %s", _exc)
|
||||
raise RuntimeError(
|
||||
f"종목 조회 실패: 네이버({naver_total}개) + KIS API 모두 불가. "
|
||||
f"네트워크 및 API 설정을 확인하세요."
|
||||
)
|
||||
|
||||
# KOSPI 60% / KOSDAQ 40% 비율로 합산
|
||||
kospi_n = int(limit * 0.6)
|
||||
kosdaq_n = limit - kospi_n
|
||||
all_stocks = kospi_stocks[:kospi_n] + kosdaq_stocks[:kosdaq_n]
|
||||
top_with_mkt = all_stocks[:limit]
|
||||
top_stocks = [(code, name) for code, name, _ in top_with_mkt]
|
||||
|
||||
kospi_n = sum(1 for *_, mkt in top_with_mkt if mkt == "KOSPI")
|
||||
kosdaq_n = sum(1 for *_, mkt in top_with_mkt if mkt == "KOSDAQ")
|
||||
logger.info("✅ 선정 %d개 (KOSPI %d / KOSDAQ %d)", len(top_stocks), kospi_n, kosdaq_n)
|
||||
|
||||
if len(top_stocks) < MIN_STOCK_COUNT:
|
||||
if settings.KIS_MODE == "virtual":
|
||||
# 모의투자 서버는 KOSDAQ 거래량 순위 미지원 — 조회된 종목으로 진행
|
||||
logger.warning(
|
||||
"⚠️ 모의투자 서버 종목 수 제한: %d개 (기준 %d개). "
|
||||
"KOSDAQ 거래량 순위 미지원으로 인한 정상 동작 — 배치 계속 진행합니다.",
|
||||
len(top_stocks), MIN_STOCK_COUNT
|
||||
)
|
||||
else:
|
||||
raise RuntimeError(
|
||||
f"종목 조회 결과 너무 적음: {len(top_stocks)}개 (최소 {MIN_STOCK_COUNT}개 필요). "
|
||||
"KIS API 오류 또는 장 휴장 여부를 확인하세요."
|
||||
)
|
||||
|
||||
return top_stocks
|
||||
|
||||
|
||||
@@ -189,12 +274,20 @@ async def analyze_one_stock(
|
||||
) -> tuple[str, Optional[StockRecommendation]]:
|
||||
"""종목 분석만 수행하고 결과 반환. DB 저장은 Streaming 저장기에서 처리.
|
||||
|
||||
DB(stock_daily_prices)에서 차트 데이터를 조회 — KIS API 호출 없음.
|
||||
|
||||
Returns: (status, result)
|
||||
status: "ok" | "no_chart" | "price_filter" | "error"
|
||||
"""
|
||||
try:
|
||||
logger.info("[%d/%d] 분석 중: %s (%s)", idx, total, stock_code, stock_name)
|
||||
result = await analyze_stock_for_batch(stock_code, stock_name)
|
||||
|
||||
chart_data = await _fetch_db_chart(stock_code)
|
||||
if not chart_data:
|
||||
logger.info("샘 차트없음(DB) %s (%s)", stock_code, stock_name)
|
||||
return "no_chart", None
|
||||
|
||||
result = await analyze_stock_for_batch_db(chart_data, stock_code, stock_name)
|
||||
|
||||
if not result:
|
||||
logger.info("샘 차트없음 %s (%s)", stock_code, stock_name)
|
||||
@@ -300,11 +393,35 @@ async def batch_analyze_all_stocks(limit: int = None):
|
||||
logger.info("⚙️ 동시 처리: %d종목 | 벌크저장 단위: %d개 | API 세마포어: %dreq",
|
||||
BATCH_CONCURRENCY, BULK_SAVE_CHUNK, settings.API_MAX_CONCURRENT_REQUESTS)
|
||||
|
||||
# Step 0: DB 데이터 사전 확인 (collect_krx_data.py 미실행 감지)
|
||||
async with AsyncSessionLocal() as _db:
|
||||
_db_check = await _db.execute(
|
||||
sa_select(StockDailyPrice.trade_date)
|
||||
.order_by(StockDailyPrice.trade_date.desc())
|
||||
.limit(1)
|
||||
)
|
||||
_last_row = _db_check.scalar()
|
||||
if _last_row is None:
|
||||
logger.error(
|
||||
"❌ stock_daily_prices 테이블이 비어있습니다. "
|
||||
"먼저 collect_krx_data.py를 실행하여 데이터를 수집하세요."
|
||||
)
|
||||
raise RuntimeError("DB에 차트 데이터 없음 — collect_krx_data.py 선행 실행 필요")
|
||||
from datetime import timedelta as _td
|
||||
_days_old = (date.today() - _last_row).days
|
||||
if _days_old > 3:
|
||||
logger.warning(
|
||||
"⚠️ DB 마지막 데이터: %s (%d일 전) — collect_krx_data.py --update 실행 권장",
|
||||
_last_row, _days_old
|
||||
)
|
||||
else:
|
||||
logger.info("✅ DB 최신 데이터: %s", _last_row)
|
||||
|
||||
# Step 1: 시장 레짐 판별
|
||||
regime = await detect_and_log_market_regime()
|
||||
|
||||
# Step 2: 종목 목록 조회 (모듈 레벨 싱글톤 재사용, 불필요한 새 인스턴스 생성 금지)
|
||||
all_stocks = await get_all_tradable_stocks(korea_investment_api, limit=500)
|
||||
# Step 2: 종목 목록 조회 (KRX OpenAPI)
|
||||
all_stocks = await get_all_tradable_stocks(limit=500)
|
||||
filtered_stocks = all_stocks[:limit] if limit else all_stocks
|
||||
total = len(filtered_stocks)
|
||||
logger.info("📊 분석 대상: %d개 (시장 레짐: %s)", total, regime)
|
||||
@@ -314,43 +431,41 @@ async def batch_analyze_all_stocks(limit: int = None):
|
||||
queue: asyncio.Queue = asyncio.Queue(maxsize=BULK_SAVE_CHUNK * 3)
|
||||
semaphore = asyncio.Semaphore(BATCH_CONCURRENCY)
|
||||
|
||||
# saver_task를 먼저 시작
|
||||
saver_task = asyncio.create_task(_streaming_saver(queue, analysis_date))
|
||||
|
||||
async def analyze_one(i: int, code: str, name: str):
|
||||
try:
|
||||
async with semaphore:
|
||||
status, result = await analyze_one_stock(i, total, code, name)
|
||||
await queue.put((status, code, name, result))
|
||||
# saver가 이미 종료(예외 등)됐으면 put 블로킹 방지
|
||||
if not saver_task.done():
|
||||
await queue.put((status, code, name, result))
|
||||
else:
|
||||
logger.warning("⚠️ saver 종료됨 — %s 저장 건너뜀", code)
|
||||
except Exception as exc:
|
||||
logger.error("❌ analyze_one unexpected error %s: %s", code, exc)
|
||||
await queue.put(("error", code, name, None))
|
||||
if not saver_task.done():
|
||||
await queue.put(("error", code, name, None))
|
||||
|
||||
# 🔧 [fix] saver_task 예외 감지: asyncio.wait로 saver_task 비정상 종료 시 조기 감지
|
||||
saver_task = asyncio.create_task(_streaming_saver(queue, analysis_date))
|
||||
|
||||
# 분석 태스크 전체 실행
|
||||
analysis_gather = asyncio.gather(
|
||||
# 분석 태스크 전체 실행 (완료까지 대기)
|
||||
await asyncio.gather(
|
||||
*[analyze_one(i, c, n) for i, (c, n) in enumerate(filtered_stocks, 1)],
|
||||
return_exceptions=True,
|
||||
)
|
||||
|
||||
# saver_task가 예외로 죽으면 analysis_gather도 조기 취소
|
||||
done, pending = await asyncio.wait(
|
||||
[asyncio.ensure_future(analysis_gather), saver_task],
|
||||
return_when=asyncio.FIRST_EXCEPTION,
|
||||
)
|
||||
|
||||
# saver_task 예외 발생 시 분석 태스크 취소 처리
|
||||
for task in pending:
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except (asyncio.CancelledError, Exception):
|
||||
pass
|
||||
|
||||
# sentinel 전송 → 저장기 종료 (정상 흐름에서도 항상 실행)
|
||||
# 모든 분석 완료 후 sentinel 전송 → saver 정상 종료
|
||||
# 기존 코드의 deadlock 원인: sentinel을 asyncio.wait() 이후에 보냈기 때문에
|
||||
# FIRST_EXCEPTION 모드에서 예외 없으면 ALL_COMPLETED 동작 → saver가 sentinel을
|
||||
# 기다리며 무한 대기 (deadlock)
|
||||
if not saver_task.done():
|
||||
await queue.put(None)
|
||||
|
||||
stats = await saver_task if not saver_task.cancelled() else {}
|
||||
stats = {}
|
||||
try:
|
||||
stats = await saver_task
|
||||
except Exception as exc:
|
||||
logger.error("❌ saver_task 최종 오류: %s", exc)
|
||||
|
||||
ok_count = stats.get("ok", 0)
|
||||
no_chart = stats.get("no_chart", 0)
|
||||
@@ -375,7 +490,6 @@ async def batch_analyze_all_stocks(limit: int = None):
|
||||
|
||||
# 결과 요약
|
||||
duration = (datetime.now(KST) - start_time).total_seconds()
|
||||
api_stats = korea_investment_api.get_monitor_stats()
|
||||
|
||||
logger.info("=" * 60)
|
||||
logger.info("✅ Batch analysis completed!")
|
||||
@@ -386,9 +500,6 @@ async def batch_analyze_all_stocks(limit: int = None):
|
||||
logger.info(" ├─ 오류: %d건", err_count)
|
||||
logger.info("🔍 DB 스캐너 신호: %d개", len(scanner_tickers))
|
||||
logger.info("⏱️ %.1f초 (%.1f분)", duration, duration / 60)
|
||||
logger.info("📡 API 통계 — 호출:%d 오류:%d(%.1f%%) 평균:%.2f초",
|
||||
api_stats['total_calls'], api_stats['total_errors'],
|
||||
api_stats['error_rate'] * 100, api_stats['avg_duration'])
|
||||
logger.info("=" * 60)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user