5a4c770279
Settings improvements: - Reorganize config groups with logical ordering (server, auth, ai, trading, etc.) - Add description/tooltip for each config item with question mark icon - Add icon to each group header - Support i18n for descriptions (zh-CN, zh-TW, en-US) - Move order execution config (ORDER_MODE, MAKER_WAIT_SEC, MAKER_OFFSET_BPS) to env Commission fee fixes: - Fix fee extraction in exchange clients: Bybit, Coinbase, Kraken, Gate, Kucoin, Bitfinex - Properly accumulate and record commission fees in pending_order_worker - Add fee/fee_ccy fields to wait_for_fill returns Frontend updates: - Remove order_mode config from trading-assistant frontend (now uses env config) - Add sorted schema display by order field - Add tooltip with description on hover
202 lines
8.5 KiB
Python
202 lines
8.5 KiB
Python
"""
|
|
Coinbase Exchange (legacy, direct REST) client.
|
|
|
|
Auth headers:
|
|
- CB-ACCESS-KEY
|
|
- CB-ACCESS-SIGN = base64(hmac_sha256(base64_decode(secret), timestamp + method + request_path + body))
|
|
- CB-ACCESS-TIMESTAMP (seconds)
|
|
- CB-ACCESS-PASSPHRASE
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import hashlib
|
|
import hmac
|
|
import time
|
|
from typing import Any, Dict, Optional
|
|
|
|
from app.services.live_trading.base import BaseRestClient, LiveOrderResult, LiveTradingError
|
|
from app.services.live_trading.symbols import to_coinbase_product_id
|
|
|
|
|
|
class CoinbaseExchangeClient(BaseRestClient):
|
|
def __init__(
|
|
self,
|
|
*,
|
|
api_key: str,
|
|
secret_key: str,
|
|
passphrase: str,
|
|
base_url: str = "https://api.exchange.coinbase.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()
|
|
self.passphrase = (passphrase or "").strip()
|
|
if not self.api_key or not self.secret_key or not self.passphrase:
|
|
raise LiveTradingError("Missing CoinbaseExchange api_key/secret_key/passphrase")
|
|
|
|
try:
|
|
self._secret_bytes = base64.b64decode(self.secret_key)
|
|
except Exception as e:
|
|
raise LiveTradingError(f"Invalid CoinbaseExchange secret_key (base64 decode failed): {e}")
|
|
|
|
def _sign(self, message: str) -> str:
|
|
mac = hmac.new(self._secret_bytes, message.encode("utf-8"), hashlib.sha256).digest()
|
|
return base64.b64encode(mac).decode("utf-8")
|
|
|
|
def _headers(self, ts: str, sign: str) -> Dict[str, str]:
|
|
return {
|
|
"CB-ACCESS-KEY": self.api_key,
|
|
"CB-ACCESS-SIGN": sign,
|
|
"CB-ACCESS-TIMESTAMP": ts,
|
|
"CB-ACCESS-PASSPHRASE": self.passphrase,
|
|
"Content-Type": "application/json",
|
|
}
|
|
|
|
def _signed_request(self, method: str, path: str, *, params: Optional[Dict[str, Any]] = None, json_body: Optional[Dict[str, Any]] = None) -> Any:
|
|
m = str(method or "GET").upper()
|
|
ts = str(int(time.time()))
|
|
body_str = self._json_dumps(json_body) if json_body is not None else ""
|
|
# Coinbase expects request_path to include query string for signature when GET params exist.
|
|
# We keep signature aligned with actual request params by relying on requests to encode params,
|
|
# but include them in the prehash in a stable order.
|
|
signed_path = path
|
|
if params:
|
|
# stable ordering
|
|
items = []
|
|
for k in sorted(params.keys()):
|
|
v = params.get(k)
|
|
if v is None:
|
|
continue
|
|
items.append(f"{k}={v}")
|
|
if items:
|
|
signed_path = f"{path}?{'&'.join(items)}"
|
|
prehash = f"{ts}{m}{signed_path}{body_str}"
|
|
sign = self._sign(prehash)
|
|
code, data, text = self._request(m, path, params=params, data=body_str if body_str else None, headers=self._headers(ts, sign))
|
|
if code >= 400:
|
|
raise LiveTradingError(f"CoinbaseExchange HTTP {code}: {text[:500]}")
|
|
return data
|
|
|
|
def _public_request(self, method: str, path: str, *, params: Optional[Dict[str, Any]] = None) -> Any:
|
|
code, data, text = self._request(method, path, params=params, headers=None, json_body=None, data=None)
|
|
if code >= 400:
|
|
raise LiveTradingError(f"CoinbaseExchange HTTP {code}: {text[:500]}")
|
|
return data
|
|
|
|
def ping(self) -> bool:
|
|
try:
|
|
_ = self._public_request("GET", "/time")
|
|
return True
|
|
except Exception:
|
|
return False
|
|
|
|
def get_accounts(self) -> Any:
|
|
return self._signed_request("GET", "/accounts")
|
|
|
|
def place_market_order(self, *, symbol: str, side: str, size: float, 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")
|
|
body: Dict[str, Any] = {
|
|
"product_id": to_coinbase_product_id(symbol),
|
|
"side": sd,
|
|
"type": "market",
|
|
"size": str(qty),
|
|
}
|
|
if client_order_id:
|
|
body["client_oid"] = str(client_order_id)
|
|
raw = self._signed_request("POST", "/orders", json_body=body)
|
|
oid = str(raw.get("id") or raw.get("order_id") or raw.get("client_oid") or "")
|
|
return LiveOrderResult(exchange_id="coinbaseexchange", exchange_order_id=oid, filled=0.0, avg_price=0.0, raw=raw if isinstance(raw, dict) else {"raw": raw})
|
|
|
|
def place_limit_order(self, *, symbol: str, side: str, size: float, price: float, 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")
|
|
body: Dict[str, Any] = {
|
|
"product_id": to_coinbase_product_id(symbol),
|
|
"side": sd,
|
|
"type": "limit",
|
|
"price": str(px),
|
|
"size": str(qty),
|
|
"time_in_force": "GTC",
|
|
}
|
|
if client_order_id:
|
|
body["client_oid"] = str(client_order_id)
|
|
raw = self._signed_request("POST", "/orders", json_body=body)
|
|
oid = str(raw.get("id") or raw.get("order_id") or raw.get("client_oid") or "")
|
|
return LiveOrderResult(exchange_id="coinbaseexchange", exchange_order_id=oid, filled=0.0, avg_price=0.0, raw=raw if isinstance(raw, dict) else {"raw": raw})
|
|
|
|
def cancel_order(self, *, order_id: str = "", client_order_id: str = "") -> Any:
|
|
if order_id:
|
|
return self._signed_request("DELETE", f"/orders/{str(order_id)}")
|
|
if client_order_id:
|
|
return self._signed_request("DELETE", f"/orders/client:{str(client_order_id)}")
|
|
raise LiveTradingError("CoinbaseExchange cancel_order requires order_id or client_order_id")
|
|
|
|
def get_order(self, *, order_id: str = "", client_order_id: str = "") -> Any:
|
|
if order_id:
|
|
return self._signed_request("GET", f"/orders/{str(order_id)}")
|
|
if client_order_id:
|
|
return self._signed_request("GET", f"/orders/client:{str(client_order_id)}")
|
|
raise LiveTradingError("CoinbaseExchange get_order requires order_id or client_order_id")
|
|
|
|
def wait_for_fill(
|
|
self,
|
|
*,
|
|
order_id: str = "",
|
|
client_order_id: str = "",
|
|
max_wait_sec: float = 10.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:
|
|
resp = self.get_order(order_id=str(order_id or ""), client_order_id=str(client_order_id or ""))
|
|
last = resp if isinstance(resp, dict) else {"raw": resp}
|
|
except Exception:
|
|
last = last or {}
|
|
status = str(last.get("status") or "")
|
|
filled = 0.0
|
|
avg_price = 0.0
|
|
fee = 0.0
|
|
fee_ccy = ""
|
|
try:
|
|
filled = float(last.get("filled_size") or 0.0)
|
|
except Exception:
|
|
filled = 0.0
|
|
try:
|
|
executed_value = float(last.get("executed_value") or 0.0)
|
|
if filled > 0 and executed_value > 0:
|
|
avg_price = executed_value / filled
|
|
except Exception:
|
|
avg_price = 0.0
|
|
# Extract fee from fill_fees (Coinbase API field)
|
|
try:
|
|
fee = abs(float(last.get("fill_fees") or 0.0))
|
|
except Exception:
|
|
fee = 0.0
|
|
# Coinbase fees are typically in the quote currency (e.g., 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 ("done", "rejected", "canceled", "cancelled"):
|
|
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))
|
|
|
|
|