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(