diff --git a/backend/api/routes/account.py b/backend/api/routes/account.py index d76be5c..b402136 100644 --- a/backend/api/routes/account.py +++ b/backend/api/routes/account.py @@ -2,9 +2,11 @@ 账户实时数据API - 从币安API获取实时账户和订单数据 """ from fastapi import APIRouter, HTTPException +from fastapi import Query import sys from pathlib import Path import logging +import time project_root = Path(__file__).parent.parent.parent.parent sys.path.insert(0, str(project_root)) @@ -17,6 +19,236 @@ logger = logging.getLogger(__name__) router = APIRouter() +async def _ensure_exchange_sltp_for_symbol(symbol: str): + """ + 在币安侧补挂该 symbol 的止损/止盈保护单(STOP_MARKET + TAKE_PROFIT_MARKET)。 + 该接口用于“手动补挂”,不依赖 trading_system 的监控任务。 + """ + # 从数据库读取API密钥 + api_key = TradingConfig.get_value('BINANCE_API_KEY') + api_secret = TradingConfig.get_value('BINANCE_API_SECRET') + use_testnet = TradingConfig.get_value('USE_TESTNET', False) + if not api_key or not api_secret: + raise HTTPException(status_code=400, detail="API密钥未配置") + + # 导入交易系统的BinanceClient(复用其精度/持仓模式处理) + try: + from binance_client import BinanceClient + except ImportError: + trading_system_path = project_root / 'trading_system' + sys.path.insert(0, str(trading_system_path)) + from binance_client import BinanceClient + + client = BinanceClient(api_key=api_key, api_secret=api_secret, testnet=use_testnet) + await client.connect() + + try: + # 1) 获取当前持仓(需要知道方向) + raw_positions = await client.client.futures_position_information(symbol=symbol) + nonzero = [] + for p in raw_positions or []: + try: + amt = float(p.get("positionAmt", 0) or 0) + if amt != 0: + nonzero.append((amt, p)) + except Exception: + continue + if not nonzero: + raise HTTPException(status_code=400, detail=f"{symbol} 当前无持仓,无法补挂止盈止损") + + # 2) 获取持仓模式 + dual_side = None + try: + mode_res = await client.client.futures_get_position_mode() + if isinstance(mode_res, dict): + dual_side = bool(mode_res.get("dualSidePosition")) + except Exception: + dual_side = None + + # 3) 取净持仓(单向)或第一条非零腿(对冲/兜底) + amt, p0 = nonzero[0] + net_amt = sum([a for a, _ in nonzero]) + if dual_side is False: + amt = net_amt + side = "BUY" if amt > 0 else "SELL" + + mark_price = None + try: + mark_price = float(p0.get("markPrice", 0) or 0) or None + except Exception: + mark_price = None + + # 4) 从数据库 open trade 取止损/止盈价(优先取最近一条) + from database.models import Trade + open_trades = Trade.get_by_symbol(symbol, status='open') or [] + if not open_trades: + raise HTTPException(status_code=400, detail=f"{symbol} 数据库无 open 交易记录,无法确定止损止盈价") + trade = open_trades[0] + sl = trade.get("stop_loss_price") + tp = trade.get("take_profit_2") or trade.get("take_profit_price") or trade.get("take_profit_1") + try: + sl = float(sl) if sl is not None else None + except Exception: + sl = None + try: + tp = float(tp) if tp is not None else None + except Exception: + tp = None + if not sl or not tp: + raise HTTPException(status_code=400, detail=f"{symbol} 数据库缺少止损/止盈价,无法补挂") + + # 5) 取消旧的保护单,避免重复 + try: + orders = await client.client.futures_get_open_orders(symbol=symbol) + for o in orders or []: + try: + if not isinstance(o, dict): + continue + otype = str(o.get("type") or "").upper() + if otype in {"STOP_MARKET", "TAKE_PROFIT_MARKET", "TRAILING_STOP_MARKET"}: + oid = o.get("orderId") + if oid: + await client.client.futures_cancel_order(symbol=symbol, orderId=int(oid)) + except Exception: + continue + except Exception: + pass + + # 6) 下保护单(closePosition=True) + symbol_info = await client.get_symbol_info(symbol) + # 使用 trading_system/binance_client 的格式化方法(如果不存在则回退简单格式) + try: + fmt_price = BinanceClient._format_price_str_with_rounding # type: ignore[attr-defined] + except Exception: + fmt_price = None + + def _fmt(price: float, rounding_mode: str) -> str: + if fmt_price: + return fmt_price(price, symbol_info, rounding_mode) # type: ignore[misc] + return str(round(float(price), int(symbol_info.get("pricePrecision", 8) or 8) if symbol_info else 8)) + + # 触发价避免“立即触发” + cp = float(mark_price) if mark_price else None + tick = float(symbol_info.get("tickSize", 0) or 0) if symbol_info else 0.0 + pp = int(symbol_info.get("pricePrecision", 8) or 8) if symbol_info else 8 + min_step = tick if tick and tick > 0 else (10 ** (-pp) if pp and pp > 0 else 1e-8) + + sl_price = float(sl) + tp_price = float(tp) + if cp and cp > 0: + # stop + if side == "BUY" and sl_price >= cp: + sl_price = max(0.0, cp - min_step) + if side == "SELL" and sl_price <= cp: + sl_price = cp + min_step + # tp + if side == "BUY" and tp_price <= cp: + tp_price = cp + min_step + if side == "SELL" and tp_price >= cp: + tp_price = max(0.0, cp - min_step) + + # rounding:止损 long 用 UP,short 用 DOWN;止盈 long 用 DOWN,short 用 UP + sl_round = "UP" if side == "BUY" else "DOWN" + tp_round = "DOWN" if side == "BUY" else "UP" + + close_side = "SELL" if side == "BUY" else "BUY" + sl_params = { + "symbol": symbol, + "side": close_side, + "type": "STOP_MARKET", + "stopPrice": _fmt(sl_price, sl_round), + "closePosition": True, + "workingType": "MARK_PRICE", + } + tp_params = { + "symbol": symbol, + "side": close_side, + "type": "TAKE_PROFIT_MARKET", + "stopPrice": _fmt(tp_price, tp_round), + "closePosition": True, + "workingType": "MARK_PRICE", + } + if dual_side is True: + sl_params["positionSide"] = "LONG" if side == "BUY" else "SHORT" + tp_params["positionSide"] = "LONG" if side == "BUY" else "SHORT" + + sl_order = await client.client.futures_create_order(**sl_params) + tp_order = await client.client.futures_create_order(**tp_params) + return { + "symbol": symbol, + "position_side": side, + "dual_side_position": dual_side, + "stop_loss_price": sl_price, + "take_profit_price": tp_price, + "orders": { + "stop_market": sl_order, + "take_profit_market": tp_order, + }, + } + finally: + await client.disconnect() + + +@router.post("/positions/{symbol}/sltp/ensure") +async def ensure_position_sltp(symbol: str): + """ + 手动补挂该 symbol 的止盈止损保护单(币安侧可见)。 + """ + try: + return await _ensure_exchange_sltp_for_symbol(symbol) + except HTTPException: + raise + except Exception as e: + logger.error(f"{symbol} 补挂止盈止损失败: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"补挂止盈止损失败: {str(e)}") + + +@router.post("/positions/sltp/ensure-all") +async def ensure_all_positions_sltp(limit: int = Query(50, ge=1, le=200, description="最多处理多少个持仓symbol")): + """ + 批量补挂当前所有持仓的止盈止损保护单。 + """ + # 先拿当前持仓symbol列表 + api_key = TradingConfig.get_value('BINANCE_API_KEY') + api_secret = TradingConfig.get_value('BINANCE_API_SECRET') + use_testnet = TradingConfig.get_value('USE_TESTNET', False) + if not api_key or not api_secret: + raise HTTPException(status_code=400, detail="API密钥未配置") + + try: + from binance_client import BinanceClient + except ImportError: + trading_system_path = project_root / 'trading_system' + sys.path.insert(0, str(trading_system_path)) + from binance_client import BinanceClient + + client = BinanceClient(api_key=api_key, api_secret=api_secret, testnet=use_testnet) + await client.connect() + try: + positions = await client.get_open_positions() + symbols = [p["symbol"] for p in (positions or []) if float(p.get("positionAmt", 0) or 0) != 0] + finally: + await client.disconnect() + + symbols = symbols[: int(limit or 50)] + results = [] + errors = [] + for sym in symbols: + try: + res = await _ensure_exchange_sltp_for_symbol(sym) + results.append({"symbol": sym, "ok": True, "orders": res.get("orders")}) + except Exception as e: + errors.append({"symbol": sym, "ok": False, "error": str(e)}) + + return { + "total": len(symbols), + "ok": len([r for r in results if r.get("ok")]), + "failed": len(errors), + "results": results, + "errors": errors, + } + + async def get_realtime_account_data(): """从币安API实时获取账户数据""" logger.info("=" * 60) diff --git a/frontend/src/components/StatsDashboard.css b/frontend/src/components/StatsDashboard.css index 6fae898..4784ed2 100644 --- a/frontend/src/components/StatsDashboard.css +++ b/frontend/src/components/StatsDashboard.css @@ -473,6 +473,34 @@ background: #95a5a6; } +.sltp-btn { + padding: 0.5rem 1rem; + background: #1f7aec; + color: white; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 0.9rem; + font-weight: 500; + transition: all 0.3s; + white-space: nowrap; + margin-right: 8px; +} + +.sltp-btn:hover:not(:disabled) { + background: #175fb8; +} + +.sltp-btn:active:not(:disabled) { + transform: scale(0.95); +} + +.sltp-btn:disabled { + opacity: 0.6; + cursor: not-allowed; + background: #95a5a6; +} + .message { padding: 1rem; margin-bottom: 1rem; diff --git a/frontend/src/components/StatsDashboard.jsx b/frontend/src/components/StatsDashboard.jsx index 8eeda3a..4aef04d 100644 --- a/frontend/src/components/StatsDashboard.jsx +++ b/frontend/src/components/StatsDashboard.jsx @@ -7,6 +7,7 @@ const StatsDashboard = () => { const [dashboardData, setDashboardData] = useState(null) const [loading, setLoading] = useState(true) const [closingSymbol, setClosingSymbol] = useState(null) + const [sltpSymbol, setSltpSymbol] = useState(null) const [message, setMessage] = useState('') const [tradingConfig, setTradingConfig] = useState(null) @@ -105,6 +106,26 @@ const StatsDashboard = () => { } } + const handleEnsureSLTP = async (symbol) => { + if (!window.confirm(`确定要为 ${symbol} 补挂“币安止损/止盈保护单”吗?\n\n说明:将自动取消该交易对已有的 STOP/TP 保护单并重新挂单(避免重复)。`)) { + return + } + setSltpSymbol(symbol) + setMessage('') + try { + const res = await api.ensurePositionSLTP(symbol) + const slId = res?.orders?.stop_market?.orderId + const tpId = res?.orders?.take_profit_market?.orderId + setMessage(`${symbol} 已补挂保护单:SL=${slId || '-'} / TP=${tpId || '-'}`) + await loadDashboard() + } catch (error) { + setMessage(`补挂失败 ${symbol}: ${error.message || '未知错误'}`) + } finally { + setSltpSymbol(null) + setTimeout(() => setMessage(''), 4000) + } + } + const handleSyncPositions = async () => { if (!window.confirm('确定要同步持仓状态吗?这将检查币安实际持仓并更新数据库状态。')) { return @@ -552,6 +573,14 @@ const StatsDashboard = () => { ({parseFloat(trade.pnl_percent).toFixed(2)}%) )} +