This commit is contained in:
薇薇安 2026-01-18 23:57:22 +08:00
parent 1b4b881eeb
commit 6bec109ce9
7 changed files with 673 additions and 209 deletions

View File

@ -262,18 +262,29 @@ async def get_realtime_positions():
if mark_price == 0: if mark_price == 0:
mark_price = entry_price mark_price = entry_price
position_value = abs(position_amt * mark_price) # === 名义/保证金口径说明(与币安展示更接近)===
# - 币安的名义价值/仓位价值通常随标记价(markPrice)变动
# - DB 中的 notional_usdt/margin_usdt 通常是“开仓时”写入,用于复盘/统计
# - 若发生部分止盈/减仓:币安 positionAmt 会变小,但 DB 里的 notional/margin 可能仍是“原始开仓量”
# → 会出现:数量=6.8,但名义/保证金像是 13.6 的两倍(与你反馈一致)
#
# 因此:实时持仓展示统一使用“当前数量×标记价”的实时名义/保证金,
# 并额外返回 original_* 字段保留 DB 开仓口径,避免混用导致误解。
# 计算开仓时的USDT数量名义价值 # 兼容旧字段entry_value_usdt 仍保留(但它是按入场价计算的名义)
entry_value_usdt = abs(position_amt) * entry_price entry_value_usdt = abs(position_amt) * entry_price
# 计算收益率:盈亏 / 保证金(与币安一致)
# 保证金 = 名义价值 / 杠杆
leverage = float(pos.get('leverage', 1)) leverage = float(pos.get('leverage', 1))
margin = entry_value_usdt / leverage if leverage > 0 else entry_value_usdt if leverage <= 0:
leverage = 1.0
# 当前持仓名义价值USDT按标记价
notional_usdt_live = abs(position_amt) * mark_price
# 当前持仓保证金USDT名义/杠杆
margin_usdt_live = notional_usdt_live / leverage
pnl_percent = 0 pnl_percent = 0
if margin > 0: if margin_usdt_live > 0:
pnl_percent = (unrealized_pnl / margin) * 100 pnl_percent = (unrealized_pnl / margin_usdt_live) * 100
# 尝试从数据库获取开仓时间、止损止盈价格(以及交易规模字段) # 尝试从数据库获取开仓时间、止损止盈价格(以及交易规模字段)
entry_time = None entry_time = None
@ -314,9 +325,12 @@ async def get_realtime_positions():
"entry_price": entry_price, "entry_price": entry_price,
# 兼容旧字段entry_value_usdt 仍保留(前端已有使用) # 兼容旧字段entry_value_usdt 仍保留(前端已有使用)
"entry_value_usdt": entry_value_usdt, "entry_value_usdt": entry_value_usdt,
# 新字段:名义/保证金若DB有则优先使用DB否则使用实时计算 # 实时展示字段:与币安更一致(按当前数量×标记价)
"notional_usdt": db_notional_usdt if db_notional_usdt is not None else entry_value_usdt, "notional_usdt": notional_usdt_live,
"margin_usdt": db_margin_usdt if db_margin_usdt is not None else margin, "margin_usdt": margin_usdt_live,
# 额外返回“开仓记录口径”(用于排查部分止盈/减仓导致的不一致)
"original_notional_usdt": db_notional_usdt,
"original_margin_usdt": db_margin_usdt,
"mark_price": mark_price, "mark_price": mark_price,
"pnl": unrealized_pnl, "pnl": unrealized_pnl,
"pnl_percent": pnl_percent, # 基于保证金的盈亏百分比 "pnl_percent": pnl_percent, # 基于保证金的盈亏百分比
@ -384,12 +398,40 @@ async def close_position(symbol: str):
logger.info("✓ 币安API连接成功") logger.info("✓ 币安API连接成功")
try: try:
# 检查币安是否有持仓 # 检查币安是否有持仓(使用原始 position_information确保能拿到 positionSide 以处理 -4061
logger.info(f"检查 {symbol} 在币安的持仓状态...") logger.info(f"检查 {symbol} 在币安的持仓状态...")
# 读取持仓模式dualSidePosition=True => 对冲模式(必须传 positionSide=LONG/SHORT
dual_side = None
try:
mode_res = await client.client.futures_get_position_mode()
if isinstance(mode_res, dict):
dual_side = bool(mode_res.get("dualSidePosition"))
except Exception as e:
logger.warning(f"读取持仓模式失败(将按单向模式兜底): {e}")
dual_side = None
raw_positions = await client.client.futures_position_information(symbol=symbol)
nonzero_positions = []
for p in raw_positions or []:
try:
amt = float(p.get("positionAmt", 0))
except Exception:
continue
if abs(amt) > 0:
nonzero_positions.append((amt, p))
# 兼容旧逻辑:如果原始接口异常,回退到封装方法
if not nonzero_positions:
try:
positions = await client.get_open_positions() positions = await client.get_open_positions()
position = next((p for p in positions if p['symbol'] == symbol and float(p['positionAmt']) != 0), None) position = next((p for p in positions if p['symbol'] == symbol and float(p['positionAmt']) != 0), None)
if position:
nonzero_positions = [(float(position["positionAmt"]), {"positionAmt": position["positionAmt"]})]
except Exception:
nonzero_positions = []
if not position: if not nonzero_positions:
logger.warning(f"{symbol} 币安账户中没有持仓,可能已被平仓") logger.warning(f"{symbol} 币安账户中没有持仓,可能已被平仓")
# 检查数据库中是否有未平仓的记录,如果有则更新 # 检查数据库中是否有未平仓的记录,如果有则更新
open_trades = Trade.get_by_symbol(symbol, status='open') open_trades = Trade.get_by_symbol(symbol, status='open')
@ -426,55 +468,91 @@ async def close_position(symbol: str):
"status": "closed" "status": "closed"
} }
# 获取持仓信息 # 获取交易对精度信息,调整数量精度(平仓不要向上补 minQty避免超过持仓数量
position_amt = float(position['positionAmt']) symbol_info = None
logger.info(f"✓ 币安账户中有 {symbol} 持仓: {position_amt:.4f}")
# 确定平仓方向(与持仓相反)
side = 'SELL' if position_amt > 0 else 'BUY'
quantity = abs(position_amt)
logger.info(f"开始执行平仓操作: {symbol} {side} {quantity:.4f} @ MARKET (reduceOnly=true)...")
# 直接调用币安 API 平仓,绕过 BinanceClient 的检查逻辑
# 对于平仓操作,应该直接使用币安 API不需要名义价值和保证金检查
try: try:
# 获取交易对精度信息,调整数量精度
symbol_info = await client.get_symbol_info(symbol) symbol_info = await client.get_symbol_info(symbol)
if symbol_info: except Exception:
symbol_info = None
def _adjust_close_qty(qty: float) -> float:
if qty is None:
return 0.0
q = float(qty)
if not symbol_info:
return q
quantity_precision = symbol_info.get('quantityPrecision', 8) quantity_precision = symbol_info.get('quantityPrecision', 8)
step_size = symbol_info.get('stepSize', 0) step_size = float(symbol_info.get('stepSize', 0) or 0)
min_qty = symbol_info.get('minQty', 0) if step_size and step_size > 0:
# 向下取整,避免超过持仓
# 调整数量精度 q = float(int(q / step_size)) * step_size
if step_size > 0:
adjusted_quantity = float(int(quantity / step_size)) * step_size
else: else:
adjusted_quantity = round(quantity, quantity_precision) q = round(q, quantity_precision)
q = round(q, quantity_precision)
return q
# 确保不小于最小数量 # 组装平仓订单(对冲模式可能同币种有 LONG/SHORT 两个仓位,这里一并平掉)
if min_qty > 0 and adjusted_quantity < min_qty: orders = []
adjusted_quantity = min_qty order_ids = []
adjusted_quantity = round(adjusted_quantity, quantity_precision) # 如果 dual_side 无法读取,按 raw_positions 是否包含 positionSide 来推断
if adjusted_quantity != quantity: if dual_side is None:
logger.info(f"数量精度调整: {quantity} -> {adjusted_quantity}") if any(isinstance(p, dict) and (p.get("positionSide") in ("LONG", "SHORT")) for _, p in nonzero_positions):
quantity = adjusted_quantity dual_side = True
else:
dual_side = False
# 直接调用币安 API 下单(使用 reduceOnly="true" logger.info(f"{symbol} 持仓模式: {'HEDGE(对冲)' if dual_side else 'ONE-WAY(单向)'}")
order = await client.client.futures_create_order(
symbol=symbol, # 构造待平仓列表:[(positionSide, amt)]
side=side, to_close = []
type='MARKET', if dual_side:
quantity=quantity, for amt, p in nonzero_positions:
reduceOnly="true" # 使用字符串格式符合币安API要求 ps = (p.get("positionSide") or "").upper()
if ps not in ("LONG", "SHORT"):
ps = "LONG" if amt > 0 else "SHORT"
to_close.append((ps, amt))
else:
# 单向模式只应存在一个净仓位;如果有多个,按合计处理
net_amt = sum([amt for amt, _ in nonzero_positions])
if abs(net_amt) > 0:
to_close.append(("BOTH", net_amt))
logger.info(f"✓ 币安账户中 {symbol} 待平仓: {to_close}")
for ps, amt in to_close:
side = 'SELL' if float(amt) > 0 else 'BUY'
quantity = abs(float(amt))
quantity = _adjust_close_qty(quantity)
if quantity <= 0:
logger.warning(f"{symbol} 平仓数量调整后为0跳过该仓位: positionSide={ps}, amt={amt}")
continue
order_params = {
"symbol": symbol,
"side": side,
"type": "MARKET",
"quantity": quantity,
}
# 对冲模式必须传 positionSide=LONG/SHORT并且某些账户会 -1106因此这里不再传 reduceOnly
if dual_side and ps in ("LONG", "SHORT"):
order_params["positionSide"] = ps
else:
# 单向模式用 reduceOnly 防止反向开仓
order_params["reduceOnly"] = True
logger.info(
f"开始执行平仓下单: {symbol} side={side} qty={quantity} "
f"positionSide={order_params.get('positionSide')} reduceOnly={order_params.get('reduceOnly')}"
) )
try:
order = await client.client.futures_create_order(**order_params)
if not order: if not order:
error_msg = f"{symbol} 平仓失败币安API返回 None" raise RuntimeError("币安API返回 None")
logger.error(error_msg) orders.append(order)
raise HTTPException(status_code=500, detail=error_msg) oid = order.get("orderId")
if oid:
order_ids.append(oid)
except Exception as order_error: except Exception as order_error:
error_msg = f"{symbol} 平仓失败:下单异常 - {str(order_error)}" error_msg = f"{symbol} 平仓失败:下单异常 - {str(order_error)}"
logger.error(error_msg) logger.error(error_msg)
@ -483,21 +561,23 @@ async def close_position(symbol: str):
logger.error(f" 完整错误堆栈:\n{traceback.format_exc()}") logger.error(f" 完整错误堆栈:\n{traceback.format_exc()}")
raise HTTPException(status_code=500, detail=error_msg) raise HTTPException(status_code=500, detail=error_msg)
order_id = order.get('orderId') if not orders:
logger.info(f"{symbol} 平仓订单已提交 (订单ID: {order_id})") raise HTTPException(status_code=400, detail=f"{symbol} 无可平仓的有效仓位数量调整后为0或无持仓")
logger.info(f"{symbol} 平仓订单已提交: {order_ids}")
# 等待订单成交,获取实际成交价格 # 等待订单成交,获取实际成交价格
import asyncio import asyncio
await asyncio.sleep(1) await asyncio.sleep(1)
# 获取订单详情 # 获取订单详情(可能多个订单,按订单号分别取价)
exit_price = None exit_prices = {}
for oid in order_ids:
try: try:
order_info = await client.client.futures_get_order(symbol=symbol, orderId=order_id) order_info = await client.client.futures_get_order(symbol=symbol, orderId=oid)
if order_info: if order_info:
exit_price = float(order_info.get('avgPrice', 0)) or float(order_info.get('price', 0)) p = float(order_info.get('avgPrice', 0)) or float(order_info.get('price', 0))
if exit_price <= 0 and order_info.get('fills'): if p <= 0 and order_info.get('fills'):
# 计算加权平均成交价格
total_qty = 0 total_qty = 0
total_value = 0 total_value = 0
for fill in order_info.get('fills', []): for fill in order_info.get('fills', []):
@ -506,23 +586,48 @@ async def close_position(symbol: str):
total_qty += qty total_qty += qty
total_value += qty * price total_value += qty * price
if total_qty > 0: if total_qty > 0:
exit_price = total_value / total_qty p = total_value / total_qty
if p > 0:
exit_prices[oid] = p
except Exception as e: except Exception as e:
logger.warning(f"获取订单详情失败: {e},使用当前价格") logger.warning(f"获取订单详情失败 (orderId={oid}): {e}")
# 如果无法获取订单价格,使用当前价格 # 兜底:如果无法获取订单价格,使用当前价格
if not exit_price or exit_price <= 0: fallback_exit_price = None
try:
ticker = await client.get_ticker_24h(symbol) ticker = await client.get_ticker_24h(symbol)
exit_price = float(ticker['price']) if ticker else float(position.get('entryPrice', 0)) fallback_exit_price = float(ticker['price']) if ticker else None
except Exception:
fallback_exit_price = None
# 更新数据库记录 # 更新数据库记录
open_trades = Trade.get_by_symbol(symbol, status='open') open_trades = Trade.get_by_symbol(symbol, status='open')
if open_trades: if open_trades:
trade = open_trades[0] # 对冲模式可能有多条 tradeBUY/LONG 和 SELL/SHORT尽量按方向匹配订单更新
used_order_ids = set()
for trade in open_trades:
try:
entry_price = float(trade['entry_price']) entry_price = float(trade['entry_price'])
trade_quantity = float(trade['quantity']) trade_quantity = float(trade['quantity'])
except Exception:
continue
# 计算盈亏 # 选择一个未使用的 orderId如果只有一个就复用
chosen_oid = None
for oid in order_ids:
if oid not in used_order_ids:
chosen_oid = oid
break
if chosen_oid is None and order_ids:
chosen_oid = order_ids[0]
if chosen_oid:
used_order_ids.add(chosen_oid)
exit_price = exit_prices.get(chosen_oid) if chosen_oid else None
if not exit_price:
exit_price = fallback_exit_price or entry_price
# 计算盈亏(数据库侧依旧按名义盈亏;收益率展示用保证金口径在前端/统计里另算)
if trade['side'] == 'BUY': if trade['side'] == 'BUY':
pnl = (exit_price - entry_price) * trade_quantity pnl = (exit_price - entry_price) * trade_quantity
pnl_percent = ((exit_price - entry_price) / entry_price) * 100 pnl_percent = ((exit_price - entry_price) / entry_price) * 100
@ -530,16 +635,15 @@ async def close_position(symbol: str):
pnl = (entry_price - exit_price) * trade_quantity pnl = (entry_price - exit_price) * trade_quantity
pnl_percent = ((entry_price - exit_price) / entry_price) * 100 pnl_percent = ((entry_price - exit_price) / entry_price) * 100
# 更新数据库
Trade.update_exit( Trade.update_exit(
trade_id=trade['id'], trade_id=trade['id'],
exit_price=exit_price, exit_price=exit_price,
exit_reason='manual', exit_reason='manual',
pnl=pnl, pnl=pnl,
pnl_percent=pnl_percent, pnl_percent=pnl_percent,
exit_order_id=order_id exit_order_id=chosen_oid
) )
logger.info(f"✓ 已更新数据库记录 (盈亏: {pnl:.2f} USDT, {pnl_percent:.2f}%)") logger.info(f"✓ 已更新数据库记录 trade_id={trade['id']} order_id={chosen_oid} (盈亏: {pnl:.2f} USDT, {pnl_percent:.2f}%)")
logger.info(f"{symbol} 平仓成功") logger.info(f"{symbol} 平仓成功")
return { return {

View File

@ -6,11 +6,171 @@ from typing import Optional, List
from datetime import datetime, timedelta from datetime import datetime, timedelta
from database.models import TradeRecommendation from database.models import TradeRecommendation
import logging import logging
import os
import json
from typing import Any, Dict, Tuple
import aiohttp
try:
import redis.asyncio as redis_async
except Exception: # pragma: no cover
redis_async = None
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/recommendations", tags=["recommendations"]) router = APIRouter(prefix="/api/recommendations", tags=["recommendations"])
REDIS_KEY_RECOMMENDATIONS_SNAPSHOT = "recommendations:snapshot"
REDIS_KEY_RECOMMENDATIONS_HASH = "recommendations:realtime"
REDIS_KEY_MARK_PRICE_ALL = "market:mark_price:all"
REDIS_KEY_MARK_PRICE_ALL_LOCK = "lock:market:mark_price:all"
def _beijing_time_str() -> str:
from datetime import timezone
return datetime.now(tz=timezone(timedelta(hours=8))).strftime("%Y-%m-%d %H:%M:%S")
def _redis_connection_kwargs() -> Tuple[str, Dict[str, Any]]:
"""
从环境变量构建 Redis 连接参数兼容 Valkey Serverless + TLS
"""
redis_url = (os.getenv("REDIS_URL", "") or "").strip() or "redis://localhost:6379"
username = os.getenv("REDIS_USERNAME", None)
password = os.getenv("REDIS_PASSWORD", None)
ssl_cert_reqs = (os.getenv("REDIS_SSL_CERT_REQS", "required") or "required").strip()
ssl_ca_certs = os.getenv("REDIS_SSL_CA_CERTS", None)
kwargs: Dict[str, Any] = {"decode_responses": True}
if username:
kwargs["username"] = username
if password:
kwargs["password"] = password
use_tls = redis_url.startswith("rediss://") or (os.getenv("REDIS_USE_TLS", "False").lower() == "true")
if use_tls and not redis_url.startswith("rediss://"):
if redis_url.startswith("redis://"):
redis_url = redis_url.replace("redis://", "rediss://", 1)
else:
redis_url = f"rediss://{redis_url}"
if use_tls or redis_url.startswith("rediss://"):
kwargs["ssl_cert_reqs"] = ssl_cert_reqs
if ssl_ca_certs:
kwargs["ssl_ca_certs"] = ssl_ca_certs
# redis-py: ssl_check_hostname 用于控制主机名校验
if ssl_cert_reqs == "required":
kwargs["ssl_check_hostname"] = True
else:
kwargs["ssl_check_hostname"] = False
return redis_url, kwargs
async def _get_redis():
if redis_async is None:
return None
redis_url, kwargs = _redis_connection_kwargs()
try:
client = redis_async.from_url(redis_url, **kwargs)
await client.ping()
return client
except Exception as e:
logger.warning(f"Redis 不可用recommendations: {e}")
return None
async def _get_cached_json(client, key: str) -> Optional[Any]:
try:
raw = await client.get(key)
if not raw:
return None
return json.loads(raw)
except Exception:
return None
async def _set_cached_json(client, key: str, value: Any, ttl_sec: int) -> None:
await client.setex(key, ttl_sec, json.dumps(value, ensure_ascii=False))
async def _refresh_mark_price_all_if_needed(client, min_refresh_sec: int = 10, ttl_sec: int = 30) -> Dict[str, Any]:
"""
获取并缓存 Binance U本位合约 Mark PricepremiumIndex
- 使用单 key 全量缓存适合给推荐页面当前价格
- Redis NX lock 防止并发刷新风暴
"""
now_ms = int(__import__("time").time() * 1000)
cached = await _get_cached_json(client, REDIS_KEY_MARK_PRICE_ALL)
if isinstance(cached, dict):
updated_at_ms = cached.get("updated_at_ms")
try:
updated_at_ms = int(updated_at_ms) if updated_at_ms is not None else None
except Exception:
updated_at_ms = None
if updated_at_ms and (now_ms - updated_at_ms) < (min_refresh_sec * 1000):
return cached
# 尝试抢锁刷新(锁很短,避免慢请求时阻塞)
try:
locked = await client.set(REDIS_KEY_MARK_PRICE_ALL_LOCK, str(now_ms), nx=True, ex=min_refresh_sec)
except Exception:
locked = False
if not locked:
# 没拿到锁就直接返回旧值(如果有)
return cached if isinstance(cached, dict) else {"updated_at_ms": None, "updated_at": None, "items": {}}
# 真的去 Binance 拉一次
url = "https://fapi.binance.com/fapi/v1/premiumIndex"
try:
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=6)) as session:
async with session.get(url) as resp:
if resp.status != 200:
raise RuntimeError(f"binance premiumIndex http={resp.status}")
payload = await resp.json()
except Exception as e:
logger.warning(f"刷新 mark price 失败: {e}")
# 刷新失败,返回旧值
return cached if isinstance(cached, dict) else {"updated_at_ms": None, "updated_at": None, "items": {}}
items: Dict[str, Dict[str, Any]] = {}
if isinstance(payload, list):
for row in payload:
if not isinstance(row, dict):
continue
sym = row.get("symbol")
if not sym or not isinstance(sym, str):
continue
# 只保留 USDT 永续相关(避免无关数据撑爆)
if not sym.endswith("USDT"):
continue
try:
mp = float(row.get("markPrice", 0) or 0)
except Exception:
mp = 0.0
t = row.get("time") # 毫秒
try:
t = int(t) if t is not None else None
except Exception:
t = None
items[sym] = {"markPrice": mp, "time": t}
snapshot = {
"updated_at_ms": now_ms,
"updated_at": _beijing_time_str(),
"items": items,
"source": "binance_premiumIndex",
}
try:
await _set_cached_json(client, REDIS_KEY_MARK_PRICE_ALL, snapshot, ttl_sec=ttl_sec)
except Exception as e:
logger.warning(f"写入 {REDIS_KEY_MARK_PRICE_ALL} 失败(不影响返回): {e}")
return snapshot
@router.get("") @router.get("")
async def get_recommendations( async def get_recommendations(
@ -36,100 +196,83 @@ async def get_recommendations(
""" """
try: try:
if type == 'realtime': if type == 'realtime':
# 从Redis缓存读取推荐如果存在 # 实时推荐:统一只读 Redis全局一份 snapshot不在请求里实时生成避免“页面刷新=触发扫描”)
import sys
from pathlib import Path
current_file = Path(__file__)
backend_path = current_file.parent.parent.parent
project_root = backend_path.parent
trading_system_path = project_root / 'trading_system'
if not trading_system_path.exists():
alternative_path = backend_path / 'trading_system'
if alternative_path.exists():
trading_system_path = alternative_path
else:
raise HTTPException(
status_code=500,
detail=f"交易系统模块不存在"
)
sys.path.insert(0, str(trading_system_path))
sys.path.insert(0, str(project_root))
from binance_client import BinanceClient
import config
# 创建客户端用于访问Redis
client = BinanceClient(
api_key=config.BINANCE_API_KEY,
api_secret=config.BINANCE_API_SECRET,
testnet=config.USE_TESTNET
)
# 连接Redis如果还没有连接
try:
await client.redis_cache.connect()
except:
pass
# 从Redis读取推荐
recommendations = [] recommendations = []
cache_available = False cache_available = False
snapshot_meta: Dict[str, Any] = {}
rds = await _get_redis()
if rds is None:
raise HTTPException(status_code=503, detail="Redis 不可用,无法读取推荐缓存")
# 1) 优先读取 snapshot单 key
try: try:
import time snapshot = await _get_cached_json(rds, REDIS_KEY_RECOMMENDATIONS_SNAPSHOT)
cache_key = "recommendations:realtime" if isinstance(snapshot, dict):
cached_data = await client.redis_cache.hgetall(cache_key) items = snapshot.get("items", [])
if isinstance(items, list) and items:
recommendations = items
cache_available = True
snapshot_meta = {
"generated_at_ms": snapshot.get("generated_at_ms"),
"generated_at": snapshot.get("generated_at"),
"ttl_sec": snapshot.get("ttl_sec"),
}
except Exception as e:
logger.warning(f"读取 {REDIS_KEY_RECOMMENDATIONS_SNAPSHOT} 失败: {e}")
# 2) 兼容旧缓存Hash
if not recommendations:
try:
import time as _time
cached_data = await rds.hgetall(REDIS_KEY_RECOMMENDATIONS_HASH)
if cached_data: if cached_data:
cache_available = True cache_available = True
current_time = time.time() current_time = _time.time()
max_age = 3600 * 2 # 推荐最大保留时间2小时 max_age = 3600 * 2 # 推荐最大保留时间2小时
for raw in cached_data.values():
# 过滤过期推荐 try:
for rec in cached_data.values(): rec = json.loads(raw) if isinstance(raw, str) else raw
except Exception:
rec = None
if isinstance(rec, dict): if isinstance(rec, dict):
rec_timestamp = rec.get('timestamp', 0) rec_timestamp = rec.get('timestamp', 0)
# 如果推荐时间超过2小时跳过 if current_time - float(rec_timestamp or 0) > max_age:
if current_time - rec_timestamp > max_age:
continue continue
recommendations.append(rec) recommendations.append(rec)
# 按时间戳降序排序(最新的在前)
recommendations.sort(key=lambda x: x.get('timestamp', 0), reverse=True) recommendations.sort(key=lambda x: x.get('timestamp', 0), reverse=True)
logger.info(f"从Redis读取到 {len(recommendations)} 个有效推荐(已过滤过期)") logger.info(f"从Redis(Hash)读取到 {len(recommendations)} 个有效推荐(已过滤过期)")
except Exception as e: except Exception as e:
logger.warning(f"从Redis读取推荐失败: {e}") logger.warning(f"从Redis(Hash)读取推荐失败: {e}")
# 如果Redis中没有推荐实时生成 # 3) 合并“标记价”(mark price)作为真正的“当前价格”,并携带时间戳用于前端展示
if not recommendations: mark_snapshot = {}
logger.info("Redis中没有推荐实时生成推荐...")
try: try:
from market_scanner import MarketScanner mark_snapshot = await _refresh_mark_price_all_if_needed(rds)
from risk_manager import RiskManager
from trade_recommender import TradeRecommender
await client.connect()
try:
scanner = MarketScanner(client)
risk_manager = RiskManager(client)
recommender = TradeRecommender(client, scanner, risk_manager)
# 生成推荐会自动保存到Redis
# 降低信号强度阈值以获取更多推荐(推荐系统可以更宽松)
recommendations = await recommender.generate_recommendations(
min_signal_strength=max(2, min_signal_strength - 3), # 降低3个等级以获取更多推荐最低2
max_recommendations=max(limit, 50), # 至少生成50个推荐
add_to_cache=True,
min_quality_score=0.0
)
logger.info(f"实时生成了 {len(recommendations)} 个推荐")
finally:
await client.disconnect()
except Exception as e: except Exception as e:
logger.error(f"实时生成推荐失败: {e}") logger.debug(f"刷新/读取 mark price 失败(不影响返回): {e}")
import traceback
logger.error(traceback.format_exc()) mark_items = mark_snapshot.get("items", {}) if isinstance(mark_snapshot, dict) else {}
mark_updated_at = mark_snapshot.get("updated_at")
mark_updated_at_ms = mark_snapshot.get("updated_at_ms")
if isinstance(mark_items, dict) and mark_items:
for rec in recommendations:
if not isinstance(rec, dict):
continue
sym = rec.get("symbol")
if not sym:
continue
mp = mark_items.get(sym)
if isinstance(mp, dict) and mp.get("markPrice"):
rec["current_price"] = float(mp.get("markPrice") or rec.get("current_price") or 0)
rec["current_price_source"] = "mark_price"
rec["current_price_time_ms"] = mp.get("time") or mark_updated_at_ms
rec["price_updated"] = True
else:
# 保留推荐生成时的 current_price但给出来源/时间(用于提示“可能过时”)
rec.setdefault("current_price_source", rec.get("current_price_source") or "snapshot")
rec.setdefault("price_updated", False)
# 方向过滤 # 方向过滤
if direction: if direction:
@ -143,6 +286,12 @@ async def get_recommendations(
"count": len(recommendations), "count": len(recommendations),
"type": "realtime", "type": "realtime",
"from_cache": cache_available, "from_cache": cache_available,
"meta": {
**snapshot_meta,
"price_source": "mark_price" if mark_items else None,
"price_updated_at": mark_updated_at,
"price_updated_at_ms": mark_updated_at_ms,
},
"data": recommendations "data": recommendations
} }

View File

@ -230,6 +230,17 @@
gap: 8px; gap: 8px;
} }
.price-item.price-meta {
font-size: 12px;
color: #666;
}
.price-updated-badge {
margin-left: 6px;
font-size: 12px;
vertical-align: middle;
}
.price-item label { .price-item label {
font-weight: bold; font-weight: bold;
color: #666; color: #666;

View File

@ -204,6 +204,25 @@ function Recommendations() {
} }
} }
const formatTimeMs = (ms) => {
if (!ms && ms !== 0) return '-'
try {
const date = new Date(Number(ms))
if (isNaN(date.getTime())) return '-'
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
timeZone: 'Asia/Shanghai'
})
} catch (e) {
return '-'
}
}
const getStatusBadge = (status) => { const getStatusBadge = (status) => {
const statusMap = { const statusMap = {
active: { text: '有效', class: 'status-active' }, active: { text: '有效', class: 'status-active' },
@ -338,11 +357,25 @@ function Recommendations() {
<label>当前价格:</label> <label>当前价格:</label>
<span> <span>
{parseFloat(rec.current_price || 0).toFixed(4)} USDT {parseFloat(rec.current_price || 0).toFixed(4)} USDT
{rec.price_updated && ( {rec.current_price_source === 'mark_price' && (
<span className="price-updated-badge" title="价格已通过WebSocket实时更新">🟢</span> <span
className="price-updated-badge"
title={`标记价Binance premiumIndex 更新时间: ${formatTimeMs(rec.current_price_time_ms)}`}
>
🟢
</span>
)} )}
</span> </span>
</div> </div>
{(rec.current_price_source || rec.current_price_time_ms) && (
<div className="price-item price-meta">
<label>价格时间:</label>
<span>
{rec.current_price_source === 'mark_price' ? '标记价' : (rec.current_price_source || 'snapshot')}
{rec.current_price_time_ms ? ` · ${formatTimeMs(rec.current_price_time_ms)}` : ''}
</span>
</div>
)}
{rec.change_percent !== undefined && rec.change_percent !== null && ( {rec.change_percent !== undefined && rec.change_percent !== null && (
<div className="price-item"> <div className="price-item">
<label>24h涨跌:</label> <label>24h涨跌:</label>

View File

@ -511,6 +511,7 @@ class BinanceClient:
) )
result = {} result = {}
now_ms = int(__import__("time").time() * 1000)
for ticker in tickers: for ticker in tickers:
symbol = ticker['symbol'] symbol = ticker['symbol']
if symbol.endswith('USDT'): if symbol.endswith('USDT'):
@ -518,7 +519,9 @@ class BinanceClient:
'symbol': symbol, 'symbol': symbol,
'price': float(ticker.get('lastPrice', 0)), 'price': float(ticker.get('lastPrice', 0)),
'volume': float(ticker.get('quoteVolume', 0)), 'volume': float(ticker.get('quoteVolume', 0)),
'changePercent': float(ticker.get('priceChangePercent', 0)) 'changePercent': float(ticker.get('priceChangePercent', 0)),
# 用于前端展示“当前价格更新时间”(以及后端合并时判断新鲜度)
'ts': now_ms
} }
# 写入 Redis 缓存TTL: 30秒 # 写入 Redis 缓存TTL: 30秒

View File

@ -183,6 +183,19 @@ class MarketScanner:
if not ticker: if not ticker:
return None return None
# 24h ticker 的“最新价”(更接近用户理解的“当前价格”)
# 注意:技术指标仍然基于 K 线收盘价计算;这里额外携带一份展示用价格与时间戳
ticker_price = ticker.get('price')
try:
ticker_price = float(ticker_price) if ticker_price is not None else None
except Exception:
ticker_price = None
ticker_ts = ticker.get('ts')
try:
ticker_ts = int(ticker_ts) if ticker_ts is not None else None
except Exception:
ticker_ts = None
# 获取更多K线数据用于技术指标计算使用配置的主周期 # 获取更多K线数据用于技术指标计算使用配置的主周期
primary_interval = config.TRADING_CONFIG.get('PRIMARY_INTERVAL', '1h') primary_interval = config.TRADING_CONFIG.get('PRIMARY_INTERVAL', '1h')
klines = await self.client.get_klines( klines = await self.client.get_klines(
@ -270,9 +283,17 @@ class MarketScanner:
return { return {
'symbol': symbol, 'symbol': symbol,
# 技术分析使用的价格K线收盘价
'price': current_price, 'price': current_price,
'kline_close_price': current_price,
# 展示用“当前价”24h ticker 最新价,通常更贴近用户的直觉)
'ticker_price': ticker_price,
'ticker_ts': ticker_ts,
'prevPrice': prev_price, 'prevPrice': prev_price,
'changePercent': change_percent, # 1) 主周期涨跌幅(用于内部信号)
'kline_change_percent': change_percent,
# 2) 24h涨跌幅用于前端展示更符合“24h涨跌”的文案
'changePercent': float(ticker.get('changePercent', 0) or 0),
'volume24h': ticker.get('volume', 0), 'volume24h': ticker.get('volume', 0),
'direction': 'UP' if change_percent > 0 else 'DOWN', 'direction': 'UP' if change_percent > 0 else 'DOWN',
'rsi': rsi, 'rsi': rsi,

View File

@ -194,6 +194,7 @@ class TradeRecommender:
if add_to_cache: if add_to_cache:
try: try:
cache_key = "recommendations:realtime" cache_key = "recommendations:realtime"
snapshot_key = "recommendations:snapshot"
# 增量更新:只更新或添加新的推荐,保留其他推荐 # 增量更新:只更新或添加新的推荐,保留其他推荐
updated_count = 0 updated_count = 0
@ -210,6 +211,20 @@ class TradeRecommender:
except: except:
pass pass
# 写入“全量快照”(单 key一份数据给所有用户读
try:
now_ms = int(__import__("time").time() * 1000)
snapshot = {
"generated_at_ms": now_ms,
"generated_at": datetime.now().isoformat(),
"ttl_sec": 7200,
"count": len(final_recommendations),
"items": final_recommendations,
}
await self.client.redis_cache.set(snapshot_key, snapshot, ttl=7200) # 2小时有效
except Exception as e:
logger.warning(f"写入 recommendations:snapshot 失败(不影响返回): {e}")
logger.info(f"已更新 {updated_count} 个推荐到Redis缓存总计 {len(final_recommendations)} 个)") logger.info(f"已更新 {updated_count} 个推荐到Redis缓存总计 {len(final_recommendations)} 个)")
except Exception as e: except Exception as e:
logger.warning(f"保存推荐到Redis失败: {e}") logger.warning(f"保存推荐到Redis失败: {e}")
@ -355,7 +370,30 @@ class TradeRecommender:
""" """
try: try:
symbol = symbol_info['symbol'] symbol = symbol_info['symbol']
current_price = symbol_info['price'] # 技术分析价K线收盘价用于指标/信号一致性
analysis_price = symbol_info.get('kline_close_price', symbol_info.get('price'))
try:
analysis_price = float(analysis_price) if analysis_price is not None else None
except Exception:
analysis_price = None
# 展示用“当前价”:优先用 ticker_price更贴近用户理解的“当前价格”
current_price = symbol_info.get('ticker_price', None)
try:
current_price = float(current_price) if current_price is not None else None
except Exception:
current_price = None
# 回退:如果拿不到 ticker_price就用分析价兜底
if current_price is None:
current_price = analysis_price
price_ts_ms = symbol_info.get("ticker_ts", None)
try:
price_ts_ms = int(price_ts_ms) if price_ts_ms is not None else None
except Exception:
price_ts_ms = None
direction = trade_signal['direction'] direction = trade_signal['direction']
# 计算建议的止损止盈(基于保证金) # 计算建议的止损止盈(基于保证金)
@ -473,11 +511,16 @@ class TradeRecommender:
'symbol': symbol, 'symbol': symbol,
'direction': direction, 'direction': direction,
'current_price': current_price, 'current_price': current_price,
# 价格元信息:前端用于展示“这是不是实时的”
'current_price_source': 'ticker_24h' if symbol_info.get('ticker_price') is not None else 'kline_close',
'current_price_time_ms': price_ts_ms,
'change_percent': symbol_info.get('changePercent', 0), 'change_percent': symbol_info.get('changePercent', 0),
'recommendation_reason': trade_signal['reason'], 'recommendation_reason': trade_signal['reason'],
'signal_strength': signal_strength, 'signal_strength': signal_strength,
'market_regime': market_regime, 'market_regime': market_regime,
'trend_4h': trend_4h, 'trend_4h': trend_4h,
# 额外保留分析价(用于排查“为什么信号这样算”)
'analysis_price': analysis_price,
'rsi': symbol_info.get('rsi'), 'rsi': symbol_info.get('rsi'),
'macd_histogram': symbol_info.get('macd', {}).get('histogram') if symbol_info.get('macd') else None, 'macd_histogram': symbol_info.get('macd', {}).get('histogram') if symbol_info.get('macd') else None,
'bollinger_upper': symbol_info.get('bollinger', {}).get('upper') if symbol_info.get('bollinger') else None, 'bollinger_upper': symbol_info.get('bollinger', {}).get('upper') if symbol_info.get('bollinger') else None,
@ -524,12 +567,112 @@ class TradeRecommender:
f"分类: {recommendation_category}, 风险: {risk_level})" f"分类: {recommendation_category}, 风险: {risk_level})"
) )
return [limit_recommendation] recs = [limit_recommendation]
# 将“单条推荐”也写入 Redis统一推荐缓存来源避免只在手动 generate 时才更新)
await self._update_realtime_cache(recs)
return recs
except Exception as e: except Exception as e:
logger.error(f"创建推荐失败 {symbol_info.get('symbol', 'unknown')}: {e}", exc_info=True) logger.error(f"创建推荐失败 {symbol_info.get('symbol', 'unknown')}: {e}", exc_info=True)
return None return None
async def _update_realtime_cache(self, recs: List[Dict]) -> None:
"""
将推荐写入 Redis
- Hash: recommendations:realtime增量更新
- Snapshot: recommendations:snapshot全量快照给前端统一读取
"""
if not recs:
return
try:
rc = getattr(self.client, "redis_cache", None)
if not rc or not getattr(rc, "redis", None) or not getattr(rc, "_connected", False):
return
cache_key = "recommendations:realtime"
snapshot_key = "recommendations:snapshot"
lock_key = "lock:recommendations:snapshot:update"
# 1) 增量写 Hash每条推荐一个 field
for rec in recs:
if not isinstance(rec, dict):
continue
sym = rec.get("symbol")
if not sym:
continue
rec_key = f"{sym}_{rec.get('order_type', 'LIMIT')}"
await rc.hset(cache_key, rec_key, rec, ttl=3600)
try:
await rc.redis.expire(cache_key, 3600)
except Exception:
pass
# 2) 合并写 Snapshot单 key给所有用户读
now_ms = int(__import__("time").time() * 1000)
try:
got_lock = await rc.redis.set(lock_key, str(now_ms), nx=True, ex=2)
except Exception:
got_lock = False
if not got_lock:
return
existing = None
try:
existing = await rc.get(snapshot_key)
except Exception:
existing = None
# 从 snapshot 取旧 items如果没有就从 Hash 兜底(避免首次写入为空)
items: List[Dict] = []
if isinstance(existing, dict) and isinstance(existing.get("items"), list):
items = [x for x in existing.get("items", []) if isinstance(x, dict)]
# 合并:按 rec_key 覆盖(同 symbol + order_type
merged: Dict[str, Dict] = {}
for x in items:
sym = x.get("symbol")
if not sym:
continue
k = f"{sym}_{x.get('order_type', 'LIMIT')}"
merged[k] = x
for x in recs:
if not isinstance(x, dict):
continue
sym = x.get("symbol")
if not sym:
continue
k = f"{sym}_{x.get('order_type', 'LIMIT')}"
merged[k] = x
# 过滤过期2小时
max_age_sec = 3600 * 2
now_s = __import__("time").time()
filtered: List[Dict] = []
for x in merged.values():
ts = x.get("timestamp", 0)
try:
ts = float(ts) if ts is not None else 0.0
except Exception:
ts = 0.0
if ts and (now_s - ts) > max_age_sec:
continue
filtered.append(x)
filtered.sort(key=lambda x: x.get("timestamp", 0) or 0, reverse=True)
# 防止快照无限长(前端默认 limit=50这里留一点冗余
filtered = filtered[:200]
snapshot = {
"generated_at_ms": now_ms,
"generated_at": datetime.now().isoformat(),
"ttl_sec": 7200,
"count": len(filtered),
"items": filtered,
}
await rc.set(snapshot_key, snapshot, ttl=7200)
except Exception as e:
logger.debug(f"更新推荐 Redis 缓存失败(可忽略): {e}")
def _classify_recommendation( def _classify_recommendation(
self, self,
market_regime: str, market_regime: str,