diff --git a/backend_api_python/DEPLOY.md b/backend_api_python/DEPLOY.md deleted file mode 100644 index 998ab6b..0000000 --- a/backend_api_python/DEPLOY.md +++ /dev/null @@ -1,230 +0,0 @@ -# Python API 部署说明 - -## 1. 环境准备 - -### 安装 Python 3.8+ - -```bash -# Ubuntu/Debian -sudo apt-get update -sudo apt-get install python3 python3-pip python3-venv - -# CentOS/RHEL -sudo yum install python3 python3-pip -``` - -### 创建虚拟环境(推荐) - -```bash -cd backend_api_python -python3 -m venv venv -source venv/bin/activate # Linux/Mac -# 或 -venv\Scripts\activate # Windows -``` - -### 安装依赖 - -```bash -pip install -r requirements.txt -``` - -## 2. 开发环境运行 - -```bash -python run.py -``` - -服务将在 `http://localhost:5000` 启动 - -## 3. 生产环境部署 - -### 使用 Gunicorn - -```bash -# 安装 gunicorn(已在 requirements.txt 中) -pip install gunicorn - -# 启动服务 -gunicorn -c gunicorn_config.py "run:app" - -# 或使用命令行参数 -gunicorn -w 4 -b 0.0.0.0:5000 --timeout 120 "run:app" -``` - -### 使用 Supervisor 管理进程 - -创建 `/etc/supervisor/conf.d/quantdinger_python_api.conf`: - -```ini -[program:quantdinger_python_api] -command=/path/to/venv/bin/gunicorn -c /path/to/gunicorn_config.py "run:app" -directory=/path/to/backend_api_python -user=www-data -autostart=true -autorestart=true -redirect_stderr=true -stdout_logfile=/path/to/logs/supervisor.log -``` - -启动: - -```bash -sudo supervisorctl reread -sudo supervisorctl update -sudo supervisorctl start quantdinger_python_api -``` - -### 使用 Systemd 管理服务 - -创建 `/etc/systemd/system/quantdinger-python-api.service`: - -```ini -[Unit] -Description=QuantDinger Python API Service -After=network.target - -[Service] -Type=notify -User=www-data -Group=www-data -WorkingDirectory=/path/to/backend_api_python -Environment="PATH=/path/to/venv/bin" -ExecStart=/path/to/venv/bin/gunicorn -c /path/to/gunicorn_config.py "run:app" -Restart=always -RestartSec=10 - -[Install] -WantedBy=multi-user.target -``` - -启动: - -```bash -sudo systemctl daemon-reload -sudo systemctl enable quantdinger-python-api -sudo systemctl start quantdinger-python-api -sudo systemctl status quantdinger-python-api -``` - -## 4. Nginx 反向代理配置 - -在 Nginx 配置文件中添加: - -```nginx -upstream python_api { - server 127.0.0.1:5000; - keepalive 32; -} - -server { - listen 80; - server_name api-python.quantdinger.com; - - location / { - proxy_pass http://python_api; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - - # WebSocket 支持(如果需要) - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection "upgrade"; - - # 超时设置 - proxy_connect_timeout 60s; - proxy_send_timeout 60s; - proxy_read_timeout 60s; - } -} -``` - -## 5. PHP 配置 -(已移除)本仓库当前不包含 PHP 网关/后台服务,前端开发环境通过 `quantdinger_vue/vue.config.js` 将 `/api` 代理到 Python 服务即可。 - -## 6. 日志管理 - -创建日志目录: - -```bash -mkdir -p logs -chmod 755 logs -``` - -日志文件: -- `logs/access.log` - 访问日志 -- `logs/error.log` - 错误日志 -- `logs/gunicorn.pid` - Gunicorn 进程ID - -## 7. 监控和健康检查 - -### 健康检查接口 - -```bash -curl http://localhost:5000/health -``` - -### 监控脚本示例 - -```bash -#!/bin/bash -# check_api.sh - -API_URL="http://localhost:5000/health" -RESPONSE=$(curl -s -o /dev/null -w "%{http_code}" $API_URL) - -if [ $RESPONSE -ne 200 ]; then - echo "API 服务异常,状态码: $RESPONSE" - # 发送告警通知 - # 重启服务 - systemctl restart quantdinger-python-api -fi -``` - -## 8. 常见问题 - -### AKSHARE 安装失败 - -```bash -# 可能需要安装系统依赖 -sudo apt-get install build-essential -pip install akshare --upgrade -``` - -### 端口被占用 - -```bash -# 查看端口占用 -lsof -i :5000 -# 或 -netstat -tulpn | grep 5000 - -# 修改端口 -export PYTHON_API_PORT=5001 -``` - -### 权限问题 - -```bash -# 确保日志目录有写权限 -chown -R www-data:www-data logs/ -chmod -R 755 logs/ -``` - -## 9. 性能优化 - -1. **增加 Worker 数量**:根据 CPU 核心数调整 -2. **使用异步 Worker**:`worker_class = "gevent"`(需要安装 gevent) -3. **启用缓存**:使用 Redis 缓存指数数据 -4. **数据库连接池**:如果使用数据库,配置连接池 - -## 10. 安全建议 - -1. 使用 HTTPS -2. 配置防火墙规则 -3. 限制 API 访问频率 -4. 使用 API Key 认证(如果需要) -5. 定期更新依赖包 - diff --git a/backend_api_python/app/routes/dashboard.py b/backend_api_python/app/routes/dashboard.py index 2b4677d..fc68182 100644 --- a/backend_api_python/app/routes/dashboard.py +++ b/backend_api_python/app/routes/dashboard.py @@ -107,7 +107,7 @@ def summary(): cur = db.cursor() cur.execute( """ - SELECT id, strategy_name, strategy_type, status, initial_capital + SELECT id, strategy_name, strategy_type, status, initial_capital, trading_config FROM qd_strategies_trading """ ) @@ -116,7 +116,23 @@ def summary(): running = [s for s in strategies if (s.get("status") or "").strip().lower() == "running"] indicator_strategy_count = len([s for s in running if (s.get("strategy_type") or "") == "IndicatorStrategy"]) - ai_strategy_count = max(0, len(running) - indicator_strategy_count) + + # "AI strategies" in dashboard card: count strategies that enabled AI analysis/filtering. + # This aligns with the UI toggle `enable_ai_filter` in trading_config. + def _truthy(v: Any) -> bool: + if v is True: + return True + if isinstance(v, (int, float)) and float(v) == 1: + return True + if isinstance(v, str) and v.strip().lower() in ("1", "true", "yes", "y", "on"): + return True + return False + + ai_enabled_strategy_count = 0 + for s in strategies: + tc = _safe_json_loads(s.get("trading_config"), {}) or {} + if isinstance(tc, dict) and _truthy(tc.get("enable_ai_filter")): + ai_enabled_strategy_count += 1 # Positions (best-effort) with get_db_connection() as db: @@ -212,7 +228,7 @@ def summary(): "code": 1, "msg": "success", "data": { - "ai_strategy_count": int(ai_strategy_count), + "ai_strategy_count": int(ai_enabled_strategy_count), "indicator_strategy_count": int(indicator_strategy_count), "total_equity": float(total_equity), "total_pnl": float(total_pnl), diff --git a/backend_api_python/app/routes/strategy.py b/backend_api_python/app/routes/strategy.py index c75212f..e3cc878 100644 --- a/backend_api_python/app/routes/strategy.py +++ b/backend_api_python/app/routes/strategy.py @@ -116,7 +116,7 @@ def get_trades(): cur = db.cursor() cur.execute( """ - SELECT id, strategy_id, symbol, type, price, amount, value, commission, profit, created_at + SELECT id, strategy_id, symbol, type, price, amount, value, commission, commission_ccy, profit, created_at FROM qd_strategy_trades WHERE strategy_id = ? ORDER BY id DESC diff --git a/backend_api_python/app/services/live_trading/binance.py b/backend_api_python/app/services/live_trading/binance.py index a70ca05..56e4b64 100644 --- a/backend_api_python/app/services/live_trading/binance.py +++ b/backend_api_python/app/services/live_trading/binance.py @@ -254,6 +254,82 @@ class BinanceFuturesClient(BaseRestClient): """ return self._signed_request("GET", "/fapi/v2/account", params={}) + def get_user_trades(self, *, symbol: str, order_id: str = "", limit: int = 100) -> Any: + """ + Fetch user trades (fills). + + Endpoint: GET /fapi/v1/userTrades + + Note: Binance order endpoints do NOT include commissions; commissions live on fills. + """ + sym = to_binance_futures_symbol(symbol) + if not sym: + return [] + params: Dict[str, Any] = {"symbol": sym} + if order_id: + # Binance expects numeric orderId; keep as string and let server validate. + params["orderId"] = str(order_id) + try: + lim = int(limit or 100) + except Exception: + lim = 100 + lim = max(1, min(1000, lim)) + params["limit"] = lim + data = self._signed_request("GET", "/fapi/v1/userTrades", params=params) + return data + + def get_fee_for_order(self, *, symbol: str, order_id: str) -> Tuple[float, str]: + """ + Best-effort: sum commissions from fills for a specific order. + + Returns: (total_fee, fee_ccy) + """ + try: + trades = self.get_user_trades(symbol=symbol, order_id=str(order_id or ""), limit=200) + except Exception: + trades = [] + if not isinstance(trades, list): + return 0.0, "" + total_fee = 0.0 + fee_ccy = "" + for t in trades: + if not isinstance(t, dict): + continue + try: + fee = float(t.get("commission") or 0.0) + except Exception: + fee = 0.0 + ccy = str(t.get("commissionAsset") or "").strip() + if fee != 0.0: + total_fee += abs(float(fee)) + if (not fee_ccy) and ccy: + fee_ccy = ccy + return float(total_fee), str(fee_ccy or "") + + def set_leverage(self, *, symbol: str, leverage: float) -> Dict[str, Any]: + """ + Set futures leverage for a symbol (USDT-M). + + Endpoint: POST /fapi/v1/leverage + + Notes: + - Binance applies leverage per symbol. + - If leverage is not set, the exchange may keep a default (often 1x), + which will change the actual margin used for a given notional. + """ + sym = to_binance_futures_symbol(symbol) + if not sym: + raise LiveTradingError(f"Invalid symbol: {symbol}") + try: + lev = int(float(leverage or 1.0)) + except Exception: + lev = 1 + if lev < 1: + lev = 1 + if lev > 125: + lev = 125 + return self._signed_request("POST", "/fapi/v1/leverage", params={"symbol": sym, "leverage": lev}) + def get_dual_side_position(self) -> Optional[bool]: """ Best-effort read of position mode: diff --git a/backend_api_python/app/services/live_trading/binance_spot.py b/backend_api_python/app/services/live_trading/binance_spot.py index b132da7..4288c41 100644 --- a/backend_api_python/app/services/live_trading/binance_spot.py +++ b/backend_api_python/app/services/live_trading/binance_spot.py @@ -322,6 +322,55 @@ class BinanceSpotClient(BaseRestClient): """ return self._signed_request("GET", "/api/v3/account", params={}) + def get_my_trades(self, *, symbol: str, order_id: str = "", limit: int = 100) -> Any: + """ + Fetch spot trade fills. + + Endpoint: GET /api/v3/myTrades + """ + sym = to_binance_futures_symbol(symbol) + if not sym: + return [] + params: Dict[str, Any] = {"symbol": sym} + if order_id: + params["orderId"] = str(order_id) + try: + lim = int(limit or 100) + except Exception: + lim = 100 + lim = max(1, min(1000, lim)) + params["limit"] = lim + data = self._signed_request("GET", "/api/v3/myTrades", params=params) + return data + + def get_fee_for_order(self, *, symbol: str, order_id: str) -> Tuple[float, str]: + """ + Best-effort: sum commissions from fills for a specific spot order. + + Returns: (total_fee, fee_ccy) + """ + try: + trades = self.get_my_trades(symbol=symbol, order_id=str(order_id or ""), limit=200) + except Exception: + trades = [] + if not isinstance(trades, list): + return 0.0, "" + total_fee = 0.0 + fee_ccy = "" + for t in trades: + if not isinstance(t, dict): + continue + try: + fee = float(t.get("commission") or 0.0) + except Exception: + fee = 0.0 + ccy = str(t.get("commissionAsset") or "").strip() + if fee != 0.0: + total_fee += abs(float(fee)) + if (not fee_ccy) and ccy: + fee_ccy = ccy + return float(total_fee), str(fee_ccy or "") + def cancel_order(self, *, symbol: str, order_id: str = "", client_order_id: str = "") -> Dict[str, Any]: sym = to_binance_futures_symbol(symbol) params: Dict[str, Any] = {"symbol": sym} diff --git a/backend_api_python/app/services/live_trading/bitfinex.py b/backend_api_python/app/services/live_trading/bitfinex.py new file mode 100644 index 0000000..d5c695f --- /dev/null +++ b/backend_api_python/app/services/live_trading/bitfinex.py @@ -0,0 +1,256 @@ +""" +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 + status = "" + # best-effort parsing from array fields + 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 + if filled > 0 and avg_price > 0: + return {"filled": filled, "avg_price": avg_price, "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, "status": status, "order": last} + if time.time() >= end_ts: + return {"filled": filled, "avg_price": avg_price, "status": status, "order": last} + time.sleep(float(poll_interval_sec or 0.5)) + + diff --git a/backend_api_python/app/services/live_trading/bitget_spot.py b/backend_api_python/app/services/live_trading/bitget_spot.py index c83b9da..1afac2d 100644 --- a/backend_api_python/app/services/live_trading/bitget_spot.py +++ b/backend_api_python/app/services/live_trading/bitget_spot.py @@ -316,6 +316,8 @@ class BitgetSpotClient(BaseRestClient): fills = data if isinstance(data, list) else [] total_base = 0.0 total_quote = 0.0 + total_fee = 0.0 + fee_ccy = "" if isinstance(fills, list): for f in fills: try: @@ -324,10 +326,33 @@ class BitgetSpotClient(BaseRestClient): if sz > 0 and px > 0: total_base += sz total_quote += sz * px + # Best-effort fee extraction (fields vary by endpoint/version). + fee_v = f.get("fee") + if fee_v is None: + fee_v = f.get("fillFee") + if fee_v is None: + fee_v = f.get("tradeFee") + try: + fee = float(fee_v or 0.0) + except Exception: + fee = 0.0 + ccy = str(f.get("feeCoin") or f.get("feeCcy") or f.get("fillFeeCoin") or f.get("fillFeeCcy") or "").strip() + if fee != 0.0: + total_fee += abs(float(fee)) + if (not fee_ccy) and ccy: + fee_ccy = ccy except Exception: continue if total_base > 0 and total_quote > 0: - return {"filled": total_base, "avg_price": total_quote / total_base, "state": state, "order": last_order, "fills": last_fills} + return { + "filled": total_base, + "avg_price": total_quote / total_base, + "fee": float(total_fee), + "fee_ccy": str(fee_ccy or ""), + "state": state, + "order": last_order, + "fills": last_fills + } except Exception: pass diff --git a/backend_api_python/app/services/live_trading/bybit.py b/backend_api_python/app/services/live_trading/bybit.py new file mode 100644 index 0000000..001d161 --- /dev/null +++ b/backend_api_python/app/services/live_trading/bybit.py @@ -0,0 +1,350 @@ +""" +Bybit (direct REST) client for spot / linear perpetual orders (v5). + +Signing (v5): +- X-BAPI-SIGN = hex(hmac_sha256(secret, timestamp + api_key + recv_window + payload)) +- payload: + - GET: query string (sorted, urlencoded) + - POST: raw body string +""" + +from __future__ import annotations + +import hashlib +import hmac +import time +from decimal import Decimal, ROUND_DOWN +from typing import Any, Dict, Optional, Tuple +from urllib.parse import urlencode + +from app.services.live_trading.base import BaseRestClient, LiveOrderResult, LiveTradingError +from app.services.live_trading.symbols import to_bybit_symbol + + +class BybitClient(BaseRestClient): + def __init__( + self, + *, + api_key: str, + secret_key: str, + base_url: str = "https://api.bybit.com", + timeout_sec: float = 15.0, + category: str = "linear", # "linear" (USDT perpetual) or "spot" + recv_window_ms: int = 5000, + ): + 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.category = (category or "linear").strip().lower() + if self.category not in ("linear", "spot"): + self.category = "linear" + try: + self.recv_window_ms = int(recv_window_ms or 5000) + except Exception: + self.recv_window_ms = 5000 + if self.recv_window_ms <= 0: + self.recv_window_ms = 5000 + + if not self.api_key or not self.secret_key: + raise LiveTradingError("Missing Bybit api_key/secret_key") + + # Best-effort cache for linear instrument metadata (qty step, min qty, etc.) + # Key: f"{category}:{symbol}" -> (fetched_at_ts, info_dict) + self._inst_cache: Dict[str, Tuple[float, Dict[str, Any]]] = {} + self._inst_cache_ttl_sec = 300.0 + + @staticmethod + def _to_dec(x: Any) -> Decimal: + try: + return Decimal(str(x)) + except Exception: + return Decimal("0") + + @staticmethod + def _dec_str(d: Decimal) -> str: + try: + return format(d, "f") + except Exception: + return str(d) + + @staticmethod + def _floor_to_step(value: Decimal, step: Decimal) -> Decimal: + if step is None: + return value + if value <= 0: + return Decimal("0") + try: + st = Decimal(step) + except Exception: + st = Decimal("0") + if st <= 0: + return value + try: + n = (value / st).to_integral_value(rounding=ROUND_DOWN) + return n * st + except Exception: + return Decimal("0") + + def _sign(self, prehash: str) -> str: + return hmac.new(self.secret_key.encode("utf-8"), prehash.encode("utf-8"), hashlib.sha256).hexdigest() + + def _headers(self, ts_ms: str, sign: str) -> Dict[str, str]: + return { + "X-BAPI-API-KEY": self.api_key, + "X-BAPI-SIGN": sign, + "X-BAPI-TIMESTAMP": ts_ms, + "X-BAPI-RECV-WINDOW": str(self.recv_window_ms), + "X-BAPI-SIGN-TYPE": "2", + "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, + ) -> Dict[str, Any]: + m = str(method or "GET").upper() + ts_ms = str(int(time.time() * 1000)) + + body_str = self._json_dumps(json_body) if json_body is not None else "" + qs = "" + if params: + norm = {str(k): "" if v is None else str(v) for k, v in dict(params).items()} + qs = urlencode(sorted(norm.items()), doseq=True) + + payload = qs if m == "GET" else body_str + prehash = f"{ts_ms}{self.api_key}{self.recv_window_ms}{payload}" + sign = self._sign(prehash) + + code, data, text = self._request( + m, + path, + params=params if (m == "GET" and params) else (params or None), + data=body_str if body_str else None, + headers=self._headers(ts_ms, sign), + ) + if code >= 400: + raise LiveTradingError(f"Bybit HTTP {code}: {text[:500]}") + if isinstance(data, dict): + rc = data.get("retCode") + if rc not in (0, "0", None, ""): + raise LiveTradingError(f"Bybit error: {data}") + return data if isinstance(data, dict) else {"raw": data} + + def _public_request(self, method: str, path: str, *, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + code, data, text = self._request(method, path, params=params, headers=None, json_body=None, data=None) + if code >= 400: + raise LiveTradingError(f"Bybit HTTP {code}: {text[:500]}") + if isinstance(data, dict): + rc = data.get("retCode") + if rc not in (0, "0", None, ""): + raise LiveTradingError(f"Bybit error: {data}") + return data if isinstance(data, dict) else {"raw": data} + + def ping(self) -> bool: + try: + data = self._public_request("GET", "/v5/market/time") + return isinstance(data, dict) and (data.get("retCode") in (0, "0", None, "")) + except Exception: + return False + + def get_wallet_balance(self, *, account_type: str = "UNIFIED") -> Dict[str, Any]: + return self._signed_request("GET", "/v5/account/wallet-balance", params={"accountType": str(account_type or "UNIFIED")}) + + def get_instrument_info(self, *, category: str, symbol: str) -> Dict[str, Any]: + cat = str(category or self.category or "linear").strip().lower() + sym = to_bybit_symbol(symbol) + if not sym: + return {} + key = f"{cat}:{sym}" + now = time.time() + cached = self._inst_cache.get(key) + if cached: + ts, obj = cached + if obj and (now - float(ts or 0.0)) <= float(self._inst_cache_ttl_sec or 300.0): + return obj + raw = self._public_request("GET", "/v5/market/instruments-info", params={"category": cat, "symbol": sym}) + lst = (((raw.get("result") or {}).get("list")) if isinstance(raw, dict) else None) or [] + first: Dict[str, Any] = lst[0] if isinstance(lst, list) and lst else {} + if isinstance(first, dict) and first: + self._inst_cache[key] = (now, first) + return first if isinstance(first, dict) else {} + + def _normalize_qty(self, *, symbol: str, qty: float) -> Decimal: + q = self._to_dec(qty) + if q <= 0: + return Decimal("0") + sym = to_bybit_symbol(symbol) + try: + info = self.get_instrument_info(category=self.category, symbol=sym) or {} + except Exception: + info = {} + lot = (info.get("lotSizeFilter") if isinstance(info, dict) else None) or {} + step = self._to_dec((lot or {}).get("qtyStep") or "0") + mn = self._to_dec((lot or {}).get("minOrderQty") or "0") + if step > 0: + q = self._floor_to_step(q, step) + if mn > 0 and q < mn: + return Decimal("0") + return q + + def place_market_order( + self, + *, + symbol: str, + side: str, + qty: float, + reduce_only: bool = False, + client_order_id: Optional[str] = None, + ) -> LiveOrderResult: + sym = to_bybit_symbol(symbol) + sd = (side or "").strip().lower() + if sd not in ("buy", "sell"): + raise LiveTradingError(f"Invalid side: {side}") + q_req = float(qty or 0.0) + q_dec = self._normalize_qty(symbol=symbol, qty=q_req) + if float(q_dec or 0) <= 0: + raise LiveTradingError(f"Invalid qty (below step/min): requested={q_req}") + body: Dict[str, Any] = { + "category": self.category, + "symbol": sym, + "side": "Buy" if sd == "buy" else "Sell", + "orderType": "Market", + "qty": self._dec_str(q_dec), + "timeInForce": "GTC", + } + if reduce_only and self.category == "linear": + body["reduceOnly"] = True + if client_order_id: + body["orderLinkId"] = str(client_order_id) + raw = self._signed_request("POST", "/v5/order/create", json_body=body) + res = (raw.get("result") or {}) if isinstance(raw, dict) else {} + oid = str(res.get("orderId") or res.get("orderLinkId") or "") + return LiveOrderResult(exchange_id="bybit", exchange_order_id=oid, filled=0.0, avg_price=0.0, raw=raw) + + def place_limit_order( + self, + *, + symbol: str, + side: str, + qty: float, + price: float, + reduce_only: bool = False, + client_order_id: Optional[str] = None, + ) -> LiveOrderResult: + sym = to_bybit_symbol(symbol) + sd = (side or "").strip().lower() + if sd not in ("buy", "sell"): + raise LiveTradingError(f"Invalid side: {side}") + q_req = float(qty or 0.0) + px = float(price or 0.0) + if q_req <= 0 or px <= 0: + raise LiveTradingError("Invalid qty/price") + q_dec = self._normalize_qty(symbol=symbol, qty=q_req) + if float(q_dec or 0) <= 0: + raise LiveTradingError(f"Invalid qty (below step/min): requested={q_req}") + body: Dict[str, Any] = { + "category": self.category, + "symbol": sym, + "side": "Buy" if sd == "buy" else "Sell", + "orderType": "Limit", + "qty": self._dec_str(q_dec), + "price": str(px), + "timeInForce": "GTC", + } + if reduce_only and self.category == "linear": + body["reduceOnly"] = True + if client_order_id: + body["orderLinkId"] = str(client_order_id) + raw = self._signed_request("POST", "/v5/order/create", json_body=body) + res = (raw.get("result") or {}) if isinstance(raw, dict) else {} + oid = str(res.get("orderId") or res.get("orderLinkId") or "") + return LiveOrderResult(exchange_id="bybit", exchange_order_id=oid, filled=0.0, avg_price=0.0, raw=raw) + + def cancel_order(self, *, symbol: str, order_id: str = "", client_order_id: str = "") -> Dict[str, Any]: + sym = to_bybit_symbol(symbol) + body: Dict[str, Any] = {"category": self.category, "symbol": sym} + if order_id: + body["orderId"] = str(order_id) + elif client_order_id: + body["orderLinkId"] = str(client_order_id) + else: + raise LiveTradingError("Bybit cancel_order requires order_id or client_order_id") + return self._signed_request("POST", "/v5/order/cancel", json_body=body) + + def get_order(self, *, symbol: str, order_id: str = "", client_order_id: str = "") -> Dict[str, Any]: + sym = to_bybit_symbol(symbol) + params: Dict[str, Any] = {"category": self.category, "symbol": sym} + if order_id: + params["orderId"] = str(order_id) + elif client_order_id: + params["orderLinkId"] = str(client_order_id) + else: + raise LiveTradingError("Bybit get_order requires order_id or client_order_id") + raw = self._signed_request("GET", "/v5/order/realtime", params=params) + lst = (((raw.get("result") or {}).get("list")) if isinstance(raw, dict) else None) or [] + first: Dict[str, Any] = lst[0] if isinstance(lst, list) and lst else {} + return first if isinstance(first, dict) else {} + + def wait_for_fill( + self, + *, + symbol: str, + 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(symbol=symbol, order_id=str(order_id or ""), client_order_id=str(client_order_id or "")) + except Exception: + last = last or {} + status = str(last.get("orderStatus") or last.get("order_status") or "") + try: + filled = float(last.get("cumExecQty") or 0.0) + except Exception: + filled = 0.0 + avg_price = 0.0 + try: + avg_price = float(last.get("avgPrice") or 0.0) + except Exception: + avg_price = 0.0 + if filled > 0 and avg_price > 0: + return {"filled": filled, "avg_price": avg_price, "status": status, "order": last} + if status.lower() in ("filled", "cancelled", "canceled", "rejected"): + return {"filled": filled, "avg_price": avg_price, "status": status, "order": last} + if time.time() >= end_ts: + return {"filled": filled, "avg_price": avg_price, "status": status, "order": last} + time.sleep(float(poll_interval_sec or 0.5)) + + def get_positions(self) -> Dict[str, Any]: + if self.category != "linear": + raise LiveTradingError("Bybit positions are only supported for linear category in this client") + return self._signed_request("GET", "/v5/position/list", params={"category": "linear"}) + + def set_leverage(self, *, symbol: str, leverage: float) -> bool: + if self.category != "linear": + return False + sym = to_bybit_symbol(symbol) + try: + lv = int(float(leverage or 1.0)) + except Exception: + lv = 1 + if lv < 1: + lv = 1 + # Bybit leverage caps vary per symbol; keep best-effort. + body = {"category": "linear", "symbol": sym, "buyLeverage": str(lv), "sellLeverage": str(lv)} + try: + resp = self._signed_request("POST", "/v5/position/set-leverage", json_body=body) + ok = isinstance(resp, dict) and (resp.get("retCode") in (0, "0", None, "")) + return bool(ok) + except Exception: + return False + + diff --git a/backend_api_python/app/services/live_trading/coinbase_exchange.py b/backend_api_python/app/services/live_trading/coinbase_exchange.py new file mode 100644 index 0000000..74c566d --- /dev/null +++ b/backend_api_python/app/services/live_trading/coinbase_exchange.py @@ -0,0 +1,191 @@ +""" +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 + 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 + if filled > 0 and avg_price > 0: + return {"filled": filled, "avg_price": avg_price, "status": status, "order": last} + if status.lower() in ("done", "rejected", "canceled", "cancelled"): + return {"filled": filled, "avg_price": avg_price, "status": status, "order": last} + if time.time() >= end_ts: + return {"filled": filled, "avg_price": avg_price, "status": status, "order": last} + time.sleep(float(poll_interval_sec or 0.5)) + + diff --git a/backend_api_python/app/services/live_trading/execution.py b/backend_api_python/app/services/live_trading/execution.py index 2cac2c1..291fe3a 100644 --- a/backend_api_python/app/services/live_trading/execution.py +++ b/backend_api_python/app/services/live_trading/execution.py @@ -12,6 +12,14 @@ from app.services.live_trading.binance_spot import BinanceSpotClient from app.services.live_trading.okx import OkxClient from app.services.live_trading.bitget import BitgetMixClient from app.services.live_trading.bitget_spot import BitgetSpotClient +from app.services.live_trading.bybit import BybitClient +from app.services.live_trading.coinbase_exchange import CoinbaseExchangeClient +from app.services.live_trading.kraken import KrakenClient +from app.services.live_trading.kraken_futures import KrakenFuturesClient +from app.services.live_trading.kucoin import KucoinSpotClient +from app.services.live_trading.kucoin import KucoinFuturesClient +from app.services.live_trading.gate import GateSpotClient, GateUsdtFuturesClient +from app.services.live_trading.bitfinex import BitfinexClient, BitfinexDerivativesClient def _signal_to_sides(signal_type: str) -> Tuple[str, str, bool]: @@ -108,6 +116,33 @@ def place_order_from_signal( size=qty, client_order_id=client_order_id, ) + if isinstance(client, BybitClient): + return client.place_market_order( + symbol=symbol, + side=side, + qty=qty, + reduce_only=reduce_only, + client_order_id=client_order_id, + ) + if isinstance(client, CoinbaseExchangeClient): + return client.place_market_order(symbol=symbol, side=side, size=qty, client_order_id=client_order_id) + if isinstance(client, KrakenClient): + return client.place_market_order(symbol=symbol, side=side, size=qty, client_order_id=client_order_id) + if isinstance(client, KucoinSpotClient): + # KuCoin market BUY often requires quote funds; this simplified path does not convert. + return client.place_market_order(symbol=symbol, side=side, size=qty, client_order_id=client_order_id, quote_size=False) + if isinstance(client, KucoinFuturesClient): + return client.place_market_order(symbol=symbol, side=side, size=qty, reduce_only=reduce_only, client_order_id=client_order_id) + if isinstance(client, GateSpotClient): + return client.place_market_order(symbol=symbol, side=side, size=qty, client_order_id=client_order_id) + if isinstance(client, GateUsdtFuturesClient): + return client.place_market_order(symbol=symbol, side=side, size=qty, reduce_only=reduce_only, client_order_id=client_order_id) + if isinstance(client, BitfinexClient): + return client.place_market_order(symbol=symbol, side=side, size=qty, client_order_id=client_order_id) + if isinstance(client, BitfinexDerivativesClient): + return client.place_market_order(symbol=symbol, side=side, size=qty, client_order_id=client_order_id) + if isinstance(client, KrakenFuturesClient): + return client.place_market_order(symbol=symbol, side=side, size=qty, reduce_only=reduce_only, client_order_id=client_order_id) raise LiveTradingError(f"Unsupported client type: {type(client)}") diff --git a/backend_api_python/app/services/live_trading/factory.py b/backend_api_python/app/services/live_trading/factory.py index 3e7cd21..49ecbe6 100644 --- a/backend_api_python/app/services/live_trading/factory.py +++ b/backend_api_python/app/services/live_trading/factory.py @@ -12,6 +12,13 @@ from app.services.live_trading.binance_spot import BinanceSpotClient from app.services.live_trading.okx import OkxClient from app.services.live_trading.bitget import BitgetMixClient from app.services.live_trading.bitget_spot import BitgetSpotClient +from app.services.live_trading.bybit import BybitClient +from app.services.live_trading.coinbase_exchange import CoinbaseExchangeClient +from app.services.live_trading.kraken import KrakenClient +from app.services.live_trading.kraken_futures import KrakenFuturesClient +from app.services.live_trading.kucoin import KucoinSpotClient, KucoinFuturesClient +from app.services.live_trading.gate import GateSpotClient, GateUsdtFuturesClient +from app.services.live_trading.bitfinex import BitfinexClient, BitfinexDerivativesClient def _get(cfg: Dict[str, Any], *keys: str) -> str: @@ -54,6 +61,46 @@ def create_client(exchange_config: Dict[str, Any], *, market_type: str = "swap") return BitgetSpotClient(api_key=api_key, secret_key=secret_key, passphrase=passphrase, base_url=base_url, channel_api_code=channel_api_code) return BitgetMixClient(api_key=api_key, secret_key=secret_key, passphrase=passphrase, base_url=base_url) + if exchange_id == "bybit": + base_url = _get(exchange_config, "base_url", "baseUrl") or "https://api.bybit.com" + category = "spot" if mt == "spot" else "linear" + recv_window_ms = int(exchange_config.get("recv_window_ms") or exchange_config.get("recvWindow") or 5000) + return BybitClient(api_key=api_key, secret_key=secret_key, base_url=base_url, category=category, recv_window_ms=recv_window_ms) + + if exchange_id in ("coinbaseexchange", "coinbase_exchange"): + base_url = _get(exchange_config, "base_url", "baseUrl") or "https://api.exchange.coinbase.com" + if mt != "spot": + raise LiveTradingError("CoinbaseExchange only supports spot market_type in this project") + return CoinbaseExchangeClient(api_key=api_key, secret_key=secret_key, passphrase=passphrase, base_url=base_url) + + if exchange_id == "kraken": + base_url = _get(exchange_config, "base_url", "baseUrl") or "https://api.kraken.com" + if mt == "spot": + return KrakenClient(api_key=api_key, secret_key=secret_key, base_url=base_url) + # Futures/perp + fut_url = _get(exchange_config, "futures_base_url", "futuresBaseUrl") or "https://futures.kraken.com" + return KrakenFuturesClient(api_key=api_key, secret_key=secret_key, base_url=fut_url) + + if exchange_id == "kucoin": + base_url = _get(exchange_config, "base_url", "baseUrl") or "https://api.kucoin.com" + if mt == "spot": + return KucoinSpotClient(api_key=api_key, secret_key=secret_key, passphrase=passphrase, base_url=base_url) + fut_url = _get(exchange_config, "futures_base_url", "futuresBaseUrl") or "https://api-futures.kucoin.com" + return KucoinFuturesClient(api_key=api_key, secret_key=secret_key, passphrase=passphrase, base_url=fut_url) + + if exchange_id == "gate": + base_url = _get(exchange_config, "base_url", "baseUrl") or "https://api.gateio.ws" + if mt == "spot": + return GateSpotClient(api_key=api_key, secret_key=secret_key, base_url=base_url) + # Default to USDT futures for swap + return GateUsdtFuturesClient(api_key=api_key, secret_key=secret_key, base_url=base_url) + + if exchange_id == "bitfinex": + base_url = _get(exchange_config, "base_url", "baseUrl") or "https://api.bitfinex.com" + if mt == "spot": + return BitfinexClient(api_key=api_key, secret_key=secret_key, base_url=base_url) + return BitfinexDerivativesClient(api_key=api_key, secret_key=secret_key, base_url=base_url) + raise LiveTradingError(f"Unsupported exchange_id: {exchange_id}") diff --git a/backend_api_python/app/services/live_trading/gate.py b/backend_api_python/app/services/live_trading/gate.py new file mode 100644 index 0000000..02f59a5 --- /dev/null +++ b/backend_api_python/app/services/live_trading/gate.py @@ -0,0 +1,342 @@ +""" +Gate.io (direct REST) clients: +- Spot: /api/v4/spot/* +- Futures USDT: /api/v4/futures/usdt/* + +Signing (apiv4): +SIGN = hex(hmac_sha512(secret, method + "\\n" + url + "\\n" + query + "\\n" + body + "\\n" + timestamp)) +Headers: +- KEY: api key +- Timestamp: unix seconds +- SIGN: signature hex +""" + +from __future__ import annotations + +import hashlib +import hmac +import time +from decimal import Decimal, ROUND_DOWN +from typing import Any, Dict, Optional, Tuple +from urllib.parse import urlencode + +from app.services.live_trading.base import BaseRestClient, LiveOrderResult, LiveTradingError +from app.services.live_trading.symbols import to_gate_currency_pair + + +class _GateBase(BaseRestClient): + def __init__(self, *, api_key: str, secret_key: str, base_url: str = "https://api.gateio.ws", 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 Gate api_key/secret_key") + + def _sign(self, *, method: str, url: str, query_string: str, body_str: str, ts: str) -> str: + msg = f"{method.upper()}\n{url}\n{query_string}\n{body_str}\n{ts}" + return hmac.new(self.secret_key.encode("utf-8"), msg.encode("utf-8"), hashlib.sha512).hexdigest() + + def _headers(self, ts: str, sign: str) -> Dict[str, str]: + return {"KEY": self.api_key, "Timestamp": ts, "SIGN": sign, "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 "" + qs = "" + if params: + norm = {str(k): "" if v is None else str(v) for k, v in dict(params).items()} + qs = urlencode(sorted(norm.items()), doseq=True) + sign = self._sign(method=m, url=path, query_string=qs, body_str=body_str, ts=ts) + 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"Gate 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"Gate HTTP {code}: {text[:500]}") + return data + + +class GateSpotClient(_GateBase): + def ping(self) -> bool: + try: + _ = self._public_request("GET", "/api/v4/spot/time") + return True + except Exception: + return False + + def get_accounts(self) -> Any: + return self._signed_request("GET", "/api/v4/spot/accounts") + + 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] = { + "currency_pair": to_gate_currency_pair(symbol), + "side": sd, + "type": "limit", + "amount": str(qty), + "price": str(px), + "time_in_force": "gtc", + } + if client_order_id: + body["text"] = str(client_order_id) + raw = self._signed_request("POST", "/api/v4/spot/orders", json_body=body) + oid = str(raw.get("id") or "") if isinstance(raw, dict) else "" + return LiveOrderResult(exchange_id="gate", exchange_order_id=oid, filled=0.0, avg_price=0.0, raw=raw if isinstance(raw, dict) else {"raw": raw}) + + 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] = { + "currency_pair": to_gate_currency_pair(symbol), + "side": sd, + "type": "market", + "amount": str(qty), + } + if client_order_id: + body["text"] = str(client_order_id) + raw = self._signed_request("POST", "/api/v4/spot/orders", json_body=body) + oid = str(raw.get("id") or "") if isinstance(raw, dict) else "" + return LiveOrderResult(exchange_id="gate", 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) -> Any: + if not order_id: + raise LiveTradingError("Gate spot cancel_order requires order_id") + return self._signed_request("DELETE", f"/api/v4/spot/orders/{str(order_id)}") + + def get_order(self, *, order_id: str) -> Any: + if not order_id: + raise LiveTradingError("Gate spot get_order requires order_id") + return self._signed_request("GET", f"/api/v4/spot/orders/{str(order_id)}") + + 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: Dict[str, Any] = {} + while True: + try: + resp = self.get_order(order_id=str(order_id)) + 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 + try: + filled = float(last.get("filled_amount") or 0.0) + except Exception: + filled = 0.0 + try: + filled_total = float(last.get("filled_total") or 0.0) + if filled > 0 and filled_total > 0: + avg_price = filled_total / filled + except Exception: + avg_price = 0.0 + if filled > 0 and avg_price > 0: + return {"filled": filled, "avg_price": avg_price, "status": status, "order": last} + if status.lower() in ("closed", "cancelled", "canceled"): + return {"filled": filled, "avg_price": avg_price, "status": status, "order": last} + if time.time() >= end_ts: + return {"filled": filled, "avg_price": avg_price, "status": status, "order": last} + time.sleep(float(poll_interval_sec or 0.5)) + + +class GateUsdtFuturesClient(_GateBase): + def __init__(self, *, api_key: str, secret_key: str, base_url: str = "https://api.gateio.ws", timeout_sec: float = 15.0): + super().__init__(api_key=api_key, secret_key=secret_key, base_url=base_url, timeout_sec=timeout_sec) + # Best-effort cache for contract metadata to convert base qty -> contracts. + self._contract_cache: Dict[str, Tuple[float, Dict[str, Any]]] = {} + self._contract_cache_ttl_sec = 300.0 + + @staticmethod + def _to_dec(x: Any) -> Decimal: + try: + return Decimal(str(x)) + except Exception: + return Decimal("0") + + @staticmethod + def _floor(value: Decimal) -> Decimal: + try: + return value.to_integral_value(rounding=ROUND_DOWN) + except Exception: + return Decimal("0") + + def ping(self) -> bool: + try: + _ = self._public_request("GET", "/api/v4/futures/usdt/time") + return True + except Exception: + return False + + def get_contract(self, *, contract: str) -> Dict[str, Any]: + c = str(contract or "").strip() + if not c: + return {} + now = time.time() + cached = self._contract_cache.get(c) + if cached: + ts, obj = cached + if obj and (now - float(ts or 0.0)) <= float(self._contract_cache_ttl_sec or 300.0): + return obj + raw = self._public_request("GET", f"/api/v4/futures/usdt/contracts/{c}") + obj = raw if isinstance(raw, dict) else {} + if obj: + self._contract_cache[c] = (now, obj) + return obj + + def _base_to_contracts(self, *, contract: str, base_size: float) -> int: + req = self._to_dec(base_size) + if req <= 0: + return 0 + meta: Dict[str, Any] = {} + try: + meta = self.get_contract(contract=contract) or {} + except Exception: + meta = {} + qm = self._to_dec(meta.get("quanto_multiplier") or meta.get("quantoMultiplier") or meta.get("contract_size") or meta.get("contractSize") or "0") + if qm <= 0: + # Fallback: 1 contract ~= 1 base unit (best-effort) + qm = Decimal("1") + contracts = req / qm + return int(self._floor(contracts)) + + def get_accounts(self) -> Any: + return self._signed_request("GET", "/api/v4/futures/usdt/accounts") + + def get_positions(self) -> Any: + return self._signed_request("GET", "/api/v4/futures/usdt/positions") + + def set_leverage(self, *, contract: str, leverage: float) -> bool: + c = str(contract or "").strip() + if not c: + return False + try: + lv = int(float(leverage or 1.0)) + except Exception: + lv = 1 + if lv < 1: + lv = 1 + try: + _ = self._signed_request("POST", f"/api/v4/futures/usdt/positions/{c}/leverage", json_body={"leverage": str(lv)}) + return True + except Exception: + return False + + 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}") + contract = to_gate_currency_pair(symbol) + csz = self._base_to_contracts(contract=contract, base_size=float(size or 0.0)) + if csz <= 0: + raise LiveTradingError("Invalid size (converted contracts <= 0)") + signed_size = int(csz) if sd == "buy" else -int(csz) + body: Dict[str, Any] = {"contract": contract, "size": signed_size, "price": "0", "tif": "ioc"} + if reduce_only: + body["reduce_only"] = True + if client_order_id: + body["text"] = str(client_order_id) + raw = self._signed_request("POST", "/api/v4/futures/usdt/orders", json_body=body) + oid = str(raw.get("id") or "") if isinstance(raw, dict) else "" + return LiveOrderResult(exchange_id="gate", 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, + 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}") + contract = to_gate_currency_pair(symbol) + csz = self._base_to_contracts(contract=contract, base_size=float(size or 0.0)) + if csz <= 0: + raise LiveTradingError("Invalid size (converted contracts <= 0)") + px = float(price or 0.0) + if px <= 0: + raise LiveTradingError("Invalid price") + signed_size = int(csz) if sd == "buy" else -int(csz) + body: Dict[str, Any] = {"contract": contract, "size": signed_size, "price": str(px), "tif": "gtc"} + if reduce_only: + body["reduce_only"] = True + if client_order_id: + body["text"] = str(client_order_id) + raw = self._signed_request("POST", "/api/v4/futures/usdt/orders", json_body=body) + oid = str(raw.get("id") or "") if isinstance(raw, dict) else "" + return LiveOrderResult(exchange_id="gate", 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) -> Any: + if not order_id: + raise LiveTradingError("Gate futures cancel_order requires order_id") + return self._signed_request("DELETE", f"/api/v4/futures/usdt/orders/{str(order_id)}") + + def get_order(self, *, order_id: str) -> Any: + if not order_id: + raise LiveTradingError("Gate futures get_order requires order_id") + return self._signed_request("GET", f"/api/v4/futures/usdt/orders/{str(order_id)}") + + def wait_for_fill(self, *, order_id: str, contract: 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] = {} + qm = Decimal("1") + try: + meta = self.get_contract(contract=str(contract)) or {} + qm = self._to_dec(meta.get("quanto_multiplier") or meta.get("contract_size") or "1") + if qm <= 0: + qm = Decimal("1") + except Exception: + qm = Decimal("1") + while True: + try: + resp = self.get_order(order_id=str(order_id)) + 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 + try: + # Gate futures often returns "filled_size" in contracts. + filled_ct = abs(float(last.get("filled_size") or last.get("filledSize") or 0.0)) + filled = float(Decimal(str(filled_ct)) * qm) + except Exception: + filled = 0.0 + try: + avg_price = float(last.get("fill_price") or last.get("fillPrice") or last.get("price") or 0.0) + except Exception: + avg_price = 0.0 + if filled > 0 and avg_price > 0: + return {"filled": filled, "avg_price": avg_price, "status": status, "order": last} + if str(status).lower() in ("finished", "cancelled", "canceled"): + return {"filled": filled, "avg_price": avg_price, "status": status, "order": last} + if time.time() >= end_ts: + return {"filled": filled, "avg_price": avg_price, "status": status, "order": last} + time.sleep(float(poll_interval_sec or 0.5)) + + diff --git a/backend_api_python/app/services/live_trading/kraken.py b/backend_api_python/app/services/live_trading/kraken.py new file mode 100644 index 0000000..28e66fe --- /dev/null +++ b/backend_api_python/app/services/live_trading/kraken.py @@ -0,0 +1,175 @@ +""" +Kraken (direct REST) client (spot). + +Auth: +- API-Key: api key string +- API-Sign: base64(hmac_sha512(base64_decode(secret), uri_path + sha256(nonce + postdata))) + +Notes: +- Kraken spot uses asset pairs like XBTUSDT; we do best-effort normalization. +- This client is spot-only in this project (no futures). +""" + +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_pair + + +class KrakenClient(BaseRestClient): + def __init__(self, *, api_key: str, secret_key: str, base_url: str = "https://api.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 Kraken api_key/secret_key") + try: + self._secret_bytes = base64.b64decode(self.secret_key) + except Exception as e: + raise LiveTradingError(f"Invalid Kraken secret_key (base64 decode failed): {e}") + + def ping(self) -> bool: + try: + code, data, _ = self._request("GET", "/0/public/Time") + return code == 200 and isinstance(data, dict) and (data.get("error") in ([], None, "")) + except Exception: + return False + + def get_balance(self) -> Dict[str, Any]: + """ + Private balance endpoint (best-effort credential validation). + """ + return self._signed_request("POST", "/0/private/Balance", data={}) + + def _sign(self, *, urlpath: str, nonce: str, postdata: str) -> str: + sha = hashlib.sha256((nonce + postdata).encode("utf-8")).digest() + mac = hmac.new(self._secret_bytes, urlpath.encode("utf-8") + sha, hashlib.sha512).digest() + return base64.b64encode(mac).decode("utf-8") + + def _signed_request(self, method: str, path: str, *, data: Dict[str, Any]) -> Dict[str, Any]: + m = str(method or "POST").upper() + if m != "POST": + raise LiveTradingError("Kraken private endpoints in this client use POST") + nonce = str(int(time.time() * 1000)) + body = dict(data or {}) + body["nonce"] = nonce + postdata = urlencode(body, doseq=True) + sign = self._sign(urlpath=path, nonce=nonce, postdata=postdata) + headers = {"API-Key": self.api_key, "API-Sign": sign, "Content-Type": "application/x-www-form-urlencoded"} + code, resp, text = self._request("POST", path, params=None, json_body=None, data=postdata, headers=headers) + if code >= 400: + raise LiveTradingError(f"Kraken HTTP {code}: {text[:500]}") + if isinstance(resp, dict): + errs = resp.get("error") + if isinstance(errs, list) and errs: + raise LiveTradingError(f"Kraken error: {errs}") + return resp if isinstance(resp, dict) else {"raw": resp} + + def add_order( + self, + *, + pair: str, + side: str, + ordertype: str, + volume: float, + price: float = 0.0, + client_order_id: str = "", + ) -> Dict[str, Any]: + sd = (side or "").strip().lower() + if sd not in ("buy", "sell"): + raise LiveTradingError(f"Invalid side: {side}") + ot = (ordertype or "").strip().lower() + if ot not in ("market", "limit"): + raise LiveTradingError(f"Invalid ordertype: {ordertype}") + vol = float(volume or 0.0) + if vol <= 0: + raise LiveTradingError("Invalid volume") + body: Dict[str, Any] = {"pair": str(pair), "type": sd, "ordertype": ot, "volume": str(vol)} + if ot == "limit": + px = float(price or 0.0) + if px <= 0: + raise LiveTradingError("Invalid limit price") + body["price"] = str(px) + # Best-effort userref (integer). Only digits allowed. Keep short. + if client_order_id: + try: + body["userref"] = int("".join([c for c in str(client_order_id) if c.isdigit()])[:9] or "0") + except Exception: + pass + return self._signed_request("POST", "/0/private/AddOrder", data=body) + + def place_market_order(self, *, symbol: str, side: str, size: float, client_order_id: Optional[str] = None) -> LiveOrderResult: + pair = to_kraken_pair(symbol) + raw = self.add_order(pair=pair, side=side, ordertype="market", volume=float(size or 0.0), client_order_id=str(client_order_id or "")) + txid = "" + try: + tx = ((raw.get("result") or {}).get("txid")) if isinstance(raw, dict) else None + if isinstance(tx, list) and tx: + txid = str(tx[0]) + except Exception: + txid = "" + return LiveOrderResult(exchange_id="kraken", exchange_order_id=txid, filled=0.0, avg_price=0.0, raw=raw) + + def place_limit_order(self, *, symbol: str, side: str, size: float, price: float, client_order_id: Optional[str] = None) -> LiveOrderResult: + pair = to_kraken_pair(symbol) + raw = self.add_order(pair=pair, side=side, ordertype="limit", volume=float(size or 0.0), price=float(price or 0.0), client_order_id=str(client_order_id or "")) + txid = "" + try: + tx = ((raw.get("result") or {}).get("txid")) if isinstance(raw, dict) else None + if isinstance(tx, list) and tx: + txid = str(tx[0]) + except Exception: + txid = "" + return LiveOrderResult(exchange_id="kraken", exchange_order_id=txid, filled=0.0, avg_price=0.0, raw=raw) + + def cancel_order(self, *, order_id: str) -> Dict[str, Any]: + if not order_id: + raise LiveTradingError("Kraken cancel_order requires order_id") + return self._signed_request("POST", "/0/private/CancelOrder", data={"txid": str(order_id)}) + + def get_order(self, *, order_id: str) -> Dict[str, Any]: + if not order_id: + raise LiveTradingError("Kraken get_order requires order_id") + resp = self._signed_request("POST", "/0/private/QueryOrders", data={"txid": str(order_id)}) + res = (resp.get("result") or {}) if isinstance(resp, dict) else {} + od = (res.get(str(order_id)) if isinstance(res, dict) else None) or {} + return od if isinstance(od, dict) else {} + + 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: Dict[str, Any] = {} + while True: + try: + last = self.get_order(order_id=str(order_id)) + except Exception: + last = last or {} + status = str(last.get("status") or "") + filled = 0.0 + avg_price = 0.0 + try: + filled = float(last.get("vol_exec") or 0.0) + except Exception: + filled = 0.0 + # Kraken provides "cost" in quote currency. avg = cost / filled. + try: + cost = float(last.get("cost") or 0.0) + if filled > 0 and cost > 0: + avg_price = cost / filled + except Exception: + avg_price = 0.0 + if filled > 0 and avg_price > 0: + return {"filled": filled, "avg_price": avg_price, "status": status, "order": last} + if status.lower() in ("closed", "canceled", "cancelled", "expired"): + return {"filled": filled, "avg_price": avg_price, "status": status, "order": last} + if time.time() >= end_ts: + return {"filled": filled, "avg_price": avg_price, "status": status, "order": last} + time.sleep(float(poll_interval_sec or 0.5)) + + diff --git a/backend_api_python/app/services/live_trading/kraken_futures.py b/backend_api_python/app/services/live_trading/kraken_futures.py new file mode 100644 index 0000000..ddfaeb0 --- /dev/null +++ b/backend_api_python/app/services/live_trading/kraken_futures.py @@ -0,0 +1,205 @@ +""" +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: +- Nonce: +- 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 + 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 + if filled > 0 and avg_price > 0: + return {"filled": filled, "avg_price": avg_price, "status": status, "order": last} + if status.lower() in ("filled", "cancelled", "canceled", "rejected"): + return {"filled": filled, "avg_price": avg_price, "status": status, "order": last} + if time.time() >= end_ts: + return {"filled": filled, "avg_price": avg_price, "status": status, "order": last} + time.sleep(float(poll_interval_sec or 0.5)) + + diff --git a/backend_api_python/app/services/live_trading/kucoin.py b/backend_api_python/app/services/live_trading/kucoin.py new file mode 100644 index 0000000..1bad9a7 --- /dev/null +++ b/backend_api_python/app/services/live_trading/kucoin.py @@ -0,0 +1,508 @@ +""" +KuCoin (direct REST) client (spot). + +Signing (v2): +- KC-API-SIGN = base64(hmac_sha256(secret, timestamp + method + requestPathWithQuery + body)) +- KC-API-PASSPHRASE = base64(hmac_sha256(secret, passphrase)) +- KC-API-KEY-VERSION: 2 +""" + +from __future__ import annotations + +import base64 +import hashlib +import hmac +import time +from decimal import Decimal, ROUND_DOWN +from typing import Any, Dict, Optional, Tuple +from urllib.parse import urlencode + +from app.services.live_trading.base import BaseRestClient, LiveOrderResult, LiveTradingError +from app.services.live_trading.symbols import to_kucoin_symbol + + +class KucoinSpotClient(BaseRestClient): + def __init__( + self, + *, + api_key: str, + secret_key: str, + passphrase: str, + base_url: str = "https://api.kucoin.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 KuCoin api_key/secret_key/passphrase") + + def _b64_hmac_sha256(self, key: str, msg: str) -> str: + mac = hmac.new(key.encode("utf-8"), msg.encode("utf-8"), hashlib.sha256).digest() + return base64.b64encode(mac).decode("utf-8") + + def _headers(self, ts_ms: str, sign: str) -> Dict[str, str]: + # passphrase must be signed (v2) + p = self._b64_hmac_sha256(self.secret_key, self.passphrase) + return { + "KC-API-KEY": self.api_key, + "KC-API-SIGN": sign, + "KC-API-TIMESTAMP": ts_ms, + "KC-API-PASSPHRASE": p, + "KC-API-KEY-VERSION": "2", + "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_ms = str(int(time.time() * 1000)) + body_str = self._json_dumps(json_body) if json_body is not None else "" + qs = "" + if params: + norm = {str(k): "" if v is None else str(v) for k, v in dict(params).items()} + qs = urlencode(sorted(norm.items()), doseq=True) + signed_path = f"{path}?{qs}" if qs else path + prehash = f"{ts_ms}{m}{signed_path}{body_str}" + sign = self._b64_hmac_sha256(self.secret_key, prehash) + code, data, text = self._request(m, path, params=params, data=body_str if body_str else None, headers=self._headers(ts_ms, sign)) + if code >= 400: + raise LiveTradingError(f"KuCoin 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"KuCoin HTTP {code}: {text[:500]}") + return data + + def ping(self) -> bool: + try: + d = self._public_request("GET", "/api/v1/timestamp") + return isinstance(d, dict) and str(d.get("code") or "") in ("200000", "0", "") + except Exception: + return False + + def get_accounts(self) -> Any: + return self._signed_request("GET", "/api/v1/accounts") + + 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] = { + "clientOid": str(client_order_id or str(int(time.time() * 1000))), + "side": sd, + "symbol": to_kucoin_symbol(symbol), + "type": "limit", + "price": str(px), + "size": str(qty), + "timeInForce": "GTC", + } + raw = self._signed_request("POST", "/api/v1/orders", json_body=body) + oid = "" + if isinstance(raw, dict): + d = raw.get("data") + if isinstance(d, dict): + oid = str(d.get("orderId") or "") + elif isinstance(d, str): + oid = str(d) + return LiveOrderResult(exchange_id="kucoin", exchange_order_id=oid, filled=0.0, avg_price=0.0, raw=raw if isinstance(raw, dict) else {"raw": raw}) + + def place_market_order( + self, + *, + symbol: str, + side: str, + size: float, + client_order_id: Optional[str] = None, + quote_size: bool = False, + ) -> LiveOrderResult: + """ + KuCoin market order: + - sell: use size (base quantity) + - buy: typically use funds (quote quantity). Set quote_size=True to treat `size` as funds. + """ + 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] = { + "clientOid": str(client_order_id or str(int(time.time() * 1000))), + "side": sd, + "symbol": to_kucoin_symbol(symbol), + "type": "market", + } + if sd == "buy" and quote_size: + body["funds"] = str(qty) + else: + body["size"] = str(qty) + raw = self._signed_request("POST", "/api/v1/orders", json_body=body) + oid = "" + if isinstance(raw, dict): + d = raw.get("data") + if isinstance(d, dict): + oid = str(d.get("orderId") or "") + elif isinstance(d, str): + oid = str(d) + return LiveOrderResult(exchange_id="kucoin", 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"/api/v1/orders/{str(order_id)}") + if client_order_id: + return self._signed_request("DELETE", f"/api/v1/order/client-order/{str(client_order_id)}") + raise LiveTradingError("KuCoin 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"/api/v1/orders/{str(order_id)}") + if client_order_id: + return self._signed_request("GET", f"/api/v1/order/client-order/{str(client_order_id)}") + raise LiveTradingError("KuCoin get_order requires order_id or client_order_id") + + def get_fills(self, *, order_id: str) -> Any: + return self._signed_request("GET", "/api/v1/fills", params={"orderId": str(order_id)}) + + 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: Dict[str, Any] = {} + while True: + try: + resp = self.get_order(order_id=str(order_id)) + last = resp if isinstance(resp, dict) else {"raw": resp} + except Exception: + last = last or {} + data = last.get("data") if isinstance(last, dict) else None + od = data if isinstance(data, dict) else {} + status = str(od.get("isActive") if od else "") + filled = 0.0 + avg_price = 0.0 + fee = 0.0 + fee_ccy = "" + try: + filled = float(od.get("dealSize") or 0.0) + except Exception: + filled = 0.0 + try: + funds = float(od.get("dealFunds") or 0.0) + if filled > 0 and funds > 0: + avg_price = funds / filled + except Exception: + avg_price = 0.0 + try: + fee = abs(float(od.get("fee") or 0.0)) + except Exception: + fee = 0.0 + fee_ccy = str(od.get("feeCurrency") or "").strip() + 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 order is inactive, consider it terminal + try: + is_active = bool(od.get("isActive")) + except Exception: + is_active = False + if not is_active: + 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)) + + +class KucoinFuturesClient(BaseRestClient): + """ + KuCoin Futures (USDT perpetual) direct REST client. + + Notes: + - Base URL typically: https://api-futures.kucoin.com + - Auth headers/signing are the same KC-API-* style as spot (v2 passphrase signing), + but endpoints and symbol formats differ. + - Futures order size is typically in contracts; we convert from "base qty" best-effort. + """ + + def __init__( + self, + *, + api_key: str, + secret_key: str, + passphrase: str, + base_url: str = "https://api-futures.kucoin.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 KuCoin Futures api_key/secret_key/passphrase") + + # Best-effort contract cache: symbol -> (ts, contract_dict) + self._contract_cache: Dict[str, Tuple[float, Dict[str, Any]]] = {} + self._contract_cache_ttl_sec = 300.0 + + def _b64_hmac_sha256(self, key: str, msg: str) -> str: + mac = hmac.new(key.encode("utf-8"), msg.encode("utf-8"), hashlib.sha256).digest() + return base64.b64encode(mac).decode("utf-8") + + def _headers(self, ts_ms: str, sign: str) -> Dict[str, str]: + p = self._b64_hmac_sha256(self.secret_key, self.passphrase) + return { + "KC-API-KEY": self.api_key, + "KC-API-SIGN": sign, + "KC-API-TIMESTAMP": ts_ms, + "KC-API-PASSPHRASE": p, + "KC-API-KEY-VERSION": "2", + "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_ms = str(int(time.time() * 1000)) + body_str = self._json_dumps(json_body) if json_body is not None else "" + qs = "" + if params: + norm = {str(k): "" if v is None else str(v) for k, v in dict(params).items()} + qs = urlencode(sorted(norm.items()), doseq=True) + signed_path = f"{path}?{qs}" if qs else path + prehash = f"{ts_ms}{m}{signed_path}{body_str}" + sign = self._b64_hmac_sha256(self.secret_key, prehash) + code, data, text = self._request(m, path, params=params, data=body_str if body_str else None, headers=self._headers(ts_ms, sign)) + if code >= 400: + raise LiveTradingError(f"KuCoinFutures 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"KuCoinFutures HTTP {code}: {text[:500]}") + return data + + def ping(self) -> bool: + try: + d = self._public_request("GET", "/api/v1/timestamp") + return isinstance(d, dict) and str(d.get("code") or "") in ("200000", "0", "") + except Exception: + return False + + def get_contract(self, *, symbol: str) -> Dict[str, Any]: + from app.services.live_trading.symbols import to_kucoin_futures_symbol + + sym = to_kucoin_futures_symbol(symbol) + if not sym: + return {} + now = time.time() + cached = self._contract_cache.get(sym) + if cached: + ts, obj = cached + if obj and (now - float(ts or 0.0)) <= float(self._contract_cache_ttl_sec or 300.0): + return obj + # KuCoin futures active contracts list + raw = self._public_request("GET", "/api/v1/contracts/active") + data = (raw.get("data") if isinstance(raw, dict) else None) or [] + found: Dict[str, Any] = {} + if isinstance(data, list): + for it in data: + if not isinstance(it, dict): + continue + if str(it.get("symbol") or "").upper() == sym.upper(): + found = it + break + if found: + self._contract_cache[sym] = (now, found) + return found + + def _base_to_contracts(self, *, symbol: str, base_size: float) -> int: + """ + Convert base-asset qty -> contracts best-effort using multiplier. + """ + from app.services.live_trading.symbols import to_kucoin_futures_symbol + + req = Decimal(str(base_size or 0.0)) + if req <= 0: + return 0 + sym = to_kucoin_futures_symbol(symbol) + meta: Dict[str, Any] = {} + try: + meta = self.get_contract(symbol=sym) or {} + except Exception: + meta = {} + # multiplier is base per contract for many KuCoin perps (best-effort) + mult = Decimal(str(meta.get("multiplier") or meta.get("lotSize") or "0")) + if mult <= 0: + mult = Decimal("1") + ct = (req / mult).to_integral_value(rounding=ROUND_DOWN) + try: + return int(ct) + except Exception: + return 0 + + def get_accounts(self) -> Any: + # Futures account overview + return self._signed_request("GET", "/api/v1/account-overview", params={"currency": "USDT"}) + + def get_positions(self) -> Any: + return self._signed_request("GET", "/api/v1/positions") + + def set_leverage(self, *, symbol: str, leverage: float) -> bool: + from app.services.live_trading.symbols import to_kucoin_futures_symbol + + sym = to_kucoin_futures_symbol(symbol) + try: + lv = int(float(leverage or 1.0)) + except Exception: + lv = 1 + if lv < 1: + lv = 1 + body = {"symbol": sym, "leverage": str(lv)} + try: + _ = self._signed_request("POST", "/api/v1/position/leverage", json_body=body) + return True + except Exception: + return False + + def place_market_order( + self, + *, + symbol: str, + side: str, + size: float, + reduce_only: bool = False, + client_order_id: Optional[str] = None, + ) -> LiveOrderResult: + from app.services.live_trading.symbols import to_kucoin_futures_symbol + + sd = (side or "").strip().lower() + if sd not in ("buy", "sell"): + raise LiveTradingError(f"Invalid side: {side}") + sym = to_kucoin_futures_symbol(symbol) + qty_ct = self._base_to_contracts(symbol=sym, base_size=float(size or 0.0)) + if qty_ct <= 0: + raise LiveTradingError("Invalid size (converted contracts <= 0)") + body: Dict[str, Any] = { + "clientOid": str(client_order_id or str(int(time.time() * 1000))), + "side": sd, + "symbol": sym, + "type": "market", + "size": qty_ct, + } + if reduce_only: + body["reduceOnly"] = True + raw = self._signed_request("POST", "/api/v1/orders", json_body=body) + oid = "" + if isinstance(raw, dict): + d = raw.get("data") + if isinstance(d, dict): + oid = str(d.get("orderId") or "") + elif isinstance(d, str): + oid = str(d) + return LiveOrderResult(exchange_id="kucoin", 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, + reduce_only: bool = False, + post_only: bool = False, + client_order_id: Optional[str] = None, + ) -> LiveOrderResult: + from app.services.live_trading.symbols import to_kucoin_futures_symbol + + sd = (side or "").strip().lower() + if sd not in ("buy", "sell"): + raise LiveTradingError(f"Invalid side: {side}") + sym = to_kucoin_futures_symbol(symbol) + px = float(price or 0.0) + if px <= 0: + raise LiveTradingError("Invalid price") + qty_ct = self._base_to_contracts(symbol=sym, base_size=float(size or 0.0)) + if qty_ct <= 0: + raise LiveTradingError("Invalid size (converted contracts <= 0)") + body: Dict[str, Any] = { + "clientOid": str(client_order_id or str(int(time.time() * 1000))), + "side": sd, + "symbol": sym, + "type": "limit", + "price": str(px), + "size": qty_ct, + } + if reduce_only: + body["reduceOnly"] = True + if post_only: + body["postOnly"] = True + raw = self._signed_request("POST", "/api/v1/orders", json_body=body) + oid = "" + if isinstance(raw, dict): + d = raw.get("data") + if isinstance(d, dict): + oid = str(d.get("orderId") or "") + elif isinstance(d, str): + oid = str(d) + return LiveOrderResult(exchange_id="kucoin", 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"/api/v1/orders/{str(order_id)}") + if client_order_id: + return self._signed_request("DELETE", f"/api/v1/orders/client-order/{str(client_order_id)}") + raise LiveTradingError("KuCoinFutures 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"/api/v1/orders/{str(order_id)}") + if client_order_id: + return self._signed_request("GET", f"/api/v1/orders/byClientOid", params={"clientOid": str(client_order_id)}) + raise LiveTradingError("KuCoinFutures get_order requires order_id or client_order_id") + + def wait_for_fill(self, *, 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: + resp = self.get_order(order_id=str(order_id)) + last = resp if isinstance(resp, dict) else {"raw": resp} + except Exception: + last = last or {} + od = (last.get("data") if isinstance(last, dict) else None) or {} + status = str(od.get("status") or "") + filled = 0.0 + avg_price = 0.0 + try: + # dealSize is in contracts; convert back to base using multiplier best-effort. + deal_ct = float(od.get("dealSize") or 0.0) + except Exception: + deal_ct = 0.0 + try: + deal_value = float(od.get("dealValue") or 0.0) + except Exception: + deal_value = 0.0 + # Best-effort: infer avg price from dealValue / (deal contracts * multiplier) + mult = 1.0 + try: + sym = str(od.get("symbol") or "") + meta = self.get_contract(symbol=sym) or {} + mult = float(meta.get("multiplier") or meta.get("lotSize") or 1.0) + if mult <= 0: + mult = 1.0 + except Exception: + mult = 1.0 + filled = abs(float(deal_ct or 0.0)) * float(mult) + if filled > 0 and deal_value > 0: + avg_price = float(deal_value) / float(filled) + if filled > 0 and avg_price > 0: + return {"filled": filled, "avg_price": avg_price, "status": status, "order": last} + if status.lower() in ("done", "canceled", "cancelled", "filled"): + return {"filled": filled, "avg_price": avg_price, "status": status, "order": last} + if time.time() >= end_ts: + return {"filled": filled, "avg_price": avg_price, "status": status, "order": last} + time.sleep(float(poll_interval_sec or 0.5)) + + diff --git a/backend_api_python/app/services/live_trading/symbols.py b/backend_api_python/app/services/live_trading/symbols.py index 3011e81..96b4eb9 100644 --- a/backend_api_python/app/services/live_trading/symbols.py +++ b/backend_api_python/app/services/live_trading/symbols.py @@ -10,7 +10,7 @@ We convert them into exchange-specific identifiers. from __future__ import annotations -from typing import Tuple +from typing import Dict, Tuple def _split_base_quote(symbol: str) -> Tuple[str, str]: @@ -53,3 +53,135 @@ def to_bitget_um_symbol(symbol: str) -> str: return f"{base}{quote}" +_KRAKEN_BASE_MAP: Dict[str, str] = { + # Common spot naming differences + "BTC": "XBT", +} + +_BITFINEX_QUOTE_MAP: Dict[str, str] = { + # Bitfinex uses "UST" for Tether USDt + "USDT": "UST", +} + +_KUCOIN_FUTURES_BASE_MAP: Dict[str, str] = { + # KuCoin futures uses XBT for BTC on many contracts + "BTC": "XBT", +} + + +def to_bybit_symbol(symbol: str) -> str: + """ + Bybit symbol format (v5): typically concatenated, e.g. BTCUSDT. + """ + return to_binance_futures_symbol(symbol) + + +def to_coinbase_product_id(symbol: str) -> str: + """ + Coinbase Exchange product id format: BASE-QUOTE, e.g. BTC-USDT. + """ + base, quote = _split_base_quote(symbol) + if not base or not quote: + return symbol + return f"{base}-{quote}" + + +def to_kraken_pair(symbol: str) -> str: + """ + Kraken spot pair format is exchange-specific (e.g. XBTUSDT). + We use a best-effort mapping for common assets; callers can override by passing + already-normalized Kraken pair strings. + """ + base, quote = _split_base_quote(symbol) + if not base or not quote: + return symbol + b = _KRAKEN_BASE_MAP.get(base, base) + return f"{b}{quote}" + + +def to_kucoin_symbol(symbol: str) -> str: + """ + KuCoin spot symbol format: BASE-QUOTE, e.g. BTC-USDT. + """ + base, quote = _split_base_quote(symbol) + if not base or not quote: + return symbol + return f"{base}-{quote}" + + +def to_kucoin_futures_symbol(symbol: str) -> str: + """ + KuCoin Futures (USDT perpetual) symbol is exchange-specific, common examples: + - XBTUSDTM, ETHUSDTM + + We provide a best-effort mapping: BASEQUOTE + "M". + If caller already provides an exchange-native symbol (no '/'), we return as-is. + """ + s = (symbol or "").strip() + if "/" not in s: + return s + base, quote = _split_base_quote(symbol) + if not base or not quote: + return s + b = _KUCOIN_FUTURES_BASE_MAP.get(base, base) + return f"{b}{quote}M" + + +def to_kraken_futures_symbol(symbol: str) -> str: + """ + Kraken Futures instruments are exchange-specific (e.g. PF_XBTUSD, PI_XBTUSD). + This helper is best-effort: + - If caller already passes an exchange-native instrument (contains '_' or starts with PF_/PI_), return as-is. + - Otherwise, map BTC->XBT and assume USD quote for futures (most Kraken Futures perps are USD margined). + """ + s = (symbol or "").strip() + if not s: + return s + up = s.upper() + if "_" in up or up.startswith("PF_") or up.startswith("PI_"): + return s + base, quote = _split_base_quote(symbol) + if not base: + return s + b = _KRAKEN_BASE_MAP.get(base, base) + q = "USD" + # Keep USDT as USD best-effort (platform-dependent) + if quote and quote.upper() == "USD": + q = "USD" + return f"PF_{b}{q}" + + +def to_gate_currency_pair(symbol: str) -> str: + """ + Gate spot/futures currency_pair/contract format: BASE_QUOTE, e.g. BTC_USDT. + """ + base, quote = _split_base_quote(symbol) + if not base or not quote: + return symbol + return f"{base}_{quote}" + + +def to_bitfinex_spot_symbol(symbol: str) -> str: + """ + Bitfinex spot trading symbol format: tBASEQUOTE, e.g. tBTCUST. + """ + base, quote = _split_base_quote(symbol) + if not base or not quote: + s = str(symbol or "").strip() + return s if s.startswith("t") else f"t{s}" + q = _BITFINEX_QUOTE_MAP.get(quote, quote) + return f"t{base}{q}" + + +def to_bitfinex_perp_symbol(symbol: str) -> str: + """ + Bitfinex derivatives perpetual naming (best-effort): tBASEF0:QUOTEF0, e.g. tBTCF0:USTF0. + """ + base, quote = _split_base_quote(symbol) + if not base or not quote: + s = str(symbol or "").strip() + return s if s.startswith("t") else f"t{s}" + q = _BITFINEX_QUOTE_MAP.get(quote, quote) + return f"t{base}F0:{q}F0" + + diff --git a/backend_api_python/app/services/pending_order_worker.py b/backend_api_python/app/services/pending_order_worker.py index 372c0ce..0318924 100644 --- a/backend_api_python/app/services/pending_order_worker.py +++ b/backend_api_python/app/services/pending_order_worker.py @@ -25,7 +25,17 @@ from app.services.live_trading.binance_spot import BinanceSpotClient from app.services.live_trading.okx import OkxClient from app.services.live_trading.bitget import BitgetMixClient from app.services.live_trading.bitget_spot import BitgetSpotClient +from app.services.live_trading.bybit import BybitClient +from app.services.live_trading.coinbase_exchange import CoinbaseExchangeClient +from app.services.live_trading.kraken import KrakenClient +from app.services.live_trading.kraken_futures import KrakenFuturesClient +from app.services.live_trading.kucoin import KucoinSpotClient +from app.services.live_trading.kucoin import KucoinFuturesClient +from app.services.live_trading.gate import GateSpotClient, GateUsdtFuturesClient +from app.services.live_trading.bitfinex import BitfinexClient +from app.services.live_trading.bitfinex import BitfinexDerivativesClient from app.services.live_trading.symbols import to_okx_swap_inst_id +from app.services.live_trading.symbols import to_gate_currency_pair from app.utils.db import get_db_connection from app.utils.logger import get_logger @@ -225,6 +235,116 @@ class PendingOrderWorker: side = "long" if hold_side == "long" else "short" exch_size.setdefault(hb_sym, {"long": 0.0, "short": 0.0})[side] = abs(float(total)) + elif isinstance(client, BybitClient) and market_type == "swap": + # Bybit linear positions + resp = client.get_positions() + lst = (((resp.get("result") or {}).get("list")) if isinstance(resp, dict) else None) or [] + if isinstance(lst, list): + for p in lst: + if not isinstance(p, dict): + continue + sym = str(p.get("symbol") or "").strip().upper() + side0 = str(p.get("side") or "").strip().lower() # Buy/Sell + try: + sz = float(p.get("size") or 0.0) + except Exception: + sz = 0.0 + if not sym or abs(sz) <= 0: + continue + hb_sym = sym + if hb_sym.endswith("USDT") and len(hb_sym) > 4 and "/" not in hb_sym: + hb_sym = f"{hb_sym[:-4]}/USDT" + side = "long" if side0 == "buy" else ("short" if side0 == "sell" else ("long" if sz > 0 else "short")) + exch_size.setdefault(hb_sym, {"long": 0.0, "short": 0.0})[side] = abs(float(sz)) + + elif isinstance(client, GateUsdtFuturesClient) and market_type == "swap": + resp = client.get_positions() + items = resp if isinstance(resp, list) else [] + if isinstance(items, list): + for p in items: + if not isinstance(p, dict): + continue + contract = str(p.get("contract") or "").strip() + try: + sz_ct = float(p.get("size") or 0.0) # contracts, signed + except Exception: + sz_ct = 0.0 + if not contract or abs(sz_ct) <= 0: + continue + hb_sym = contract.replace("_", "/") + side = "long" if sz_ct > 0 else "short" + # Convert contracts -> base using quanto_multiplier. + qty_base = abs(sz_ct) + try: + meta = client.get_contract(contract=contract) or {} + qm = float(meta.get("quanto_multiplier") or meta.get("contract_size") or 0.0) + if qm > 0: + qty_base = qty_base * qm + except Exception: + pass + exch_size.setdefault(hb_sym, {"long": 0.0, "short": 0.0})[side] = float(qty_base) + + elif isinstance(client, KucoinFuturesClient) and market_type == "swap": + resp = client.get_positions() + data = (resp.get("data") if isinstance(resp, dict) else None) or [] + if isinstance(data, list): + for p in data: + if not isinstance(p, dict): + continue + sym = str(p.get("symbol") or "").strip() + try: + qty_ct = float(p.get("currentQty") or p.get("quantity") or 0.0) + except Exception: + qty_ct = 0.0 + if not sym or abs(qty_ct) <= 0: + continue + side = "long" if qty_ct > 0 else "short" + # Convert contracts -> base using multiplier. + qty_base = abs(qty_ct) + try: + meta = client.get_contract(symbol=sym) or {} + mult = float(meta.get("multiplier") or meta.get("lotSize") or 0.0) + if mult > 0: + qty_base = qty_base * mult + except Exception: + pass + exch_size.setdefault(sym, {"long": 0.0, "short": 0.0})[side] = float(qty_base) + + elif isinstance(client, KrakenFuturesClient) and market_type == "swap": + resp = client.get_open_positions() + positions = (resp.get("openPositions") if isinstance(resp, dict) else None) or (resp.get("open_positions") if isinstance(resp, dict) else None) or [] + if isinstance(positions, list): + for p in positions: + if not isinstance(p, dict): + continue + sym = str(p.get("symbol") or p.get("instrument") or "").strip() + try: + sz = float(p.get("size") or p.get("positionSize") or 0.0) + except Exception: + sz = 0.0 + if not sym or abs(sz) <= 0: + continue + side = "long" if sz > 0 else "short" + exch_size.setdefault(sym, {"long": 0.0, "short": 0.0})[side] = abs(float(sz)) + + elif isinstance(client, BitfinexDerivativesClient) and market_type == "swap": + resp = client.get_positions() + items = resp if isinstance(resp, list) else [] + if isinstance(items, list): + for p in items: + # Bitfinex positions are arrays; best-effort parse: + # [symbol, status, amount, base_price, ...] + try: + if isinstance(p, list) and len(p) >= 3: + sym = str(p[0] or "") + amt = float(p[2] or 0.0) + if not sym or abs(amt) <= 0: + continue + side = "long" if amt > 0 else "short" + exch_size.setdefault(sym, {"long": 0.0, "short": 0.0})[side] = abs(float(amt)) + except Exception: + continue + else: # Spot reconciliation is optional; skip for now (keeps self-check low-risk). logger.debug(f"position sync: skip unsupported market/client: sid={sid}, cfg={safe_cfg}, market_type={market_type}, client={type(client)}") @@ -642,12 +762,38 @@ class PendingOrderWorker: if leverage <= 0: leverage = 1.0 + # Collect raw exchange interactions / intermediate states for debugging & persistence. + phases: Dict[str, Any] = {} + + # Ensure ref price exists (used by maker pricing, fallbacks, and local DB snapshots). + if ref_price <= 0: + try: + if isinstance(client, BinanceFuturesClient): + ref_price = float(client.get_mark_price(symbol=str(symbol)) or 0.0) + except Exception: + pass + + # Binance Futures leverage is per-symbol on the exchange side. + # If we do not set it, Binance may keep default 1x and the user will observe + # margin ~= notional (i.e., "margin = invested * leverage" when we sized using leverage). + if isinstance(client, BinanceFuturesClient) and market_type == "swap": + try: + client.set_leverage(symbol=str(symbol), leverage=float(leverage or 1.0)) + phases["set_leverage"] = {"exchange": "binance", "symbol": str(symbol), "leverage": float(leverage or 1.0)} + except Exception as e: + # Safer default: do NOT place orders with an unintended leverage. + err = f"binance_set_leverage_failed:{e}" + logger.warning(f"live leverage set failed: pending_id={order_id}, strategy_id={strategy_id}, cfg={safe_cfg}, err={e}") + self._mark_failed(order_id=order_id, error=err) + _console_print(f"[worker] order rejected: strategy_id={strategy_id} pending_id={order_id} {err}") + _notify_live_best_effort(status="failed", error=err, amount_hint=amount, price_hint=ref_price) + return + # Accumulate fills across phases total_base = 0.0 total_quote = 0.0 total_fee = 0.0 fee_ccy = "" - phases: Dict[str, Any] = {} def _apply_fill(filled_qty: float, avg_px: float) -> None: nonlocal total_base, total_quote @@ -668,6 +814,23 @@ class PendingOrderWorker: if (not fee_ccy) and ccy: fee_ccy = str(ccy or "") + def _fetch_fee_best_effort(*, order_id0: str, client_order_id0: str) -> Tuple[float, str]: + """ + Some exchanges (notably Binance) do not expose commissions on order endpoints. + We fetch fills and sum commissions best-effort. + """ + oid = str(order_id0 or "").strip() + if not oid: + return 0.0, "" + try: + if isinstance(client, BinanceFuturesClient): + return client.get_fee_for_order(symbol=str(symbol), order_id=oid) + if isinstance(client, BinanceSpotClient): + return client.get_fee_for_order(symbol=str(symbol), order_id=oid) + except Exception: + return 0.0, "" + return 0.0, "" + def _current_avg() -> float: return float(total_quote / total_base) if total_base > 0 else 0.0 @@ -770,6 +933,104 @@ class PendingOrderWorker: price=limit_price, client_order_id=limit_client_oid, ) + elif isinstance(client, BybitClient): + res1 = client.place_limit_order( + symbol=str(symbol), + side=side, + qty=remaining, + price=limit_price, + reduce_only=reduce_only, + client_order_id=limit_client_oid, + ) + elif isinstance(client, CoinbaseExchangeClient): + res1 = client.place_limit_order( + symbol=str(symbol), + side=side, + size=remaining, + price=limit_price, + client_order_id=limit_client_oid, + ) + elif isinstance(client, KrakenClient): + # Kraken is spot-only and returns txid as order id. + res1 = client.place_limit_order( + symbol=str(symbol), + side=side, + size=remaining, + price=limit_price, + client_order_id=limit_client_oid, + ) + elif isinstance(client, KrakenFuturesClient): + # Kraken Futures expects instrument symbols; size is treated as contracts in this client. + res1 = client.place_limit_order( + symbol=str(symbol), + side=side, + size=remaining, + price=limit_price, + reduce_only=reduce_only, + post_only=(order_mode in ("maker", "maker_then_market", "limit_first", "limit")), + client_order_id=limit_client_oid, + ) + elif isinstance(client, KucoinSpotClient): + res1 = client.place_limit_order( + symbol=str(symbol), + side=side, + size=remaining, + price=limit_price, + client_order_id=limit_client_oid, + ) + elif isinstance(client, KucoinFuturesClient): + try: + if market_type == "swap": + client.set_leverage(symbol=str(symbol), leverage=leverage) + except Exception: + pass + res1 = client.place_limit_order( + symbol=str(symbol), + side=side, + size=remaining, + price=limit_price, + reduce_only=reduce_only, + post_only=(order_mode in ("maker", "maker_then_market", "limit_first", "limit")), + client_order_id=limit_client_oid, + ) + elif isinstance(client, GateSpotClient): + res1 = client.place_limit_order( + symbol=str(symbol), + side=side, + size=remaining, + price=limit_price, + client_order_id=limit_client_oid, + ) + elif isinstance(client, GateUsdtFuturesClient): + # Best-effort set leverage before futures order + try: + client.set_leverage(contract=to_gate_currency_pair(str(symbol)), leverage=leverage) + except Exception: + pass + res1 = client.place_limit_order( + symbol=str(symbol), + side=side, + size=remaining, + price=limit_price, + reduce_only=reduce_only, + client_order_id=limit_client_oid, + ) + elif isinstance(client, BitfinexClient): + res1 = client.place_limit_order( + symbol=str(symbol), + side=side, + size=remaining, + price=limit_price, + client_order_id=limit_client_oid, + ) + elif isinstance(client, BitfinexDerivativesClient): + res1 = client.place_limit_order( + symbol=str(symbol), + side=side, + size=remaining, + price=limit_price, + client_order_id=limit_client_oid, + ) else: raise LiveTradingError(f"Unsupported client type: {type(client)}") @@ -781,10 +1042,14 @@ class PendingOrderWorker: q = client.wait_for_fill(symbol=str(symbol), order_id=limit_order_id, client_order_id=limit_client_oid, max_wait_sec=maker_wait_sec) phases["limit_query"] = q _apply_fill(float(q.get("filled") or 0.0), float(q.get("avg_price") or 0.0)) + fee_v, fee_c = _fetch_fee_best_effort(order_id0=limit_order_id, client_order_id0=limit_client_oid) + _apply_fee(float(fee_v or 0.0), str(fee_c or "")) elif isinstance(client, BinanceSpotClient): q = client.wait_for_fill(symbol=str(symbol), order_id=limit_order_id, client_order_id=limit_client_oid, max_wait_sec=maker_wait_sec) phases["limit_query"] = q _apply_fill(float(q.get("filled") or 0.0), float(q.get("avg_price") or 0.0)) + fee_v, fee_c = _fetch_fee_best_effort(order_id0=limit_order_id, client_order_id0=limit_client_oid) + _apply_fee(float(fee_v or 0.0), str(fee_c or "")) elif isinstance(client, OkxClient): q = client.wait_for_fill(symbol=str(symbol), ord_id=limit_order_id, cl_ord_id=limit_client_oid, market_type=market_type, max_wait_sec=maker_wait_sec) phases["limit_query"] = q @@ -800,6 +1065,48 @@ class PendingOrderWorker: q = client.wait_for_fill(symbol=str(symbol), order_id=limit_order_id, client_order_id=limit_client_oid, max_wait_sec=maker_wait_sec) phases["limit_query"] = q _apply_fill(float(q.get("filled") or 0.0), float(q.get("avg_price") or 0.0)) + _apply_fee(float(q.get("fee") or 0.0), str(q.get("fee_ccy") or "")) + elif isinstance(client, BybitClient): + q = client.wait_for_fill(symbol=str(symbol), order_id=limit_order_id, client_order_id=limit_client_oid, max_wait_sec=maker_wait_sec) + phases["limit_query"] = q + _apply_fill(float(q.get("filled") or 0.0), float(q.get("avg_price") or 0.0)) + elif isinstance(client, CoinbaseExchangeClient): + q = client.wait_for_fill(order_id=limit_order_id, client_order_id=limit_client_oid, max_wait_sec=maker_wait_sec) + phases["limit_query"] = q + _apply_fill(float(q.get("filled") or 0.0), float(q.get("avg_price") or 0.0)) + elif isinstance(client, KrakenClient): + q = client.wait_for_fill(order_id=limit_order_id, max_wait_sec=maker_wait_sec) + phases["limit_query"] = q + _apply_fill(float(q.get("filled") or 0.0), float(q.get("avg_price") or 0.0)) + elif isinstance(client, KrakenFuturesClient): + q = client.wait_for_fill(order_id=limit_order_id, client_order_id=limit_client_oid, max_wait_sec=maker_wait_sec) + phases["limit_query"] = q + _apply_fill(float(q.get("filled") or 0.0), float(q.get("avg_price") or 0.0)) + elif isinstance(client, KucoinSpotClient): + q = client.wait_for_fill(order_id=limit_order_id, max_wait_sec=maker_wait_sec) + phases["limit_query"] = q + _apply_fill(float(q.get("filled") or 0.0), float(q.get("avg_price") or 0.0)) + _apply_fee(float(q.get("fee") or 0.0), str(q.get("fee_ccy") or "")) + elif isinstance(client, KucoinFuturesClient): + q = client.wait_for_fill(order_id=limit_order_id, max_wait_sec=maker_wait_sec) + phases["limit_query"] = q + _apply_fill(float(q.get("filled") or 0.0), float(q.get("avg_price") or 0.0)) + elif isinstance(client, GateSpotClient): + q = client.wait_for_fill(order_id=limit_order_id, max_wait_sec=maker_wait_sec) + phases["limit_query"] = q + _apply_fill(float(q.get("filled") or 0.0), float(q.get("avg_price") or 0.0)) + elif isinstance(client, GateUsdtFuturesClient): + q = client.wait_for_fill(order_id=limit_order_id, contract=to_gate_currency_pair(str(symbol)), max_wait_sec=maker_wait_sec) + phases["limit_query"] = q + _apply_fill(float(q.get("filled") or 0.0), float(q.get("avg_price") or 0.0)) + elif isinstance(client, BitfinexClient): + q = client.wait_for_fill(order_id=limit_order_id, max_wait_sec=maker_wait_sec) + phases["limit_query"] = q + _apply_fill(float(q.get("filled") or 0.0), float(q.get("avg_price") or 0.0)) + elif isinstance(client, BitfinexDerivativesClient): + q = client.wait_for_fill(order_id=limit_order_id, max_wait_sec=maker_wait_sec) + phases["limit_query"] = q + _apply_fill(float(q.get("filled") or 0.0), float(q.get("avg_price") or 0.0)) remaining = max(0.0, float(amount or 0.0) - total_base) @@ -842,6 +1149,26 @@ class PendingOrderWorker: phases["limit_cancel"] = client.cancel_order(symbol=str(symbol), product_type=product_type, margin_coin=margin_coin, order_id=limit_order_id, client_oid=limit_client_oid) elif isinstance(client, BitgetSpotClient): phases["limit_cancel"] = client.cancel_order(symbol=str(symbol), client_order_id=limit_client_oid) + elif isinstance(client, BybitClient): + phases["limit_cancel"] = client.cancel_order(symbol=str(symbol), order_id=limit_order_id, client_order_id=limit_client_oid) + elif isinstance(client, CoinbaseExchangeClient): + phases["limit_cancel"] = client.cancel_order(order_id=limit_order_id, client_order_id=limit_client_oid) + elif isinstance(client, KrakenClient): + phases["limit_cancel"] = client.cancel_order(order_id=limit_order_id) + elif isinstance(client, KrakenFuturesClient): + phases["limit_cancel"] = client.cancel_order(order_id=limit_order_id, client_order_id=limit_client_oid) + elif isinstance(client, KucoinSpotClient): + phases["limit_cancel"] = client.cancel_order(order_id=limit_order_id, client_order_id=limit_client_oid) + elif isinstance(client, KucoinFuturesClient): + phases["limit_cancel"] = client.cancel_order(order_id=limit_order_id, client_order_id=limit_client_oid) + elif isinstance(client, GateSpotClient): + phases["limit_cancel"] = client.cancel_order(order_id=limit_order_id) + elif isinstance(client, GateUsdtFuturesClient): + phases["limit_cancel"] = client.cancel_order(order_id=limit_order_id) + elif isinstance(client, BitfinexClient): + phases["limit_cancel"] = client.cancel_order(order_id=limit_order_id, client_order_id=limit_client_oid) + elif isinstance(client, BitfinexDerivativesClient): + phases["limit_cancel"] = client.cancel_order(order_id=limit_order_id, client_order_id=limit_client_oid) except Exception: pass except LiveTradingError as e: @@ -930,6 +1257,95 @@ class PendingOrderWorker: size=mkt_size, client_order_id=market_client_oid, ) + elif isinstance(client, BybitClient): + res2 = client.place_market_order( + symbol=str(symbol), + side=side, + qty=remaining, + reduce_only=reduce_only, + client_order_id=market_client_oid, + ) + elif isinstance(client, CoinbaseExchangeClient): + res2 = client.place_market_order( + symbol=str(symbol), + side=side, + size=remaining, + client_order_id=market_client_oid, + ) + elif isinstance(client, KrakenClient): + res2 = client.place_market_order( + symbol=str(symbol), + side=side, + size=remaining, + client_order_id=market_client_oid, + ) + elif isinstance(client, KrakenFuturesClient): + res2 = client.place_market_order( + symbol=str(symbol), + side=side, + size=remaining, + reduce_only=reduce_only, + client_order_id=market_client_oid, + ) + elif isinstance(client, KucoinSpotClient): + # KuCoin market BUY expects quote funds; convert base->quote using ref_price. + if side == "buy" and ref_price > 0: + res2 = client.place_market_order( + symbol=str(symbol), + side=side, + size=float(remaining) * float(ref_price), + quote_size=True, + client_order_id=market_client_oid, + ) + else: + res2 = client.place_market_order( + symbol=str(symbol), + side=side, + size=remaining, + quote_size=False, + client_order_id=market_client_oid, + ) + elif isinstance(client, KucoinFuturesClient): + try: + if market_type == "swap": + client.set_leverage(symbol=str(symbol), leverage=leverage) + except Exception: + pass + res2 = client.place_market_order( + symbol=str(symbol), + side=side, + size=remaining, + reduce_only=reduce_only, + client_order_id=market_client_oid, + ) + elif isinstance(client, GateSpotClient): + res2 = client.place_market_order( + symbol=str(symbol), + side=side, + size=remaining, + client_order_id=market_client_oid, + ) + elif isinstance(client, GateUsdtFuturesClient): + try: + client.set_leverage(contract=to_gate_currency_pair(str(symbol)), leverage=leverage) + except Exception: + pass + res2 = client.place_market_order( + symbol=str(symbol), + side=side, + size=remaining, + reduce_only=reduce_only, + client_order_id=market_client_oid, + ) + elif isinstance(client, BitfinexClient): + res2 = client.place_market_order( + symbol=str(symbol), + side=side, + size=remaining, + client_order_id=market_client_oid, + ) + elif isinstance(client, BitfinexDerivativesClient): + res2 = client.place_market_order(symbol=str(symbol), side=side, size=remaining, client_order_id=market_client_oid) else: raise LiveTradingError(f"Unsupported client type: {type(client)}") @@ -941,10 +1357,14 @@ class PendingOrderWorker: q2 = client.wait_for_fill(symbol=str(symbol), order_id=market_order_id, client_order_id=market_client_oid, max_wait_sec=3.0) phases["market_query"] = q2 _apply_fill(float(q2.get("filled") or 0.0), float(q2.get("avg_price") or 0.0)) + fee_v, fee_c = _fetch_fee_best_effort(order_id0=market_order_id, client_order_id0=market_client_oid) + _apply_fee(float(fee_v or 0.0), str(fee_c or "")) elif isinstance(client, BinanceSpotClient): q2 = client.wait_for_fill(symbol=str(symbol), order_id=market_order_id, client_order_id=market_client_oid, max_wait_sec=3.0) phases["market_query"] = q2 _apply_fill(float(q2.get("filled") or 0.0), float(q2.get("avg_price") or 0.0)) + fee_v, fee_c = _fetch_fee_best_effort(order_id0=market_order_id, client_order_id0=market_client_oid) + _apply_fee(float(fee_v or 0.0), str(fee_c or "")) elif isinstance(client, OkxClient): # OKX fills endpoint may lag shortly after execution; wait a bit longer to capture fee. q2 = client.wait_for_fill(symbol=str(symbol), ord_id=market_order_id, cl_ord_id=market_client_oid, market_type=market_type, max_wait_sec=12.0) @@ -961,6 +1381,48 @@ class PendingOrderWorker: q2 = client.wait_for_fill(symbol=str(symbol), order_id=market_order_id, client_order_id=market_client_oid, max_wait_sec=3.0) phases["market_query"] = q2 _apply_fill(float(q2.get("filled") or 0.0), float(q2.get("avg_price") or 0.0)) + _apply_fee(float(q2.get("fee") or 0.0), str(q2.get("fee_ccy") or "")) + elif isinstance(client, BybitClient): + q2 = client.wait_for_fill(symbol=str(symbol), order_id=market_order_id, client_order_id=market_client_oid, max_wait_sec=3.0) + phases["market_query"] = q2 + _apply_fill(float(q2.get("filled") or 0.0), float(q2.get("avg_price") or 0.0)) + elif isinstance(client, CoinbaseExchangeClient): + q2 = client.wait_for_fill(order_id=market_order_id, client_order_id=market_client_oid, max_wait_sec=3.0) + phases["market_query"] = q2 + _apply_fill(float(q2.get("filled") or 0.0), float(q2.get("avg_price") or 0.0)) + elif isinstance(client, KrakenClient): + q2 = client.wait_for_fill(order_id=market_order_id, max_wait_sec=3.0) + phases["market_query"] = q2 + _apply_fill(float(q2.get("filled") or 0.0), float(q2.get("avg_price") or 0.0)) + elif isinstance(client, KrakenFuturesClient): + q2 = client.wait_for_fill(order_id=market_order_id, client_order_id=market_client_oid, max_wait_sec=3.0) + phases["market_query"] = q2 + _apply_fill(float(q2.get("filled") or 0.0), float(q2.get("avg_price") or 0.0)) + elif isinstance(client, KucoinSpotClient): + q2 = client.wait_for_fill(order_id=market_order_id, max_wait_sec=3.0) + phases["market_query"] = q2 + _apply_fill(float(q2.get("filled") or 0.0), float(q2.get("avg_price") or 0.0)) + _apply_fee(float(q2.get("fee") or 0.0), str(q2.get("fee_ccy") or "")) + elif isinstance(client, KucoinFuturesClient): + q2 = client.wait_for_fill(order_id=market_order_id, max_wait_sec=3.0) + phases["market_query"] = q2 + _apply_fill(float(q2.get("filled") or 0.0), float(q2.get("avg_price") or 0.0)) + elif isinstance(client, GateSpotClient): + q2 = client.wait_for_fill(order_id=market_order_id, max_wait_sec=3.0) + phases["market_query"] = q2 + _apply_fill(float(q2.get("filled") or 0.0), float(q2.get("avg_price") or 0.0)) + elif isinstance(client, GateUsdtFuturesClient): + q2 = client.wait_for_fill(order_id=market_order_id, contract=to_gate_currency_pair(str(symbol)), max_wait_sec=3.0) + phases["market_query"] = q2 + _apply_fill(float(q2.get("filled") or 0.0), float(q2.get("avg_price") or 0.0)) + elif isinstance(client, BitfinexClient): + q2 = client.wait_for_fill(order_id=market_order_id, max_wait_sec=3.0) + phases["market_query"] = q2 + _apply_fill(float(q2.get("filled") or 0.0), float(q2.get("avg_price") or 0.0)) + elif isinstance(client, BitfinexDerivativesClient): + q2 = client.wait_for_fill(order_id=market_order_id, max_wait_sec=3.0) + phases["market_query"] = q2 + _apply_fill(float(q2.get("filled") or 0.0), float(q2.get("avg_price") or 0.0)) except LiveTradingError as e: logger.warning(f"live market phase failed: pending_id={order_id}, strategy_id={strategy_id}, cfg={safe_cfg}, err={e}") phases["market_error"] = str(e) @@ -1015,6 +1477,10 @@ class PendingOrderWorker: # Record trade + update local position snapshot (best-effort). try: if filled > 0 and avg_price > 0: + logger.info( + f"live record begin: pending_id={order_id} strategy_id={strategy_id} symbol={symbol} " + f"signal={signal_type} filled={filled} avg_price={avg_price} fee={total_fee} fee_ccy={fee_ccy}" + ) profit, _pos = apply_fill_to_local_position( strategy_id=strategy_id, symbol=str(symbol), @@ -1037,6 +1503,7 @@ class PendingOrderWorker: commission_ccy=str(fee_ccy or "").strip().upper(), profit=profit, ) + logger.info(f"live record done: pending_id={order_id} strategy_id={strategy_id} symbol={symbol} signal={signal_type}") except Exception as e: logger.warning(f"record_trade/update_position failed: pending_id={order_id}, err={e}") diff --git a/backend_api_python/app/services/signal_notifier.py b/backend_api_python/app/services/signal_notifier.py index 4740c43..23b234e 100644 --- a/backend_api_python/app/services/signal_notifier.py +++ b/backend_api_python/app/services/signal_notifier.py @@ -18,6 +18,8 @@ notification_config = { from __future__ import annotations import html +import hmac +import hashlib import json import os import smtplib @@ -186,6 +188,13 @@ class SignalNotifier: ok, err = self._notify_webhook( url=url, payload=payload, + headers_override=(targets.get("webhook_headers") or targets.get("webhookHeaders") or None), + token_override=(targets.get("webhook_token") or targets.get("webhookToken") or None), + signing_secret_override=( + targets.get("webhook_signing_secret") + or targets.get("webhookSigningSecret") + or None + ), ) elif c == "discord": url = (targets.get("discord") or "").strip() @@ -228,6 +237,11 @@ class SignalNotifier: ok, err = False, str(e) results[c] = {"ok": bool(ok), "error": (err or "")} + if not ok and c in ("webhook", "discord"): + # Keep logs high-signal and avoid leaking full URLs (webhook URLs contain secrets). + logger.info( + f"notify failed: channel={c} strategy_id={strategy_id} symbol={symbol} signal={signal_type} err={err}" + ) return results @@ -462,16 +476,93 @@ class SignalNotifier: logger.warning(f"browser notify persist failed: {e}") return False, str(e) - def _notify_webhook(self, *, url: str, payload: Dict[str, Any]) -> Tuple[bool, str]: + def _notify_webhook( + self, + *, + url: str, + payload: Dict[str, Any], + headers_override: Any = None, + token_override: Any = None, + signing_secret_override: Any = None, + ) -> Tuple[bool, str]: + """ + Generic webhook delivery. + + Supports (best-effort): + - per-strategy headers: notification_config.targets.webhook_headers (dict or JSON string) + - per-strategy bearer token: notification_config.targets.webhook_token + - global bearer token: SIGNAL_WEBHOOK_TOKEN + - optional signing secret: notification_config.targets.webhook_signing_secret or env SIGNAL_WEBHOOK_SIGNING_SECRET + Adds headers: + - X-QD-Timestamp: unix seconds + - X-QD-Signature: hex(HMAC_SHA256("{ts}.{body}", secret)) + - retry once on 429/5xx + """ if not url: return False, "missing_webhook_url" - headers = {"Content-Type": "application/json"} - if self.webhook_token: - headers["Authorization"] = f"Bearer {self.webhook_token}" + if not (str(url).startswith("http://") or str(url).startswith("https://")): + return False, "invalid_webhook_url" + + headers: Dict[str, str] = { + "Content-Type": "application/json", + "User-Agent": "QuantDinger/1.0 (+https://www.quantdinger.com)", + } + + # Per-strategy header overrides (optional) + wh = headers_override + if isinstance(wh, str) and wh.strip(): + try: + obj = json.loads(wh) + wh = obj if isinstance(obj, dict) else None + except Exception: + wh = None + if isinstance(wh, dict): + for k, v in wh.items(): + kk = str(k or "").strip() + if not kk: + continue + headers[kk] = str(v if v is not None else "") + + # Auth (per-strategy token first, fallback to global token) + tok = str(token_override or "").strip() + if not tok: + tok = self.webhook_token + if tok and "Authorization" not in headers: + headers["Authorization"] = f"Bearer {tok}" + + # Optional signing secret (per-strategy override, else env) + signing_secret = str(signing_secret_override or "").strip() or (os.getenv("SIGNAL_WEBHOOK_SIGNING_SECRET") or "").strip() + if signing_secret: + try: + ts = str(int(time.time())) + body = json.dumps(payload or {}, ensure_ascii=False, separators=(",", ":")).encode("utf-8") + sig_base = (ts + ".").encode("utf-8") + body + sig = hmac.new(signing_secret.encode("utf-8"), sig_base, hashlib.sha256).hexdigest() + headers["X-QD-Timestamp"] = ts + headers["X-QD-Signature"] = sig + # Send raw bytes so signature matches what we sign. + def _post_once(timeout: float) -> requests.Response: + return requests.post(url, data=body, headers=headers, timeout=timeout) + except Exception as e: + return False, f"webhook_signing_failed:{e}" + else: + def _post_once(timeout: float) -> requests.Response: + return requests.post(url, json=payload, headers=headers, timeout=timeout) + + # Post with minimal retry on 429/5xx try: - resp = requests.post(url, json=payload, headers=headers, timeout=self.timeout_sec) + resp = _post_once(self.timeout_sec) if 200 <= resp.status_code < 300: return True, "" + if resp.status_code in (429, 500, 502, 503, 504): + try: + time.sleep(1.0) + except Exception: + pass + resp2 = _post_once(self.timeout_sec) + if 200 <= resp2.status_code < 300: + return True, "" + return False, f"http_{resp2.status_code}:{(resp2.text or '')[:300]}" return False, f"http_{resp.status_code}:{(resp.text or '')[:300]}" except Exception as e: return False, str(e) @@ -479,6 +570,8 @@ class SignalNotifier: def _notify_discord(self, *, url: str, payload: Dict[str, Any], fallback_text: str) -> Tuple[bool, str]: if not url: return False, "missing_discord_webhook_url" + if not (str(url).startswith("http://") or str(url).startswith("https://")): + return False, "invalid_discord_webhook_url" strategy = (payload or {}).get("strategy") or {} instrument = (payload or {}).get("instrument") or {} @@ -508,15 +601,41 @@ class SignalNotifier: embed["timestamp"] = str(payload.get("timestamp_iso") or "") if trace.get("pending_order_id"): embed["footer"] = {"text": f"pending_order_id={int(trace.get('pending_order_id'))}"} + headers = { + "Content-Type": "application/json", + "User-Agent": "QuantDinger/1.0 (+https://www.quantdinger.com)", + } + + def _post(payload_json: Dict[str, Any]) -> requests.Response: + return requests.post(url, json=payload_json, headers=headers, timeout=self.timeout_sec) + try: - resp = requests.post(url, json={"content": "", "embeds": [embed]}, timeout=self.timeout_sec) + resp = _post({"content": "", "embeds": [embed]}) if 200 <= resp.status_code < 300: return True, "" - # Fallback: try plain text. + + # Rate limit: retry once if Discord asks us to. + if resp.status_code == 429: + try: + data = resp.json() if resp is not None else {} + retry_after = float((data or {}).get("retry_after") or 1.0) + time.sleep(min(max(retry_after, 0.5), 3.0)) + except Exception: + try: + time.sleep(1.0) + except Exception: + pass + resp_retry = _post({"content": "", "embeds": [embed]}) + if 200 <= resp_retry.status_code < 300: + return True, "" + resp = resp_retry + + # Fallback: plain text (some servers reject embeds) try: - resp2 = requests.post(url, json={"content": str(fallback_text or "")[:1900]}, timeout=self.timeout_sec) + resp2 = _post({"content": str(fallback_text or "")[:1900]}) if 200 <= resp2.status_code < 300: return True, "" + # If fallback also fails, return the original error (more useful than fallback sometimes). except Exception: pass return False, f"http_{resp.status_code}:{(resp.text or '')[:300]}" diff --git a/backend_api_python/app/services/strategy.py b/backend_api_python/app/services/strategy.py index 42ff578..1f87923 100644 --- a/backend_api_python/app/services/strategy.py +++ b/backend_api_python/app/services/strategy.py @@ -60,13 +60,174 @@ class StrategyService: 获取交易所交易对列表 (无需API Key) """ try: - import ccxt - exchange_id = exchange_config.get('exchange_id', '') proxies = exchange_config.get('proxies') if not exchange_id: return {'success': False, 'message': '请选择交易所', 'symbols': []} + + # For these exchanges, prefer direct REST (no ccxt), aligned with local live-trading design. + ex = str(exchange_id or "").strip().lower() + if ex in ("bybit", "coinbaseexchange", "coinbase_exchange", "kraken", "kucoin", "gate", "bitfinex"): + import requests + + def _req_json(url: str) -> Any: + r = requests.get(url, timeout=15, proxies=proxies) + r.raise_for_status() + return r.json() + + symbols: List[str] = [] + market_type = str(exchange_config.get("market_type") or exchange_config.get("defaultType") or "spot").strip().lower() + if market_type in ("futures", "future", "perp", "perpetual"): + market_type = "swap" + if ex == "bybit": + base = str(exchange_config.get("base_url") or exchange_config.get("baseUrl") or "https://api.bybit.com").rstrip("/") + cat = "spot" if market_type == "spot" else "linear" + j = _req_json(f"{base}/v5/market/instruments-info?category={cat}") + lst = (((j.get("result") or {}).get("list")) if isinstance(j, dict) else None) or [] + if isinstance(lst, list): + for it in lst: + if not isinstance(it, dict): + continue + sym = str(it.get("symbol") or "") + status = str(it.get("status") or "").lower() + if not sym or (status and status not in ("trading", "tradable", "online")): + continue + if sym.endswith("USDT") and len(sym) > 4: + symbols.append(f"{sym[:-4]}/USDT") + symbols = sorted(list(set(symbols))) + return {'success': True, 'message': f'获取成功,共 {len(symbols)} 个交易对', 'symbols': symbols} + + if ex in ("coinbaseexchange", "coinbase_exchange"): + base = str(exchange_config.get("base_url") or exchange_config.get("baseUrl") or "https://api.exchange.coinbase.com").rstrip("/") + j = _req_json(f"{base}/products") + if isinstance(j, list): + for it in j: + if not isinstance(it, dict): + continue + if str(it.get("status") or "").lower() not in ("online", ""): + continue + base_ccy = str(it.get("base_currency") or "").upper() + quote_ccy = str(it.get("quote_currency") or "").upper() + if quote_ccy == "USDT" and base_ccy: + symbols.append(f"{base_ccy}/USDT") + symbols = sorted(list(set(symbols))) + return {'success': True, 'message': f'获取成功,共 {len(symbols)} 个交易对', 'symbols': symbols} + + if ex == "kraken": + if market_type == "spot": + j = _req_json("https://api.kraken.com/0/public/AssetPairs") + res = (j.get("result") if isinstance(j, dict) else None) or {} + if isinstance(res, dict): + for _k, v in res.items(): + if not isinstance(v, dict): + continue + wsname = str(v.get("wsname") or "") + if not wsname or "/" not in wsname: + continue + base_ccy, quote_ccy = wsname.split("/", 1) + if str(quote_ccy).upper() == "USDT": + symbols.append(f"{str(base_ccy).upper()}/USDT") + else: + base = str(exchange_config.get("futures_base_url") or exchange_config.get("futuresBaseUrl") or "https://futures.kraken.com").rstrip("/") + j = _req_json(f"{base}/derivatives/api/v3/instruments") + instruments = j.get("instruments") if isinstance(j, dict) else None + if isinstance(instruments, list): + for it in instruments: + if not isinstance(it, dict): + continue + sym = str(it.get("symbol") or "") + typ = str(it.get("type") or "").lower() + if sym and ("perpetual" in typ or typ.startswith("pf") or sym.startswith("PF_")): + symbols.append(sym) + symbols = sorted(list(set(symbols))) + return {'success': True, 'message': f'获取成功,共 {len(symbols)} 个交易对', 'symbols': symbols} + + if ex == "kucoin": + if market_type == "spot": + base = str(exchange_config.get("base_url") or exchange_config.get("baseUrl") or "https://api.kucoin.com").rstrip("/") + j = _req_json(f"{base}/api/v1/symbols") + data = (j.get("data") if isinstance(j, dict) else None) or [] + if isinstance(data, list): + for it in data: + if not isinstance(it, dict): + continue + if not bool(it.get("enableTrading", True)): + continue + if str(it.get("quoteCurrency") or "").upper() != "USDT": + continue + b = str(it.get("baseCurrency") or "").upper() + if b: + symbols.append(f"{b}/USDT") + else: + base = str(exchange_config.get("futures_base_url") or exchange_config.get("futuresBaseUrl") or "https://api-futures.kucoin.com").rstrip("/") + j = _req_json(f"{base}/api/v1/contracts/active") + data = (j.get("data") if isinstance(j, dict) else None) or [] + if isinstance(data, list): + for it in data: + if not isinstance(it, dict): + continue + sym = str(it.get("symbol") or "") + if not sym or not sym.upper().endswith("USDTM"): + continue + base_ccy = sym[:-5].upper() + if base_ccy == "XBT": + base_ccy = "BTC" + if base_ccy: + symbols.append(f"{base_ccy}/USDT") + symbols = sorted(list(set(symbols))) + return {'success': True, 'message': f'获取成功,共 {len(symbols)} 个交易对', 'symbols': symbols} + + if ex == "gate": + base = str(exchange_config.get("base_url") or exchange_config.get("baseUrl") or "https://api.gateio.ws").rstrip("/") + if market_type == "spot": + j = _req_json(f"{base}/api/v4/spot/currency_pairs") + if isinstance(j, list): + for it in j: + if not isinstance(it, dict): + continue + if str(it.get("trade_status") or "").lower() not in ("tradable", "trading", ""): + continue + base_ccy = str(it.get("base") or "").upper() + quote_ccy = str(it.get("quote") or "").upper() + if quote_ccy == "USDT" and base_ccy: + symbols.append(f"{base_ccy}/USDT") + else: + j = _req_json(f"{base}/api/v4/futures/usdt/contracts") + if isinstance(j, list): + for it in j: + if not isinstance(it, dict): + continue + name = str(it.get("name") or it.get("contract") or "") + if name and name.upper().endswith("_USDT"): + symbols.append(name.replace("_", "/")) + symbols = sorted(list(set(symbols))) + return {'success': True, 'message': f'获取成功,共 {len(symbols)} 个交易对', 'symbols': symbols} + + if ex == "bitfinex": + j = _req_json("https://api-pub.bitfinex.com/v2/conf/pub:list:pair:exchange") if market_type == "spot" else _req_json( + "https://api-pub.bitfinex.com/v2/conf/pub:list:pair:futures" + ) + pairs = [] + if isinstance(j, list) and j and isinstance(j[0], list): + pairs = j[0] + for p in pairs: + s = str(p or "").upper() + if not s: + continue + if market_type != "spot": + symbols.append(s) + continue + # Focus USDT (Bitfinex uses UST) + if s.endswith("UST") and len(s) > 3: + symbols.append(f"{s[:-3]}/USDT") + elif s.endswith("USDT") and len(s) > 4: + symbols.append(f"{s[:-4]}/USDT") + symbols = sorted(list(set(symbols))) + return {'success': True, 'message': f'获取成功,共 {len(symbols)} 个交易对', 'symbols': symbols} + return {'success': True, 'message': '获取成功', 'symbols': symbols} + + import ccxt # 创建交易所实例 (public only) exchange_class = getattr(ccxt, exchange_id, None) @@ -112,6 +273,14 @@ class StrategyService: from app.services.live_trading.binance_spot import BinanceSpotClient from app.services.live_trading.okx import OkxClient from app.services.live_trading.bitget import BitgetMixClient + from app.services.live_trading.bybit import BybitClient + from app.services.live_trading.coinbase_exchange import CoinbaseExchangeClient + from app.services.live_trading.kraken import KrakenClient + from app.services.live_trading.kraken_futures import KrakenFuturesClient + from app.services.live_trading.kucoin import KucoinSpotClient + from app.services.live_trading.kucoin import KucoinFuturesClient + from app.services.live_trading.gate import GateSpotClient, GateUsdtFuturesClient + from app.services.live_trading.bitfinex import BitfinexClient, BitfinexDerivativesClient resolved = resolve_exchange_config(exchange_config or {}) safe_cfg = safe_exchange_config_for_log(resolved) @@ -160,6 +329,26 @@ class StrategyService: elif isinstance(client, BitgetMixClient): product_type = str(resolved.get("product_type") or resolved.get("productType") or "USDT-FUTURES") priv_data = client.get_accounts(product_type=product_type) + elif isinstance(client, BybitClient): + priv_data = client.get_wallet_balance() + elif isinstance(client, CoinbaseExchangeClient): + priv_data = client.get_accounts() + elif isinstance(client, KrakenClient): + priv_data = client.get_balance() + elif isinstance(client, KrakenFuturesClient): + priv_data = client.get_accounts() + elif isinstance(client, KucoinSpotClient): + priv_data = client.get_accounts() + elif isinstance(client, KucoinFuturesClient): + priv_data = client.get_accounts() + elif isinstance(client, GateSpotClient): + priv_data = client.get_accounts() + elif isinstance(client, GateUsdtFuturesClient): + priv_data = client.get_accounts() + elif isinstance(client, BitfinexClient): + priv_data = client.get_wallets() + elif isinstance(client, BitfinexDerivativesClient): + priv_data = client.get_wallets() except Exception as e: msg = str(e) # Add actionable hints for the most common Binance auth error. diff --git a/backend_api_python/app/services/trading_executor.py b/backend_api_python/app/services/trading_executor.py index 0f51ed8..c030f35 100644 --- a/backend_api_python/app/services/trading_executor.py +++ b/backend_api_python/app/services/trading_executor.py @@ -1889,7 +1889,9 @@ class TradingExecutor: if market_type == 'spot': amount = available_capital * position_ratio / current_price else: - amount = (initial_capital * position_ratio * leverage) / current_price + # Futures sizing: treat available_capital as margin budget. + # Notional = margin * leverage, so base quantity = (margin * leverage) / price. + amount = (available_capital * position_ratio * leverage) / current_price # Reduce sizing: position_size is treated as a reduce ratio (close X% of current position). if sig in ("reduce_long", "reduce_short"): diff --git a/quantdinger_vue/Dockerfile b/quantdinger_vue/Dockerfile deleted file mode 100644 index 3ebc1f5..0000000 --- a/quantdinger_vue/Dockerfile +++ /dev/null @@ -1,6 +0,0 @@ -FROM nginx - -RUN rm /etc/nginx/conf.d/default.conf - -ADD deploy/nginx.conf /etc/nginx/conf.d/default.conf -COPY dist/ /usr/share/nginx/html/ diff --git a/quantdinger_vue/LICENSE b/quantdinger_vue/LICENSE deleted file mode 100644 index 66eef0b..0000000 --- a/quantdinger_vue/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2018 Anan Yang - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. \ No newline at end of file diff --git a/quantdinger_vue/src/locales/lang/en-US.js b/quantdinger_vue/src/locales/lang/en-US.js index 51f45f5..9313c69 100644 --- a/quantdinger_vue/src/locales/lang/en-US.js +++ b/quantdinger_vue/src/locales/lang/en-US.js @@ -934,6 +934,7 @@ const locale = { 'dashboard.aiStrategies': 'AI Strategies', 'dashboard.indicatorStrategies': 'Indicator Strategies', 'dashboard.running': 'Running', + 'dashboard.enabled': 'Enabled', 'dashboard.pnlHistory': 'P&L History', 'dashboard.strategyPerformance': 'Strategy Performance', 'dashboard.recentTrades': 'Recent Trades', @@ -959,6 +960,7 @@ const locale = { 'dashboard.orderTable.amount': 'Amount', 'dashboard.orderTable.price': 'Filled Price', 'dashboard.orderTable.status': 'Status', + 'dashboard.orderTable.timeInfo': 'Time', 'dashboard.orderTable.executedAt': 'Executed Time', 'dashboard.orderTable.exchange': 'Exchange', 'dashboard.orderTable.notify': 'Notify', diff --git a/quantdinger_vue/src/locales/lang/zh-CN.js b/quantdinger_vue/src/locales/lang/zh-CN.js index 16a0b91..4ef8cdd 100644 --- a/quantdinger_vue/src/locales/lang/zh-CN.js +++ b/quantdinger_vue/src/locales/lang/zh-CN.js @@ -842,6 +842,7 @@ const locale = { 'dashboard.aiStrategies': 'AI 策略', 'dashboard.indicatorStrategies': '指标策略', 'dashboard.running': '运行中', + 'dashboard.enabled': '已启用', 'dashboard.pnlHistory': '历史盈亏', 'dashboard.strategyPerformance': '策略盈亏占比', 'dashboard.recentTrades': '最近交易', @@ -867,6 +868,7 @@ const locale = { 'dashboard.orderTable.amount': '数量', 'dashboard.orderTable.price': '成交价', 'dashboard.orderTable.status': '状态', + 'dashboard.orderTable.timeInfo': '时间', 'dashboard.orderTable.executedAt': '执行时间', 'dashboard.orderTable.exchange': '交易所', 'dashboard.orderTable.notify': '通知方式', diff --git a/quantdinger_vue/src/locales/lang/zh-TW.js b/quantdinger_vue/src/locales/lang/zh-TW.js index d537441..2cc3cb6 100644 --- a/quantdinger_vue/src/locales/lang/zh-TW.js +++ b/quantdinger_vue/src/locales/lang/zh-TW.js @@ -843,6 +843,7 @@ const locale = { 'dashboard.aiStrategies': 'AI 策略', 'dashboard.indicatorStrategies': '指標策略', 'dashboard.running': '運行中', + 'dashboard.enabled': '已啟用', 'dashboard.pnlHistory': '歷史盈虧', 'dashboard.strategyPerformance': '策略盈虧佔比', 'dashboard.recentTrades': '最近交易', @@ -868,6 +869,7 @@ const locale = { 'dashboard.orderTable.amount': '數量', 'dashboard.orderTable.price': '成交價', 'dashboard.orderTable.status': '狀態', + 'dashboard.orderTable.timeInfo': '時間', 'dashboard.orderTable.executedAt': '執行時間', 'dashboard.orderTable.exchange': '交易所', 'dashboard.orderTable.notify': '通知方式', diff --git a/quantdinger_vue/src/views/dashboard/index.vue b/quantdinger_vue/src/views/dashboard/index.vue index 2e38fad..6dd86ab 100644 --- a/quantdinger_vue/src/views/dashboard/index.vue +++ b/quantdinger_vue/src/views/dashboard/index.vue @@ -41,7 +41,7 @@ -
+
@@ -49,7 +49,7 @@
{{ $t('dashboard.aiStrategies') }}
{{ summary.ai_strategy_count }} - {{ $t('dashboard.running') }} + {{ $t('dashboard.enabled') }}
@@ -229,29 +229,12 @@
-
- - -