This commit is contained in:
薇薇安 2026-03-01 18:08:21 +08:00
parent e2c37e6d62
commit 4ea1c53813
5 changed files with 89 additions and 42 deletions

View File

@ -752,6 +752,7 @@ async def fetch_realtime_positions(account_id: int):
entry_order_id = None entry_order_id = None
entry_order_type = None entry_order_type = None
id = None id = None
update_time_ms = pos.get('updateTime') # 币安持仓最后更新时间(ms),无 DB 时可用于展示
try: try:
from database.models import Trade from database.models import Trade
db_trades = Trade.get_by_symbol(pos.get('symbol'), status='open', account_id=account_id) 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)) info = await client.client.futures_get_order(symbol=pos.get('symbol'), orderId=int(entry_order_id))
if isinstance(info, dict): if isinstance(info, dict):
entry_order_type = info.get("type") 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: except Exception:
entry_order_type = None entry_order_type = None
@ -838,6 +847,7 @@ async def fetch_realtime_positions(account_id: int):
"leverage": int(pos.get('leverage', 1)), "leverage": int(pos.get('leverage', 1)),
"entry_time": entry_time, "entry_time": entry_time,
"created_at": created_at, "created_at": created_at,
"update_time": int(update_time_ms) // 1000 if update_time_ms else None,
"stop_loss_price": stop_loss_price, "stop_loss_price": stop_loss_price,
"take_profit_price": take_profit_price, "take_profit_price": take_profit_price,
"take_profit_1": take_profit_1, "take_profit_1": take_profit_1,

View File

@ -304,13 +304,20 @@ const StatsDashboard = () => {
const exportData = openTrades.map(trade => { const exportData = openTrades.map(trade => {
const slOrders = trade.open_orders?.filter(o => o.type.includes('STOP')) || [] const slOrders = trade.open_orders?.filter(o => o.type.includes('STOP')) || []
const tpOrders = trade.open_orders?.filter(o => o.type.includes('TAKE_PROFIT')) || [] 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 { return {
...trade, ...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_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('; '), active_tp_orders: tpOrders.map(o => `${o.type} @ ${o.stopPrice || o.price} (${o.status})`).join('; '),
//
binance_open_orders_raw: trade.open_orders || [] binance_open_orders_raw: trade.open_orders || []
} }
}) })
@ -739,7 +746,7 @@ const StatsDashboard = () => {
</div> </div>
<div className="trade-info"> <div className="trade-info">
<div className="position-time-row"> <div className="position-time-row">
开仓时间: {(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) : '—'} 创建时间: {(trade.created_at != null && trade.created_at !== '') ? formatEntryTime(trade.created_at) : '—'}
</div> </div>

View File

@ -932,36 +932,50 @@ class BinanceClient:
} }
self._price_cache.pop(symbol, None) self._price_cache.pop(symbol, None)
logger.debug(f"{symbol} 未在缓存中使用REST API获取") logger.debug(f"{symbol} 未在缓存中使用REST API获取")
try: last_err = None
ticker = await self._rate_limited_request( for attempt in range(2):
f'ticker_{symbol}', try:
self.client.futures_symbol_ticker(symbol=symbol) ticker = await self._rate_limited_request(
) f'ticker_{symbol}',
stats = await self._rate_limited_request( self.client.futures_symbol_ticker(symbol=symbol)
f'stats_{symbol}', )
self.client.futures_ticker(symbol=symbol) stats = await self._rate_limited_request(
) f'stats_{symbol}',
result = { self.client.futures_ticker(symbol=symbol)
'symbol': symbol, )
'price': float(ticker['price']), result = {
'volume': float(stats.get('quoteVolume', 0)), 'symbol': symbol,
'changePercent': float(stats.get('priceChangePercent', 0)) 'price': float(ticker['price']),
} 'volume': float(stats.get('quoteVolume', 0)),
# 只写 Redis仅当 Redis 写入失败时才写进程内存(降级) 'changePercent': float(stats.get('priceChangePercent', 0))
wrote_redis = await self.redis_cache.set(cache_key, result, ttl=30) }
if not wrote_redis: # 只写 Redis仅当 Redis 写入失败时才写进程内存(降级)
if len(self._price_cache) >= self._price_cache_max_size: wrote_redis = await self.redis_cache.set(cache_key, result, ttl=30)
oldest_key = min(self._price_cache.keys(), key=lambda k: self._price_cache[k].get('timestamp', 0)) if not wrote_redis:
self._price_cache.pop(oldest_key, None) if len(self._price_cache) >= self._price_cache_max_size:
self._price_cache[symbol] = {**result, 'timestamp': time.time()} oldest_key = min(self._price_cache.keys(), key=lambda k: self._price_cache[k].get('timestamp', 0))
return result self._price_cache.pop(oldest_key, None)
except BinanceAPIException as e: self._price_cache[symbol] = {**result, 'timestamp': time.time()}
error_code = e.code if hasattr(e, 'code') else None return result
if error_code == -1003: except (TimeoutError, asyncio.TimeoutError) as e:
logger.warning(f"获取 {symbol} 24小时行情失败: API请求频率过高建议使用WebSocket或增加扫描间隔") last_err = e
else: if attempt == 0:
logger.error(f"获取 {symbol} 24小时行情失败: {e}") 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
return None
async def get_all_tickers_24h(self) -> Dict[str, Dict]: async def get_all_tickers_24h(self) -> Dict[str, Dict]:
""" """
@ -1247,10 +1261,14 @@ class BinanceClient:
'error_msg': error_msg, '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: Returns:
持仓列表 持仓列表
""" """
@ -1266,8 +1284,8 @@ class BinanceClient:
self.client.futures_position_information(recvWindow=20000), self.client.futures_position_information(recvWindow=20000),
timeout=read_timeout timeout=read_timeout
) )
# 只保留非零持仓,且名义价值 >= 配置阈值,避免灰尘持仓被当成“有仓”;与仪表板不一致时可调低 POSITION_MIN_NOTIONAL_USDT 或设为 0 # 只保留非零持仓;名义价值过滤:用于展示/风控时默认过滤灰尘仓include_low_notional=True 时不过滤以准确计数
min_notional = getattr(config, 'POSITION_MIN_NOTIONAL_USDT', 1.0) min_notional = getattr(config, 'POSITION_MIN_NOTIONAL_USDT', 1.0) if not include_low_notional else 0
open_positions = [] open_positions = []
skipped_low = [] skipped_low = []
for pos in positions: for pos in positions:
@ -1285,7 +1303,8 @@ class BinanceClient:
'entryPrice': entry_price, 'entryPrice': entry_price,
'markPrice': float(pos.get('markPrice', 0)), 'markPrice': float(pos.get('markPrice', 0)),
'unRealizedProfit': float(pos['unRealizedProfit']), '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): if skipped_low and logger.isEnabledFor(logging.DEBUG):
logger.debug( logger.debug(

View File

@ -327,6 +327,17 @@ class PositionManager:
# 判断是否应该交易 # 判断是否应该交易
if not await self.risk_manager.should_trade(symbol, change_percent): if not await self.risk_manager.should_trade(symbol, change_percent):
return None 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 报错) # 设置杠杆(确保为 int避免动态杠杆传入 float 导致 API/range 报错)
actual_leverage = await self.client.set_leverage(symbol, int(leverage)) actual_leverage = await self.client.set_leverage(symbol, int(leverage))

View File

@ -876,16 +876,16 @@ class RiskManager:
logger.debug(f"{symbol} 涨跌幅 {change_percent:.2f}% 小于阈值") logger.debug(f"{symbol} 涨跌幅 {change_percent:.2f}% 小于阈值")
return False return False
# 检查是否已有持仓 / 总持仓数量限制(优先 WS 缓存 # 检查是否已有持仓 / 总持仓数量限制(用 REST 全量持仓数,确保 MAX_OPEN_POSITIONS 按实际持仓数校验,不因 min_notional 过滤漏计
positions = await _get_positions_from_cache(self.client) if _get_stream_instance() else None positions = await self.client.get_open_positions(include_low_notional=True)
if positions is None: if not positions:
positions = await self.client.get_open_positions() positions = []
try: try:
max_open = int(config.TRADING_CONFIG.get("MAX_OPEN_POSITIONS", 0) or 0) max_open = int(config.TRADING_CONFIG.get("MAX_OPEN_POSITIONS", 0) or 0)
except Exception: except Exception:
max_open = 0 max_open = 0
if max_open > 0 and len(positions) >= max_open: 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 return False
existing_position = next( existing_position = next(