Files
DinQuant/backend_api_python/app/routes/quick_trade.py
T
Dinger 8563e4ea53 v3.0.1
Signed-off-by: Dinger <quantdinger@gmail.com>
2026-04-07 22:47:07 +08:00

1514 lines
66 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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