feat(spot_order): 增强现货下单API的错误处理与文档说明

在现货下单API中添加了对下单金额的最小限制(5 USDT),并改进了错误处理机制,针对不同的Binance API异常提供了详细的错误信息。更新了API文档说明,确保用户能够更清晰地理解下单逻辑与要求。此改动提升了系统的健壮性与用户体验。
This commit is contained in:
薇薇安 2026-02-25 09:26:29 +08:00
parent cbba86001a
commit 163b8303ec
5 changed files with 1855 additions and 52 deletions

View File

@ -1583,12 +1583,21 @@ async def place_spot_order(
price: float = Query(None, description="限价单价格LIMIT 时必填)"), price: float = Query(None, description="限价单价格LIMIT 时必填)"),
account_id: int = Depends(get_account_id), account_id: int = Depends(get_account_id),
): ):
"""现货一键下单(市价单或限价单)。仅支持当前账号 API 密钥对应的现货账户。""" """
现货一键下单参考文档POST /api/v3/order现货
市价单使用 quoteOrderQty 表示要花费/收到的 USDT 数量限价单使用 quantity+price
"""
try: try:
if side not in ("BUY", "SELL"): if side not in ("BUY", "SELL"):
raise HTTPException(status_code=400, detail="side 必须是 BUY 或 SELL") raise HTTPException(status_code=400, detail="side 必须是 BUY 或 SELL")
if quote_order_qty <= 0: if quote_order_qty <= 0:
raise HTTPException(status_code=400, detail="下单金额必须大于 0") raise HTTPException(status_code=400, detail="下单金额必须大于 0")
# 币安现货多数交易对 MIN_NOTIONAL 为 5 USDT
if quote_order_qty < 5:
raise HTTPException(
status_code=400,
detail="现货下单金额不能低于 5 USDT满足 MIN_NOTIONAL请调大默认下单金额",
)
if order_type.upper() == "LIMIT" and (price is None or price <= 0): if order_type.upper() == "LIMIT" and (price is None or price <= 0):
raise HTTPException(status_code=400, detail="限价单必须填写 price") raise HTTPException(status_code=400, detail="限价单必须填写 price")
@ -1598,27 +1607,32 @@ async def place_spot_order(
try: try:
from binance_client import BinanceClient from binance_client import BinanceClient
from binance.exceptions import BinanceAPIException
except ImportError: except ImportError:
trading_system_path = project_root / "trading_system" trading_system_path = project_root / "trading_system"
sys.path.insert(0, str(trading_system_path)) sys.path.insert(0, str(trading_system_path))
from binance_client import BinanceClient from binance_client import BinanceClient
from binance.exceptions import BinanceAPIException
client = BinanceClient(api_key=api_key, api_secret=api_secret, testnet=use_testnet) client = BinanceClient(api_key=api_key, api_secret=api_secret, testnet=use_testnet)
await client.connect() await client.connect()
try: try:
# 现货使用底层 AsyncClient 的 create_orderspot API # 现货接口POST /api/v3/order与合约 fapi 不同。python-binance 中 create_order 为现货。
# 文档:市价单可用 quoteOrderQty 设置 quote asset 数量,传字符串避免精度问题。
quote_str = f"{quote_order_qty:.2f}"
if order_type.upper() == "MARKET": if order_type.upper() == "MARKET":
order = await client.client.create_order( order = await client.client.create_order(
symbol=symbol, symbol=symbol.strip().upper(),
side=side, side=side,
type="MARKET", type="MARKET",
quoteOrderQty=quote_order_qty, quoteOrderQty=quote_str,
) )
else: else:
# LIMIT: 需要 quantity用 quote_order_qty / price 估算
qty = quote_order_qty / price qty = quote_order_qty / price
# 限价单需要 quantity + pricequantity 需符合交易对 LOT_SIZE
order = await client.client.create_order( order = await client.client.create_order(
symbol=symbol, symbol=symbol.strip().upper(),
side=side, side=side,
type="LIMIT", type="LIMIT",
timeInForce="GTC", timeInForce="GTC",
@ -1636,6 +1650,19 @@ async def place_spot_order(
await client.disconnect() await client.disconnect()
except HTTPException: except HTTPException:
raise raise
except BinanceAPIException as e:
code = getattr(e, "code", getattr(e, "status_code", ""))
msg = str(e).strip() or getattr(e, "message", "")
if code == -2015:
detail = "API Key 无现货交易权限或无效。请在币安 API 管理中勾选「启用现货与杠杆交易」并使用该 Key。"
elif code == -1013:
detail = f"订单不满足交易对规则(如 MIN_NOTIONAL 约 5 USDT: {msg}"
elif code == -1121:
detail = f"交易对格式无效: {msg}"
else:
detail = f"币安现货接口错误 (code={code}): {msg}"
logger.warning("现货下单 BinanceAPIException: %s", detail)
raise HTTPException(status_code=400, detail=detail)
except Exception as e: except Exception as e:
logger.exception("现货下单失败: %s", e) logger.exception("现货下单失败: %s", e)
raise HTTPException(status_code=500, detail=f"现货下单失败: {str(e)}") raise HTTPException(status_code=500, detail=f"现货下单失败: {str(e)}")

1784
docs/bian/现货交易.txt Normal file

File diff suppressed because it is too large Load Diff

View File

@ -715,6 +715,9 @@ class MarketScanner:
except Exception as e: except Exception as e:
logger.debug(f"{symbol} 缓存技术指标计算结果失败: {e}") logger.debug(f"{symbol} 缓存技术指标计算结果失败: {e}")
# 统一:缓存可能返回 None保证下游始终拿到 'trending'/'ranging'/'unknown'
market_regime = market_regime or 'unknown'
# 计算交易信号得分(用于排序)- 保留用于兼容性 # 计算交易信号得分(用于排序)- 保留用于兼容性
signal_score = 0 signal_score = 0

View File

@ -85,7 +85,7 @@ class RedisCache:
self._connected = False self._connected = False
async def connect(self): async def connect(self):
"""连接 Redis""" """连接 Redis。若已连接且 ping 正常则直接返回,避免重复连接和刷屏日志。"""
if not REDIS_AVAILABLE: if not REDIS_AVAILABLE:
import sys import sys
import os import os
@ -98,6 +98,15 @@ class RedisCache:
self._connected = False self._connected = False
return return
# 已连接且连接仍有效时不再重复连接,避免多模块多次 connect 时刷屏
if self._connected and self.redis:
try:
await self.redis.ping()
return
except Exception:
self._connected = False
self.redis = None
try: try:
# 构建连接参数 # 构建连接参数
connection_kwargs = {} connection_kwargs = {}
@ -107,86 +116,66 @@ class RedisCache:
# redis-py的异步客户端不支持直接传递ssl上下文 # redis-py的异步客户端不支持直接传递ssl上下文
# 而是使用ssl_cert_reqs、ssl_ca_certs等参数 # 而是使用ssl_cert_reqs、ssl_ca_certs等参数
# 当URL是rediss://时redis-py会自动启用SSL # 当URL是rediss://时redis-py会自动启用SSL
# 设置证书验证要求(字符串格式:'required', 'optional', 'none'
connection_kwargs['ssl_cert_reqs'] = self.ssl_cert_reqs connection_kwargs['ssl_cert_reqs'] = self.ssl_cert_reqs
# 如果提供了 CA 证书路径
if self.ssl_ca_certs: if self.ssl_ca_certs:
connection_kwargs['ssl_ca_certs'] = self.ssl_ca_certs connection_kwargs['ssl_ca_certs'] = self.ssl_ca_certs
# 设置主机名验证根据ssl_cert_reqs自动设置但可以显式指定
if self.ssl_cert_reqs == 'none': if self.ssl_cert_reqs == 'none':
connection_kwargs['ssl_check_hostname'] = False connection_kwargs['ssl_check_hostname'] = False
elif self.ssl_cert_reqs == 'required': elif self.ssl_cert_reqs == 'required':
connection_kwargs['ssl_check_hostname'] = True connection_kwargs['ssl_check_hostname'] = True
else: # optional else:
connection_kwargs['ssl_check_hostname'] = False connection_kwargs['ssl_check_hostname'] = False
logger.debug("使用 TLS 连接 Redis: %s (ssl_cert_reqs=%s)", self.redis_url, self.ssl_cert_reqs)
logger.info(f"使用 TLS 连接 Redis: {self.redis_url} (ssl_cert_reqs={self.ssl_cert_reqs})")
# 如果 URL 中不包含用户名和密码,且提供了独立的用户名和密码参数,则添加到连接参数中 # 如果 URL 中不包含用户名和密码,且提供了独立的用户名和密码参数,则添加到连接参数中
# 注意:如果 URL 中已经包含认证信息(如 redis://user:pass@host:port则优先使用 URL 中的
if self.username and self.password: if self.username and self.password:
# 检查 URL 中是否已包含认证信息格式redis://user:pass@host:port
url_parts = self.redis_url.split('://') url_parts = self.redis_url.split('://')
if len(url_parts) == 2: if len(url_parts) == 2:
url_after_scheme = url_parts[1] url_after_scheme = url_parts[1]
# 如果 URL 中不包含 @ 符号,说明没有在 URL 中指定认证信息
if '@' not in url_after_scheme: if '@' not in url_after_scheme:
# URL 中不包含认证信息,使用独立的用户名和密码参数
connection_kwargs['username'] = self.username connection_kwargs['username'] = self.username
connection_kwargs['password'] = self.password connection_kwargs['password'] = self.password
logger.info(f"使用独立的用户名和密码进行认证: {self.username}") logger.debug("使用独立的用户名和密码进行认证(不记录用户名)")
else: else:
logger.info("URL 中已包含认证信息,优先使用 URL 中的认证信息") logger.debug("URL 中已包含认证信息,优先使用 URL 中的认证信息")
else: else:
# URL 格式异常,尝试使用独立的用户名和密码
connection_kwargs['username'] = self.username connection_kwargs['username'] = self.username
connection_kwargs['password'] = self.password connection_kwargs['password'] = self.password
logger.info(f"URL 格式异常,使用独立的用户名和密码进行认证: {self.username}") logger.debug("URL 格式异常,使用独立的用户名和密码进行认证")
# 验证URL格式redis-py要求URL必须有正确的scheme # 验证URL格式
if not self.redis_url or not any(self.redis_url.startswith(scheme) for scheme in ['redis://', 'rediss://', 'unix://']): if not self.redis_url or not any(self.redis_url.startswith(s) for s in ['redis://', 'rediss://', 'unix://']):
raise ValueError( raise ValueError(
f"Redis URL必须指定以下scheme之一 (redis://, rediss://, unix://): {self.redis_url}" f"Redis URL必须指定以下scheme之一 (redis://, rediss://, unix://): {self.redis_url}"
) )
# 创建 Redis 连接
# 使用 redis.asyncio.from_urlredis-py 4.2+)或 aioredis.from_url向后兼容
# redis-py 需要 decode_responses=Trueaioredis 2.0 可能不需要
# 检查是否是 redis.asyncio通过检查模块名称
try: try:
module_name = aioredis.__name__ if hasattr(aioredis, '__name__') else '' module_name = aioredis.__name__ if hasattr(aioredis, '__name__') else ''
is_redis_py = 'redis.asyncio' in module_name or 'redis' in module_name is_redis_py = 'redis.asyncio' in module_name or 'redis' in module_name
except: except Exception:
is_redis_py = False is_redis_py = False
if is_redis_py: if is_redis_py:
# redis-py 4.2+ 的异步客户端,需要 decode_responses=True
connection_kwargs['decode_responses'] = True connection_kwargs['decode_responses'] = True
# aioredis 2.0 不需要 decode_responses它默认返回字节
self.redis = await aioredis.from_url( self.redis = await aioredis.from_url(
self.redis_url, self.redis_url,
**connection_kwargs **connection_kwargs
) )
# 测试连接
await self.redis.ping() await self.redis.ping()
self._connected = True self._connected = True
logger.info(f"✓ Redis 连接成功: {self.redis_url}") logger.info("✓ Redis 连接成功: %s", self.redis_url)
except Exception as e: except Exception as e:
logger.warning(f"Redis 连接失败: {e},将使用内存缓存") logger.warning("Redis 连接失败: %s,将使用内存缓存", e)
self.redis = None
self._connected = False
if self.redis: if self.redis:
try: try:
await self.redis.close() await self.redis.close()
except: except Exception:
pass pass
self.redis = None self.redis = None
self._connected = False
async def get(self, key: str) -> Optional[Any]: async def get(self, key: str) -> Optional[Any]:
""" """

View File

@ -124,8 +124,8 @@ class TradingStrategy:
symbol = symbol_info['symbol'] symbol = symbol_info['symbol']
change_percent = symbol_info['changePercent'] change_percent = symbol_info['changePercent']
direction = symbol_info['direction'] direction = symbol_info['direction']
market_regime = symbol_info.get('marketRegime', 'unknown') market_regime = symbol_info.get('marketRegime') or 'unknown'
# unknown 表示 K 线不足或未判定趋势/震荡,属正常;仅生成推荐,不满足 ONLY_TRENDING 时不自动下单
logger.info( logger.info(
f"处理交易对: {symbol} " f"处理交易对: {symbol} "
f"({direction} {change_percent:.2f}%, 市场状态: {market_regime})" f"({direction} {change_percent:.2f}%, 市场状态: {market_regime})"