feat(spot): 3단계 운영 파이프라인 — composite_v3 + MTF paper/live
MTF 필터 백테스트, paper/live 체결, 빗썸 Private API 연동 및 운영 스크립트·설계 문서를 추가해 2단계 전략을 실거래 단계에 연결한다. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
60
src/deepcoin/api/bithumb_auth.py
Normal file
60
src/deepcoin/api/bithumb_auth.py
Normal file
@@ -0,0 +1,60 @@
|
||||
"""빗썸 Private API JWT 인증."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import uuid
|
||||
from typing import Any
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import jwt
|
||||
|
||||
|
||||
def build_jwt_token(
|
||||
access_key: str,
|
||||
secret_key: str,
|
||||
*,
|
||||
params: dict[str, Any] | None = None,
|
||||
) -> str:
|
||||
"""빗썸 v2.1 JWT Bearer 토큰을 생성한다.
|
||||
|
||||
Args:
|
||||
access_key: API Access Key.
|
||||
secret_key: API Secret Key.
|
||||
params: POST/DELETE body 또는 query 파라미터. 있으면 query_hash 포함.
|
||||
|
||||
Returns:
|
||||
JWT 문자열 (Bearer 접두사 없음).
|
||||
"""
|
||||
import time
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
"access_key": access_key,
|
||||
"nonce": str(uuid.uuid4()),
|
||||
"timestamp": round(time.time() * 1000),
|
||||
}
|
||||
if params:
|
||||
query = urlencode(params, doseq=True).encode()
|
||||
payload["query_hash"] = hashlib.sha512(query).hexdigest()
|
||||
payload["query_hash_alg"] = "SHA512"
|
||||
return jwt.encode(payload, secret_key, algorithm="HS256")
|
||||
|
||||
|
||||
def auth_headers(
|
||||
access_key: str,
|
||||
secret_key: str,
|
||||
*,
|
||||
params: dict[str, Any] | None = None,
|
||||
) -> dict[str, str]:
|
||||
"""Authorization 헤더 dict를 반환한다."""
|
||||
token = build_jwt_token(access_key, secret_key, params=params)
|
||||
return {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
|
||||
def dumps_params(params: dict[str, Any]) -> str:
|
||||
"""주문 body JSON 직렬화 (키 순서 유지)."""
|
||||
return json.dumps(params, separators=(",", ":"))
|
||||
143
src/deepcoin/api/bithumb_private.py
Normal file
143
src/deepcoin/api/bithumb_private.py
Normal file
@@ -0,0 +1,143 @@
|
||||
"""빗썸 Private REST API — 잔고·주문."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
|
||||
from deepcoin.api.bithumb_auth import auth_headers, dumps_params
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BithumbPrivateClient:
|
||||
"""빗썸 v2.1 Private API 클라이언트."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
access_key: str,
|
||||
secret_key: str,
|
||||
base_url: str = "https://api.bithumb.com",
|
||||
sleep_sec: float = 0.35,
|
||||
retries: int = 3,
|
||||
) -> None:
|
||||
"""클라이언트를 초기화한다.
|
||||
|
||||
Args:
|
||||
access_key: API Access Key.
|
||||
secret_key: API Secret Key.
|
||||
base_url: API 베이스 URL.
|
||||
sleep_sec: 연속 요청 간 대기(초).
|
||||
retries: 실패 시 재시도 횟수.
|
||||
"""
|
||||
if not access_key or not secret_key:
|
||||
raise ValueError("BITHUMB_ACCESS_KEY / BITHUMB_SECRET_KEY 가 필요합니다.")
|
||||
self.access_key = access_key
|
||||
self.secret_key = secret_key
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.sleep_sec = sleep_sec
|
||||
self.retries = retries
|
||||
self._session = requests.Session()
|
||||
|
||||
def _request(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
params: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""인증 요청을 수행한다."""
|
||||
url = f"{self.base_url}{path}"
|
||||
body = dumps_params(params) if params else None
|
||||
headers = auth_headers(
|
||||
self.access_key,
|
||||
self.secret_key,
|
||||
params=params,
|
||||
)
|
||||
last_error: Exception | None = None
|
||||
for attempt in range(1, self.retries + 1):
|
||||
try:
|
||||
response = self._session.request(
|
||||
method,
|
||||
url,
|
||||
data=body,
|
||||
headers=headers,
|
||||
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()
|
||||
time.sleep(self.sleep_sec)
|
||||
if not isinstance(payload, (dict, list)):
|
||||
raise ValueError(f"Unexpected response: {type(payload)}")
|
||||
return payload if isinstance(payload, dict) else {"data": payload}
|
||||
except Exception as exc:
|
||||
last_error = exc
|
||||
wait = self.sleep_sec * attempt * 2
|
||||
logger.warning("Private API failed (%s/%s): %s", attempt, self.retries, exc)
|
||||
time.sleep(wait)
|
||||
if last_error is not None:
|
||||
raise last_error
|
||||
return {}
|
||||
|
||||
def get_accounts(self) -> list[dict[str, Any]]:
|
||||
"""전체 계좌(잔고)를 조회한다."""
|
||||
payload = self._request("GET", "/v1/accounts")
|
||||
if isinstance(payload, list):
|
||||
return payload
|
||||
return payload.get("data", payload) if isinstance(payload.get("data"), list) else []
|
||||
|
||||
def get_balance(self, currency: str) -> tuple[float, float]:
|
||||
"""통화별 잔고를 반환한다.
|
||||
|
||||
Returns:
|
||||
(available, locked) 수량 또는 원화.
|
||||
"""
|
||||
currency = currency.upper()
|
||||
for row in self.get_accounts():
|
||||
if str(row.get("currency", "")).upper() == currency:
|
||||
return float(row.get("balance", 0)), float(row.get("locked", 0))
|
||||
return 0.0, 0.0
|
||||
|
||||
def market_buy_krw(self, market: str, krw_amount: float) -> dict[str, Any]:
|
||||
"""시장가 매수 (원화 금액).
|
||||
|
||||
Args:
|
||||
market: 예) KRW-BTC.
|
||||
krw_amount: 매수 원화 금액.
|
||||
|
||||
Returns:
|
||||
주문 응답 dict.
|
||||
"""
|
||||
params = {
|
||||
"market": market,
|
||||
"side": "bid",
|
||||
"price": str(int(krw_amount)),
|
||||
"ord_type": "price",
|
||||
}
|
||||
return self._request("POST", "/v1/orders", params=params)
|
||||
|
||||
def market_sell_volume(self, market: str, volume: float) -> dict[str, Any]:
|
||||
"""시장가 매도 (코인 수량).
|
||||
|
||||
Args:
|
||||
market: 예) KRW-BTC.
|
||||
volume: 매도 수량.
|
||||
|
||||
Returns:
|
||||
주문 응답 dict.
|
||||
"""
|
||||
params = {
|
||||
"market": market,
|
||||
"side": "ask",
|
||||
"volume": f"{volume:.8f}".rstrip("0").rstrip("."),
|
||||
"ord_type": "market",
|
||||
}
|
||||
return self._request("POST", "/v1/orders", params=params)
|
||||
Reference in New Issue
Block a user