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_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
|
||||||
|
|
||||||
|
|||||||
11
README.md
11
README.md
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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")),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user