87f2845483
- Cleaned up whitespace and formatting in various files including http.py, language.py, logger.py, safe_exec.py, and SQL migration scripts. - Consolidated import statements and removed unnecessary blank lines. - Updated logging configuration for better clarity. - Enhanced the safe execution code with improved error handling and logging. - Removed commented-out code and unnecessary variables in backfill_zero_trades.py and other scripts. - Added a pyproject.toml for Ruff and Vulture configuration. - Introduced requirements-dev.txt for development dependencies. - Removed commented-out stock entries in init.sql for cleaner migration scripts.
257 lines
9.7 KiB
Python
257 lines
9.7 KiB
Python
"""
|
|
Kraken Futures (direct REST) client.
|
|
|
|
Kraken Futures (formerly CryptoFacilities) uses a different API than Kraken spot.
|
|
Base URL example: https://futures.kraken.com
|
|
API prefix: /derivatives/api/v3
|
|
|
|
Auth (best-effort):
|
|
- APIKey: <api key>
|
|
- Nonce: <milliseconds>
|
|
- Authent: base64(hmac_sha256(secret, nonce + postdata + endpoint_path))
|
|
|
|
IMPORTANT:
|
|
- Instruments are exchange-specific (e.g. PF_XBTUSD, PI_XBTUSD). This project will pass through
|
|
those symbols if you choose them in UI, or best-effort map BTC/USDT -> PF_XBTUSD.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import hashlib
|
|
import hmac
|
|
import time
|
|
from typing import Any, Dict, Optional
|
|
from urllib.parse import urlencode
|
|
|
|
from app.services.live_trading.base import BaseRestClient, LiveOrderResult, LiveTradingError
|
|
from app.services.live_trading.symbols import to_kraken_futures_symbol
|
|
|
|
|
|
class KrakenFuturesClient(BaseRestClient):
|
|
def __init__(
|
|
self, *, api_key: str, secret_key: str, base_url: str = "https://futures.kraken.com", timeout_sec: float = 15.0
|
|
):
|
|
super().__init__(base_url=base_url, timeout_sec=timeout_sec)
|
|
self.api_key = (api_key or "").strip()
|
|
self.secret_key = (secret_key or "").strip()
|
|
if not self.api_key or not self.secret_key:
|
|
raise LiveTradingError("Missing KrakenFutures api_key/secret_key")
|
|
|
|
def _b64_hmac_sha256(self, msg: str) -> str:
|
|
mac = hmac.new(self.secret_key.encode("utf-8"), msg.encode("utf-8"), hashlib.sha256).digest()
|
|
return base64.b64encode(mac).decode("utf-8")
|
|
|
|
def _headers(self, nonce: str, authent: str) -> Dict[str, str]:
|
|
return {
|
|
"APIKey": self.api_key,
|
|
"Nonce": nonce,
|
|
"Authent": authent,
|
|
"Content-Type": "application/x-www-form-urlencoded",
|
|
}
|
|
|
|
def _signed_request(self, method: str, path: str, *, data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
m = str(method or "POST").upper()
|
|
# Kraken Futures private endpoints often use POST.
|
|
nonce = str(int(time.time() * 1000))
|
|
body = dict(data or {})
|
|
postdata = urlencode(body, doseq=True) if body else ""
|
|
# Sign with endpoint path (not including domain)
|
|
prehash = f"{nonce}{postdata}{path}"
|
|
authent = self._b64_hmac_sha256(prehash)
|
|
code, resp, text = self._request(
|
|
m,
|
|
path,
|
|
params=None,
|
|
json_body=None,
|
|
data=postdata if postdata else None,
|
|
headers=self._headers(nonce, authent),
|
|
)
|
|
if code >= 400:
|
|
raise LiveTradingError(f"KrakenFutures HTTP {code}: {text[:500]}")
|
|
if isinstance(resp, dict):
|
|
# Futures API often uses "result":"success"/"error" or "errors"
|
|
if str(resp.get("result") or "").lower() == "error" or resp.get("errors"):
|
|
raise LiveTradingError(f"KrakenFutures error: {resp}")
|
|
return resp if isinstance(resp, dict) else {"raw": resp}
|
|
|
|
def _public_request(self, method: str, path: str, *, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
code, resp, text = self._request(method, path, params=params, headers=None, json_body=None, data=None)
|
|
if code >= 400:
|
|
raise LiveTradingError(f"KrakenFutures HTTP {code}: {text[:500]}")
|
|
return resp if isinstance(resp, dict) else {"raw": resp}
|
|
|
|
def ping(self) -> bool:
|
|
try:
|
|
_ = self._public_request("GET", "/derivatives/api/v3/tickers")
|
|
return True
|
|
except Exception:
|
|
return False
|
|
|
|
def get_accounts(self) -> Dict[str, Any]:
|
|
# Best-effort private endpoint (varies by account type)
|
|
return self._signed_request("GET", "/derivatives/api/v3/accounts")
|
|
|
|
def get_open_positions(self) -> Dict[str, Any]:
|
|
return self._signed_request("GET", "/derivatives/api/v3/openpositions")
|
|
|
|
def place_market_order(
|
|
self,
|
|
*,
|
|
symbol: str,
|
|
side: str,
|
|
size: float,
|
|
reduce_only: bool = False,
|
|
client_order_id: Optional[str] = None,
|
|
) -> LiveOrderResult:
|
|
sd = (side or "").strip().lower()
|
|
if sd not in ("buy", "sell"):
|
|
raise LiveTradingError(f"Invalid side: {side}")
|
|
qty = float(size or 0.0)
|
|
if qty <= 0:
|
|
raise LiveTradingError("Invalid size")
|
|
instr = to_kraken_futures_symbol(symbol)
|
|
body: Dict[str, Any] = {
|
|
"orderType": "mkt",
|
|
"symbol": str(instr),
|
|
"side": sd,
|
|
# Kraken Futures uses "size" in contracts; we treat incoming size as "contracts" for now.
|
|
"size": str(qty),
|
|
}
|
|
if reduce_only:
|
|
body["reduceOnly"] = "true"
|
|
if client_order_id:
|
|
body["cliOrdId"] = str(client_order_id)[:32]
|
|
raw = self._signed_request("POST", "/derivatives/api/v3/sendorder", data=body)
|
|
oid = (
|
|
str((raw.get("sendStatus") or {}).get("order_id") or (raw.get("order_id") or ""))
|
|
if isinstance(raw, dict)
|
|
else ""
|
|
)
|
|
return LiveOrderResult(exchange_id="kraken", exchange_order_id=oid, filled=0.0, avg_price=0.0, raw=raw)
|
|
|
|
def place_limit_order(
|
|
self,
|
|
*,
|
|
symbol: str,
|
|
side: str,
|
|
size: float,
|
|
price: float,
|
|
reduce_only: bool = False,
|
|
post_only: bool = False,
|
|
client_order_id: Optional[str] = None,
|
|
) -> LiveOrderResult:
|
|
sd = (side or "").strip().lower()
|
|
if sd not in ("buy", "sell"):
|
|
raise LiveTradingError(f"Invalid side: {side}")
|
|
qty = float(size or 0.0)
|
|
px = float(price or 0.0)
|
|
if qty <= 0 or px <= 0:
|
|
raise LiveTradingError("Invalid size/price")
|
|
instr = to_kraken_futures_symbol(symbol)
|
|
body: Dict[str, Any] = {
|
|
"orderType": "lmt",
|
|
"symbol": str(instr),
|
|
"side": sd,
|
|
"size": str(qty),
|
|
"limitPrice": str(px),
|
|
}
|
|
if reduce_only:
|
|
body["reduceOnly"] = "true"
|
|
if post_only:
|
|
body["postOnly"] = "true"
|
|
if client_order_id:
|
|
body["cliOrdId"] = str(client_order_id)[:32]
|
|
raw = self._signed_request("POST", "/derivatives/api/v3/sendorder", data=body)
|
|
oid = (
|
|
str((raw.get("sendStatus") or {}).get("order_id") or (raw.get("order_id") or ""))
|
|
if isinstance(raw, dict)
|
|
else ""
|
|
)
|
|
return LiveOrderResult(exchange_id="kraken", exchange_order_id=oid, filled=0.0, avg_price=0.0, raw=raw)
|
|
|
|
def cancel_order(self, *, order_id: str = "", client_order_id: str = "") -> Dict[str, Any]:
|
|
body: Dict[str, Any] = {}
|
|
if order_id:
|
|
body["order_id"] = str(order_id)
|
|
elif client_order_id:
|
|
body["cliOrdId"] = str(client_order_id)
|
|
else:
|
|
raise LiveTradingError("KrakenFutures cancel_order requires order_id or client_order_id")
|
|
return self._signed_request("POST", "/derivatives/api/v3/cancelorder", data=body)
|
|
|
|
def get_order(self, *, order_id: str = "", client_order_id: str = "") -> Dict[str, Any]:
|
|
params: Dict[str, Any] = {}
|
|
if order_id:
|
|
params["order_id"] = str(order_id)
|
|
elif client_order_id:
|
|
params["cliOrdId"] = str(client_order_id)
|
|
else:
|
|
raise LiveTradingError("KrakenFutures get_order requires order_id or client_order_id")
|
|
return self._signed_request("GET", "/derivatives/api/v3/order", data=params)
|
|
|
|
def wait_for_fill(
|
|
self,
|
|
*,
|
|
order_id: str = "",
|
|
client_order_id: str = "",
|
|
max_wait_sec: float = 3.0,
|
|
poll_interval_sec: float = 0.5,
|
|
) -> Dict[str, Any]:
|
|
end_ts = time.time() + float(max_wait_sec or 0.0)
|
|
last: Dict[str, Any] = {}
|
|
while True:
|
|
try:
|
|
last = self.get_order(order_id=str(order_id or ""), client_order_id=str(client_order_id or ""))
|
|
except Exception:
|
|
last = last or {}
|
|
status = str(last.get("status") or last.get("orderStatus") or "")
|
|
filled = 0.0
|
|
avg_price = 0.0
|
|
fee = 0.0
|
|
fee_ccy = ""
|
|
try:
|
|
filled = float(last.get("filledSize") or last.get("filled_size") or 0.0)
|
|
except Exception:
|
|
filled = 0.0
|
|
try:
|
|
avg_price = float(last.get("avgFillPrice") or last.get("avg_fill_price") or 0.0)
|
|
except Exception:
|
|
avg_price = 0.0
|
|
# Extract fee from Kraken Futures API (if available)
|
|
try:
|
|
fee = abs(float(last.get("fee") or 0.0))
|
|
except Exception:
|
|
fee = 0.0
|
|
# Kraken Futures fees are typically in USD
|
|
if fee > 0:
|
|
fee_ccy = "USD"
|
|
if filled > 0 and avg_price > 0:
|
|
return {
|
|
"filled": filled,
|
|
"avg_price": avg_price,
|
|
"fee": fee,
|
|
"fee_ccy": fee_ccy,
|
|
"status": status,
|
|
"order": last,
|
|
}
|
|
if status.lower() in ("filled", "cancelled", "canceled", "rejected"):
|
|
return {
|
|
"filled": filled,
|
|
"avg_price": avg_price,
|
|
"fee": fee,
|
|
"fee_ccy": fee_ccy,
|
|
"status": status,
|
|
"order": last,
|
|
}
|
|
if time.time() >= end_ts:
|
|
return {
|
|
"filled": filled,
|
|
"avg_price": avg_price,
|
|
"fee": fee,
|
|
"fee_ccy": fee_ccy,
|
|
"status": status,
|
|
"order": last,
|
|
}
|
|
time.sleep(float(poll_interval_sec or 0.5))
|