diff --git a/backend/api/routes/account.py b/backend/api/routes/account.py index 285337d..7e1f91b 100644 --- a/backend/api/routes/account.py +++ b/backend/api/routes/account.py @@ -89,6 +89,17 @@ async def get_realtime_account_data(): except Exception as e: logger.error(f" ✗ 币安API连接失败: {e}", exc_info=True) raise + + # 读取持仓模式(单向/对冲),用于前端“重点说明/自检” + dual_side_position = None + position_mode = None + try: + mode_res = await client.client.futures_get_position_mode() + if isinstance(mode_res, dict): + dual_side_position = bool(mode_res.get("dualSidePosition")) + position_mode = "hedge" if dual_side_position else "one_way" + except Exception as e: + logger.warning(f"读取持仓模式失败(将显示为未知): {e}") # 获取账户余额 logger.info("步骤5: 获取账户余额...") @@ -194,7 +205,10 @@ async def get_realtime_account_data(): # 保证金占用(名义/杠杆汇总) "total_margin_value": total_margin_value, "total_pnl": total_pnl, - "open_positions": open_positions_count + "open_positions": open_positions_count, + # 账户持仓模式(重点:建议使用 one_way) + "position_mode": position_mode, + "dual_side_position": dual_side_position, } logger.info("=" * 60) @@ -227,6 +241,7 @@ async def get_realtime_account(): @router.get("/positions") async def get_realtime_positions(): """获取实时持仓数据""" + client = None try: # 从数据库读取API密钥 api_key = TradingConfig.get_value('BINANCE_API_KEY') @@ -260,7 +275,6 @@ async def get_realtime_positions(): logger.info("连接币安API获取持仓...") await client.connect() positions = await client.get_open_positions() - await client.disconnect() logger.info(f"获取到 {len(positions)} 个持仓") @@ -311,25 +325,44 @@ async def get_realtime_positions(): atr_value = None db_margin_usdt = None db_notional_usdt = None + entry_order_id = None + entry_order_type = None try: from database.models import Trade db_trades = Trade.get_by_symbol(pos.get('symbol'), status='open') if db_trades: - # 找到匹配的交易记录(通过symbol和entry_price匹配) + # 找到匹配的交易记录(优先通过 entry_price 近似匹配;否则取最新一条 open 记录兜底) + matched = None for db_trade in db_trades: - if abs(float(db_trade.get('entry_price', 0)) - entry_price) < 0.01: - entry_time = db_trade.get('entry_time') - # 尝试从数据库获取止损止盈价格(如果存储了) - stop_loss_price = db_trade.get('stop_loss_price') - take_profit_price = db_trade.get('take_profit_price') - take_profit_1 = db_trade.get('take_profit_1') - take_profit_2 = db_trade.get('take_profit_2') - atr_value = db_trade.get('atr') - db_margin_usdt = db_trade.get('margin_usdt') - db_notional_usdt = db_trade.get('notional_usdt') - break + try: + if abs(float(db_trade.get('entry_price', 0)) - entry_price) < 0.01: + matched = db_trade + break + except Exception: + continue + if matched is None: + matched = db_trades[0] + + entry_time = matched.get('entry_time') + stop_loss_price = matched.get('stop_loss_price') + take_profit_price = matched.get('take_profit_price') + take_profit_1 = matched.get('take_profit_1') + take_profit_2 = matched.get('take_profit_2') + atr_value = matched.get('atr') + db_margin_usdt = matched.get('margin_usdt') + db_notional_usdt = matched.get('notional_usdt') + entry_order_id = matched.get('entry_order_id') except Exception as e: logger.debug(f"获取数据库信息失败: {e}") + + # 如果数据库中有 entry_order_id,尝试从币安查询订单类型(LIMIT/MARKET) + if entry_order_id: + try: + info = await client.client.futures_get_order(symbol=pos.get('symbol'), orderId=int(entry_order_id)) + if isinstance(info, dict): + entry_order_type = info.get("type") + except Exception: + entry_order_type = None # 如果没有从数据库获取到止损止盈价格,前端会自己计算 # 注意:数据库可能没有存储止损止盈价格,这是正常的 @@ -357,6 +390,8 @@ async def get_realtime_positions(): "take_profit_1": take_profit_1, "take_profit_2": take_profit_2, "atr": atr_value, + "entry_order_id": entry_order_id, + "entry_order_type": entry_order_type, # LIMIT / MARKET(用于仪表板展示“限价/市价”) }) logger.info(f"格式化后 {len(formatted_positions)} 个有效持仓") @@ -367,6 +402,13 @@ async def get_realtime_positions(): error_msg = f"获取持仓数据失败: {str(e)}" logger.error(error_msg, exc_info=True) raise HTTPException(status_code=500, detail=error_msg) + finally: + # 确保断开连接(避免连接泄漏) + try: + if client is not None: + await client.disconnect() + except Exception: + pass @router.post("/positions/{symbol}/close") diff --git a/frontend/src/components/StatsDashboard.css b/frontend/src/components/StatsDashboard.css index 574e1e2..6fae898 100644 --- a/frontend/src/components/StatsDashboard.css +++ b/frontend/src/components/StatsDashboard.css @@ -54,6 +54,99 @@ color: #34495e; } +.dashboard-notice { + margin: 12px 0 18px; + padding: 14px 14px; + border-radius: 10px; + border: 1px solid #e9ecef; + background: #f8f9fa; +} + +.dashboard-notice.important { + border-color: #ffe8a1; + background: #fff8db; +} + +.notice-title { + font-weight: 700; + color: #7a5a00; + margin-bottom: 8px; +} + +.notice-body { + display: flex; + flex-direction: column; + gap: 8px; + color: #5c4a00; + font-size: 13px; + line-height: 1.5; +} + +.notice-row { + display: flex; + gap: 10px; + align-items: flex-start; +} + +.notice-label { + min-width: 70px; + font-weight: 700; +} + +.notice-value.ok { + color: #1e7e34; + font-weight: 700; +} + +.notice-value.warn { + color: #b04a00; + font-weight: 700; +} + +.notice-text { + color: #5c4a00; +} + +.entry-type-summary { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 10px; +} + +.entry-type-line { + margin-bottom: 6px; + color: #444; + font-size: 13px; +} + +.entry-type-badge { + display: inline-block; + padding: 2px 8px; + border-radius: 999px; + font-size: 12px; + font-weight: 700; + border: 1px solid rgba(0,0,0,0.08); +} + +.entry-type-badge.limit { + background: rgba(33, 150, 243, 0.12); + color: #1565c0; + border-color: rgba(21, 101, 192, 0.25); +} + +.entry-type-badge.market { + background: rgba(255, 152, 0, 0.14); + color: #8a4f00; + border-color: rgba(138, 79, 0, 0.25); +} + +.entry-type-badge.unknown { + background: rgba(158, 158, 158, 0.14); + color: #555; + border-color: rgba(0,0,0,0.18); +} + .account-info { display: flex; flex-direction: column; diff --git a/frontend/src/components/StatsDashboard.jsx b/frontend/src/components/StatsDashboard.jsx index 4ac138e..d923153 100644 --- a/frontend/src/components/StatsDashboard.jsx +++ b/frontend/src/components/StatsDashboard.jsx @@ -10,6 +10,17 @@ const StatsDashboard = () => { const [message, setMessage] = useState('') const [tradingConfig, setTradingConfig] = useState(null) + const getCfgVal = (key, fallback = null) => { + try { + const cfg = tradingConfig || {} + const item = cfg?.[key] + if (item && typeof item === 'object' && 'value' in item) return item.value + return fallback + } catch (e) { + return fallback + } + } + useEffect(() => { loadDashboard() loadTradingConfig() @@ -33,9 +44,9 @@ const StatsDashboard = () => { try { const data = await api.getDashboard() setDashboardData(data) - // 如果dashboard数据中包含配置,也更新配置状态 + // dashboard 的 trading_config 可能是“子集”,不要覆盖完整配置;合并即可 if (data.trading_config) { - setTradingConfig(data.trading_config) + setTradingConfig((prev) => ({ ...(prev || {}), ...(data.trading_config || {}) })) } } catch (error) { console.error('Failed to load dashboard:', error) @@ -123,6 +134,16 @@ const StatsDashboard = () => { const account = dashboardData?.account const openTrades = dashboardData?.open_trades || [] + const entryTypeCounts = openTrades.reduce( + (acc, t) => { + const tp = String(t?.entry_order_type || '').toUpperCase() + if (tp === 'LIMIT') acc.limit += 1 + else if (tp === 'MARKET') acc.market += 1 + else acc.unknown += 1 + return acc + }, + { limit: 0, market: 0, unknown: 0 } + ) return (
@@ -150,6 +171,38 @@ const StatsDashboard = () => { {message}
)} + +
+
重要说明:币安账户请使用「单向持仓模式(One-way)」
+
+
+ 当前检测: + + {account?.position_mode === 'one_way' + ? '单向(推荐)' + : account?.position_mode === 'hedge' + ? '对冲(不推荐,容易出现 positionSide/mode 相关错误)' + : '未知(建议重启后端/交易系统后再看)'} + +
+
+ 操作指引: + + 币安 U本位合约 → 偏好设置/设置 → 持仓模式(Position Mode) → 选择「单向」。切换通常要求当前无持仓/无挂单。 + +
+
+ 入场逻辑: + + 智能入场(方案C):先限价回调入场,未成交会有限追价;趋势强时在偏离可控范围内可市价兜底(减少错过)。 + 当前配置:SMART_ENTRY_ENABLED={String(getCfgVal('SMART_ENTRY_ENABLED', true))}, + LIMIT_ORDER_OFFSET_PCT={String(getCfgVal('LIMIT_ORDER_OFFSET_PCT', '0.5'))}, + ENTRY_MAX_DRIFT_PCT_TRENDING={String(getCfgVal('ENTRY_MAX_DRIFT_PCT_TRENDING', '0.6'))}, + ENTRY_MAX_DRIFT_PCT_RANGING={String(getCfgVal('ENTRY_MAX_DRIFT_PCT_RANGING', '0.3'))}。 + +
+
+
@@ -184,6 +237,12 @@ const StatsDashboard = () => { 持仓数量: {account.open_positions}
+
+ 持仓模式: + + {account.position_mode === 'one_way' ? '单向' : account.position_mode === 'hedge' ? '对冲' : '未知'} + +
) : (
暂无数据
@@ -243,6 +302,11 @@ const StatsDashboard = () => {

当前持仓

+
+ 限价入场: {entryTypeCounts.limit} + 市价入场: {entryTypeCounts.market} + 未知: {entryTypeCounts.unknown} +
{openTrades.length > 0 ? (
{openTrades.map((trade, index) => { @@ -396,6 +460,25 @@ const StatsDashboard = () => { {trade.side}
+
+ 入场类型:{' '} + + {String(trade.entry_order_type || '').toUpperCase() === 'LIMIT' + ? '限价' + : String(trade.entry_order_type || '').toUpperCase() === 'MARKET' + ? '市价' + : '未知'} + +
数量: {parseFloat(trade.quantity || 0).toFixed(4)}
名义: {entryValue >= 0.01 ? entryValue.toFixed(2) : entryValue.toFixed(4)} USDT
保证金: {margin >= 0.01 ? margin.toFixed(2) : margin.toFixed(4)} USDT