"""빗썸 캔들 역방향 페이지네이션 다운로더.""" from __future__ import annotations import logging from dataclasses import dataclass from datetime import datetime, timedelta from typing import Any from deepcoin.api.bithumb import BithumbCandleClient, parse_kst_datetime from deepcoin.config import Settings from deepcoin.data.candle_store import CandleStore, candle_to_row from deepcoin.data.intervals import interval_label logger = logging.getLogger(__name__) @dataclass class DownloadResult: """단일 인터벌 다운로드 결과.""" interval_min: int requests: int saved_rows: int oldest_kst: datetime | None newest_kst: datetime | None reached_target: bool class CandleDownloader: """지정 기간 캔들을 수집해 SQLite에 저장한다.""" def __init__(self, settings: Settings, client: BithumbCandleClient | None = None) -> None: """다운로더를 초기화한다. Args: settings: 애플리케이션 설정. client: API 클라이언트. None이면 기본 생성. """ self.settings = settings self.client = client or BithumbCandleClient( base_url=settings.api_url, count=settings.candle_count, sleep_sec=settings.request_sleep_sec, retries=settings.request_retries, ) def download_interval( self, store: CandleStore, interval_min: int, days: int | None = None, ) -> DownloadResult: """한 인터벌의 캔들을 역방향으로 수집한다. Args: store: SQLite 저장소. interval_min: 분 단위 (1440=일봉). days: 수집 일수. None이면 settings.download_days. Returns: DownloadResult. """ lookback_days = days if days is not None else self.settings.download_days target_start = datetime.now() - timedelta(days=lookback_days) table = store.ensure_table(self.settings.symbol, interval_min) to_kst: datetime | None = None requests = 0 saved_rows = 0 oldest_kst: datetime | None = None newest_kst: datetime | None = None reached_target = False seen_oldest: set[str] = set() logger.info( "수집 시작: %s %s, 목표=%s 이후", self.settings.market, interval_label(interval_min), target_start.strftime("%Y-%m-%d %H:%M:%S"), ) while True: batch = self.client.fetch_candles( market=self.settings.market, interval_min=interval_min, to_kst=to_kst, ) requests += 1 if not batch: logger.info("%s: 더 이상 데이터 없음", interval_label(interval_min)) break rows = [ candle_to_row(c, self.settings.symbol, self.settings.coin_name) for c in batch ] saved_rows += store.upsert_rows(table, rows) batch_oldest = parse_kst_datetime(batch[-1]["candle_date_time_kst"]) batch_newest = parse_kst_datetime(batch[0]["candle_date_time_kst"]) if newest_kst is None or batch_newest > newest_kst: newest_kst = batch_newest if oldest_kst is None or batch_oldest < oldest_kst: oldest_kst = batch_oldest if batch_oldest <= target_start: reached_target = True logger.info( "%s: 목표 기간 도달 (oldest=%s)", interval_label(interval_min), batch_oldest.strftime("%Y-%m-%d %H:%M:%S"), ) break oldest_key = batch[-1]["candle_date_time_kst"] if oldest_key in seen_oldest: logger.warning("%s: 페이지네이션 정체 — 중단", interval_label(interval_min)) break seen_oldest.add(oldest_key) to_kst = batch_oldest if requests % 20 == 0: logger.info( "%s: 진행 중 requests=%s saved=%s oldest=%s", interval_label(interval_min), requests, saved_rows, batch_oldest.strftime("%Y-%m-%d %H:%M:%S"), ) return DownloadResult( interval_min=interval_min, requests=requests, saved_rows=saved_rows, oldest_kst=oldest_kst, newest_kst=newest_kst, reached_target=reached_target, ) def download_all(self, store: CandleStore, days: int | None = None) -> list[DownloadResult]: """설정된 모든 인터벌을 수집한다. Args: store: SQLite 저장소. days: 수집 일수. Returns: 인터벌별 DownloadResult 리스트. """ results: list[DownloadResult] = [] for interval in self.settings.download_intervals: if interval == 1: logger.warning("1분봉은 장기 수집 시 요청량이 매우 큽니다 — 건너뜁니다.") continue result = self.download_interval(store, interval, days=days) results.append(result) return results