diff --git a/backend/api/routes/account.py b/backend/api/routes/account.py
index 0d18e15..fb5ad49 100644
--- a/backend/api/routes/account.py
+++ b/backend/api/routes/account.py
@@ -752,6 +752,7 @@ async def fetch_realtime_positions(account_id: int):
entry_order_id = None
entry_order_type = None
id = None
+ update_time_ms = pos.get('updateTime') # 币安持仓最后更新时间(ms),无 DB 时可用于展示
try:
from database.models import Trade
db_trades = Trade.get_by_symbol(pos.get('symbol'), status='open', account_id=account_id)
@@ -786,6 +787,14 @@ async def fetch_realtime_positions(account_id: int):
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")
+ # 用开仓订单的下单时间补全开仓时间(当 DB 未提供时)
+ order_time_ms = info.get("time")
+ if (entry_time is None or created_at is None) and order_time_ms:
+ order_time_sec = int(order_time_ms) // 1000
+ if entry_time is None:
+ entry_time = order_time_sec
+ if created_at is None:
+ created_at = order_time_sec
except Exception:
entry_order_type = None
@@ -838,6 +847,7 @@ async def fetch_realtime_positions(account_id: int):
"leverage": int(pos.get('leverage', 1)),
"entry_time": entry_time,
"created_at": created_at,
+ "update_time": int(update_time_ms) // 1000 if update_time_ms else None,
"stop_loss_price": stop_loss_price,
"take_profit_price": take_profit_price,
"take_profit_1": take_profit_1,
diff --git a/frontend/src/components/StatsDashboard.jsx b/frontend/src/components/StatsDashboard.jsx
index 8593ba5..870d658 100644
--- a/frontend/src/components/StatsDashboard.jsx
+++ b/frontend/src/components/StatsDashboard.jsx
@@ -304,13 +304,20 @@ const StatsDashboard = () => {
const exportData = openTrades.map(trade => {
const slOrders = trade.open_orders?.filter(o => o.type.includes('STOP')) || []
const tpOrders = trade.open_orders?.filter(o => o.type.includes('TAKE_PROFIT')) || []
-
+ const formatTs = (ts) => {
+ if (ts == null) return null
+ try {
+ const ms = typeof ts === 'number' ? (ts < 1e12 ? ts * 1000 : ts) : Date.parse(ts)
+ return isNaN(ms) ? null : new Date(ms).toISOString().replace('T', ' ').slice(0, 19)
+ } catch { return null }
+ }
return {
...trade,
+ entry_time_display: formatTs(trade.entry_time) || (trade.update_time ? `(仅最后更新 ${formatTs(trade.update_time)})` : null),
+ update_time_display: formatTs(trade.update_time),
// 添加易读的汇总字段
active_sl_orders: slOrders.map(o => `${o.type} @ ${o.stopPrice} (${o.status})`).join('; '),
active_tp_orders: tpOrders.map(o => `${o.type} @ ${o.stopPrice || o.price} (${o.status})`).join('; '),
- // 确保原始数据也在
binance_open_orders_raw: trade.open_orders || []
}
})
@@ -739,7 +746,7 @@ const StatsDashboard = () => {
- 开仓时间: {(trade.entry_time || trade.created_at) ? formatEntryTime(trade.entry_time || trade.created_at) : '—'}
+ 开仓时间: {(trade.entry_time || trade.created_at) ? formatEntryTime(trade.entry_time || trade.created_at) : (trade.update_time ? `最后更新 ${formatEntryTime(trade.update_time)}(非开仓时间)` : '—')}
{' · '}
创建时间: {(trade.created_at != null && trade.created_at !== '') ? formatEntryTime(trade.created_at) : '—'}
diff --git a/trading_system/binance_client.py b/trading_system/binance_client.py
index 5ac1a47..9527da3 100644
--- a/trading_system/binance_client.py
+++ b/trading_system/binance_client.py
@@ -932,36 +932,50 @@ class BinanceClient:
}
self._price_cache.pop(symbol, None)
logger.debug(f"{symbol} 未在缓存中,使用REST API获取")
- try:
- ticker = await self._rate_limited_request(
- f'ticker_{symbol}',
- self.client.futures_symbol_ticker(symbol=symbol)
- )
- stats = await self._rate_limited_request(
- f'stats_{symbol}',
- self.client.futures_ticker(symbol=symbol)
- )
- result = {
- 'symbol': symbol,
- 'price': float(ticker['price']),
- 'volume': float(stats.get('quoteVolume', 0)),
- 'changePercent': float(stats.get('priceChangePercent', 0))
- }
- # 只写 Redis;仅当 Redis 写入失败时才写进程内存(降级)
- wrote_redis = await self.redis_cache.set(cache_key, result, ttl=30)
- if not wrote_redis:
- if len(self._price_cache) >= self._price_cache_max_size:
- oldest_key = min(self._price_cache.keys(), key=lambda k: self._price_cache[k].get('timestamp', 0))
- self._price_cache.pop(oldest_key, None)
- self._price_cache[symbol] = {**result, 'timestamp': time.time()}
- return result
- except BinanceAPIException as e:
- error_code = e.code if hasattr(e, 'code') else None
- if error_code == -1003:
- logger.warning(f"获取 {symbol} 24小时行情失败: API请求频率过高,建议使用WebSocket或增加扫描间隔")
- else:
- logger.error(f"获取 {symbol} 24小时行情失败: {e}")
+ last_err = None
+ for attempt in range(2):
+ try:
+ ticker = await self._rate_limited_request(
+ f'ticker_{symbol}',
+ self.client.futures_symbol_ticker(symbol=symbol)
+ )
+ stats = await self._rate_limited_request(
+ f'stats_{symbol}',
+ self.client.futures_ticker(symbol=symbol)
+ )
+ result = {
+ 'symbol': symbol,
+ 'price': float(ticker['price']),
+ 'volume': float(stats.get('quoteVolume', 0)),
+ 'changePercent': float(stats.get('priceChangePercent', 0))
+ }
+ # 只写 Redis;仅当 Redis 写入失败时才写进程内存(降级)
+ wrote_redis = await self.redis_cache.set(cache_key, result, ttl=30)
+ if not wrote_redis:
+ if len(self._price_cache) >= self._price_cache_max_size:
+ oldest_key = min(self._price_cache.keys(), key=lambda k: self._price_cache[k].get('timestamp', 0))
+ self._price_cache.pop(oldest_key, None)
+ self._price_cache[symbol] = {**result, 'timestamp': time.time()}
+ return result
+ except (TimeoutError, asyncio.TimeoutError) as e:
+ last_err = e
+ if attempt == 0:
+ logger.warning(f"获取 {symbol} 24小时行情超时,2秒后重试: {e}")
+ await asyncio.sleep(2)
+ else:
+ logger.warning(f"获取 {symbol} 24小时行情再次超时: {e},调用方将使用入场价等兜底")
+ return None
+ except BinanceAPIException as e:
+ error_code = e.code if hasattr(e, 'code') else None
+ if error_code == -1003:
+ logger.warning(f"获取 {symbol} 24小时行情失败: API请求频率过高,建议使用WebSocket或增加扫描间隔")
+ else:
+ logger.error(f"获取 {symbol} 24小时行情失败: {e}")
+ return None
+ if last_err:
+ logger.warning(f"获取 {symbol} 24小时行情失败: {last_err}")
return None
+ return None
async def get_all_tickers_24h(self) -> Dict[str, Dict]:
"""
@@ -1247,10 +1261,14 @@ class BinanceClient:
'error_msg': error_msg,
}
- async def get_open_positions(self) -> List[Dict]:
+ async def get_open_positions(self, include_low_notional: bool = False) -> List[Dict]:
"""
获取当前持仓
-
+
+ Args:
+ include_low_notional: 若为 True,则不过滤名义价值低于 POSITION_MIN_NOTIONAL_USDT 的仓位,
+ 返回所有非零持仓;用于 MAX_OPEN_POSITIONS 等需按“实际持仓数”校验的场景。默认 False 保持原行为。
+
Returns:
持仓列表
"""
@@ -1266,8 +1284,8 @@ class BinanceClient:
self.client.futures_position_information(recvWindow=20000),
timeout=read_timeout
)
- # 只保留非零持仓,且名义价值 >= 配置阈值,避免灰尘持仓被当成“有仓”;与仪表板不一致时可调低 POSITION_MIN_NOTIONAL_USDT 或设为 0
- min_notional = getattr(config, 'POSITION_MIN_NOTIONAL_USDT', 1.0)
+ # 只保留非零持仓;名义价值过滤:用于展示/风控时默认过滤灰尘仓,include_low_notional=True 时不过滤以准确计数
+ min_notional = getattr(config, 'POSITION_MIN_NOTIONAL_USDT', 1.0) if not include_low_notional else 0
open_positions = []
skipped_low = []
for pos in positions:
@@ -1285,7 +1303,8 @@ class BinanceClient:
'entryPrice': entry_price,
'markPrice': float(pos.get('markPrice', 0)),
'unRealizedProfit': float(pos['unRealizedProfit']),
- 'leverage': int(pos['leverage'])
+ 'leverage': int(pos['leverage']),
+ 'updateTime': pos.get('updateTime'), # 币安最后更新时间(ms),用于展示/兜底开仓时间
})
if skipped_low and logger.isEnabledFor(logging.DEBUG):
logger.debug(
diff --git a/trading_system/position_manager.py b/trading_system/position_manager.py
index 84cf683..1cb1baa 100644
--- a/trading_system/position_manager.py
+++ b/trading_system/position_manager.py
@@ -327,6 +327,17 @@ class PositionManager:
# 判断是否应该交易
if not await self.risk_manager.should_trade(symbol, change_percent):
return None
+
+ # 开仓前二次校验持仓数(同一轮扫描多 symbol 通过 should_trade 后串行开仓,避免超限)
+ try:
+ max_open = int(config.TRADING_CONFIG.get("MAX_OPEN_POSITIONS", 0) or 0)
+ if max_open > 0:
+ positions = await self.client.get_open_positions(include_low_notional=True)
+ if positions and len(positions) >= max_open:
+ logger.info(f"{symbol} [入场] 开仓前复核:持仓数已达上限 {len(positions)}/{max_open},跳过")
+ return None
+ except Exception as e:
+ logger.debug(f"{symbol} 开仓前持仓数复核异常(继续): {e}")
# 设置杠杆(确保为 int,避免动态杠杆传入 float 导致 API/range 报错)
actual_leverage = await self.client.set_leverage(symbol, int(leverage))
diff --git a/trading_system/risk_manager.py b/trading_system/risk_manager.py
index 9e3f57a..7d6bbc8 100644
--- a/trading_system/risk_manager.py
+++ b/trading_system/risk_manager.py
@@ -876,16 +876,16 @@ class RiskManager:
logger.debug(f"{symbol} 涨跌幅 {change_percent:.2f}% 小于阈值")
return False
- # 检查是否已有持仓 / 总持仓数量限制(优先 WS 缓存)
- positions = await _get_positions_from_cache(self.client) if _get_stream_instance() else None
- if positions is None:
- positions = await self.client.get_open_positions()
+ # 检查是否已有持仓 / 总持仓数量限制(用 REST 全量持仓数,确保 MAX_OPEN_POSITIONS 按实际持仓数校验,不因 min_notional 过滤漏计)
+ positions = await self.client.get_open_positions(include_low_notional=True)
+ if not positions:
+ positions = []
try:
max_open = int(config.TRADING_CONFIG.get("MAX_OPEN_POSITIONS", 0) or 0)
except Exception:
max_open = 0
if max_open > 0 and len(positions) >= max_open:
- logger.debug(f"{symbol} 持仓数量已达上限:{len(positions)}/{max_open},跳过开仓")
+ logger.info(f"{symbol} 持仓数量已达上限:{len(positions)}/{max_open},跳过开仓")
return False
existing_position = next(