diff --git a/.env.example b/.env.example index 3397cee..943c3d2 100644 --- a/.env.example +++ b/.env.example @@ -5,7 +5,7 @@ BITHUMB_ACCESS_KEY= BITHUMB_SECRET_KEY= BITHUMB_API_URL=https://api.bithumb.com BITHUMB_API_CANDLE_COUNT=200 -BITHUMB_MINUTE_INTERVALS=3,5,10,15,30,60,240 +BITHUMB_MINUTE_INTERVALS=1,3,5,10,15,30,60,240 HTS_API_RETRY_SLEEP_SEC=0.5 # --- 텔레그램 (선택) --- @@ -16,11 +16,13 @@ COIN_TELEGRAM_CHAT_ID= SYMBOL=BTC COIN_NAME=비트코인 -# --- 데이터 수집 (2017-01-01~, DB 최신 기준 약 3447일) --- -# 인터벌 코드: 분봉=분, 일봉=1440, 주봉=10080, 월봉=43200 +# --- 데이터 수집 (기본 2017~, 10년=3650일) --- +# 인터벌 코드: 분봉=분(1=1분), 일봉=1440, 주봉=10080, 월봉=43200 +# 1분봉 10년 ≈ 2.6만 API 요청 · 수 시간 소요 — 필요 시: +# python scripts/download_candles.py --days 3650 --intervals 1 DB_PATH=coins.db -DOWNLOAD_DAYS=3447 -DOWNLOAD_INTERVALS=3,5,10,15,30,60,240,1440,10080,43200 +DOWNLOAD_DAYS=3650 +DOWNLOAD_INTERVALS=1,3,5,10,15,30,60,240,1440,10080,43200 API_REQUEST_SLEEP_SEC=0.35 API_REQUEST_RETRIES=3 diff --git a/README.md b/README.md index e7ee1b9..6adddbc 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ - 빗썸 Public API(v1) 기반 분·일·주·월봉 캔들 수집 - SQLite(`coins.db`) 저장 — 테이블명 `{SYMBOL}_{인터벌코드}` (예: `BTC_60`, `BTC_10080`) -- 2017-01-01~ 역방향 페이지네이션 수집 (기본 3447일) +- 2017-01-01~ 역방향 페이지네이션 수집 (기본 3650일·10년, **1분봉 포함**) - Ground Truth 기반 선물 롱·숏 벤치마크 및 인과 전략 시뮬레이션 ## 요구사항 @@ -30,8 +30,8 @@ cp .env.example .env # API 키 등 입력 | `SYMBOL` | 코인 심볼 | `BTC` | | `COIN_NAME` | 코인 이름 | `비트코인` | | `DB_PATH` | SQLite 경로 | `coins.db` | -| `DOWNLOAD_DAYS` | 수집·차트 일수 (2017~) | `3447` | -| `DOWNLOAD_INTERVALS` | 인터벌 코드 목록 | `3,5,10,15,30,60,240,1440,10080,43200` | +| `DOWNLOAD_DAYS` | 수집·차트 일수 (10년) | `3650` | +| `DOWNLOAD_INTERVALS` | 인터벌 코드 목록 (`1`=1분봉) | `1,3,5,10,15,30,60,240,1440,10080,43200` | | `BITHUMB_API_CANDLE_COUNT` | 요청당 캔들 수 (최대 200) | `200` | | `API_REQUEST_SLEEP_SEC` | API 호출 간격(초) | `0.35` | @@ -57,9 +57,12 @@ cp .env.example .env # API 키 등 입력 0단계(GT 타점)를 먼저 만든 뒤, 1단계 sim을 돌립니다. ```bash -# 사전: 데이터 수집 +# 사전: 데이터 수집 (전체 인터벌) python scripts/01_download.py +# 1분봉 10년만 수집 (수 시간 소요) +python scripts/download_candles.py --days 3650 --intervals 1 + # 0단계: Ground Truth 타점 생성 (v1/v2/v3) python scripts/02_ground_truth.py diff --git a/scripts/download_candles.py b/scripts/download_candles.py index b79388e..0b0e915 100644 --- a/scripts/download_candles.py +++ b/scripts/download_candles.py @@ -18,7 +18,7 @@ from dataclasses import replace from deepcoin.config import load_settings from deepcoin.data.candle_store import CandleStore from deepcoin.data.downloader import CandleDownloader -from deepcoin.data.intervals import interval_label +from deepcoin.data.intervals import INTERVAL_1MIN, estimate_download_requests, interval_label def _configure_logging(verbose: bool) -> None: @@ -44,7 +44,12 @@ def main() -> int: "--intervals", type=str, default=None, - help="쉼표 구분 인터벌. 분봉=분, 일=1440, 주=10080, 월=43200", + help="쉼표 구분 인터벌. 분봉=분(1=1분), 일=1440, 주=10080, 월=43200", + ) + 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() @@ -59,15 +64,29 @@ def main() -> int: 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 - logging.getLogger(__name__).info( + log = logging.getLogger(__name__) + log.info( "대상=%s DB=%s days=%s intervals=%s", settings.market, settings.db_path, days, settings.download_intervals, ) + for interval in settings.download_intervals: + est = estimate_download_requests(interval, days, batch_size=settings.candle_count) + log.info( + "예상 API 요청: %s ≈ %s회 (sleep %.2fs)", + interval_label(interval), + est, + settings.request_sleep_sec, + ) store = CandleStore(settings.db_path) try: diff --git a/src/deepcoin/config.py b/src/deepcoin/config.py index d4cfbc3..a845c17 100644 --- a/src/deepcoin/config.py +++ b/src/deepcoin/config.py @@ -334,7 +334,7 @@ def load_settings(env_path: Path | None = None) -> Settings: api_url=os.getenv("BITHUMB_API_URL", "https://api.bithumb.com").rstrip("/"), candle_count=int(os.getenv("BITHUMB_API_CANDLE_COUNT", "200")), download_intervals=intervals, - download_days=int(os.getenv("DOWNLOAD_DAYS", "3447")), + download_days=int(os.getenv("DOWNLOAD_DAYS", "3650")), db_path=db_path, request_sleep_sec=float(os.getenv("API_REQUEST_SLEEP_SEC", "0.35")), request_retries=int(os.getenv("API_REQUEST_RETRIES", "3")), diff --git a/src/deepcoin/data/downloader.py b/src/deepcoin/data/downloader.py index 34dbaf1..7f51817 100644 --- a/src/deepcoin/data/downloader.py +++ b/src/deepcoin/data/downloader.py @@ -10,7 +10,11 @@ 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 +from deepcoin.data.intervals import ( + INTERVAL_1MIN, + estimate_download_requests, + interval_label, +) logger = logging.getLogger(__name__) @@ -45,6 +49,14 @@ class CandleDownloader: retries=settings.request_retries, ) + def _progress_every(self, interval_min: int, est_requests: int) -> int: + """인터벌·규모에 따른 진행 로그 주기(요청 수)를 반환한다.""" + if interval_min == INTERVAL_1MIN or est_requests >= 5000: + return 50 + if est_requests >= 1000: + return 20 + return 10 + def download_interval( self, store: CandleStore, @@ -55,7 +67,7 @@ class CandleDownloader: Args: store: SQLite 저장소. - interval_min: 분 단위 (1440=일봉). + interval_min: 분 단위 (1=1분, 1440=일봉). days: 수집 일수. None이면 settings.download_days. Returns: @@ -65,6 +77,12 @@ class CandleDownloader: target_start = datetime.now() - timedelta(days=lookback_days) table = store.ensure_table(self.settings.symbol, interval_min) + est_requests = estimate_download_requests( + interval_min, lookback_days, batch_size=self.settings.candle_count, + ) + est_seconds = est_requests * self.settings.request_sleep_sec + progress_every = self._progress_every(interval_min, est_requests) + to_kst: datetime | None = None requests = 0 saved_rows = 0 @@ -74,10 +92,12 @@ class CandleDownloader: seen_oldest: set[str] = set() logger.info( - "수집 시작: %s %s, 목표=%s 이후", + "수집 시작: %s %s, 목표=%s 이후, 예상 요청≈%s회 (≈%.0f분)", self.settings.market, interval_label(interval_min), target_start.strftime("%Y-%m-%d %H:%M:%S"), + est_requests, + est_seconds / 60.0, ) while True: @@ -123,11 +143,14 @@ class CandleDownloader: to_kst = batch_oldest - if requests % 20 == 0: + if requests % progress_every == 0: + pct = min(100.0, requests / est_requests * 100.0) if est_requests else 0.0 logger.info( - "%s: 진행 중 requests=%s saved=%s oldest=%s", + "%s: 진행 %s/%s (%.1f%%) saved=%s oldest=%s", interval_label(interval_min), requests, + est_requests, + pct, saved_rows, batch_oldest.strftime("%Y-%m-%d %H:%M:%S"), ) @@ -153,9 +176,6 @@ class CandleDownloader: """ 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 diff --git a/src/deepcoin/data/intervals.py b/src/deepcoin/data/intervals.py index 45b23bf..a4e2422 100644 --- a/src/deepcoin/data/intervals.py +++ b/src/deepcoin/data/intervals.py @@ -7,7 +7,9 @@ INTERVAL_DAILY = 1440 INTERVAL_WEEKLY = 10080 # 7 * 24 * 60 INTERVAL_MONTHLY = 43200 # 30 * 24 * 60 (월봉 식별용 관례값) -MINUTE_INTERVALS = frozenset({1, 3, 5, 10, 15, 30, 60, 240}) +INTERVAL_1MIN = 1 + +MINUTE_INTERVALS = frozenset({INTERVAL_1MIN, 3, 5, 10, 15, 30, 60, 240}) CALENDAR_INTERVALS = frozenset({INTERVAL_DAILY, INTERVAL_WEEKLY, INTERVAL_MONTHLY}) DEFAULT_DOWNLOAD_INTERVALS = [ @@ -45,3 +47,32 @@ def interval_label(interval_min: int) -> str: def is_calendar_interval(interval_min: int) -> bool: """일/주/월봉 여부.""" return interval_min in CALENDAR_INTERVALS + + +def estimate_bars_per_day(interval_min: int) -> int: + """인터벌별 하루 예상 봉 수를 반환한다.""" + if interval_min == INTERVAL_DAILY: + return 1 + if interval_min == INTERVAL_WEEKLY: + return 1 # 주 1봉 — 일수 환산은 download 측에서 처리 + if interval_min == INTERVAL_MONTHLY: + return 1 + return max(1, (24 * 60) // interval_min) + + +def estimate_download_requests( + interval_min: int, + days: int, + batch_size: int = 200, +) -> int: + """역방향 페이지네이션 예상 API 요청 횟수를 반환한다.""" + if is_calendar_interval(interval_min): + if interval_min == INTERVAL_DAILY: + total_bars = days + elif interval_min == INTERVAL_WEEKLY: + total_bars = max(1, days // 7) + else: + total_bars = max(1, days // 30) + else: + total_bars = days * estimate_bars_per_day(interval_min) + return max(1, (total_bars + batch_size - 1) // batch_size)