feat: 1분봉 수집 지원 및 10년 기본 수집 기간 확장

1분봉 건너뛰기를 제거하고 예상 API 요청·진행률 로그를 추가한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dsyoon
2026-06-11 13:33:05 +09:00
parent c164dfbc84
commit 6e72fe44a7
6 changed files with 97 additions and 22 deletions

View File

@@ -5,7 +5,7 @@ BITHUMB_ACCESS_KEY=
BITHUMB_SECRET_KEY= BITHUMB_SECRET_KEY=
BITHUMB_API_URL=https://api.bithumb.com BITHUMB_API_URL=https://api.bithumb.com
BITHUMB_API_CANDLE_COUNT=200 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 HTS_API_RETRY_SLEEP_SEC=0.5
# --- 텔레그램 (선택) --- # --- 텔레그램 (선택) ---
@@ -16,11 +16,13 @@ COIN_TELEGRAM_CHAT_ID=
SYMBOL=BTC SYMBOL=BTC
COIN_NAME=비트코인 COIN_NAME=비트코인
# --- 데이터 수집 (2017-01-01~, DB 최신 기준 약 3447일) --- # --- 데이터 수집 (기본 2017~, 10년=3650일) ---
# 인터벌 코드: 분봉=분, 일봉=1440, 주봉=10080, 월봉=43200 # 인터벌 코드: 분봉=분(1=1분), 일봉=1440, 주봉=10080, 월봉=43200
# 1분봉 10년 ≈ 2.6만 API 요청 · 수 시간 소요 — 필요 시:
# python scripts/download_candles.py --days 3650 --intervals 1
DB_PATH=coins.db DB_PATH=coins.db
DOWNLOAD_DAYS=3447 DOWNLOAD_DAYS=3650
DOWNLOAD_INTERVALS=3,5,10,15,30,60,240,1440,10080,43200 DOWNLOAD_INTERVALS=1,3,5,10,15,30,60,240,1440,10080,43200
API_REQUEST_SLEEP_SEC=0.35 API_REQUEST_SLEEP_SEC=0.35
API_REQUEST_RETRIES=3 API_REQUEST_RETRIES=3

View File

@@ -6,7 +6,7 @@
- 빗썸 Public API(v1) 기반 분·일·주·월봉 캔들 수집 - 빗썸 Public API(v1) 기반 분·일·주·월봉 캔들 수집
- SQLite(`coins.db`) 저장 — 테이블명 `{SYMBOL}_{인터벌코드}` (예: `BTC_60`, `BTC_10080`) - SQLite(`coins.db`) 저장 — 테이블명 `{SYMBOL}_{인터벌코드}` (예: `BTC_60`, `BTC_10080`)
- 2017-01-01~ 역방향 페이지네이션 수집 (기본 3447일) - 2017-01-01~ 역방향 페이지네이션 수집 (기본 3650일·10년, **1분봉 포함**)
- Ground Truth 기반 선물 롱·숏 벤치마크 및 인과 전략 시뮬레이션 - Ground Truth 기반 선물 롱·숏 벤치마크 및 인과 전략 시뮬레이션
## 요구사항 ## 요구사항
@@ -30,8 +30,8 @@ cp .env.example .env # API 키 등 입력
| `SYMBOL` | 코인 심볼 | `BTC` | | `SYMBOL` | 코인 심볼 | `BTC` |
| `COIN_NAME` | 코인 이름 | `비트코인` | | `COIN_NAME` | 코인 이름 | `비트코인` |
| `DB_PATH` | SQLite 경로 | `coins.db` | | `DB_PATH` | SQLite 경로 | `coins.db` |
| `DOWNLOAD_DAYS` | 수집·차트 일수 (2017~) | `3447` | | `DOWNLOAD_DAYS` | 수집·차트 일수 (10년) | `3650` |
| `DOWNLOAD_INTERVALS` | 인터벌 코드 목록 | `3,5,10,15,30,60,240,1440,10080,43200` | | `DOWNLOAD_INTERVALS` | 인터벌 코드 목록 (`1`=1분봉) | `1,3,5,10,15,30,60,240,1440,10080,43200` |
| `BITHUMB_API_CANDLE_COUNT` | 요청당 캔들 수 (최대 200) | `200` | | `BITHUMB_API_CANDLE_COUNT` | 요청당 캔들 수 (최대 200) | `200` |
| `API_REQUEST_SLEEP_SEC` | API 호출 간격(초) | `0.35` | | `API_REQUEST_SLEEP_SEC` | API 호출 간격(초) | `0.35` |
@@ -57,9 +57,12 @@ cp .env.example .env # API 키 등 입력
0단계(GT 타점)를 먼저 만든 뒤, 1단계 sim을 돌립니다. 0단계(GT 타점)를 먼저 만든 뒤, 1단계 sim을 돌립니다.
```bash ```bash
# 사전: 데이터 수집 # 사전: 데이터 수집 (전체 인터벌)
python scripts/01_download.py python scripts/01_download.py
# 1분봉 10년만 수집 (수 시간 소요)
python scripts/download_candles.py --days 3650 --intervals 1
# 0단계: Ground Truth 타점 생성 (v1/v2/v3) # 0단계: Ground Truth 타점 생성 (v1/v2/v3)
python scripts/02_ground_truth.py python scripts/02_ground_truth.py

View File

@@ -18,7 +18,7 @@ from dataclasses import replace
from deepcoin.config import load_settings from deepcoin.config import load_settings
from deepcoin.data.candle_store import CandleStore from deepcoin.data.candle_store import CandleStore
from deepcoin.data.downloader import CandleDownloader 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: def _configure_logging(verbose: bool) -> None:
@@ -44,7 +44,12 @@ def main() -> int:
"--intervals", "--intervals",
type=str, type=str,
default=None, 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="디버그 로그") parser.add_argument("-v", "--verbose", action="store_true", help="디버그 로그")
args = parser.parse_args() args = parser.parse_args()
@@ -59,15 +64,29 @@ def main() -> int:
int(x.strip()) for x in args.intervals.split(",") if x.strip() 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 days = args.days or settings.download_days
logging.getLogger(__name__).info( log = logging.getLogger(__name__)
log.info(
"대상=%s DB=%s days=%s intervals=%s", "대상=%s DB=%s days=%s intervals=%s",
settings.market, settings.market,
settings.db_path, settings.db_path,
days, days,
settings.download_intervals, 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) store = CandleStore(settings.db_path)
try: try:

View File

@@ -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("/"), api_url=os.getenv("BITHUMB_API_URL", "https://api.bithumb.com").rstrip("/"),
candle_count=int(os.getenv("BITHUMB_API_CANDLE_COUNT", "200")), candle_count=int(os.getenv("BITHUMB_API_CANDLE_COUNT", "200")),
download_intervals=intervals, download_intervals=intervals,
download_days=int(os.getenv("DOWNLOAD_DAYS", "3447")), download_days=int(os.getenv("DOWNLOAD_DAYS", "3650")),
db_path=db_path, db_path=db_path,
request_sleep_sec=float(os.getenv("API_REQUEST_SLEEP_SEC", "0.35")), request_sleep_sec=float(os.getenv("API_REQUEST_SLEEP_SEC", "0.35")),
request_retries=int(os.getenv("API_REQUEST_RETRIES", "3")), request_retries=int(os.getenv("API_REQUEST_RETRIES", "3")),

View File

@@ -10,7 +10,11 @@ from typing import Any
from deepcoin.api.bithumb import BithumbCandleClient, parse_kst_datetime from deepcoin.api.bithumb import BithumbCandleClient, parse_kst_datetime
from deepcoin.config import Settings from deepcoin.config import Settings
from deepcoin.data.candle_store import CandleStore, candle_to_row 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__) logger = logging.getLogger(__name__)
@@ -45,6 +49,14 @@ class CandleDownloader:
retries=settings.request_retries, 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( def download_interval(
self, self,
store: CandleStore, store: CandleStore,
@@ -55,7 +67,7 @@ class CandleDownloader:
Args: Args:
store: SQLite 저장소. store: SQLite 저장소.
interval_min: 분 단위 (1440=일봉). interval_min: 분 단위 (1=1분, 1440=일봉).
days: 수집 일수. None이면 settings.download_days. days: 수집 일수. None이면 settings.download_days.
Returns: Returns:
@@ -65,6 +77,12 @@ class CandleDownloader:
target_start = datetime.now() - timedelta(days=lookback_days) target_start = datetime.now() - timedelta(days=lookback_days)
table = store.ensure_table(self.settings.symbol, interval_min) 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 to_kst: datetime | None = None
requests = 0 requests = 0
saved_rows = 0 saved_rows = 0
@@ -74,10 +92,12 @@ class CandleDownloader:
seen_oldest: set[str] = set() seen_oldest: set[str] = set()
logger.info( logger.info(
"수집 시작: %s %s, 목표=%s 이후", "수집 시작: %s %s, 목표=%s 이후, 예상 요청≈%s회 (≈%.0f분)",
self.settings.market, self.settings.market,
interval_label(interval_min), interval_label(interval_min),
target_start.strftime("%Y-%m-%d %H:%M:%S"), target_start.strftime("%Y-%m-%d %H:%M:%S"),
est_requests,
est_seconds / 60.0,
) )
while True: while True:
@@ -123,11 +143,14 @@ class CandleDownloader:
to_kst = batch_oldest 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( logger.info(
"%s: 진행 중 requests=%s saved=%s oldest=%s", "%s: 진행 %s/%s (%.1f%%) saved=%s oldest=%s",
interval_label(interval_min), interval_label(interval_min),
requests, requests,
est_requests,
pct,
saved_rows, saved_rows,
batch_oldest.strftime("%Y-%m-%d %H:%M:%S"), batch_oldest.strftime("%Y-%m-%d %H:%M:%S"),
) )
@@ -153,9 +176,6 @@ class CandleDownloader:
""" """
results: list[DownloadResult] = [] results: list[DownloadResult] = []
for interval in self.settings.download_intervals: for interval in self.settings.download_intervals:
if interval == 1:
logger.warning("1분봉은 장기 수집 시 요청량이 매우 큽니다 — 건너뜁니다.")
continue
result = self.download_interval(store, interval, days=days) result = self.download_interval(store, interval, days=days)
results.append(result) results.append(result)
return results return results

View File

@@ -7,7 +7,9 @@ INTERVAL_DAILY = 1440
INTERVAL_WEEKLY = 10080 # 7 * 24 * 60 INTERVAL_WEEKLY = 10080 # 7 * 24 * 60
INTERVAL_MONTHLY = 43200 # 30 * 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}) CALENDAR_INTERVALS = frozenset({INTERVAL_DAILY, INTERVAL_WEEKLY, INTERVAL_MONTHLY})
DEFAULT_DOWNLOAD_INTERVALS = [ DEFAULT_DOWNLOAD_INTERVALS = [
@@ -45,3 +47,32 @@ def interval_label(interval_min: int) -> str:
def is_calendar_interval(interval_min: int) -> bool: def is_calendar_interval(interval_min: int) -> bool:
"""일/주/월봉 여부.""" """일/주/월봉 여부."""
return interval_min in CALENDAR_INTERVALS 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)