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)}%)
)}
+