vol_breakout 멀티종목 tick, vol_live HTML 모니터, 마감 봉만 저장하는 캔들 다운로드, 텔레그램 체결 알림, cron/watch 감시 스크립트 및 테스트를 포함한다. Co-authored-by: Cursor <cursoragent@cursor.com>
203 lines
6.7 KiB
Python
203 lines
6.7 KiB
Python
#!/usr/bin/env python3
|
|
"""사전: 빗썸 캔들 수집 — 기본: 전체 인터벌 증분 갱신, --full: 전체 인터벌 풀 다운."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import logging
|
|
import sys
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
SRC = ROOT / "src"
|
|
if str(SRC) not in sys.path:
|
|
sys.path.insert(0, str(SRC))
|
|
|
|
from dataclasses import replace
|
|
|
|
from bithumb.config import load_settings, resolve_coin_name
|
|
from bithumb.data.candle_store import CandleStore
|
|
from bithumb.data.downloader import CandleDownloader
|
|
from bithumb.data.intervals import INTERVAL_1MIN, estimate_download_requests, interval_label
|
|
|
|
|
|
def _configure_logging(verbose: bool) -> None:
|
|
"""로깅 레벨을 설정한다."""
|
|
level = logging.DEBUG if verbose else logging.INFO
|
|
logging.basicConfig(
|
|
level=level,
|
|
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
|
datefmt="%Y-%m-%d %H:%M:%S",
|
|
)
|
|
|
|
|
|
def _parse_symbols(raw: str | None, defaults: list[str]) -> list[str]:
|
|
"""CLI --symbols 또는 기본 DOWNLOAD_SYMBOLS 목록."""
|
|
if raw:
|
|
return [part.strip().upper() for part in raw.split(",") if part.strip()]
|
|
return list(defaults)
|
|
|
|
|
|
def _log_interval_estimates(
|
|
*,
|
|
store: CandleStore,
|
|
symbol: str,
|
|
intervals: list[int],
|
|
days: int,
|
|
full: bool,
|
|
batch_size: int,
|
|
sleep_sec: float,
|
|
log: logging.Logger,
|
|
) -> None:
|
|
"""인터벌별 예상 API 요청 수를 로깅한다."""
|
|
for interval in intervals:
|
|
if full:
|
|
est = estimate_download_requests(interval, days, batch_size=batch_size)
|
|
log.info(
|
|
"예상 API 요청: %s %s ≈ %s회 (풀 다운, sleep %.2fs)",
|
|
symbol,
|
|
interval_label(interval),
|
|
est,
|
|
sleep_sec,
|
|
)
|
|
continue
|
|
_, _, db_max = store.get_range(symbol, interval)
|
|
if db_max is None:
|
|
est = estimate_download_requests(interval, days, batch_size=batch_size)
|
|
log.info(
|
|
"예상 API 요청: %s %s ≈ %s회 (DB 없음 → 풀 다운)",
|
|
symbol,
|
|
interval_label(interval),
|
|
est,
|
|
)
|
|
else:
|
|
gap_days = max(1, (datetime.now() - db_max).days + 1)
|
|
est = estimate_download_requests(interval, gap_days, batch_size=batch_size)
|
|
log.info(
|
|
"예상 API 요청: %s %s ≈ %s회 (증분, DB=%s, 갭≈%s일)",
|
|
symbol,
|
|
interval_label(interval),
|
|
est,
|
|
db_max.strftime("%Y-%m-%d %H:%M:%S"),
|
|
gap_days,
|
|
)
|
|
|
|
|
|
def main() -> int:
|
|
"""CLI 진입점."""
|
|
parser = argparse.ArgumentParser(
|
|
description="빗썸 캔들 데이터 수집 (DOWNLOAD_INTERVALS 전체, 1분봉 포함)",
|
|
)
|
|
parser.add_argument(
|
|
"--full",
|
|
action="store_true",
|
|
help="전체 인터벌을 DOWNLOAD_DAYS 구간만큼 역방향 풀 다운 (최초 1회·재구축)",
|
|
)
|
|
parser.add_argument(
|
|
"--days",
|
|
type=int,
|
|
default=None,
|
|
help="풀 다운(--full) 또는 DB 비어 있을 때 목표 일수 (기본: DOWNLOAD_DAYS)",
|
|
)
|
|
parser.add_argument(
|
|
"--intervals",
|
|
type=str,
|
|
default=None,
|
|
help="(고급) 쉼표 구분 인터벌만 수집. 기본: .env DOWNLOAD_INTERVALS 전체",
|
|
)
|
|
parser.add_argument(
|
|
"--symbols",
|
|
type=str,
|
|
default=None,
|
|
help="쉼표 구분 심볼 (기본: .env DOWNLOAD_SYMBOLS 또는 SYMBOL)",
|
|
)
|
|
parser.add_argument(
|
|
"--include-1min",
|
|
action="store_true",
|
|
help="1분봉(1)을 기존 DOWNLOAD_INTERVALS에 추가하여 수집",
|
|
)
|
|
parser.add_argument("-v", "--verbose", action="store_true", help="디버그 로그")
|
|
args = parser.parse_args()
|
|
|
|
_configure_logging(args.verbose)
|
|
base_settings = load_settings()
|
|
symbols = _parse_symbols(args.symbols, base_settings.download_symbols)
|
|
|
|
settings = base_settings
|
|
if args.intervals:
|
|
settings = replace(
|
|
settings,
|
|
download_intervals=[
|
|
int(x.strip()) for x in args.intervals.split(",") if x.strip()
|
|
],
|
|
)
|
|
elif args.include_1min and INTERVAL_1MIN not in settings.download_intervals:
|
|
settings = replace(
|
|
settings,
|
|
download_intervals=sorted({*settings.download_intervals, INTERVAL_1MIN}),
|
|
)
|
|
|
|
days = args.days or settings.download_days
|
|
mode_label = "full" if args.full else "incremental"
|
|
log = logging.getLogger(__name__)
|
|
log.info(
|
|
"대상=%s DB=%s mode=%s days=%s intervals=%s",
|
|
",".join(f"KRW-{s}" for s in symbols),
|
|
settings.db_path,
|
|
mode_label,
|
|
days,
|
|
settings.download_intervals,
|
|
)
|
|
|
|
store = CandleStore(settings.db_path)
|
|
exit_code = 0
|
|
try:
|
|
for symbol in symbols:
|
|
symbol_settings = replace(
|
|
settings,
|
|
symbol=symbol,
|
|
coin_name=resolve_coin_name(symbol),
|
|
)
|
|
print(f"\n=== {symbol} ({symbol_settings.coin_name}) ===")
|
|
_log_interval_estimates(
|
|
store=store,
|
|
symbol=symbol,
|
|
intervals=symbol_settings.download_intervals,
|
|
days=days,
|
|
full=args.full,
|
|
batch_size=symbol_settings.candle_count,
|
|
sleep_sec=symbol_settings.request_sleep_sec,
|
|
log=log,
|
|
)
|
|
|
|
downloader = CandleDownloader(symbol_settings)
|
|
results = downloader.download_all(store, days=days, full=args.full)
|
|
|
|
print(f"\n--- {symbol} 수집 완료 ({mode_label}) ---")
|
|
for result in results:
|
|
count, min_dt, max_dt = store.get_range(symbol, result.interval_min)
|
|
min_s = min_dt.strftime("%Y-%m-%d %H:%M:%S") if min_dt else "-"
|
|
max_s = max_dt.strftime("%Y-%m-%d %H:%M:%S") if max_dt else "-"
|
|
if result.mode == "uptodate":
|
|
flag = "UPTODATE"
|
|
elif result.reached_target:
|
|
flag = "OK"
|
|
else:
|
|
flag = "PARTIAL"
|
|
exit_code = 1
|
|
label = interval_label(result.interval_min)
|
|
print(
|
|
f"[{flag}] {symbol} {label} ({result.interval_min}) mode={result.mode} | "
|
|
f"requests={result.requests} upsert={result.saved_rows} "
|
|
f"db_rows={count} range={min_s} ~ {max_s}"
|
|
)
|
|
finally:
|
|
store.close()
|
|
|
|
return exit_code
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|