feat: 1분봉 수집 지원 및 10년 기본 수집 기간 확장
1분봉 건너뛰기를 제거하고 예상 API 요청·진행률 로그를 추가한다. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
12
.env.example
12
.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
|
||||
|
||||
|
||||
11
README.md
11
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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user