8563e4ea53
Signed-off-by: Dinger <quantdinger@gmail.com>
1514 lines
66 KiB
Python
1514 lines
66 KiB
Python
"""
|
||
Quick Trade API — manual / discretionary order placement.
|
||
|
||
Allows users to place market or limit orders directly from AI analysis
|
||
or indicator analysis pages, without creating a strategy first.
|
||
|
||
Endpoints:
|
||
POST /api/quick-trade/place-order — Place a quick order
|
||
POST /api/quick-trade/close-position — Close an existing position
|
||
GET /api/quick-trade/balance — Get available balance
|
||
GET /api/quick-trade/position — Get current position for symbol
|
||
GET /api/quick-trade/history — Get quick trade history
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import time
|
||
import traceback
|
||
import uuid
|
||
from typing import Any, Dict
|
||
|
||
from flask import Blueprint, g, jsonify, request
|
||
|
||
from app.utils.db import get_db_connection
|
||
from app.utils.logger import get_logger
|
||
from app.utils.auth import login_required
|
||
from app.utils.credential_crypto import decrypt_credential_blob
|
||
|
||
logger = get_logger(__name__)
|
||
|
||
import re as _re
|
||
|
||
_FRIENDLY_ERROR_PATTERNS = [
|
||
# Insufficient balance / margin
|
||
(_re.compile(r"INSUFFICIENT[_ ]?AVAILABLE|insufficient.{0,20}(balance|margin|fund)|margin.{0,30}while available|not enough|资金不足", _re.IGNORECASE),
|
||
"quickTrade.errorHints.insufficientBalance"),
|
||
# Invalid size / quantity
|
||
(_re.compile(r"invalid.{0,10}size|invalid.{0,10}(qty|quantity|amount|volume)|Order size.{0,20}(too small|below|minimum)|MIN_NOTIONAL", _re.IGNORECASE),
|
||
"quickTrade.errorHints.invalidSize"),
|
||
# Invalid price
|
||
(_re.compile(r"invalid.{0,10}price|price.{0,20}(deviate|deviation|exceed|out of range)", _re.IGNORECASE),
|
||
"quickTrade.errorHints.invalidPrice"),
|
||
# Rate limit
|
||
(_re.compile(r"rate.?limit|too many request|429|REQUEST_FREQUENCY", _re.IGNORECASE),
|
||
"quickTrade.errorHints.rateLimit"),
|
||
# API key / permission
|
||
(_re.compile(r"(invalid|wrong|expired).{0,10}(api.?key|key|signature|sign)|NOT_LOGIN|UNAUTHORIZED|permission.{0,10}denied|IP.{0,20}(not|whitelist|restrict)", _re.IGNORECASE),
|
||
"quickTrade.errorHints.authError"),
|
||
# Position / reduce-only conflict
|
||
(_re.compile(r"reduce.?only|position.{0,20}(not exist|not found|side)|POSITION_NOT_EXIST", _re.IGNORECASE),
|
||
"quickTrade.errorHints.positionConflict"),
|
||
# Network / timeout
|
||
(_re.compile(r"timeout|timed? ?out|connect|ECONNREFUSED|SSL|ConnectionError|RemoteDisconnected", _re.IGNORECASE),
|
||
"quickTrade.errorHints.networkError"),
|
||
# Exchange maintenance
|
||
(_re.compile(r"maintenance|unavailable|system.{0,10}(busy|error|upgrade)|suspend|暂停", _re.IGNORECASE),
|
||
"quickTrade.errorHints.exchangeMaintenance"),
|
||
]
|
||
|
||
|
||
def _parse_trade_error_hint(error_str: str) -> str:
|
||
"""Return a i18n key hint for common exchange trading errors, or empty string."""
|
||
s = str(error_str or "")
|
||
for pattern, hint_key in _FRIENDLY_ERROR_PATTERNS:
|
||
if pattern.search(s):
|
||
return hint_key
|
||
return ""
|
||
|
||
quick_trade_bp = Blueprint('quick_trade', __name__)
|
||
|
||
|
||
# ────────── helpers ──────────
|
||
|
||
def _symbols_match_quick_trade(user_symbol: str, position_symbol: str) -> bool:
|
||
"""Match UI symbol (e.g. ETH/USDT) with exchange-native ids (e.g. ETH_USDT, ETH-USDT-SWAP)."""
|
||
|
||
def norm(x: str) -> str:
|
||
return (x or "").strip().upper().replace("/", "").replace("-", "").replace("_", "")
|
||
|
||
a, b = norm(user_symbol), norm(position_symbol)
|
||
if not a or not b:
|
||
return False
|
||
if a == b:
|
||
return True
|
||
for suf in ("SWAP", "PERPETUAL", "PERP"):
|
||
if b.endswith(suf) and a == b[: -len(suf)]:
|
||
return True
|
||
if a.endswith(suf) and b == a[: -len(suf)]:
|
||
return True
|
||
# Substring fallback for less standard ids (min length avoids ETH vs ETHW false positives)
|
||
return (len(a) >= 6 and a in b) or (len(b) >= 6 and b in a)
|
||
|
||
|
||
def _convert_usdt_to_base_qty(client, symbol: str, usdt_amount: float, market_type: str, limit_price: float = 0.0) -> float:
|
||
"""
|
||
Convert USDT amount to base asset quantity for all exchanges.
|
||
|
||
This is a unified function that works for all exchanges.
|
||
For spot: converts USDT -> base qty (e.g., 100 USDT -> 0.033 ETH)
|
||
For swap: converts USDT -> base qty (e.g., 100 USDT -> 0.033 ETH), which will then be converted to contracts
|
||
|
||
Args:
|
||
client: Exchange client instance
|
||
symbol: Trading pair (e.g., "ETH/USDT")
|
||
usdt_amount: USDT amount to convert
|
||
market_type: "spot" or "swap"
|
||
limit_price: For limit orders, use this price if provided (optional)
|
||
|
||
Returns:
|
||
Base asset quantity
|
||
"""
|
||
if usdt_amount <= 0:
|
||
return usdt_amount
|
||
|
||
try:
|
||
# Try to get current price from exchange
|
||
current_price = 0.0
|
||
|
||
# For limit orders, use the provided price
|
||
if limit_price > 0:
|
||
current_price = limit_price
|
||
logger.info(f"Using limit price {limit_price} for USDT conversion")
|
||
else:
|
||
# Try to get current market price from exchange
|
||
if hasattr(client, "get_ticker"):
|
||
try:
|
||
ticker = client.get_ticker(symbol=symbol)
|
||
if isinstance(ticker, dict):
|
||
current_price = float(ticker.get("last") or ticker.get("lastPx") or ticker.get("close") or ticker.get("price") or 0)
|
||
except Exception:
|
||
current_price = 0.0
|
||
|
||
# OKX
|
||
from app.services.live_trading.okx import OkxClient
|
||
if current_price <= 0 and isinstance(client, OkxClient):
|
||
try:
|
||
from app.services.live_trading.symbols import to_okx_spot_inst_id, to_okx_swap_inst_id
|
||
inst_id = to_okx_spot_inst_id(symbol) if market_type == "spot" else to_okx_swap_inst_id(symbol)
|
||
logger.debug(f"OKX: Getting ticker for inst_id={inst_id}, symbol={symbol}, market_type={market_type}")
|
||
ticker = client.get_ticker(inst_id=inst_id)
|
||
if ticker:
|
||
current_price = float(ticker.get("last") or ticker.get("lastPx") or 0)
|
||
logger.debug(f"OKX: Got price {current_price} from ticker")
|
||
else:
|
||
logger.warning(f"OKX: get_ticker returned empty result for inst_id={inst_id}")
|
||
except AttributeError as e:
|
||
logger.error(f"OKX: get_ticker method not found: {e}")
|
||
raise
|
||
except Exception as e:
|
||
logger.error(f"OKX: Failed to get ticker: {e}")
|
||
raise
|
||
|
||
# Binance - try to get price from public API
|
||
from app.services.live_trading.binance import BinanceFuturesClient
|
||
from app.services.live_trading.binance_spot import BinanceSpotClient
|
||
if current_price <= 0 and isinstance(client, (BinanceFuturesClient, BinanceSpotClient)):
|
||
try:
|
||
# Binance public ticker endpoint
|
||
base_url = getattr(client, "base_url", "")
|
||
if "binance" in base_url.lower():
|
||
import requests
|
||
if isinstance(client, BinanceFuturesClient):
|
||
ticker_url = f"{base_url}/fapi/v1/ticker/price"
|
||
else:
|
||
ticker_url = f"{base_url}/api/v3/ticker/price"
|
||
from app.services.live_trading.symbols import to_binance_futures_symbol
|
||
# Binance spot and futures use the same symbol format
|
||
sym = to_binance_futures_symbol(symbol)
|
||
resp = requests.get(ticker_url, params={"symbol": sym}, timeout=5)
|
||
if resp.status_code == 200:
|
||
data = resp.json()
|
||
if isinstance(data, dict):
|
||
current_price = float(data.get("price") or 0)
|
||
except Exception:
|
||
pass
|
||
|
||
# Bybit v5 — same host as trading API; tickers/orderbook are public
|
||
from app.services.live_trading.bybit import BybitClient
|
||
if current_price <= 0 and isinstance(client, BybitClient):
|
||
try:
|
||
import requests
|
||
from app.services.live_trading.symbols import to_bybit_symbol
|
||
|
||
bu = (getattr(client, "base_url", "") or "").rstrip("/")
|
||
bsym = to_bybit_symbol(symbol).upper()
|
||
cat = "spot" if (market_type or "").strip().lower() == "spot" else "linear"
|
||
if bu and bsym:
|
||
tr = requests.get(
|
||
f"{bu}/v5/market/tickers",
|
||
params={"category": cat, "symbol": bsym},
|
||
timeout=8,
|
||
)
|
||
if tr.status_code == 200:
|
||
jd = tr.json() if tr.text else {}
|
||
lst = (((jd.get("result") or {}).get("list")) or []) if isinstance(jd, dict) else []
|
||
if lst and isinstance(lst[0], dict):
|
||
t0 = lst[0]
|
||
current_price = float(
|
||
str(
|
||
t0.get("lastPrice")
|
||
or t0.get("markPrice")
|
||
or t0.get("indexPrice")
|
||
or 0
|
||
).replace(",", "")
|
||
or 0
|
||
)
|
||
if current_price <= 0:
|
||
obr = requests.get(
|
||
f"{bu}/v5/market/orderbook",
|
||
params={"category": cat, "symbol": bsym, "limit": 25},
|
||
timeout=8,
|
||
)
|
||
if obr.status_code == 200:
|
||
od = obr.json() if obr.text else {}
|
||
res = (od.get("result") or {}) if isinstance(od, dict) else {}
|
||
bids = res.get("b") or []
|
||
asks = res.get("a") or []
|
||
bp = float(str(bids[0][0]).replace(",", "")) if bids and bids[0] else 0.0
|
||
ap = float(str(asks[0][0]).replace(",", "")) if asks and asks[0] else 0.0
|
||
if bp > 0 and ap > 0:
|
||
current_price = (bp + ap) / 2.0
|
||
else:
|
||
current_price = bp or ap
|
||
except Exception:
|
||
pass
|
||
|
||
# Other exchanges - can be added as needed
|
||
# For exchanges without price API, we'll use a fallback
|
||
|
||
if current_price > 0:
|
||
base_qty = usdt_amount / current_price
|
||
logger.info(f"Converted USDT amount {usdt_amount} to base qty {base_qty:.8f} using price {current_price} for {symbol}")
|
||
return base_qty
|
||
else:
|
||
# Can't get price - this is critical for quick trade
|
||
# Quick trade always expects USDT input, so we must convert
|
||
logger.error(f"CRITICAL: Could not get price for {symbol} on {type(client).__name__} to convert USDT amount {usdt_amount}")
|
||
logger.error(f"This will cause order to fail. Please check exchange API connectivity or symbol format.")
|
||
# Still return original amount as fallback, but log error
|
||
return usdt_amount
|
||
|
||
except Exception as e:
|
||
logger.warning(f"Failed to convert USDT amount to base qty: {e}, using original amount")
|
||
return usdt_amount
|
||
|
||
|
||
def _safe_json(v, default=None):
|
||
if v is None:
|
||
return default
|
||
if isinstance(v, (dict, list)):
|
||
return v
|
||
try:
|
||
return json.loads(v) if isinstance(v, str) else default
|
||
except Exception:
|
||
return default
|
||
|
||
|
||
def _load_credential(credential_id: int, user_id: int) -> Dict[str, Any]:
|
||
"""Load exchange credential JSON for the given user."""
|
||
with get_db_connection() as db:
|
||
cur = db.cursor()
|
||
cur.execute(
|
||
"SELECT encrypted_config FROM qd_exchange_credentials WHERE id = %s AND user_id = %s",
|
||
(int(credential_id), int(user_id)),
|
||
)
|
||
row = cur.fetchone() or {}
|
||
cur.close()
|
||
try:
|
||
plain = decrypt_credential_blob(row.get("encrypted_config"))
|
||
except ValueError as e:
|
||
logger.warning(f"decrypt credential_id={credential_id}: {e}")
|
||
return {}
|
||
return _safe_json(plain, {})
|
||
|
||
|
||
def _build_exchange_config(credential_id: int, user_id: int, overrides: Dict[str, Any] = None) -> Dict[str, Any]:
|
||
"""Build exchange config from saved credential + overrides."""
|
||
base = _load_credential(credential_id, user_id)
|
||
if not base:
|
||
raise ValueError("Credential not found or access denied")
|
||
if overrides:
|
||
for k, v in overrides.items():
|
||
if v is not None and (not isinstance(v, str) or v.strip()):
|
||
base[k] = v
|
||
return base
|
||
|
||
|
||
def _create_client(exchange_config: Dict[str, Any], market_type: str = "swap"):
|
||
"""Create exchange client from config."""
|
||
from app.services.live_trading.factory import create_client
|
||
return create_client(exchange_config, market_type=market_type)
|
||
|
||
|
||
def _record_quick_trade(
|
||
user_id: int,
|
||
credential_id: int,
|
||
exchange_id: str,
|
||
symbol: str,
|
||
side: str,
|
||
order_type: str,
|
||
amount: float,
|
||
price: float,
|
||
leverage: int,
|
||
market_type: str,
|
||
tp_price: float,
|
||
sl_price: float,
|
||
status: str,
|
||
exchange_order_id: str,
|
||
filled: float,
|
||
avg_price: float,
|
||
error_msg: str,
|
||
source: str,
|
||
raw_result: Dict[str, Any],
|
||
):
|
||
"""Insert a quick trade record into the database."""
|
||
try:
|
||
with get_db_connection() as db:
|
||
cur = db.cursor()
|
||
cur.execute(
|
||
"""
|
||
INSERT INTO qd_quick_trades
|
||
(user_id, credential_id, exchange_id, symbol, side, order_type,
|
||
amount, price, leverage, market_type, tp_price, sl_price,
|
||
status, exchange_order_id, filled_amount, avg_fill_price,
|
||
error_msg, source, raw_result, created_at)
|
||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW())
|
||
RETURNING id
|
||
""",
|
||
(
|
||
user_id, credential_id, exchange_id, symbol, side, order_type,
|
||
amount, price, leverage, market_type, tp_price, sl_price,
|
||
status, exchange_order_id, filled, avg_price,
|
||
error_msg, source, json.dumps(raw_result or {}),
|
||
),
|
||
)
|
||
row = cur.fetchone()
|
||
db.commit()
|
||
cur.close()
|
||
return (row or {}).get("id")
|
||
except Exception as e:
|
||
logger.error(f"Failed to record quick trade: {e}")
|
||
return None
|
||
|
||
|
||
# ────────── endpoints ──────────
|
||
|
||
@quick_trade_bp.route('/place-order', methods=['POST'])
|
||
@login_required
|
||
def place_order():
|
||
"""
|
||
Place a quick market or limit order.
|
||
|
||
Body JSON:
|
||
credential_id (int) — saved exchange credential ID
|
||
symbol (str) — e.g. "BTC/USDT"
|
||
side (str) — "buy" or "sell"
|
||
order_type (str) — "market" or "limit" (default: market)
|
||
amount (float) — USDT amount (always in USDT, will be converted to base qty)
|
||
price (float) — limit price (required for limit orders)
|
||
leverage (int) — leverage multiplier (default: 1)
|
||
- leverage = 1: spot market
|
||
- leverage > 1: swap (perpetual futures) market
|
||
market_type (str) — "swap" / "spot" (optional, auto-determined by leverage if not provided)
|
||
tp_price (float) — take-profit price (optional, for record only)
|
||
sl_price (float) — stop-loss price (optional, for record only)
|
||
source (str) — "ai_radar" / "ai_analysis" / "indicator" / "manual"
|
||
"""
|
||
try:
|
||
user_id = g.user_id
|
||
body = request.get_json(force=True, silent=True) or {}
|
||
|
||
credential_id = int(body.get("credential_id") or 0)
|
||
symbol = str(body.get("symbol") or "").strip()
|
||
side = str(body.get("side") or "").strip().lower()
|
||
order_type = str(body.get("order_type") or "market").strip().lower()
|
||
usdt_amount = float(body.get("amount") or 0) # Always USDT amount
|
||
price = float(body.get("price") or 0)
|
||
leverage = int(body.get("leverage") or 1)
|
||
market_type = str(body.get("market_type") or "").strip().lower()
|
||
tp_price = float(body.get("tp_price") or 0)
|
||
sl_price = float(body.get("sl_price") or 0)
|
||
source = str(body.get("source") or "manual").strip()
|
||
margin_mode = str(body.get("margin_mode") or body.get("marginMode") or "").strip().lower()
|
||
if margin_mode in ("cross", "crossed"):
|
||
margin_mode = "cross"
|
||
elif margin_mode in ("iso", "isolated"):
|
||
margin_mode = "isolated"
|
||
else:
|
||
margin_mode = ""
|
||
|
||
# ---- validation ----
|
||
if not credential_id:
|
||
return jsonify({"code": 0, "msg": "Missing credential_id"}), 400
|
||
if not symbol:
|
||
return jsonify({"code": 0, "msg": "Missing symbol"}), 400
|
||
if side not in ("buy", "sell"):
|
||
return jsonify({"code": 0, "msg": "side must be 'buy' or 'sell'"}), 400
|
||
if usdt_amount <= 0:
|
||
return jsonify({"code": 0, "msg": "amount must be > 0"}), 400
|
||
if order_type == "limit" and price <= 0:
|
||
return jsonify({"code": 0, "msg": "price required for limit orders"}), 400
|
||
|
||
# ---- market_type: leverage 1 => spot API, else perpetual (swap) ----
|
||
if market_type in ("futures", "future", "perp", "perpetual"):
|
||
market_type = "swap"
|
||
if leverage > 1:
|
||
market_type = "swap"
|
||
else:
|
||
market_type = "spot"
|
||
|
||
# ---- build exchange client ----
|
||
cfg_overrides: Dict[str, Any] = {"market_type": market_type}
|
||
if margin_mode in ("cross", "isolated"):
|
||
cfg_overrides["margin_mode"] = margin_mode
|
||
cfg_overrides["td_mode"] = margin_mode
|
||
exchange_config = _build_exchange_config(credential_id, user_id, cfg_overrides)
|
||
exchange_id = (exchange_config.get("exchange_id") or "").strip().lower()
|
||
if not exchange_id:
|
||
return jsonify({"code": 0, "msg": "Invalid credential: missing exchange_id"}), 400
|
||
|
||
client = _create_client(exchange_config, market_type=market_type)
|
||
|
||
# Binance USDT-M: sync isolated/cross margin mode (best-effort; may fail if open orders exist)
|
||
if market_type != "spot" and margin_mode in ("cross", "isolated"):
|
||
try:
|
||
from app.services.live_trading.binance import BinanceFuturesClient
|
||
if isinstance(client, BinanceFuturesClient):
|
||
client.set_margin_type(symbol=symbol, margin_mode=margin_mode)
|
||
except Exception as me:
|
||
logger.warning(f"Binance set_margin_type failed (non-fatal): {me}")
|
||
|
||
# ---- Convert USDT amount to base asset quantity ----
|
||
# Quick trade always accepts USDT amount, convert to base qty for all exchanges
|
||
# For limit orders, use the provided price; for market orders, fetch current price
|
||
limit_price_for_conversion = price if order_type == "limit" and price > 0 else 0.0
|
||
base_qty = _convert_usdt_to_base_qty(client, symbol, usdt_amount, market_type, limit_price_for_conversion)
|
||
|
||
# Validate conversion: if base_qty equals usdt_amount, conversion likely failed
|
||
# For swap markets, base_qty should be much smaller than usdt_amount (e.g., 100 USDT -> 0.033 ETH)
|
||
if market_type != "spot" and base_qty == usdt_amount and usdt_amount >= 1:
|
||
logger.error(f"USDT conversion may have failed: base_qty ({base_qty}) equals usdt_amount ({usdt_amount})")
|
||
logger.error(f"This suggests the price fetch failed. Order may fail due to insufficient margin.")
|
||
|
||
# ---- set leverage (futures only) ----
|
||
if market_type != "spot" and leverage > 1:
|
||
try:
|
||
if hasattr(client, "set_leverage"):
|
||
from app.services.live_trading.okx import OkxClient
|
||
from app.services.live_trading.gate import GateUsdtFuturesClient
|
||
|
||
# OKX requires inst_id instead of symbol
|
||
if isinstance(client, OkxClient):
|
||
from app.services.live_trading.symbols import to_okx_swap_inst_id
|
||
inst_id = to_okx_swap_inst_id(symbol)
|
||
client.set_leverage(inst_id=inst_id, lever=leverage)
|
||
# Gate requires contract (currency_pair) instead of symbol
|
||
elif isinstance(client, GateUsdtFuturesClient):
|
||
from app.services.live_trading.symbols import to_gate_currency_pair
|
||
contract = to_gate_currency_pair(symbol)
|
||
if not client.set_leverage(contract=contract, leverage=leverage):
|
||
logger.warning(
|
||
"Gate set_leverage failed (contract=%s lev=%s); order may use exchange default leverage",
|
||
contract,
|
||
leverage,
|
||
)
|
||
# Most other exchanges use symbol
|
||
else:
|
||
# Try common parameter names
|
||
try:
|
||
client.set_leverage(symbol=symbol, leverage=leverage)
|
||
except TypeError:
|
||
try:
|
||
client.set_leverage(symbol=symbol, lever=leverage)
|
||
except TypeError:
|
||
pass
|
||
except Exception as le:
|
||
logger.warning(f"set_leverage failed (non-fatal): {le}")
|
||
|
||
# ---- place order ----
|
||
# Generate client_order_id: OKX clOrdId requirements: 1-32 chars, alphanumeric, underscore, hyphen only
|
||
timestamp_suffix = str(int(time.time()))[-6:] # Last 6 digits of timestamp
|
||
uuid_suffix = uuid.uuid4().hex[:8] # 8 hex chars
|
||
client_order_id = f"qt{timestamp_suffix}{uuid_suffix}" # Total: 2 + 6 + 8 = 16 chars
|
||
|
||
result = None
|
||
if order_type == "market":
|
||
# Use execution.py's place_order_from_signal for market orders to ensure consistency
|
||
# Convert side to signal_type: buy -> open_long, sell -> open_short (for swap) or close_long (for spot)
|
||
from app.services.live_trading.execution import place_order_from_signal
|
||
|
||
if market_type == "spot":
|
||
# Spot: buy = open_long, sell = close_long (assuming we're closing a position)
|
||
signal_type = "open_long" if side == "buy" else "close_long"
|
||
else:
|
||
# Swap: buy = open_long, sell = open_short
|
||
signal_type = "open_long" if side == "buy" else "open_short"
|
||
|
||
result = place_order_from_signal(
|
||
client=client,
|
||
signal_type=signal_type,
|
||
symbol=symbol,
|
||
amount=base_qty, # Use converted base qty
|
||
market_type=market_type,
|
||
exchange_config=exchange_config,
|
||
client_order_id=client_order_id,
|
||
)
|
||
else:
|
||
# Limit orders: use direct client call (execution.py doesn't handle limit orders)
|
||
result = client.place_limit_order(
|
||
symbol=symbol,
|
||
side=side.upper() if "binance" in exchange_id else side,
|
||
**_limit_order_kwargs(client, symbol, base_qty, price, side, market_type, client_order_id),
|
||
)
|
||
|
||
# ---- extract result ----
|
||
exchange_order_id = str(getattr(result, "exchange_order_id", "") or "")
|
||
filled = float(getattr(result, "filled", 0) or 0)
|
||
avg_fill = float(getattr(result, "avg_price", 0) or 0)
|
||
raw = getattr(result, "raw", {}) or {}
|
||
|
||
# ---- record trade ----
|
||
# Record original USDT amount, not converted base qty
|
||
trade_id = _record_quick_trade(
|
||
user_id=user_id,
|
||
credential_id=credential_id,
|
||
exchange_id=exchange_id,
|
||
symbol=symbol,
|
||
side=side,
|
||
order_type=order_type,
|
||
amount=usdt_amount, # Record original USDT amount
|
||
price=price if order_type == "limit" else avg_fill,
|
||
leverage=leverage,
|
||
market_type=market_type,
|
||
tp_price=tp_price,
|
||
sl_price=sl_price,
|
||
status="filled" if filled > 0 else "submitted",
|
||
exchange_order_id=exchange_order_id,
|
||
filled=filled,
|
||
avg_price=avg_fill,
|
||
error_msg="",
|
||
source=source,
|
||
raw_result=raw,
|
||
)
|
||
|
||
return jsonify({
|
||
"code": 1,
|
||
"msg": "Order placed successfully",
|
||
"data": {
|
||
"trade_id": trade_id,
|
||
"exchange_order_id": exchange_order_id,
|
||
"filled": filled,
|
||
"avg_price": avg_fill,
|
||
"status": "filled" if filled > 0 else "submitted",
|
||
},
|
||
})
|
||
|
||
except Exception as e:
|
||
logger.error(f"quick trade failed: {e}")
|
||
logger.error(traceback.format_exc())
|
||
|
||
# Try to record the failure
|
||
try:
|
||
_record_quick_trade(
|
||
user_id=g.user_id,
|
||
credential_id=int(body.get("credential_id") or 0),
|
||
exchange_id="",
|
||
symbol=str(body.get("symbol") or ""),
|
||
side=str(body.get("side") or ""),
|
||
order_type=str(body.get("order_type") or "market"),
|
||
amount=float(body.get("amount") or 0), # Original USDT amount
|
||
price=0,
|
||
leverage=int(body.get("leverage") or 1),
|
||
market_type=str(body.get("market_type") or "swap"),
|
||
tp_price=0,
|
||
sl_price=0,
|
||
status="failed",
|
||
exchange_order_id="",
|
||
filled=0,
|
||
avg_price=0,
|
||
error_msg=str(e)[:500],
|
||
source=str(body.get("source") or "manual"),
|
||
raw_result={},
|
||
)
|
||
except Exception:
|
||
pass
|
||
|
||
err_str = str(e)
|
||
hint = _parse_trade_error_hint(err_str)
|
||
resp: Dict[str, Any] = {"code": 0, "msg": err_str}
|
||
if hint:
|
||
resp["error_hint"] = hint
|
||
return jsonify(resp), 500
|
||
|
||
|
||
def _market_order_kwargs(client, symbol, amount, side, market_type, client_order_id):
|
||
"""Build kwargs compatible with any exchange client's place_market_order."""
|
||
from app.services.live_trading.binance import BinanceFuturesClient
|
||
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
|
||
|
||
if isinstance(client, (BinanceFuturesClient, BinanceSpotClient)):
|
||
return {"quantity": amount, "client_order_id": client_order_id}
|
||
if isinstance(client, OkxClient):
|
||
kwargs = {"market_type": market_type, "size": amount, "client_order_id": client_order_id}
|
||
# For swap market, OKX requires pos_side. Infer from side:
|
||
# buy -> long, sell -> short
|
||
# The _resolve_pos_side method will handle net_mode vs long_short_mode
|
||
if market_type and market_type.strip().lower() != "spot":
|
||
pos_side = "long" if side.lower() == "buy" else "short"
|
||
kwargs["pos_side"] = pos_side
|
||
return kwargs
|
||
if isinstance(client, BitgetMixClient):
|
||
return {"size": amount, "client_order_id": client_order_id}
|
||
if isinstance(client, BybitClient):
|
||
return {"qty": amount, "client_order_id": client_order_id}
|
||
# Generic fallback
|
||
return {"size": amount, "client_order_id": client_order_id}
|
||
|
||
|
||
def _limit_order_kwargs(client, symbol, amount, price, side, market_type, client_order_id):
|
||
"""Build kwargs compatible with any exchange client's place_limit_order."""
|
||
from app.services.live_trading.binance import BinanceFuturesClient
|
||
from app.services.live_trading.binance_spot import BinanceSpotClient
|
||
from app.services.live_trading.okx import OkxClient
|
||
from app.services.live_trading.bybit import BybitClient
|
||
from app.services.live_trading.deepcoin import DeepcoinClient
|
||
|
||
if isinstance(client, (BinanceFuturesClient, BinanceSpotClient)):
|
||
return {"quantity": amount, "price": price, "client_order_id": client_order_id}
|
||
if isinstance(client, OkxClient):
|
||
kwargs = {"market_type": market_type, "size": amount, "price": price, "client_order_id": client_order_id}
|
||
# For swap market, OKX requires pos_side. Infer from side:
|
||
# buy -> long, sell -> short
|
||
# The _resolve_pos_side method will handle net_mode vs long_short_mode
|
||
if market_type and market_type.strip().lower() != "spot":
|
||
pos_side = "long" if side.lower() == "buy" else "short"
|
||
kwargs["pos_side"] = pos_side
|
||
return kwargs
|
||
if isinstance(client, (BybitClient, DeepcoinClient)):
|
||
return {"qty": amount, "price": price, "client_order_id": client_order_id}
|
||
# Generic fallback
|
||
return {"size": amount, "price": price, "client_order_id": client_order_id}
|
||
|
||
|
||
@quick_trade_bp.route('/balance', methods=['GET'])
|
||
@login_required
|
||
def get_balance():
|
||
"""
|
||
Get available balance from exchange.
|
||
|
||
Query: credential_id (int), market_type (str, default "swap")
|
||
"""
|
||
try:
|
||
user_id = g.user_id
|
||
credential_id = request.args.get("credential_id", type=int)
|
||
market_type = request.args.get("market_type", "swap").strip().lower()
|
||
|
||
if not credential_id:
|
||
return jsonify({"code": 0, "msg": "Missing credential_id"}), 400
|
||
|
||
exchange_config = _build_exchange_config(credential_id, user_id, {"market_type": market_type})
|
||
exchange_id = (exchange_config.get("exchange_id") or "").strip().lower()
|
||
client = _create_client(exchange_config, market_type=market_type)
|
||
|
||
balance_data = {"available": 0, "total": 0, "currency": "USDT"}
|
||
|
||
try:
|
||
if hasattr(client, "get_balance"):
|
||
raw = client.get_balance()
|
||
balance_data = _parse_balance(raw, exchange_id, market_type)
|
||
elif hasattr(client, "get_account"):
|
||
raw = client.get_account()
|
||
balance_data = _parse_balance(raw, exchange_id, market_type)
|
||
elif hasattr(client, "get_accounts"):
|
||
from app.services.live_trading.bitget import BitgetMixClient
|
||
|
||
if isinstance(client, BitgetMixClient):
|
||
pt = str(exchange_config.get("product_type") or exchange_config.get("productType") or "USDT-FUTURES")
|
||
raw = client.get_accounts(product_type=pt)
|
||
else:
|
||
raw = client.get_accounts()
|
||
balance_data = _parse_balance(raw, exchange_id, market_type)
|
||
elif (exchange_id or "").lower() == "bitget" and market_type == "spot" and hasattr(client, "get_assets"):
|
||
raw = client.get_assets()
|
||
balance_data = _parse_balance(raw, exchange_id, market_type)
|
||
except Exception as be:
|
||
logger.warning(f"Balance fetch failed: {be}")
|
||
balance_data["error"] = str(be)
|
||
|
||
return jsonify({"code": 1, "msg": "success", "data": balance_data})
|
||
except Exception as e:
|
||
logger.error(f"get_balance failed: {e}")
|
||
return jsonify({"code": 0, "msg": str(e)}), 500
|
||
|
||
|
||
def _parse_balance(raw: Any, exchange_id: str, market_type: str) -> Dict[str, Any]:
|
||
"""Best-effort parse balance from various exchange responses."""
|
||
result = {"available": 0, "total": 0, "currency": "USDT"}
|
||
ex0 = (exchange_id or "").strip().lower()
|
||
mt0 = (market_type or "").strip().lower()
|
||
|
||
def _num(x: Any) -> float:
|
||
try:
|
||
s = str(x).replace(",", "").strip()
|
||
if not s:
|
||
return 0.0
|
||
return float(s)
|
||
except Exception:
|
||
return 0.0
|
||
|
||
if not raw:
|
||
return result
|
||
try:
|
||
# Gate.io spot: GET /api/v4/spot/accounts returns a list
|
||
if isinstance(raw, list) and ex0 == "gate":
|
||
for item in raw:
|
||
if not isinstance(item, dict):
|
||
continue
|
||
if str(item.get("currency") or "").upper() == "USDT":
|
||
av = _num(item.get("available") or item.get("available_balance"))
|
||
lk = _num(item.get("locked") or item.get("freeze") or item.get("locked_amount"))
|
||
result["available"] = av
|
||
result["total"] = av + lk
|
||
return result
|
||
return result
|
||
|
||
if isinstance(raw, dict):
|
||
# Binance futures
|
||
if "availableBalance" in raw:
|
||
result["available"] = float(raw.get("availableBalance") or 0)
|
||
result["total"] = float(raw.get("totalWalletBalance") or raw.get("totalMarginBalance") or 0)
|
||
return result
|
||
# Binance spot
|
||
if "balances" in raw:
|
||
for b in raw.get("balances", []):
|
||
if str(b.get("asset") or "").upper() == "USDT":
|
||
result["available"] = float(b.get("free") or 0)
|
||
result["total"] = float(b.get("free") or 0) + float(b.get("locked") or 0)
|
||
return result
|
||
return result
|
||
ex = (exchange_id or "").lower()
|
||
# Gate.io USDT perpetual: GET /api/v4/futures/usdt/accounts — flat object (values often strings)
|
||
if ex == "gate" and mt0 != "spot":
|
||
if any(k in raw for k in ("available", "total", "cross_available", "cross_margin_balance")):
|
||
av = raw.get("available") or raw.get("available_balance") or raw.get("cross_available")
|
||
tot = (
|
||
raw.get("total")
|
||
or raw.get("total_balance")
|
||
or raw.get("cross_margin_balance")
|
||
or raw.get("equity")
|
||
)
|
||
result["available"] = _num(av)
|
||
result["total"] = _num(tot) if tot is not None and str(tot).strip() != "" else result["available"]
|
||
if result["total"] <= 0 < result["available"]:
|
||
result["total"] = result["available"]
|
||
return result
|
||
# Bitget mix: { code, data: [ { marginCoin, available, accountEquity, ... } ] }
|
||
# Must run before OKX — both use data as a list; OKX fallback would zero Bitget.
|
||
if ex == "bitget" and (market_type or "").lower() != "spot":
|
||
bg_data = raw.get("data")
|
||
if isinstance(bg_data, list) and bg_data:
|
||
row = None
|
||
for item in bg_data:
|
||
if isinstance(item, dict) and str(item.get("marginCoin") or "").upper() == "USDT":
|
||
row = item
|
||
break
|
||
if row is None and isinstance(bg_data[0], dict):
|
||
row = bg_data[0]
|
||
if isinstance(row, dict):
|
||
av = (
|
||
row.get("available")
|
||
or row.get("availableBalance")
|
||
or row.get("crossedMaxAvailable")
|
||
or row.get("isolatedMaxAvailable")
|
||
or 0
|
||
)
|
||
eq = row.get("accountEquity") or row.get("usdtEquity") or row.get("equity") or av
|
||
result["available"] = float(av or 0)
|
||
result["total"] = float(eq or 0) if eq is not None else result["available"]
|
||
return result
|
||
# Bitget spot: GET /api/v2/spot/account/assets
|
||
if ex == "bitget" and (market_type or "").lower() == "spot":
|
||
bg_data = raw.get("data")
|
||
if isinstance(bg_data, list):
|
||
for b in bg_data:
|
||
if isinstance(b, dict) and str(b.get("coin") or "").upper() == "USDT":
|
||
avail = float(b.get("available") or 0)
|
||
frozen = float(b.get("frozen") or b.get("locked") or 0)
|
||
result["available"] = avail
|
||
result["total"] = avail + frozen
|
||
return result
|
||
return result
|
||
# OKX
|
||
data = raw.get("data")
|
||
if isinstance(data, list) and data:
|
||
first = data[0] if isinstance(data[0], dict) else {}
|
||
# Account balance
|
||
details = first.get("details", [])
|
||
if isinstance(details, list) and details:
|
||
for d in details:
|
||
if str(d.get("ccy") or "").upper() == "USDT":
|
||
result["available"] = float(d.get("availBal") or d.get("availEq") or 0)
|
||
result["total"] = float(d.get("eq") or d.get("cashBal") or 0)
|
||
return result
|
||
# OKX-style single-account row (not Bitget — Bitget handled above)
|
||
if "availBal" in first or "availEq" in first or "totalEq" in first or "adjEq" in first:
|
||
result["available"] = float(
|
||
first.get("availBal") or first.get("availEq") or first.get("adjEq") or first.get("totalEq") or 0
|
||
)
|
||
result["total"] = float(first.get("totalEq") or first.get("adjEq") or 0)
|
||
return result
|
||
# Bybit
|
||
if "result" in raw:
|
||
res = raw["result"]
|
||
if isinstance(res, dict):
|
||
coin_list = res.get("list", [])
|
||
if isinstance(coin_list, list):
|
||
for acc in coin_list:
|
||
coins = acc.get("coin", []) if isinstance(acc, dict) else []
|
||
for c in coins:
|
||
if str(c.get("coin") or "").upper() == "USDT":
|
||
result["available"] = float(c.get("availableToWithdraw") or c.get("walletBalance") or 0)
|
||
result["total"] = float(c.get("walletBalance") or 0)
|
||
return result
|
||
# HTX spot
|
||
if isinstance(data, dict) and isinstance(data.get("list"), list):
|
||
for item in data.get("list") or []:
|
||
if str(item.get("currency") or "").upper() == "USDT" and str(item.get("type") or "").lower() in ("trade", "available", ""):
|
||
avail = float(item.get("balance") or 0)
|
||
result["available"] = avail
|
||
total = 0.0
|
||
for item in data.get("list") or []:
|
||
if str(item.get("currency") or "").upper() == "USDT":
|
||
total += float(item.get("balance") or 0)
|
||
if total > 0 or result["available"] > 0:
|
||
result["total"] = total or result["available"]
|
||
return result
|
||
# HTX swap
|
||
if isinstance(data, list) and data and isinstance(data[0], dict):
|
||
first = data[0]
|
||
if "margin_available" in first or "margin_balance" in first or "withdraw_available" in first:
|
||
result["available"] = float(first.get("margin_available") or first.get("withdraw_available") or 0)
|
||
result["total"] = float(first.get("margin_balance") or first.get("margin_static") or 0)
|
||
return result
|
||
# Fallback: try to find any USDT-like values
|
||
if isinstance(raw, dict):
|
||
for k, v in raw.items():
|
||
if "avail" in str(k).lower() and isinstance(v, (int, float)):
|
||
result["available"] = float(v)
|
||
if "total" in str(k).lower() and isinstance(v, (int, float)):
|
||
result["total"] = float(v)
|
||
except Exception as e:
|
||
logger.warning(f"_parse_balance error: {e}")
|
||
return result
|
||
|
||
|
||
def _fetch_exchange_positions_raw(
|
||
client: Any,
|
||
exchange_config: Dict[str, Any],
|
||
*,
|
||
symbol: str,
|
||
market_type: str,
|
||
) -> Any:
|
||
"""
|
||
Fetch raw position payload for quick-trade / close-position.
|
||
|
||
Many clients do not accept ``symbol=`` on ``get_positions()`` (Gate, KuCoin, Bitfinex),
|
||
or need extra args (Bitget ``product_type``, OKX ``inst_type``). Centralize here.
|
||
"""
|
||
from app.services.live_trading.binance import BinanceFuturesClient
|
||
from app.services.live_trading.bitget import BitgetMixClient
|
||
from app.services.live_trading.bybit import BybitClient
|
||
from app.services.live_trading.deepcoin import DeepcoinClient
|
||
from app.services.live_trading.gate import GateUsdtFuturesClient
|
||
from app.services.live_trading.htx import HtxClient
|
||
from app.services.live_trading.kucoin import KucoinFuturesClient
|
||
from app.services.live_trading.okx import OkxClient
|
||
from app.services.live_trading.symbols import (
|
||
to_bybit_symbol,
|
||
to_gate_currency_pair,
|
||
to_kucoin_futures_symbol,
|
||
to_okx_spot_inst_id,
|
||
to_okx_swap_inst_id,
|
||
)
|
||
|
||
mt = (market_type or "swap").strip().lower()
|
||
|
||
if isinstance(client, OkxClient):
|
||
if mt == "spot":
|
||
inst_id = to_okx_spot_inst_id(symbol)
|
||
inst_type = "SPOT"
|
||
else:
|
||
inst_id = to_okx_swap_inst_id(symbol)
|
||
inst_type = "SWAP"
|
||
return client.get_positions(inst_id=inst_id, inst_type=inst_type)
|
||
|
||
if isinstance(client, BinanceFuturesClient):
|
||
return client.get_positions(symbol=symbol)
|
||
|
||
if isinstance(client, BitgetMixClient):
|
||
pt = str(exchange_config.get("product_type") or exchange_config.get("productType") or "USDT-FUTURES")
|
||
return client.get_positions(product_type=pt, symbol=symbol)
|
||
|
||
if isinstance(client, BybitClient):
|
||
# Bybit v5 requires symbol or settleCoin; query the contract directly.
|
||
raw = client.get_positions(symbol=symbol)
|
||
lst = (((raw or {}).get("result") or {}).get("list")) if isinstance(raw, dict) else None
|
||
if not isinstance(lst, list):
|
||
return raw
|
||
sym_norm = to_bybit_symbol(symbol)
|
||
filtered = [p for p in lst if isinstance(p, dict) and str(p.get("symbol") or "").strip() == sym_norm]
|
||
if isinstance(raw, dict):
|
||
out = dict(raw)
|
||
res = dict((raw.get("result") or {}) if isinstance(raw.get("result"), dict) else {})
|
||
res["list"] = filtered
|
||
out["result"] = res
|
||
return out
|
||
return {"result": {"list": filtered}}
|
||
|
||
if isinstance(client, GateUsdtFuturesClient):
|
||
raw = client.get_positions()
|
||
items = raw if isinstance(raw, list) else []
|
||
c = to_gate_currency_pair(symbol)
|
||
logger.info("Gate positions: total=%d, target=%s, contracts=%s",
|
||
len(items), c,
|
||
[(str(p.get("contract")), p.get("size")) for p in items if isinstance(p, dict) and p.get("size")][:10])
|
||
filtered = [p for p in items if isinstance(p, dict) and str(p.get("contract") or "").strip() == c]
|
||
out = []
|
||
for p in filtered:
|
||
q = dict(p)
|
||
try:
|
||
ct_sz = float(q.get("size") or 0)
|
||
except Exception:
|
||
ct_sz = 0.0
|
||
if abs(ct_sz) > 1e-12:
|
||
base_amt = client.contracts_signed_to_base_qty(contract=c, contracts_signed=ct_sz)
|
||
if base_amt > 0:
|
||
q["positionAmt"] = base_amt
|
||
out.append(q)
|
||
logger.info("Gate filtered positions for %s: %d items, sizes=%s", c, len(out),
|
||
[(p.get("size"), p.get("positionAmt")) for p in out])
|
||
return out
|
||
|
||
if isinstance(client, KucoinFuturesClient):
|
||
raw = client.get_positions()
|
||
data = raw.get("data") if isinstance(raw, dict) else []
|
||
sym = to_kucoin_futures_symbol(symbol)
|
||
if not isinstance(data, list):
|
||
data = []
|
||
filtered = [p for p in data if isinstance(p, dict) and str(p.get("symbol") or "").strip() == sym]
|
||
if isinstance(raw, dict):
|
||
out = dict(raw)
|
||
out["data"] = filtered
|
||
return out
|
||
return {"data": filtered}
|
||
|
||
if isinstance(client, HtxClient):
|
||
raw = client.get_positions(symbol=symbol)
|
||
data = (raw.get("data") if isinstance(raw, dict) else None) or []
|
||
if not isinstance(data, list):
|
||
data = []
|
||
out_items = []
|
||
for p in data:
|
||
if not isinstance(p, dict):
|
||
continue
|
||
q = dict(p)
|
||
cc = str(q.get("contract_code") or "").strip()
|
||
if cc:
|
||
parts = cc.split("-", 1)
|
||
if len(parts) == 2:
|
||
q["symbol"] = f"{parts[0]}/{parts[1]}"
|
||
try:
|
||
vol = float(q.get("volume") or q.get("available") or 0)
|
||
except Exception:
|
||
vol = 0.0
|
||
if abs(vol) > 1e-12 and cc:
|
||
try:
|
||
info = client.get_contract_info(symbol=symbol or cc) or {}
|
||
cs = float(info.get("contract_size") or 1)
|
||
if cs <= 0:
|
||
cs = 1.0
|
||
q["positionAmt"] = abs(vol) * cs
|
||
except Exception:
|
||
pass
|
||
out_items.append(q)
|
||
logger.info("HTX positions for %s: %d items, sizes=%s", symbol, len(out_items),
|
||
[(p.get("contract_code"), p.get("volume"), p.get("positionAmt")) for p in out_items])
|
||
return {"data": out_items}
|
||
|
||
if isinstance(client, DeepcoinClient):
|
||
return client.get_positions(symbol=symbol)
|
||
|
||
if hasattr(client, "get_positions"):
|
||
try:
|
||
return client.get_positions(symbol=symbol)
|
||
except TypeError:
|
||
return client.get_positions()
|
||
|
||
if hasattr(client, "get_position"):
|
||
return client.get_position(symbol=symbol)
|
||
|
||
return None
|
||
|
||
|
||
@quick_trade_bp.route('/position', methods=['GET'])
|
||
@login_required
|
||
def get_position():
|
||
"""
|
||
Get current position for a symbol from exchange.
|
||
|
||
Query: credential_id (int), symbol (str), market_type (str)
|
||
"""
|
||
try:
|
||
user_id = g.user_id
|
||
credential_id = request.args.get("credential_id", type=int)
|
||
symbol = request.args.get("symbol", "").strip()
|
||
market_type = request.args.get("market_type", "swap").strip().lower()
|
||
|
||
if not credential_id or not symbol:
|
||
return jsonify({"code": 0, "msg": "Missing credential_id or symbol"}), 400
|
||
|
||
exchange_config = _build_exchange_config(credential_id, user_id, {"market_type": market_type})
|
||
client = _create_client(exchange_config, market_type=market_type)
|
||
|
||
positions = []
|
||
try:
|
||
raw = _fetch_exchange_positions_raw(
|
||
client, exchange_config, symbol=symbol, market_type=market_type
|
||
)
|
||
positions = _parse_positions(raw)
|
||
except Exception as pe:
|
||
logger.warning(f"Position fetch failed: {pe}")
|
||
logger.warning(traceback.format_exc())
|
||
|
||
logger.info(f"Returning {len(positions)} positions for symbol={symbol}, market_type={market_type}")
|
||
return jsonify({"code": 1, "msg": "success", "data": {"positions": positions}})
|
||
except Exception as e:
|
||
logger.error(f"get_position failed: {e}")
|
||
return jsonify({"code": 0, "msg": str(e)}), 500
|
||
|
||
|
||
def _parse_positions(raw: Any) -> list:
|
||
"""Best-effort parse positions from exchange response."""
|
||
result = []
|
||
if not raw:
|
||
return result
|
||
try:
|
||
items = []
|
||
if isinstance(raw, list):
|
||
items = raw
|
||
elif isinstance(raw, dict):
|
||
if isinstance(raw.get("raw"), list):
|
||
items = raw["raw"]
|
||
else:
|
||
data = raw.get("data") or raw.get("result") or raw.get("positions") or []
|
||
if isinstance(data, list):
|
||
items = data
|
||
elif isinstance(data, dict):
|
||
items = data.get("list", []) if "list" in data else [data]
|
||
else:
|
||
items = []
|
||
|
||
for item in items:
|
||
if not isinstance(item, dict):
|
||
continue
|
||
sym_raw = str(
|
||
item.get("symbol")
|
||
or item.get("instId")
|
||
or item.get("contract")
|
||
or item.get("contract_code")
|
||
or ""
|
||
).strip()
|
||
display_symbol = sym_raw
|
||
if sym_raw and "/" not in sym_raw:
|
||
for sep in ("_", "-"):
|
||
if sep in sym_raw:
|
||
parts = sym_raw.split(sep, 1)
|
||
if len(parts) == 2 and parts[0] and parts[1]:
|
||
display_symbol = f"{parts[0]}/{parts[1]}"
|
||
break
|
||
# For OKX, position size can be in different fields
|
||
# SWAP: posAmt, pos
|
||
# Binance futures: positionAmt
|
||
# SPOT: bal (balance), availBal (available balance)
|
||
size = float(
|
||
item.get("positionAmt")
|
||
or item.get("posAmt")
|
||
or item.get("pos")
|
||
or item.get("total")
|
||
or item.get("currentQty")
|
||
or item.get("available")
|
||
or item.get("size")
|
||
or item.get("contracts")
|
||
or item.get("bal")
|
||
or item.get("availBal")
|
||
or item.get("volume")
|
||
or item.get("current_qty")
|
||
or 0
|
||
)
|
||
if abs(size) < 1e-10:
|
||
continue
|
||
|
||
# Binance hedge: positionSide LONG/SHORT with positive positionAmt; one-way: BOTH + signed amt
|
||
side = "long"
|
||
psu = str(item.get("positionSide", "")).strip().upper()
|
||
if psu == "SHORT":
|
||
side = "short"
|
||
elif psu == "LONG":
|
||
side = "long"
|
||
elif item.get("posSide"):
|
||
pos_side = str(item.get("posSide", "")).strip().lower()
|
||
if pos_side in ("long", "short"):
|
||
side = pos_side
|
||
elif str(item.get("holdSide") or "").strip().lower() == "short":
|
||
side = "short"
|
||
elif str(item.get("holdSide") or "").strip().lower() == "long":
|
||
side = "long"
|
||
elif str(item.get("side") or "").strip().lower() in ("sell", "s"):
|
||
side = "short"
|
||
elif str(item.get("side") or "").strip().lower() in ("buy", "b"):
|
||
side = "long"
|
||
elif size < 0:
|
||
side = "short"
|
||
elif item.get("direction"):
|
||
dir_side = str(item.get("direction") or "").strip().lower()
|
||
if dir_side in ("buy", "long"):
|
||
side = "long"
|
||
elif dir_side in ("sell", "short"):
|
||
side = "short"
|
||
|
||
result.append({
|
||
"symbol": display_symbol,
|
||
"side": side,
|
||
"size": abs(size),
|
||
"entry_price": float(
|
||
item.get("entryPrice")
|
||
or item.get("entry_price")
|
||
or item.get("openPriceAvg")
|
||
or item.get("avgEntryPrice")
|
||
or item.get("avgPrice")
|
||
or item.get("avgCost")
|
||
or item.get("avgPx")
|
||
or item.get("cost_open")
|
||
or item.get("trade_avg_price")
|
||
or 0
|
||
),
|
||
"unrealized_pnl": float(
|
||
item.get("unRealizedProfit")
|
||
or item.get("unrealizedProfit")
|
||
or item.get("unrealizedPnl")
|
||
or item.get("unrealised_pnl")
|
||
or item.get("upl")
|
||
or item.get("unrealisedPnl")
|
||
or item.get("profit_unreal")
|
||
or item.get("pnl")
|
||
or 0
|
||
),
|
||
"leverage": float(item.get("leverage") or item.get("lever") or item.get("lever_rate") or item.get("cross_leverage_limit") or 1),
|
||
"mark_price": float(
|
||
item.get("markPrice")
|
||
or item.get("mark_price")
|
||
or item.get("markPx")
|
||
or item.get("last_price")
|
||
or item.get("last")
|
||
or item.get("indexPrice")
|
||
or 0
|
||
),
|
||
})
|
||
except Exception as e:
|
||
logger.warning(f"_parse_positions error: {e}")
|
||
return result
|
||
|
||
|
||
def _quick_trade_net_base_qty(
|
||
user_id: int,
|
||
credential_id: int,
|
||
symbol: str,
|
||
market_type: str,
|
||
position_side: str,
|
||
) -> float:
|
||
"""
|
||
Best-effort net base-asset qty from qd_quick_trades (filled buy − sell for long, vice versa for short).
|
||
|
||
Used when user chooses to close only the portion accumulated via Quick Trade, not manual exchange orders.
|
||
Imperfect if the user also traded the same symbol elsewhere or records are incomplete.
|
||
"""
|
||
mt = (market_type or "swap").strip().lower()
|
||
ps = (position_side or "").strip().lower()
|
||
sym = str(symbol or "").strip()
|
||
with get_db_connection() as db:
|
||
cur = db.cursor()
|
||
cur.execute(
|
||
"""
|
||
SELECT
|
||
COALESCE(SUM(CASE WHEN side = 'buy' THEN filled_amount ELSE 0 END), 0) AS b,
|
||
COALESCE(SUM(CASE WHEN side = 'sell' THEN filled_amount ELSE 0 END), 0) AS s
|
||
FROM qd_quick_trades
|
||
WHERE user_id = %s AND credential_id = %s AND symbol = %s AND market_type = %s
|
||
AND status = 'filled' AND COALESCE(filled_amount, 0) > 0
|
||
""",
|
||
(int(user_id), int(credential_id), sym, mt),
|
||
)
|
||
row = cur.fetchone() or {}
|
||
cur.close()
|
||
buy_sum = float(row.get("b") or 0)
|
||
sell_sum = float(row.get("s") or 0)
|
||
if ps == "long":
|
||
net = buy_sum - sell_sum
|
||
elif ps == "short":
|
||
net = sell_sum - buy_sum
|
||
else:
|
||
net = 0.0
|
||
return max(0.0, float(net))
|
||
|
||
|
||
@quick_trade_bp.route('/close-position', methods=['POST'])
|
||
@login_required
|
||
def close_position():
|
||
"""
|
||
Close an existing position.
|
||
|
||
Body JSON:
|
||
credential_id (int) — saved exchange credential ID
|
||
symbol (str) — e.g. "BTC/USDT"
|
||
market_type (str) — "swap" / "spot" (default: swap)
|
||
size (float) — position size to close (optional, defaults to full position)
|
||
close_scope (str) — "full" (default) or "system_tracked" (swap only: min(position, net from qd_quick_trades))
|
||
position_side (str) — optional "long" / "short"; required when both directions exist for the same symbol
|
||
source (str) — "ai_radar" / "ai_analysis" / "indicator" / "manual"
|
||
"""
|
||
try:
|
||
user_id = g.user_id
|
||
body = request.get_json(force=True, silent=True) or {}
|
||
|
||
credential_id = int(body.get("credential_id") or 0)
|
||
symbol = str(body.get("symbol") or "").strip()
|
||
market_type = str(body.get("market_type") or "swap").strip().lower()
|
||
close_size = float(body.get("size") or 0) # 0 means close full position
|
||
source = str(body.get("source") or "manual").strip()
|
||
close_scope_raw = str(body.get("close_scope") or body.get("closeScope") or "full").strip().lower()
|
||
if close_scope_raw in ("system", "system_tracked", "quick_trade", "app"):
|
||
close_scope = "system_tracked"
|
||
else:
|
||
close_scope = "full"
|
||
|
||
# ---- validation ----
|
||
if not credential_id:
|
||
return jsonify({"code": 0, "msg": "Missing credential_id"}), 400
|
||
if not symbol:
|
||
return jsonify({"code": 0, "msg": "Missing symbol"}), 400
|
||
|
||
if market_type in ("futures", "future", "perp", "perpetual"):
|
||
market_type = "swap"
|
||
|
||
# ---- build exchange client ----
|
||
exchange_config = _build_exchange_config(credential_id, user_id, {
|
||
"market_type": market_type,
|
||
})
|
||
exchange_id = (exchange_config.get("exchange_id") or "").strip().lower()
|
||
if not exchange_id:
|
||
return jsonify({"code": 0, "msg": "Invalid credential: missing exchange_id"}), 400
|
||
|
||
client = _create_client(exchange_config, market_type=market_type)
|
||
|
||
# ---- get current position ----
|
||
positions = []
|
||
try:
|
||
raw = _fetch_exchange_positions_raw(
|
||
client, exchange_config, symbol=symbol, market_type=market_type
|
||
)
|
||
positions = _parse_positions(raw)
|
||
except Exception as pe:
|
||
logger.warning(f"Position fetch failed: {pe}")
|
||
|
||
if not positions:
|
||
return jsonify({"code": 0, "msg": f"No position found for {symbol}"}), 404
|
||
|
||
want_side = str(body.get("position_side") or body.get("close_side") or "").strip().lower()
|
||
if want_side not in ("", "long", "short"):
|
||
want_side = ""
|
||
|
||
matches: list = []
|
||
for pos in positions:
|
||
pos_symbol = pos.get("symbol", "").strip()
|
||
if not _symbols_match_quick_trade(symbol, pos_symbol):
|
||
continue
|
||
ps = str(pos.get("side") or "").strip().lower()
|
||
if want_side in ("long", "short"):
|
||
if ps == want_side:
|
||
matches.append(pos)
|
||
else:
|
||
matches.append(pos)
|
||
|
||
position = None
|
||
if len(matches) == 1:
|
||
position = matches[0]
|
||
elif len(matches) > 1:
|
||
if want_side in ("long", "short"):
|
||
position = matches[0]
|
||
else:
|
||
return jsonify(
|
||
{
|
||
"code": 0,
|
||
"msg": "该交易对同时存在多仓与空仓,请在请求中指定 position_side 为 long 或 short。",
|
||
}
|
||
), 400
|
||
if not position:
|
||
return jsonify({"code": 0, "msg": f"No position found for {symbol}"}), 404
|
||
|
||
position_side = str(position.get("side") or "").strip().lower()
|
||
position_size = float(position.get("size") or 0)
|
||
|
||
if position_size <= 0:
|
||
return jsonify({"code": 0, "msg": "Position size is zero or invalid"}), 400
|
||
|
||
if close_scope == "system_tracked" and market_type != "swap":
|
||
return jsonify({"code": 0, "msg": "system_tracked close_scope is only supported for swap/perp"}), 400
|
||
|
||
tracked_net = 0.0
|
||
if close_scope == "system_tracked":
|
||
tracked_net = _quick_trade_net_base_qty(
|
||
user_id, credential_id, symbol, market_type, position_side=position_side
|
||
)
|
||
if tracked_net <= 0:
|
||
return jsonify(
|
||
{
|
||
"code": 0,
|
||
"msg": "No filled Quick Trade volume found for this symbol; use full close or check history.",
|
||
}
|
||
), 400
|
||
|
||
# Determine close size
|
||
if close_size > 0:
|
||
actual_close_size = min(close_size, position_size)
|
||
elif close_scope == "system_tracked":
|
||
actual_close_size = min(tracked_net, position_size)
|
||
logger.info(
|
||
"close_position system_tracked: symbol=%s side=%s position=%s tracked_net=%s close=%s",
|
||
symbol,
|
||
position.get("side"),
|
||
position_size,
|
||
tracked_net,
|
||
actual_close_size,
|
||
)
|
||
else:
|
||
actual_close_size = position_size
|
||
if actual_close_size > position_size:
|
||
actual_close_size = position_size
|
||
if actual_close_size <= 0:
|
||
return jsonify({"code": 0, "msg": "Close size is zero"}), 400
|
||
|
||
# ---- determine signal type based on position side ----
|
||
if market_type == "spot":
|
||
# Spot only supports long positions
|
||
if position_side != "long":
|
||
return jsonify({"code": 0, "msg": "Spot market only supports closing long positions"}), 400
|
||
signal_type = "close_long"
|
||
else:
|
||
# Swap: close_long or close_short
|
||
if position_side == "long":
|
||
signal_type = "close_long"
|
||
elif position_side == "short":
|
||
signal_type = "close_short"
|
||
else:
|
||
return jsonify({"code": 0, "msg": f"Unknown position side: {position_side}"}), 400
|
||
|
||
# ---- place close order ----
|
||
from app.services.live_trading.execution import place_order_from_signal
|
||
|
||
# Generate client_order_id
|
||
timestamp_suffix = str(int(time.time()))[-6:]
|
||
uuid_suffix = uuid.uuid4().hex[:8]
|
||
client_order_id = f"qtc{timestamp_suffix}{uuid_suffix}" # 'c' for close
|
||
|
||
result = place_order_from_signal(
|
||
client=client,
|
||
signal_type=signal_type,
|
||
symbol=symbol,
|
||
amount=actual_close_size, # Use position size directly (already in base qty)
|
||
market_type=market_type,
|
||
exchange_config=exchange_config,
|
||
client_order_id=client_order_id,
|
||
)
|
||
|
||
# ---- extract result ----
|
||
exchange_order_id = str(getattr(result, "exchange_order_id", "") or "")
|
||
filled = float(getattr(result, "filled", 0) or 0)
|
||
avg_fill = float(getattr(result, "avg_price", 0) or 0)
|
||
raw = getattr(result, "raw", {}) or {}
|
||
|
||
# ---- calculate USDT amount for recording ----
|
||
# Convert base asset quantity to USDT amount for consistent recording
|
||
# amount (USDT) = base_qty * price
|
||
usdt_amount = actual_close_size * avg_fill if avg_fill > 0 else 0
|
||
# If price is not available, try to use entry price or mark price as fallback
|
||
if usdt_amount <= 0:
|
||
entry_price = float(position.get("entry_price") or 0)
|
||
mark_price = float(position.get("mark_price") or 0)
|
||
fallback_price = mark_price if mark_price > 0 else entry_price
|
||
if fallback_price > 0:
|
||
usdt_amount = actual_close_size * fallback_price
|
||
|
||
# ---- record trade ----
|
||
trade_id = _record_quick_trade(
|
||
user_id=user_id,
|
||
credential_id=credential_id,
|
||
exchange_id=exchange_id,
|
||
symbol=symbol,
|
||
side="sell" if position_side == "long" else "buy", # Opposite of position side
|
||
order_type="market",
|
||
amount=usdt_amount, # Record USDT amount, not base asset quantity
|
||
price=avg_fill,
|
||
leverage=float(position.get("leverage") or 1),
|
||
market_type=market_type,
|
||
tp_price=0,
|
||
sl_price=0,
|
||
status="filled" if filled > 0 else "submitted",
|
||
exchange_order_id=exchange_order_id,
|
||
filled=filled,
|
||
avg_price=avg_fill,
|
||
error_msg="",
|
||
source=source,
|
||
raw_result=raw,
|
||
)
|
||
|
||
return jsonify({
|
||
"code": 1,
|
||
"msg": "Position closed successfully",
|
||
"data": {
|
||
"trade_id": trade_id,
|
||
"exchange_order_id": exchange_order_id,
|
||
"filled": filled,
|
||
"avg_price": avg_fill,
|
||
"closed_size": actual_close_size,
|
||
"position_side": position_side,
|
||
"close_scope": close_scope,
|
||
"tracked_net_base": tracked_net if close_scope == "system_tracked" else None,
|
||
"status": "filled" if filled > 0 else "submitted",
|
||
},
|
||
})
|
||
|
||
except Exception as e:
|
||
logger.error(f"close_position failed: {e}")
|
||
logger.error(traceback.format_exc())
|
||
err_str = str(e)
|
||
hint = _parse_trade_error_hint(err_str)
|
||
resp: Dict[str, Any] = {"code": 0, "msg": err_str}
|
||
if hint:
|
||
resp["error_hint"] = hint
|
||
return jsonify(resp), 500
|
||
|
||
|
||
@quick_trade_bp.route('/history', methods=['GET'])
|
||
@login_required
|
||
def get_history():
|
||
"""
|
||
Get quick trade history for the current user.
|
||
|
||
Query: limit (int, default 50), offset (int, default 0)
|
||
"""
|
||
try:
|
||
user_id = g.user_id
|
||
limit = min(int(request.args.get("limit") or 50), 200)
|
||
offset = int(request.args.get("offset") or 0)
|
||
|
||
with get_db_connection() as db:
|
||
cur = db.cursor()
|
||
cur.execute(
|
||
"""
|
||
SELECT id, exchange_id, symbol, side, order_type, amount, price,
|
||
leverage, market_type, tp_price, sl_price, status,
|
||
exchange_order_id, filled_amount, avg_fill_price,
|
||
error_msg, source, created_at
|
||
FROM qd_quick_trades
|
||
WHERE user_id = %s
|
||
ORDER BY created_at DESC
|
||
LIMIT %s OFFSET %s
|
||
""",
|
||
(user_id, limit, offset),
|
||
)
|
||
rows = cur.fetchall() or []
|
||
cur.close()
|
||
|
||
trades = []
|
||
for r in rows:
|
||
trades.append({
|
||
"id": r.get("id"),
|
||
"exchange_id": r.get("exchange_id") or "",
|
||
"symbol": r.get("symbol") or "",
|
||
"side": r.get("side") or "",
|
||
"order_type": r.get("order_type") or "market",
|
||
"amount": float(r.get("amount") or 0),
|
||
"price": float(r.get("price") or 0),
|
||
"leverage": int(r.get("leverage") or 1),
|
||
"market_type": r.get("market_type") or "swap",
|
||
"tp_price": float(r.get("tp_price") or 0),
|
||
"sl_price": float(r.get("sl_price") or 0),
|
||
"status": r.get("status") or "",
|
||
"exchange_order_id": r.get("exchange_order_id") or "",
|
||
"filled_amount": float(r.get("filled_amount") or 0),
|
||
"avg_fill_price": float(r.get("avg_fill_price") or 0),
|
||
"error_msg": r.get("error_msg") or "",
|
||
"source": r.get("source") or "",
|
||
"created_at": str(r.get("created_at") or ""),
|
||
})
|
||
|
||
return jsonify({"code": 1, "msg": "success", "data": {"trades": trades}})
|
||
except Exception as e:
|
||
logger.error(f"get_history failed: {e}")
|
||
return jsonify({"code": 0, "msg": str(e)}), 500
|