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
262 lines
12 KiB
Python
262 lines
12 KiB
Python
"""
|
|
Bitfinex (direct REST) client (v2, exchange spot).
|
|
|
|
Auth headers:
|
|
- bfx-apikey
|
|
- bfx-nonce
|
|
- bfx-signature = hex(hmac_sha384(secret, "/api/v2" + path + nonce + body))
|
|
|
|
Notes:
|
|
- This client targets "exchange" (spot) order types: EXCHANGE MARKET / EXCHANGE LIMIT.
|
|
- Derivatives/perps are not fully implemented here; only best-effort spot execution.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
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_bitfinex_spot_symbol
|
|
from app.services.live_trading.symbols import to_bitfinex_perp_symbol
|
|
|
|
|
|
class BitfinexClient(BaseRestClient):
|
|
def __init__(self, *, api_key: str, secret_key: str, base_url: str = "https://api.bitfinex.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 Bitfinex api_key/secret_key")
|
|
|
|
def _nonce(self) -> str:
|
|
# Use ms; Bitfinex accepts monotonic increasing nonces.
|
|
return str(int(time.time() * 1000))
|
|
|
|
def _sign(self, path: str, nonce: str, body_str: str) -> str:
|
|
payload = f"/api/v2{path}{nonce}{body_str}"
|
|
return hmac.new(self.secret_key.encode("utf-8"), payload.encode("utf-8"), hashlib.sha384).hexdigest()
|
|
|
|
def _headers(self, nonce: str, sign: str) -> Dict[str, str]:
|
|
return {"bfx-apikey": self.api_key, "bfx-nonce": nonce, "bfx-signature": sign, "content-type": "application/json"}
|
|
|
|
def _signed_request(self, method: str, path: str, *, json_body: Optional[Dict[str, Any]] = None) -> Any:
|
|
m = str(method or "POST").upper()
|
|
nonce = self._nonce()
|
|
body_str = self._json_dumps(json_body) if json_body is not None else ""
|
|
sign = self._sign(path, nonce, body_str)
|
|
code, data, text = self._request(m, path, params=None, data=body_str if body_str else None, headers=self._headers(nonce, sign))
|
|
if code >= 400:
|
|
raise LiveTradingError(f"Bitfinex 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"Bitfinex HTTP {code}: {text[:500]}")
|
|
return data
|
|
|
|
def ping(self) -> bool:
|
|
try:
|
|
d = self._public_request("GET", "/v2/platform/status")
|
|
return isinstance(d, list) and d and int(d[0]) == 1
|
|
except Exception:
|
|
return False
|
|
|
|
def get_wallets(self) -> Any:
|
|
"""
|
|
Private endpoint to validate credentials (best-effort).
|
|
"""
|
|
return self._signed_request("POST", "/v2/auth/r/wallets", json_body={})
|
|
|
|
|
|
class BitfinexDerivativesClient(BitfinexClient):
|
|
"""
|
|
Bitfinex derivatives/perpetual client (best-effort).
|
|
|
|
Differences vs spot:
|
|
- Symbol uses tBASEF0:QUOTEF0 (e.g. tBTCF0:USTF0)
|
|
- Order type typically uses MARKET/LIMIT (not EXCHANGE MARKET/LIMIT)
|
|
"""
|
|
|
|
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")
|
|
sym = to_bitfinex_perp_symbol(symbol)
|
|
amt = qty if sd == "buy" else -qty
|
|
body: Dict[str, Any] = {"type": "MARKET", "symbol": sym, "amount": str(amt)}
|
|
if client_order_id:
|
|
try:
|
|
cid = int("".join([c for c in str(client_order_id) if c.isdigit()])[:18] or "0")
|
|
if cid > 0:
|
|
body["cid"] = cid
|
|
except Exception:
|
|
pass
|
|
raw = self._signed_request("POST", "/v2/auth/w/order/submit", json_body=body)
|
|
oid = ""
|
|
try:
|
|
if isinstance(raw, list) and len(raw) >= 4 and isinstance(raw[3], list) and raw[3]:
|
|
order = raw[3][0]
|
|
if isinstance(order, list) and order:
|
|
oid = str(order[0])
|
|
except Exception:
|
|
oid = ""
|
|
return LiveOrderResult(exchange_id="bitfinex", exchange_order_id=oid, filled=0.0, avg_price=0.0, raw={"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")
|
|
sym = to_bitfinex_perp_symbol(symbol)
|
|
amt = qty if sd == "buy" else -qty
|
|
body: Dict[str, Any] = {"type": "LIMIT", "symbol": sym, "amount": str(amt), "price": str(px)}
|
|
if client_order_id:
|
|
try:
|
|
cid = int("".join([c for c in str(client_order_id) if c.isdigit()])[:18] or "0")
|
|
if cid > 0:
|
|
body["cid"] = cid
|
|
except Exception:
|
|
pass
|
|
raw = self._signed_request("POST", "/v2/auth/w/order/submit", json_body=body)
|
|
oid = ""
|
|
try:
|
|
if isinstance(raw, list) and len(raw) >= 4 and isinstance(raw[3], list) and raw[3]:
|
|
order = raw[3][0]
|
|
if isinstance(order, list) and order:
|
|
oid = str(order[0])
|
|
except Exception:
|
|
oid = ""
|
|
return LiveOrderResult(exchange_id="bitfinex", exchange_order_id=oid, filled=0.0, avg_price=0.0, raw={"raw": raw})
|
|
|
|
def get_positions(self) -> Any:
|
|
return self._signed_request("POST", "/v2/auth/r/positions", json_body={})
|
|
|
|
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")
|
|
sym = to_bitfinex_spot_symbol(symbol)
|
|
amt = qty if sd == "buy" else -qty
|
|
body: Dict[str, Any] = {"type": "EXCHANGE MARKET", "symbol": sym, "amount": str(amt)}
|
|
if client_order_id:
|
|
# Bitfinex uses numeric cid; best-effort digits only
|
|
try:
|
|
cid = int("".join([c for c in str(client_order_id) if c.isdigit()])[:18] or "0")
|
|
if cid > 0:
|
|
body["cid"] = cid
|
|
except Exception:
|
|
pass
|
|
raw = self._signed_request("POST", "/v2/auth/w/order/submit", json_body=body)
|
|
oid = ""
|
|
try:
|
|
# Response is usually [.., [order_fields]]
|
|
if isinstance(raw, list) and len(raw) >= 4 and isinstance(raw[3], list) and raw[3]:
|
|
order = raw[3][0]
|
|
if isinstance(order, list) and order:
|
|
oid = str(order[0])
|
|
except Exception:
|
|
oid = ""
|
|
return LiveOrderResult(exchange_id="bitfinex", exchange_order_id=oid, filled=0.0, avg_price=0.0, raw={"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")
|
|
sym = to_bitfinex_spot_symbol(symbol)
|
|
amt = qty if sd == "buy" else -qty
|
|
body: Dict[str, Any] = {"type": "EXCHANGE LIMIT", "symbol": sym, "amount": str(amt), "price": str(px)}
|
|
if client_order_id:
|
|
try:
|
|
cid = int("".join([c for c in str(client_order_id) if c.isdigit()])[:18] or "0")
|
|
if cid > 0:
|
|
body["cid"] = cid
|
|
except Exception:
|
|
pass
|
|
raw = self._signed_request("POST", "/v2/auth/w/order/submit", json_body=body)
|
|
oid = ""
|
|
try:
|
|
if isinstance(raw, list) and len(raw) >= 4 and isinstance(raw[3], list) and raw[3]:
|
|
order = raw[3][0]
|
|
if isinstance(order, list) and order:
|
|
oid = str(order[0])
|
|
except Exception:
|
|
oid = ""
|
|
return LiveOrderResult(exchange_id="bitfinex", exchange_order_id=oid, filled=0.0, avg_price=0.0, raw={"raw": raw})
|
|
|
|
def cancel_order(self, *, order_id: str = "", client_order_id: str = "") -> Any:
|
|
if order_id:
|
|
try:
|
|
oid = int(float(order_id))
|
|
except Exception:
|
|
oid = 0
|
|
if oid <= 0:
|
|
raise LiveTradingError("Bitfinex cancel_order invalid order_id")
|
|
return self._signed_request("POST", "/v2/auth/w/order/cancel", json_body={"id": oid})
|
|
# Best-effort cancel by cid is possible via /auth/w/order/cancel/multi, but not implemented.
|
|
if client_order_id:
|
|
raise LiveTradingError("Bitfinex cancel by client_order_id is not implemented (requires cid date)")
|
|
raise LiveTradingError("Bitfinex cancel_order requires order_id")
|
|
|
|
def get_order(self, *, order_id: str) -> Any:
|
|
try:
|
|
oid = int(float(order_id))
|
|
except Exception:
|
|
oid = 0
|
|
if oid <= 0:
|
|
raise LiveTradingError("Bitfinex get_order invalid order_id")
|
|
# Bitfinex v2 order status endpoint
|
|
return self._signed_request("POST", f"/v2/auth/r/order/{oid}")
|
|
|
|
def wait_for_fill(self, *, 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: Any = None
|
|
while True:
|
|
try:
|
|
last = self.get_order(order_id=str(order_id))
|
|
except Exception:
|
|
last = last or []
|
|
filled = 0.0
|
|
avg_price = 0.0
|
|
fee = 0.0
|
|
fee_ccy = ""
|
|
status = ""
|
|
# best-effort parsing from array fields
|
|
# Bitfinex order response format: [ID, GID, CID, SYMBOL, MTS_CREATE, MTS_UPDATE, AMOUNT, AMOUNT_ORIG, TYPE, TYPE_PREV, MTS_TIF, _, FLAGS, STATUS, _, PRICE_AVG, ...]
|
|
try:
|
|
if isinstance(last, list) and len(last) >= 15:
|
|
status = str(last[13] or "")
|
|
amount_remaining = float(last[6] or 0.0)
|
|
amount_orig = float(last[7] or 0.0)
|
|
filled = abs(amount_orig - amount_remaining)
|
|
avg_price = float(last[14] or 0.0)
|
|
except Exception:
|
|
pass
|
|
# Note: Bitfinex order response doesn't include fee; fee is typically in trades.
|
|
# We return 0.0 here; actual fee can be fetched via trades endpoint if needed.
|
|
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 isinstance(status, str) and ("EXECUTED" in status.upper() or "CANCELED" in status.upper()):
|
|
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))
|
|
|
|
|