refactor: DeepCoin 1·2단계 파이프라인으로 구조 재편
레거시 분석·매칭·운영 코드를 정리하고 src/deepcoin 기반으로 재구성한다. 1단계 GT는 2년 스윙·눌림목·돌파·다이버전스 타점을 차트에 표시하고, 2단계는 8개 매매 기법과 GT 정합 평가 스크립트를 추가한다. .env와 GT JSON 산출물은 추적에서 제외한다. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
134
src/deepcoin/api/bithumb.py
Normal file
134
src/deepcoin/api/bithumb.py
Normal file
@@ -0,0 +1,134 @@
|
||||
"""빗썸 Public REST API — 캔들 조회."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
|
||||
from deepcoin.data.intervals import INTERVAL_DAILY, INTERVAL_MONTHLY, INTERVAL_WEEKLY
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 인터벌(분) → 빗썸 candles API 경로 세그먼트
|
||||
_CALENDAR_PATHS: dict[int, str] = {
|
||||
INTERVAL_DAILY: "days",
|
||||
INTERVAL_WEEKLY: "weeks",
|
||||
INTERVAL_MONTHLY: "months",
|
||||
}
|
||||
|
||||
KST_FMT = "%Y-%m-%d %H:%M:%S"
|
||||
KST_FMT_T = "%Y-%m-%dT%H:%M:%S"
|
||||
|
||||
|
||||
def parse_kst_datetime(value: str) -> datetime:
|
||||
"""KST 캔들 시각 문자열을 datetime으로 변환한다.
|
||||
|
||||
Args:
|
||||
value: `yyyy-MM-dd HH:mm:ss` 또는 ISO 형식.
|
||||
|
||||
Returns:
|
||||
naive datetime (KST 기준).
|
||||
"""
|
||||
normalized = value.strip().replace("T", " ")
|
||||
return datetime.strptime(normalized, KST_FMT)
|
||||
|
||||
|
||||
def format_kst_datetime(dt: datetime) -> str:
|
||||
"""datetime을 빗썸 `to` 파라미터 형식으로 포맷한다.
|
||||
|
||||
Args:
|
||||
dt: KST 기준 시각.
|
||||
|
||||
Returns:
|
||||
`yyyy-MM-dd HH:mm:ss` 문자열.
|
||||
"""
|
||||
return dt.strftime(KST_FMT)
|
||||
|
||||
|
||||
class BithumbCandleClient:
|
||||
"""빗썸 캔들 API 클라이언트."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str = "https://api.bithumb.com",
|
||||
count: int = 200,
|
||||
sleep_sec: float = 0.35,
|
||||
retries: int = 3,
|
||||
) -> None:
|
||||
"""클라이언트를 초기화한다.
|
||||
|
||||
Args:
|
||||
base_url: API 베이스 URL.
|
||||
count: 요청당 캔들 개수 (최대 200).
|
||||
sleep_sec: 연속 요청 간 대기(초).
|
||||
retries: 실패 시 재시도 횟수.
|
||||
"""
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.count = min(max(count, 1), 200)
|
||||
self.sleep_sec = sleep_sec
|
||||
self.retries = retries
|
||||
self._session = requests.Session()
|
||||
self._session.headers.update({"accept": "application/json"})
|
||||
|
||||
def fetch_candles(
|
||||
self,
|
||||
market: str,
|
||||
interval_min: int,
|
||||
to_kst: datetime | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""캔들 배치를 조회한다 (최신순).
|
||||
|
||||
Args:
|
||||
market: 거래 페어 (예: KRW-WLD).
|
||||
interval_min: 분 단위. 1440이면 일봉 API 사용.
|
||||
to_kst: 조회 기준 시각(KST). 해당 시각 캔들은 제외.
|
||||
|
||||
Returns:
|
||||
캔들 dict 리스트. API 오류 시 빈 리스트.
|
||||
|
||||
Raises:
|
||||
requests.RequestException: 재시도 후에도 네트워크 실패.
|
||||
"""
|
||||
if interval_min in _CALENDAR_PATHS:
|
||||
segment = _CALENDAR_PATHS[interval_min]
|
||||
url = f"{self.base_url}/v1/candles/{segment}"
|
||||
params: dict[str, Any] = {"market": market, "count": self.count}
|
||||
else:
|
||||
url = f"{self.base_url}/v1/candles/minutes/{interval_min}"
|
||||
params = {"market": market, "count": self.count}
|
||||
|
||||
if to_kst is not None:
|
||||
params["to"] = format_kst_datetime(to_kst)
|
||||
|
||||
last_error: Exception | None = None
|
||||
for attempt in range(1, self.retries + 1):
|
||||
try:
|
||||
response = self._session.get(url, params=params, timeout=30)
|
||||
if response.status_code == 429:
|
||||
wait = self.sleep_sec * attempt * 3
|
||||
logger.warning("Rate limit 429 — %ss 대기 후 재시도", wait)
|
||||
time.sleep(wait)
|
||||
continue
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
if isinstance(payload, dict) and "error" in payload:
|
||||
logger.error("API error: %s", payload["error"])
|
||||
return []
|
||||
if not isinstance(payload, list):
|
||||
logger.error("Unexpected response type: %s", type(payload))
|
||||
return []
|
||||
time.sleep(self.sleep_sec)
|
||||
return payload
|
||||
except requests.RequestException as exc:
|
||||
last_error = exc
|
||||
wait = self.sleep_sec * attempt * 2
|
||||
logger.warning("Request failed (%s/%s): %s", attempt, self.retries, exc)
|
||||
time.sleep(wait)
|
||||
|
||||
if last_error is not None:
|
||||
raise last_error
|
||||
return []
|
||||
Reference in New Issue
Block a user