update
This commit is contained in:
parent
60a5410907
commit
0198dd5345
@ -1,5 +1,5 @@
|
|||||||
"""
|
"""
|
||||||
加密货币交易智能体 - 主控制器
|
加密货币交易智能体 - 主控制器(LLM 驱动版)
|
||||||
"""
|
"""
|
||||||
import asyncio
|
import asyncio
|
||||||
from typing import Dict, Any, List, Optional
|
from typing import Dict, Any, List, Optional
|
||||||
@ -13,12 +13,11 @@ from app.services.feishu_service import get_feishu_service
|
|||||||
from app.services.telegram_service import get_telegram_service
|
from app.services.telegram_service import get_telegram_service
|
||||||
from app.services.paper_trading_service import get_paper_trading_service
|
from app.services.paper_trading_service import get_paper_trading_service
|
||||||
from app.services.price_monitor_service import get_price_monitor_service
|
from app.services.price_monitor_service import get_price_monitor_service
|
||||||
from app.crypto_agent.signal_analyzer import SignalAnalyzer
|
from app.crypto_agent.llm_signal_analyzer import LLMSignalAnalyzer
|
||||||
from app.crypto_agent.strategy import TrendFollowingStrategy
|
|
||||||
|
|
||||||
|
|
||||||
class CryptoAgent:
|
class CryptoAgent:
|
||||||
"""加密货币交易信号智能体"""
|
"""加密货币交易信号智能体(LLM 驱动版)"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
"""初始化智能体"""
|
"""初始化智能体"""
|
||||||
@ -26,35 +25,30 @@ class CryptoAgent:
|
|||||||
self.binance = binance_service
|
self.binance = binance_service
|
||||||
self.feishu = get_feishu_service()
|
self.feishu = get_feishu_service()
|
||||||
self.telegram = get_telegram_service()
|
self.telegram = get_telegram_service()
|
||||||
self.analyzer = SignalAnalyzer()
|
self.llm_analyzer = LLMSignalAnalyzer()
|
||||||
self.strategy = TrendFollowingStrategy()
|
|
||||||
|
|
||||||
# 模拟交易服务
|
# 模拟交易服务
|
||||||
self.paper_trading_enabled = self.settings.paper_trading_enabled
|
self.paper_trading_enabled = self.settings.paper_trading_enabled
|
||||||
if self.paper_trading_enabled:
|
if self.paper_trading_enabled:
|
||||||
self.paper_trading = get_paper_trading_service()
|
self.paper_trading = get_paper_trading_service()
|
||||||
self.price_monitor = get_price_monitor_service()
|
self.price_monitor = get_price_monitor_service()
|
||||||
# 注册价格回调
|
|
||||||
self.price_monitor.add_price_callback(self._on_price_update)
|
self.price_monitor.add_price_callback(self._on_price_update)
|
||||||
else:
|
else:
|
||||||
self.paper_trading = None
|
self.paper_trading = None
|
||||||
self.price_monitor = None
|
self.price_monitor = None
|
||||||
|
|
||||||
# 状态管理
|
# 状态管理
|
||||||
self.last_signals: Dict[str, Dict[str, Any]] = {} # 上次信号
|
self.last_signals: Dict[str, Dict[str, Any]] = {}
|
||||||
self.last_trends: Dict[str, str] = {} # 上次趋势
|
self.signal_cooldown: Dict[str, datetime] = {}
|
||||||
self.signal_cooldown: Dict[str, datetime] = {} # 信号冷却时间
|
|
||||||
|
|
||||||
# 配置
|
# 配置
|
||||||
self.symbols = self.settings.crypto_symbols.split(',')
|
self.symbols = self.settings.crypto_symbols.split(',')
|
||||||
self.analysis_interval = self.settings.crypto_analysis_interval
|
|
||||||
self.llm_threshold = self.settings.crypto_llm_threshold
|
|
||||||
|
|
||||||
# 运行状态
|
# 运行状态
|
||||||
self.running = False
|
self.running = False
|
||||||
self._event_loop = None # 保存主事件循环引用
|
self._event_loop = None
|
||||||
|
|
||||||
logger.info(f"加密货币智能体初始化完成,监控交易对: {self.symbols}")
|
logger.info(f"加密货币智能体初始化完成(LLM 驱动),监控交易对: {self.symbols}")
|
||||||
if self.paper_trading_enabled:
|
if self.paper_trading_enabled:
|
||||||
logger.info(f"模拟交易已启用")
|
logger.info(f"模拟交易已启用")
|
||||||
|
|
||||||
@ -63,11 +57,9 @@ class CryptoAgent:
|
|||||||
if not self.paper_trading:
|
if not self.paper_trading:
|
||||||
return
|
return
|
||||||
|
|
||||||
# 检查是否有订单触发止盈止损
|
|
||||||
triggered = self.paper_trading.check_price_triggers(symbol, price)
|
triggered = self.paper_trading.check_price_triggers(symbol, price)
|
||||||
|
|
||||||
for result in triggered:
|
for result in triggered:
|
||||||
# 使用 asyncio.run_coroutine_threadsafe 从 WebSocket 线程安全地调度协程
|
|
||||||
if self._event_loop and self._event_loop.is_running():
|
if self._event_loop and self._event_loop.is_running():
|
||||||
asyncio.run_coroutine_threadsafe(self._notify_order_closed(result), self._event_loop)
|
asyncio.run_coroutine_threadsafe(self._notify_order_closed(result), self._event_loop)
|
||||||
else:
|
else:
|
||||||
@ -75,10 +67,9 @@ class CryptoAgent:
|
|||||||
|
|
||||||
async def _notify_order_closed(self, result: Dict[str, Any]):
|
async def _notify_order_closed(self, result: Dict[str, Any]):
|
||||||
"""发送订单平仓通知"""
|
"""发送订单平仓通知"""
|
||||||
is_win = result.get('is_win', False)
|
|
||||||
status = result.get('status', '')
|
status = result.get('status', '')
|
||||||
|
is_win = result.get('is_win', False)
|
||||||
|
|
||||||
# 确定图标和文本
|
|
||||||
if status == 'closed_tp':
|
if status == 'closed_tp':
|
||||||
emoji = "🎯"
|
emoji = "🎯"
|
||||||
status_text = "止盈平仓"
|
status_text = "止盈平仓"
|
||||||
@ -92,7 +83,6 @@ class CryptoAgent:
|
|||||||
win_text = "盈利" if is_win else "亏损"
|
win_text = "盈利" if is_win else "亏损"
|
||||||
side_text = "做多" if result.get('side') == 'long' else "做空"
|
side_text = "做多" if result.get('side') == 'long' else "做空"
|
||||||
|
|
||||||
# 构建消息
|
|
||||||
message = f"""{emoji} 订单{status_text}
|
message = f"""{emoji} 订单{status_text}
|
||||||
|
|
||||||
交易对: {result.get('symbol')}
|
交易对: {result.get('symbol')}
|
||||||
@ -102,10 +92,8 @@ class CryptoAgent:
|
|||||||
{win_text}: {result.get('pnl_percent', 0):+.2f}% (${result.get('pnl_amount', 0):+.2f})
|
{win_text}: {result.get('pnl_percent', 0):+.2f}% (${result.get('pnl_amount', 0):+.2f})
|
||||||
持仓时间: {result.get('hold_duration', 'N/A')}"""
|
持仓时间: {result.get('hold_duration', 'N/A')}"""
|
||||||
|
|
||||||
# 发送通知
|
|
||||||
await self.feishu.send_text(message)
|
await self.feishu.send_text(message)
|
||||||
await self.telegram.send_message(message)
|
await self.telegram.send_message(message)
|
||||||
|
|
||||||
logger.info(f"已发送订单平仓通知: {result.get('order_id')}")
|
logger.info(f"已发送订单平仓通知: {result.get('order_id')}")
|
||||||
|
|
||||||
def _get_seconds_until_next_5min(self) -> int:
|
def _get_seconds_until_next_5min(self) -> int:
|
||||||
@ -114,10 +102,8 @@ class CryptoAgent:
|
|||||||
current_minute = now.minute
|
current_minute = now.minute
|
||||||
current_second = now.second
|
current_second = now.second
|
||||||
|
|
||||||
# 计算下一个5的倍数分钟
|
|
||||||
minutes_past = current_minute % 5
|
minutes_past = current_minute % 5
|
||||||
if minutes_past == 0 and current_second == 0:
|
if minutes_past == 0 and current_second == 0:
|
||||||
# 刚好在整点,立即执行
|
|
||||||
return 0
|
return 0
|
||||||
minutes_to_wait = 5 - minutes_past if minutes_past > 0 else 5
|
minutes_to_wait = 5 - minutes_past if minutes_past > 0 else 5
|
||||||
seconds_to_wait = minutes_to_wait * 60 - current_second
|
seconds_to_wait = minutes_to_wait * 60 - current_second
|
||||||
@ -125,45 +111,43 @@ class CryptoAgent:
|
|||||||
return seconds_to_wait
|
return seconds_to_wait
|
||||||
|
|
||||||
async def run(self):
|
async def run(self):
|
||||||
"""主运行循环 - 在5的倍数分钟执行"""
|
"""主运行循环"""
|
||||||
self.running = True
|
self.running = True
|
||||||
self._event_loop = asyncio.get_event_loop() # 保存事件循环引用
|
self._event_loop = asyncio.get_event_loop()
|
||||||
|
|
||||||
# 启动横幅
|
# 启动横幅
|
||||||
logger.info("\n" + "=" * 60)
|
logger.info("\n" + "=" * 60)
|
||||||
logger.info("🚀 加密货币交易信号智能体")
|
logger.info("🚀 加密货币交易信号智能体(LLM 驱动)")
|
||||||
logger.info("=" * 60)
|
logger.info("=" * 60)
|
||||||
logger.info(f" 监控交易对: {', '.join(self.symbols)}")
|
logger.info(f" 监控交易对: {', '.join(self.symbols)}")
|
||||||
logger.info(f" 运行模式: 每5分钟整点执行 (:00, :05, :10, ...)")
|
logger.info(f" 运行模式: 每5分钟整点执行")
|
||||||
logger.info(f" LLM阈值: {self.llm_threshold * 100:.0f}%")
|
logger.info(f" 分析引擎: LLM 自主分析")
|
||||||
if self.paper_trading_enabled:
|
if self.paper_trading_enabled:
|
||||||
logger.info(f" 模拟交易: 已启用")
|
logger.info(f" 模拟交易: 已启用")
|
||||||
logger.info("=" * 60 + "\n")
|
logger.info("=" * 60 + "\n")
|
||||||
|
|
||||||
# 启动价格监控(用于模拟交易)
|
# 启动价格监控
|
||||||
if self.paper_trading_enabled and self.price_monitor:
|
if self.paper_trading_enabled and self.price_monitor:
|
||||||
for symbol in self.symbols:
|
for symbol in self.symbols:
|
||||||
self.price_monitor.subscribe_symbol(symbol)
|
self.price_monitor.subscribe_symbol(symbol)
|
||||||
logger.info(f"已启动 WebSocket 价格监控: {', '.join(self.symbols)}")
|
logger.info(f"已启动 WebSocket 价格监控: {', '.join(self.symbols)}")
|
||||||
|
|
||||||
# 发送启动通知(飞书 + Telegram)
|
# 发送启动通知
|
||||||
await self.feishu.send_text(
|
await self.feishu.send_text(
|
||||||
f"🚀 加密货币智能体已启动\n"
|
f"🚀 加密货币智能体已启动(LLM 驱动)\n"
|
||||||
f"监控交易对: {', '.join(self.symbols)}\n"
|
f"监控交易对: {', '.join(self.symbols)}\n"
|
||||||
f"运行时间: 每5分钟整点 (:00, :05, :10, ...)"
|
f"运行时间: 每5分钟整点"
|
||||||
)
|
)
|
||||||
await self.telegram.send_startup_notification(self.symbols)
|
await self.telegram.send_startup_notification(self.symbols)
|
||||||
|
|
||||||
while self.running:
|
while self.running:
|
||||||
try:
|
try:
|
||||||
# 等待到下一个5分钟整点
|
|
||||||
wait_seconds = self._get_seconds_until_next_5min()
|
wait_seconds = self._get_seconds_until_next_5min()
|
||||||
if wait_seconds > 0:
|
if wait_seconds > 0:
|
||||||
next_run = datetime.now() + timedelta(seconds=wait_seconds)
|
next_run = datetime.now() + timedelta(seconds=wait_seconds)
|
||||||
logger.info(f"⏳ 等待 {wait_seconds} 秒,下次运行: {next_run.strftime('%H:%M:%S')}")
|
logger.info(f"⏳ 等待 {wait_seconds} 秒,下次运行: {next_run.strftime('%H:%M:%S')}")
|
||||||
await asyncio.sleep(wait_seconds)
|
await asyncio.sleep(wait_seconds)
|
||||||
|
|
||||||
# 执行分析
|
|
||||||
run_time = datetime.now()
|
run_time = datetime.now()
|
||||||
logger.info("\n" + "=" * 60)
|
logger.info("\n" + "=" * 60)
|
||||||
logger.info(f"⏰ 定时任务执行 [{run_time.strftime('%Y-%m-%d %H:%M:%S')}]")
|
logger.info(f"⏰ 定时任务执行 [{run_time.strftime('%Y-%m-%d %H:%M:%S')}]")
|
||||||
@ -176,32 +160,29 @@ class CryptoAgent:
|
|||||||
logger.info(f"✅ 本轮分析完成,共分析 {len(self.symbols)} 个交易对")
|
logger.info(f"✅ 本轮分析完成,共分析 {len(self.symbols)} 个交易对")
|
||||||
logger.info("─" * 60 + "\n")
|
logger.info("─" * 60 + "\n")
|
||||||
|
|
||||||
# 等待几秒确保不会在同一分钟内重复执行
|
|
||||||
await asyncio.sleep(2)
|
await asyncio.sleep(2)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"❌ 分析循环出错: {e}")
|
logger.error(f"❌ 分析循环出错: {e}")
|
||||||
await asyncio.sleep(10) # 出错后等待10秒再继续
|
import traceback
|
||||||
|
logger.error(traceback.format_exc())
|
||||||
|
await asyncio.sleep(10)
|
||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
"""停止运行"""
|
"""停止运行"""
|
||||||
self.running = False
|
self.running = False
|
||||||
|
|
||||||
# 停止价格监控
|
|
||||||
if self.price_monitor:
|
if self.price_monitor:
|
||||||
self.price_monitor.stop()
|
self.price_monitor.stop()
|
||||||
|
|
||||||
logger.info("加密货币智能体已停止")
|
logger.info("加密货币智能体已停止")
|
||||||
|
|
||||||
async def analyze_symbol(self, symbol: str):
|
async def analyze_symbol(self, symbol: str):
|
||||||
"""
|
"""
|
||||||
分析单个交易对
|
分析单个交易对(LLM 驱动)
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
symbol: 交易对,如 'BTCUSDT'
|
symbol: 交易对,如 'BTCUSDT'
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# 分隔线
|
|
||||||
logger.info(f"\n{'─' * 50}")
|
logger.info(f"\n{'─' * 50}")
|
||||||
logger.info(f"📊 {symbol} 分析开始")
|
logger.info(f"📊 {symbol} 分析开始")
|
||||||
logger.info(f"{'─' * 50}")
|
logger.info(f"{'─' * 50}")
|
||||||
@ -213,159 +194,125 @@ class CryptoAgent:
|
|||||||
logger.warning(f"⚠️ {symbol} 数据不完整,跳过分析")
|
logger.warning(f"⚠️ {symbol} 数据不完整,跳过分析")
|
||||||
return
|
return
|
||||||
|
|
||||||
# 获取当前价格
|
# 当前价格
|
||||||
current_price = float(data['5m'].iloc[-1]['close'])
|
current_price = float(data['5m'].iloc[-1]['close'])
|
||||||
price_change_24h = self._calculate_price_change(data['1h'])
|
price_change_24h = self._calculate_price_change(data['1h'])
|
||||||
logger.info(f"💰 当前价格: ${current_price:,.2f} ({price_change_24h})")
|
logger.info(f"💰 当前价格: ${current_price:,.2f} ({price_change_24h})")
|
||||||
|
|
||||||
# 2. 分析趋势(1H + 4H)- 返回详细趋势信息
|
# 2. LLM 分析(包含新闻舆情)
|
||||||
logger.info(f"\n📈 【趋势分析】")
|
logger.info(f"\n🤖 【LLM 分析中...】")
|
||||||
trend = self.analyzer.analyze_trend(data['1h'], data['4h'])
|
result = await self.llm_analyzer.analyze(symbol, data, symbols=self.symbols)
|
||||||
trend_direction = trend.get('direction', 'neutral') if isinstance(trend, dict) else trend
|
|
||||||
trend_strength = trend.get('strength', 'unknown') if isinstance(trend, dict) else 'unknown'
|
|
||||||
trend_phase = trend.get('phase', 'unknown') if isinstance(trend, dict) else 'unknown'
|
|
||||||
|
|
||||||
# 趋势方向图标
|
# 输出分析摘要
|
||||||
trend_icon = {'bullish': '🟢 看涨', 'bearish': '🔴 看跌', 'neutral': '⚪ 震荡'}.get(trend_direction, '❓')
|
summary = result.get('analysis_summary', '无')
|
||||||
phase_text = {'impulse': '主升/主跌浪', 'correction': '回调/反弹', 'oversold': '极度超卖',
|
logger.info(f" 市场状态: {summary}")
|
||||||
'overbought': '极度超买', 'sideways': '横盘'}.get(trend_phase, trend_phase)
|
|
||||||
|
|
||||||
logger.info(f" 方向: {trend_icon} | 强度: {trend_strength} | 阶段: {phase_text}")
|
# 输出新闻情绪
|
||||||
|
news_sentiment = result.get('news_sentiment', '')
|
||||||
|
news_impact = result.get('news_impact', '')
|
||||||
|
if news_sentiment:
|
||||||
|
sentiment_icon = {'positive': '📈', 'negative': '📉', 'neutral': '➖'}.get(news_sentiment, '')
|
||||||
|
logger.info(f" 新闻情绪: {sentiment_icon} {news_sentiment}")
|
||||||
|
if news_impact:
|
||||||
|
logger.info(f" 消息影响: {news_impact}")
|
||||||
|
|
||||||
# 3. 检查趋势变化(只有之前有记录时才检查,避免启动时误报)
|
# 输出关键价位
|
||||||
if symbol in self.last_trends:
|
levels = result.get('key_levels', {})
|
||||||
last_trend = self.last_trends[symbol]
|
if levels.get('support') or levels.get('resistance'):
|
||||||
if isinstance(last_trend, dict):
|
support_str = ', '.join([f"${s:,.2f}" for s in levels.get('support', [])[:2]])
|
||||||
last_direction = last_trend.get('direction', 'neutral')
|
resistance_str = ', '.join([f"${r:,.2f}" for r in levels.get('resistance', [])[:2]])
|
||||||
else:
|
logger.info(f" 支撑位: {support_str or '-'}")
|
||||||
last_direction = last_trend
|
logger.info(f" 阻力位: {resistance_str or '-'}")
|
||||||
if last_direction != trend_direction:
|
|
||||||
await self._handle_trend_change(symbol, last_direction, trend_direction, data)
|
|
||||||
|
|
||||||
self.last_trends[symbol] = trend
|
# 3. 处理信号
|
||||||
|
signals = result.get('signals', [])
|
||||||
|
|
||||||
# 4. 分析进场信号(15M 为主,5M 辅助,传入 1H 数据用于支撑阻力位)
|
if not signals:
|
||||||
logger.info(f"\n🎯 【信号分析】")
|
logger.info(f"\n⏸️ 结论: 无交易信号,继续观望")
|
||||||
signal = self.analyzer.analyze_entry_signal(data['5m'], data['15m'], trend, data['1h'])
|
return
|
||||||
signal['symbol'] = symbol
|
|
||||||
signal['trend'] = trend_direction
|
|
||||||
signal['trend_info'] = trend if isinstance(trend, dict) else {'direction': trend}
|
|
||||||
signal['price'] = current_price
|
|
||||||
signal['timestamp'] = datetime.now()
|
|
||||||
|
|
||||||
# 输出信号详情
|
# 输出所有信号
|
||||||
action_icon = {'buy': '🟢 买入', 'sell': '🔴 卖出', 'hold': '⏸️ 观望'}.get(signal['action'], '❓')
|
logger.info(f"\n🎯 【发现 {len(signals)} 个信号】")
|
||||||
grade_icon = {'A': '⭐⭐⭐', 'B': '⭐⭐', 'C': '⭐', 'D': ''}.get(signal.get('signal_grade', 'D'), '')
|
|
||||||
signal_type = signal.get('signal_type', 'swing')
|
|
||||||
type_text = '📈短线' if signal_type == 'short_term' else '📊波段'
|
|
||||||
|
|
||||||
logger.info(f" 信号: {action_icon} | 类型: {type_text} | 置信度: {signal['confidence']}% | 等级: {signal.get('signal_grade', 'D')} {grade_icon}")
|
for sig in signals:
|
||||||
|
signal_type = sig.get('type', 'unknown')
|
||||||
|
type_map = {'short_term': '短线', 'medium_term': '中线', 'long_term': '长线'}
|
||||||
|
type_text = type_map.get(signal_type, signal_type)
|
||||||
|
|
||||||
# 输出触发原因
|
action = sig.get('action', 'wait')
|
||||||
if signal.get('reasons'):
|
action_map = {'buy': '🟢 做多', 'sell': '🔴 做空'}
|
||||||
logger.info(f" 原因: {', '.join(signal['reasons'][:5])}") # 最多显示5个原因
|
action_text = action_map.get(action, action)
|
||||||
|
|
||||||
# 输出权重详情
|
grade = sig.get('grade', 'D')
|
||||||
weights = signal.get('signal_weights', {})
|
confidence = sig.get('confidence', 0)
|
||||||
if weights:
|
grade_icon = {'A': '⭐⭐⭐', 'B': '⭐⭐', 'C': '⭐', 'D': ''}.get(grade, '')
|
||||||
logger.info(f" 权重: 买入={weights.get('buy', 0):.1f} | 卖出={weights.get('sell', 0):.1f}")
|
|
||||||
|
|
||||||
# 输出K线形态
|
logger.info(f"\n [{type_text}] {action_text}")
|
||||||
patterns = signal.get('patterns', {})
|
logger.info(f" 等级: {grade} {grade_icon} | 置信度: {confidence}%")
|
||||||
if patterns.get('bullish_patterns') or patterns.get('bearish_patterns'):
|
logger.info(f" 入场: ${sig.get('entry_price', 0):,.2f} | "
|
||||||
all_patterns = patterns.get('bullish_patterns', []) + patterns.get('bearish_patterns', [])
|
f"止损: ${sig.get('stop_loss', 0):,.2f} | "
|
||||||
logger.info(f" 形态: {', '.join(all_patterns)}")
|
f"止盈: ${sig.get('take_profit', 0):,.2f}")
|
||||||
|
logger.info(f" 原因: {sig.get('reason', '无')[:100]}")
|
||||||
|
|
||||||
# 输出成交量分析
|
if sig.get('risk_warning'):
|
||||||
vol = signal.get('volume_analysis', {})
|
logger.info(f" 风险: {sig.get('risk_warning')}")
|
||||||
if vol:
|
|
||||||
vol_icon = {'high': '📈', 'low': '📉', 'normal': '➖'}.get(vol.get('volume_signal', 'normal'), '')
|
|
||||||
confirm_text = '✓ 确认' if vol.get('volume_confirms') else '✗ 未确认'
|
|
||||||
logger.info(f" 成交量: {vol_icon} {vol.get('volume_signal', 'normal')} | {confirm_text}")
|
|
||||||
|
|
||||||
# 输出支撑阻力位
|
# 4. 选择最佳信号发送通知
|
||||||
levels = signal.get('levels', {})
|
best_signal = self.llm_analyzer.get_best_signal(result)
|
||||||
if levels.get('nearest_support') or levels.get('nearest_resistance'):
|
|
||||||
support = f"${levels['nearest_support']:,.2f}" if levels.get('nearest_support') else '-'
|
|
||||||
resistance = f"${levels['nearest_resistance']:,.2f}" if levels.get('nearest_resistance') else '-'
|
|
||||||
logger.info(f" 关键位: 支撑={support} | 阻力={resistance}")
|
|
||||||
|
|
||||||
# 5. 检查是否需要发送信号
|
if best_signal and self._should_send_signal(symbol, best_signal):
|
||||||
if self._should_send_signal(symbol, signal):
|
|
||||||
logger.info(f"\n📤 【发送通知】")
|
logger.info(f"\n📤 【发送通知】")
|
||||||
|
|
||||||
# 6. 计算止损止盈
|
# 构建通知消息
|
||||||
atr = float(data['15m'].iloc[-1].get('atr', 0))
|
message = self.llm_analyzer.format_signal_message(best_signal, symbol)
|
||||||
if atr > 0:
|
|
||||||
sl_tp = self.analyzer.calculate_stop_loss_take_profit(
|
|
||||||
signal['price'], signal['action'], atr
|
|
||||||
)
|
|
||||||
signal.update(sl_tp)
|
|
||||||
logger.info(f" 止损: ${signal['stop_loss']:,.2f} | 止盈: ${signal['take_profit']:,.2f}")
|
|
||||||
|
|
||||||
# 7. LLM 深度分析(置信度超过阈值时)
|
# 发送通知
|
||||||
if signal['confidence'] >= self.llm_threshold * 100:
|
await self.feishu.send_text(message)
|
||||||
logger.info(f" 🤖 触发 LLM 深度分析...")
|
await self.telegram.send_message(message)
|
||||||
llm_result = await self.analyzer.llm_analyze(data, signal, symbol)
|
|
||||||
|
|
||||||
# 处理 LLM 分析结果
|
logger.info(f" ✅ 已发送信号通知")
|
||||||
if llm_result.get('parsed'):
|
|
||||||
parsed = llm_result['parsed']
|
|
||||||
# 新格式使用 signal 而不是 recommendation
|
|
||||||
recommendation = parsed.get('signal', parsed.get('recommendation', {}))
|
|
||||||
|
|
||||||
# 如果 LLM 建议观望,降低置信度
|
# 更新状态
|
||||||
if recommendation.get('action') == 'wait':
|
self.last_signals[symbol] = best_signal
|
||||||
signal['confidence'] = min(signal['confidence'], 40)
|
|
||||||
signal['llm_analysis'] = llm_result.get('summary', 'LLM 建议观望')
|
|
||||||
logger.info(f" 🤖 LLM 建议: 观望")
|
|
||||||
else:
|
|
||||||
# 使用 LLM 的止损止盈建议
|
|
||||||
if recommendation.get('stop_loss'):
|
|
||||||
signal['stop_loss'] = recommendation['stop_loss']
|
|
||||||
if recommendation.get('targets'):
|
|
||||||
signal['take_profit'] = recommendation['targets'][0]
|
|
||||||
elif recommendation.get('take_profit'):
|
|
||||||
signal['take_profit'] = recommendation['take_profit']
|
|
||||||
signal['llm_analysis'] = llm_result.get('summary', '')
|
|
||||||
logger.info(f" 🤖 LLM 建议: {recommendation.get('action', 'N/A')}")
|
|
||||||
else:
|
|
||||||
signal['llm_analysis'] = llm_result.get('summary', llm_result.get('raw', '')[:200])
|
|
||||||
|
|
||||||
# 8. 发送通知(飞书 + Telegram,置信度仍然足够高时)
|
|
||||||
if signal['confidence'] >= 50:
|
|
||||||
await self.feishu.send_trading_signal(signal)
|
|
||||||
await self.telegram.send_trading_signal(signal)
|
|
||||||
|
|
||||||
# 9. 更新状态
|
|
||||||
self.last_signals[symbol] = signal
|
|
||||||
self.signal_cooldown[symbol] = datetime.now()
|
self.signal_cooldown[symbol] = datetime.now()
|
||||||
|
|
||||||
action_text = '买入' if signal['action'] == 'buy' else '卖出'
|
# 5. 创建模拟订单
|
||||||
logger.info(f" ✅ 已发送 {action_text} 信号通知(飞书+Telegram)")
|
|
||||||
|
|
||||||
# 10. 创建模拟订单
|
|
||||||
if self.paper_trading_enabled and self.paper_trading:
|
if self.paper_trading_enabled and self.paper_trading:
|
||||||
if signal.get('signal_grade', 'D') != 'D':
|
grade = best_signal.get('grade', 'D')
|
||||||
order = self.paper_trading.create_order_from_signal(signal)
|
if grade != 'D':
|
||||||
|
# 转换信号格式以兼容 paper_trading
|
||||||
|
paper_signal = self._convert_to_paper_signal(symbol, best_signal, current_price)
|
||||||
|
order = self.paper_trading.create_order_from_signal(paper_signal)
|
||||||
if order:
|
if order:
|
||||||
logger.info(f" 📝 已创建模拟订单: {order.order_id}")
|
logger.info(f" 📝 已创建模拟订单: {order.order_id}")
|
||||||
else:
|
else:
|
||||||
logger.info(f" ⏸️ 置信度不足({signal['confidence']}%),不发送通知")
|
if best_signal:
|
||||||
else:
|
logger.info(f"\n⏸️ 信号冷却中或置信度不足,不发送通知")
|
||||||
# 输出为什么不发送
|
|
||||||
if signal['action'] == 'hold':
|
|
||||||
logger.info(f"\n⏸️ 结论: 观望,无交易机会")
|
|
||||||
elif signal['confidence'] < 50:
|
|
||||||
logger.info(f"\n⏸️ 结论: 置信度不足({signal['confidence']}%),继续观望")
|
|
||||||
else:
|
|
||||||
logger.info(f"\n⏸️ 结论: 信号冷却中,跳过")
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"❌ 分析 {symbol} 出错: {e}")
|
logger.error(f"❌ 分析 {symbol} 出错: {e}")
|
||||||
import traceback
|
import traceback
|
||||||
logger.error(traceback.format_exc())
|
logger.error(traceback.format_exc())
|
||||||
|
|
||||||
|
def _convert_to_paper_signal(self, symbol: str, signal: Dict[str, Any],
|
||||||
|
current_price: float) -> Dict[str, Any]:
|
||||||
|
"""转换 LLM 信号格式为模拟交易格式"""
|
||||||
|
signal_type = signal.get('type', 'medium_term')
|
||||||
|
type_map = {'short_term': 'short_term', 'medium_term': 'swing', 'long_term': 'swing'}
|
||||||
|
|
||||||
|
return {
|
||||||
|
'symbol': symbol,
|
||||||
|
'action': signal.get('action', 'hold'),
|
||||||
|
'price': current_price,
|
||||||
|
'stop_loss': signal.get('stop_loss', 0),
|
||||||
|
'take_profit': signal.get('take_profit', 0),
|
||||||
|
'confidence': signal.get('confidence', 0),
|
||||||
|
'signal_grade': signal.get('grade', 'D'),
|
||||||
|
'signal_type': type_map.get(signal_type, 'swing'),
|
||||||
|
'reasons': [signal.get('reason', '')],
|
||||||
|
'timestamp': datetime.now()
|
||||||
|
}
|
||||||
|
|
||||||
def _calculate_price_change(self, h1_data: pd.DataFrame) -> str:
|
def _calculate_price_change(self, h1_data: pd.DataFrame) -> str:
|
||||||
"""计算24小时价格变化"""
|
"""计算24小时价格变化"""
|
||||||
if len(h1_data) < 24:
|
if len(h1_data) < 24:
|
||||||
@ -383,96 +330,56 @@ class CryptoAgent:
|
|||||||
for interval in required_intervals:
|
for interval in required_intervals:
|
||||||
if interval not in data or data[interval].empty:
|
if interval not in data or data[interval].empty:
|
||||||
return False
|
return False
|
||||||
if len(data[interval]) < 20: # 至少需要20条数据
|
if len(data[interval]) < 20:
|
||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def _should_send_signal(self, symbol: str, signal: Dict[str, Any]) -> bool:
|
def _should_send_signal(self, symbol: str, signal: Dict[str, Any]) -> bool:
|
||||||
"""
|
"""判断是否应该发送信号"""
|
||||||
判断是否应该发送信号
|
action = signal.get('action', 'wait')
|
||||||
|
if action == 'wait':
|
||||||
Args:
|
|
||||||
symbol: 交易对
|
|
||||||
signal: 信号数据
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
是否发送
|
|
||||||
"""
|
|
||||||
# 如果是观望,不发送
|
|
||||||
if signal['action'] == 'hold':
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# 置信度太低,不发送
|
confidence = signal.get('confidence', 0)
|
||||||
if signal['confidence'] < 50:
|
if confidence < 50:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# 检查冷却时间(同一交易对30分钟内不重复发送相同方向的信号)
|
# 检查冷却时间(30分钟内不重复发送相同方向的信号)
|
||||||
if symbol in self.signal_cooldown:
|
if symbol in self.signal_cooldown:
|
||||||
cooldown_end = self.signal_cooldown[symbol] + timedelta(minutes=30)
|
cooldown_end = self.signal_cooldown[symbol] + timedelta(minutes=30)
|
||||||
if datetime.now() < cooldown_end:
|
if datetime.now() < cooldown_end:
|
||||||
# 检查是否是相同方向的信号
|
|
||||||
if symbol in self.last_signals:
|
if symbol in self.last_signals:
|
||||||
if self.last_signals[symbol]['action'] == signal['action']:
|
if self.last_signals[symbol].get('action') == action:
|
||||||
logger.debug(f"{symbol} 信号冷却中,跳过")
|
logger.debug(f"{symbol} 信号冷却中,跳过")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
async def _handle_trend_change(self, symbol: str, old_trend: str, new_trend: str,
|
|
||||||
data: Dict[str, pd.DataFrame]):
|
|
||||||
"""处理趋势变化"""
|
|
||||||
price = float(data['1h'].iloc[-1]['close'])
|
|
||||||
await self.feishu.send_trend_change(symbol, old_trend, new_trend, price)
|
|
||||||
await self.telegram.send_trend_change(symbol, old_trend, new_trend, price)
|
|
||||||
logger.info(f"{symbol} 趋势变化: {old_trend} -> {new_trend}")
|
|
||||||
|
|
||||||
async def analyze_once(self, symbol: str) -> Dict[str, Any]:
|
async def analyze_once(self, symbol: str) -> Dict[str, Any]:
|
||||||
"""
|
"""单次分析(用于测试或手动触发)"""
|
||||||
单次分析(用于测试或手动触发)
|
|
||||||
|
|
||||||
Args:
|
|
||||||
symbol: 交易对
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
分析结果
|
|
||||||
"""
|
|
||||||
data = self.binance.get_multi_timeframe_data(symbol)
|
data = self.binance.get_multi_timeframe_data(symbol)
|
||||||
|
|
||||||
if not self._validate_data(data):
|
if not self._validate_data(data):
|
||||||
return {'error': '数据不完整'}
|
return {'error': '数据不完整'}
|
||||||
|
|
||||||
trend = self.analyzer.analyze_trend(data['1h'], data['4h'])
|
result = await self.llm_analyzer.analyze(symbol, data, symbols=self.symbols)
|
||||||
signal = self.analyzer.analyze_entry_signal(data['5m'], data['15m'], trend)
|
return result
|
||||||
|
|
||||||
signal['symbol'] = symbol
|
|
||||||
signal['trend'] = trend
|
|
||||||
signal['price'] = float(data['5m'].iloc[-1]['close'])
|
|
||||||
|
|
||||||
# 计算止损止盈
|
|
||||||
atr = float(data['15m'].iloc[-1].get('atr', 0))
|
|
||||||
if atr > 0:
|
|
||||||
sl_tp = self.analyzer.calculate_stop_loss_take_profit(
|
|
||||||
signal['price'], signal['action'], atr
|
|
||||||
)
|
|
||||||
signal.update(sl_tp)
|
|
||||||
|
|
||||||
return signal
|
|
||||||
|
|
||||||
def get_status(self) -> Dict[str, Any]:
|
def get_status(self) -> Dict[str, Any]:
|
||||||
"""获取智能体状态"""
|
"""获取智能体状态"""
|
||||||
return {
|
return {
|
||||||
'running': self.running,
|
'running': self.running,
|
||||||
'symbols': self.symbols,
|
'symbols': self.symbols,
|
||||||
'analysis_interval': self.analysis_interval,
|
'mode': 'LLM 驱动',
|
||||||
'last_signals': {
|
'last_signals': {
|
||||||
symbol: {
|
symbol: {
|
||||||
|
'type': sig.get('type'),
|
||||||
'action': sig.get('action'),
|
'action': sig.get('action'),
|
||||||
'confidence': sig.get('confidence'),
|
'confidence': sig.get('confidence'),
|
||||||
'timestamp': sig.get('timestamp').isoformat() if sig.get('timestamp') else None
|
'grade': sig.get('grade')
|
||||||
}
|
}
|
||||||
for symbol, sig in self.last_signals.items()
|
for symbol, sig in self.last_signals.items()
|
||||||
},
|
}
|
||||||
'last_trends': self.last_trends
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
443
backend/app/crypto_agent/llm_signal_analyzer.py
Normal file
443
backend/app/crypto_agent/llm_signal_analyzer.py
Normal file
@ -0,0 +1,443 @@
|
|||||||
|
"""
|
||||||
|
LLM 驱动的信号分析器 - 让 LLM 自主分析市场数据并给出交易信号
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import pandas as pd
|
||||||
|
from typing import Dict, Any, Optional, List
|
||||||
|
from datetime import datetime
|
||||||
|
from app.utils.logger import logger
|
||||||
|
from app.services.llm_service import llm_service
|
||||||
|
from app.services.news_service import get_news_service
|
||||||
|
|
||||||
|
|
||||||
|
class LLMSignalAnalyzer:
|
||||||
|
"""LLM 驱动的交易信号分析器"""
|
||||||
|
|
||||||
|
# 系统提示词 - 让 LLM 自主分析
|
||||||
|
SYSTEM_PROMPT = """你是一位专业的加密货币技术分析师。你的任务是综合分析市场数据和新闻舆情,判断是否存在交易机会。
|
||||||
|
|
||||||
|
## 你的分析方法
|
||||||
|
你可以自由运用你所知道的任何技术分析方法,包括但不限于:
|
||||||
|
- 趋势分析(均线、趋势线、高低点)
|
||||||
|
- 动量指标(RSI、MACD、KDJ 等)
|
||||||
|
- 波动率分析(布林带、ATR)
|
||||||
|
- 价格形态(K线形态、图表形态)
|
||||||
|
- 支撑阻力位
|
||||||
|
- 成交量分析
|
||||||
|
- 多周期共振
|
||||||
|
|
||||||
|
## 新闻舆情分析
|
||||||
|
你还需要结合最新的市场新闻进行分析:
|
||||||
|
- 重大利好/利空消息
|
||||||
|
- 市场情绪(恐慌/贪婪)
|
||||||
|
- 大户/机构动向
|
||||||
|
- 监管政策变化
|
||||||
|
- 宏观经济影响
|
||||||
|
|
||||||
|
## 信号类型
|
||||||
|
请判断是否存在以下三种类型的交易机会:
|
||||||
|
|
||||||
|
1. **短线信号**(持仓 4小时 - 1天)
|
||||||
|
- 适合快速的超跌反弹或超涨回落
|
||||||
|
- 风险较高,需要快速止盈止损
|
||||||
|
|
||||||
|
2. **中线信号**(持仓 1-7 天)
|
||||||
|
- 波段交易,顺势回调入场
|
||||||
|
- 风险适中,有明确的止损止盈
|
||||||
|
|
||||||
|
3. **长线信号**(持仓 1周以上)
|
||||||
|
- 趋势交易,大级别趋势确认
|
||||||
|
- 风险较低,止损较宽
|
||||||
|
|
||||||
|
## 输出格式
|
||||||
|
请严格按照以下 JSON 格式输出你的分析结果:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"analysis_summary": "简要描述当前市场状态(50字以内)",
|
||||||
|
"news_sentiment": "positive/negative/neutral",
|
||||||
|
"news_impact": "新闻对市场的影响分析(30字以内)",
|
||||||
|
"signals": [
|
||||||
|
{
|
||||||
|
"type": "short_term/medium_term/long_term",
|
||||||
|
"action": "buy/sell/wait",
|
||||||
|
"confidence": 0-100,
|
||||||
|
"grade": "A/B/C/D",
|
||||||
|
"entry_price": 建议入场价,
|
||||||
|
"stop_loss": 止损价,
|
||||||
|
"take_profit": 止盈价,
|
||||||
|
"reason": "详细的入场理由,说明你看到了什么技术信号和消息面因素",
|
||||||
|
"risk_warning": "风险提示"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"key_levels": {
|
||||||
|
"support": [支撑位列表],
|
||||||
|
"resistance": [阻力位列表]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 信号等级说明
|
||||||
|
- **A级**:技术面+消息面共振,高置信度(80+),强烈建议入场
|
||||||
|
- **B级**:信号较好,置信度中等(60-80),可以入场
|
||||||
|
- **C级**:有机会但需谨慎(40-60),轻仓试探
|
||||||
|
- **D级**:不建议交易(<40),继续观望
|
||||||
|
|
||||||
|
## 重要原则
|
||||||
|
1. 宁可错过,不要做错 - 没有明确信号时输出空的 signals 数组
|
||||||
|
2. 每种类型最多输出一个信号
|
||||||
|
3. 止损必须明确,风险收益比至少 1:1.5
|
||||||
|
4. 如果市场混乱或数据不足,直接建议观望
|
||||||
|
5. reason 字段要具体说明你看到了什么(如"15M RSI 从 25 回升到 35,同时 MACD 金叉,且有大户加仓消息")
|
||||||
|
6. 消息面和技术面冲突时,优先考虑技术面,但要在 risk_warning 中提示"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""初始化分析器"""
|
||||||
|
self.news_service = get_news_service()
|
||||||
|
logger.info("LLM 信号分析器初始化完成(含新闻舆情)")
|
||||||
|
|
||||||
|
async def analyze(self, symbol: str, data: Dict[str, pd.DataFrame],
|
||||||
|
symbols: List[str] = None) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
使用 LLM 分析市场数据
|
||||||
|
|
||||||
|
Args:
|
||||||
|
symbol: 交易对,如 'BTCUSDT'
|
||||||
|
data: 多周期K线数据 {'5m': df, '15m': df, '1h': df, '4h': df}
|
||||||
|
symbols: 所有监控的交易对(用于过滤相关新闻)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
分析结果
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 获取新闻数据
|
||||||
|
news_text = await self._get_news_context(symbol, symbols or [symbol])
|
||||||
|
|
||||||
|
# 构建数据提示
|
||||||
|
data_prompt = self._build_data_prompt(symbol, data, news_text)
|
||||||
|
|
||||||
|
# 调用 LLM
|
||||||
|
response = llm_service.chat([
|
||||||
|
{"role": "system", "content": self.SYSTEM_PROMPT},
|
||||||
|
{"role": "user", "content": data_prompt}
|
||||||
|
])
|
||||||
|
|
||||||
|
if not response:
|
||||||
|
logger.warning(f"{symbol} LLM 分析无响应")
|
||||||
|
return self._empty_result(symbol, "LLM 无响应")
|
||||||
|
|
||||||
|
# 解析响应
|
||||||
|
result = self._parse_response(response)
|
||||||
|
result['symbol'] = symbol
|
||||||
|
result['timestamp'] = datetime.now().isoformat()
|
||||||
|
|
||||||
|
# 记录日志
|
||||||
|
signals = result.get('signals', [])
|
||||||
|
if signals:
|
||||||
|
for sig in signals:
|
||||||
|
logger.info(f"{symbol} [{sig['type']}] {sig['action']} "
|
||||||
|
f"置信度:{sig['confidence']}% 等级:{sig['grade']} "
|
||||||
|
f"原因:{sig['reason'][:50]}...")
|
||||||
|
else:
|
||||||
|
logger.info(f"{symbol} 无交易信号 - {result.get('analysis_summary', '观望')}")
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"{symbol} LLM 分析出错: {e}")
|
||||||
|
import traceback
|
||||||
|
logger.error(traceback.format_exc())
|
||||||
|
return self._empty_result(symbol, str(e))
|
||||||
|
|
||||||
|
async def _get_news_context(self, symbol: str, symbols: List[str]) -> str:
|
||||||
|
"""获取新闻上下文"""
|
||||||
|
try:
|
||||||
|
# 获取最新新闻
|
||||||
|
all_news = await self.news_service.get_latest_news(limit=30)
|
||||||
|
|
||||||
|
# 过滤相关新闻(最近4小时)
|
||||||
|
relevant_news = self.news_service.filter_relevant_news(
|
||||||
|
all_news, symbols=symbols, hours=4
|
||||||
|
)
|
||||||
|
|
||||||
|
if not relevant_news:
|
||||||
|
return "暂无相关新闻"
|
||||||
|
|
||||||
|
# 格式化新闻
|
||||||
|
return self.news_service.format_news_for_llm(relevant_news, max_items=8)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"获取新闻失败: {e}")
|
||||||
|
return "新闻数据暂时不可用"
|
||||||
|
|
||||||
|
def _build_data_prompt(self, symbol: str, data: Dict[str, pd.DataFrame],
|
||||||
|
news_text: str = "") -> str:
|
||||||
|
"""构建数据提示词"""
|
||||||
|
parts = [f"# {symbol} 市场数据分析\n"]
|
||||||
|
parts.append(f"分析时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n")
|
||||||
|
|
||||||
|
# 当前价格
|
||||||
|
if '5m' in data and not data['5m'].empty:
|
||||||
|
current_price = float(data['5m'].iloc[-1]['close'])
|
||||||
|
parts.append(f"**当前价格**: ${current_price:,.2f}\n")
|
||||||
|
|
||||||
|
# 各周期数据
|
||||||
|
for interval in ['4h', '1h', '15m', '5m']:
|
||||||
|
df = data.get(interval)
|
||||||
|
if df is None or df.empty:
|
||||||
|
continue
|
||||||
|
|
||||||
|
parts.append(f"\n## {interval.upper()} 周期数据")
|
||||||
|
|
||||||
|
# 最新指标
|
||||||
|
latest = df.iloc[-1]
|
||||||
|
parts.append(self._format_indicators(latest))
|
||||||
|
|
||||||
|
# 最近 K 线数据
|
||||||
|
parts.append(self._format_recent_klines(df, interval))
|
||||||
|
|
||||||
|
# 添加新闻数据
|
||||||
|
if news_text and news_text != "暂无相关新闻":
|
||||||
|
parts.append(f"\n{news_text}")
|
||||||
|
|
||||||
|
parts.append("\n---")
|
||||||
|
parts.append("请综合分析以上技术数据和新闻舆情,判断是否存在短线、中线或长线的交易机会。")
|
||||||
|
parts.append("如果没有明确的交易机会,signals 数组返回空即可。")
|
||||||
|
|
||||||
|
return "\n".join(parts)
|
||||||
|
|
||||||
|
def _format_indicators(self, row: pd.Series) -> str:
|
||||||
|
"""格式化指标数据"""
|
||||||
|
lines = []
|
||||||
|
|
||||||
|
# 价格
|
||||||
|
close = row.get('close', 0)
|
||||||
|
open_price = row.get('open', 0)
|
||||||
|
high = row.get('high', 0)
|
||||||
|
low = row.get('low', 0)
|
||||||
|
change = ((close - open_price) / open_price * 100) if open_price else 0
|
||||||
|
lines.append(f"- K线: O={open_price:.2f} H={high:.2f} L={low:.2f} C={close:.2f} ({change:+.2f}%)")
|
||||||
|
|
||||||
|
# 均线
|
||||||
|
ma5 = row.get('ma5', 0)
|
||||||
|
ma10 = row.get('ma10', 0)
|
||||||
|
ma20 = row.get('ma20', 0)
|
||||||
|
ma50 = row.get('ma50', 0)
|
||||||
|
if pd.notna(ma20):
|
||||||
|
ma_str = f"- 均线: MA5={ma5:.2f}, MA10={ma10:.2f}, MA20={ma20:.2f}"
|
||||||
|
if pd.notna(ma50):
|
||||||
|
ma_str += f", MA50={ma50:.2f}"
|
||||||
|
lines.append(ma_str)
|
||||||
|
|
||||||
|
# RSI
|
||||||
|
rsi = row.get('rsi', 0)
|
||||||
|
if pd.notna(rsi):
|
||||||
|
rsi_status = "超卖" if rsi < 30 else ("超买" if rsi > 70 else "中性")
|
||||||
|
lines.append(f"- RSI: {rsi:.1f} ({rsi_status})")
|
||||||
|
|
||||||
|
# MACD
|
||||||
|
macd = row.get('macd', 0)
|
||||||
|
macd_signal = row.get('macd_signal', 0)
|
||||||
|
macd_hist = row.get('macd_hist', 0)
|
||||||
|
if pd.notna(macd):
|
||||||
|
macd_status = "多头" if macd > macd_signal else "空头"
|
||||||
|
lines.append(f"- MACD: DIF={macd:.4f}, DEA={macd_signal:.4f}, 柱={macd_hist:.4f} ({macd_status})")
|
||||||
|
|
||||||
|
# KDJ
|
||||||
|
k = row.get('k', 0)
|
||||||
|
d = row.get('d', 0)
|
||||||
|
j = row.get('j', 0)
|
||||||
|
if pd.notna(k):
|
||||||
|
lines.append(f"- KDJ: K={k:.1f}, D={d:.1f}, J={j:.1f}")
|
||||||
|
|
||||||
|
# 布林带
|
||||||
|
bb_upper = row.get('bb_upper', 0)
|
||||||
|
bb_middle = row.get('bb_middle', 0)
|
||||||
|
bb_lower = row.get('bb_lower', 0)
|
||||||
|
if pd.notna(bb_upper):
|
||||||
|
lines.append(f"- 布林带: 上={bb_upper:.2f}, 中={bb_middle:.2f}, 下={bb_lower:.2f}")
|
||||||
|
|
||||||
|
# ATR
|
||||||
|
atr = row.get('atr', 0)
|
||||||
|
if pd.notna(atr):
|
||||||
|
lines.append(f"- ATR: {atr:.2f}")
|
||||||
|
|
||||||
|
# 成交量
|
||||||
|
volume = row.get('volume', 0)
|
||||||
|
volume_ratio = row.get('volume_ratio', 0)
|
||||||
|
if pd.notna(volume_ratio):
|
||||||
|
vol_status = "放量" if volume_ratio > 1.5 else ("缩量" if volume_ratio < 0.5 else "正常")
|
||||||
|
lines.append(f"- 成交量: {volume:.2f}, 量比={volume_ratio:.2f} ({vol_status})")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
def _format_recent_klines(self, df: pd.DataFrame, interval: str) -> str:
|
||||||
|
"""格式化最近 K 线"""
|
||||||
|
# 根据周期决定显示数量
|
||||||
|
count = {'4h': 6, '1h': 12, '15m': 8, '5m': 6}.get(interval, 6)
|
||||||
|
|
||||||
|
if len(df) < count:
|
||||||
|
count = len(df)
|
||||||
|
|
||||||
|
lines = [f"\n最近 {count} 根 K 线:"]
|
||||||
|
lines.append("| 时间 | 开盘 | 最高 | 最低 | 收盘 | 涨跌 | RSI |")
|
||||||
|
lines.append("|------|------|------|------|------|------|-----|")
|
||||||
|
|
||||||
|
for i in range(-count, 0):
|
||||||
|
row = df.iloc[i]
|
||||||
|
change = ((row['close'] - row['open']) / row['open'] * 100) if row['open'] else 0
|
||||||
|
change_str = f"{change:+.2f}%"
|
||||||
|
time_str = row['open_time'].strftime('%m-%d %H:%M') if pd.notna(row.get('open_time')) else 'N/A'
|
||||||
|
rsi = row.get('rsi', 0)
|
||||||
|
rsi_str = f"{rsi:.0f}" if pd.notna(rsi) else "-"
|
||||||
|
lines.append(f"| {time_str} | {row['open']:.2f} | {row['high']:.2f} | "
|
||||||
|
f"{row['low']:.2f} | {row['close']:.2f} | {change_str} | {rsi_str} |")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
def _parse_response(self, response: str) -> Dict[str, Any]:
|
||||||
|
"""解析 LLM 响应"""
|
||||||
|
result = {
|
||||||
|
'raw_response': response,
|
||||||
|
'analysis_summary': '',
|
||||||
|
'signals': [],
|
||||||
|
'key_levels': {'support': [], 'resistance': []}
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 尝试提取 JSON
|
||||||
|
json_match = re.search(r'```json\s*([\s\S]*?)\s*```', response)
|
||||||
|
if json_match:
|
||||||
|
json_str = json_match.group(1)
|
||||||
|
else:
|
||||||
|
# 尝试直接解析
|
||||||
|
json_str = response
|
||||||
|
|
||||||
|
parsed = json.loads(json_str)
|
||||||
|
|
||||||
|
result['analysis_summary'] = parsed.get('analysis_summary', '')
|
||||||
|
result['signals'] = parsed.get('signals', [])
|
||||||
|
result['key_levels'] = parsed.get('key_levels', {'support': [], 'resistance': []})
|
||||||
|
|
||||||
|
# 验证和清理信号
|
||||||
|
valid_signals = []
|
||||||
|
for sig in result['signals']:
|
||||||
|
if self._validate_signal(sig):
|
||||||
|
valid_signals.append(sig)
|
||||||
|
result['signals'] = valid_signals
|
||||||
|
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
logger.warning("LLM 响应不是有效 JSON,尝试提取关键信息")
|
||||||
|
result['analysis_summary'] = self._extract_summary(response)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _validate_signal(self, signal: Dict[str, Any]) -> bool:
|
||||||
|
"""验证信号是否有效"""
|
||||||
|
required_fields = ['type', 'action', 'confidence', 'grade', 'reason']
|
||||||
|
for field in required_fields:
|
||||||
|
if field not in signal:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# 验证类型
|
||||||
|
if signal['type'] not in ['short_term', 'medium_term', 'long_term']:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# 验证动作
|
||||||
|
if signal['action'] not in ['buy', 'sell', 'wait']:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# wait 动作不算有效信号
|
||||||
|
if signal['action'] == 'wait':
|
||||||
|
return False
|
||||||
|
|
||||||
|
# 验证置信度
|
||||||
|
confidence = signal.get('confidence', 0)
|
||||||
|
if not isinstance(confidence, (int, float)) or confidence < 40:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _extract_summary(self, text: str) -> str:
|
||||||
|
"""从文本中提取摘要"""
|
||||||
|
text = text.strip()
|
||||||
|
if len(text) > 100:
|
||||||
|
return text[:100] + "..."
|
||||||
|
return text
|
||||||
|
|
||||||
|
def _empty_result(self, symbol: str, reason: str = "") -> Dict[str, Any]:
|
||||||
|
"""返回空结果"""
|
||||||
|
return {
|
||||||
|
'symbol': symbol,
|
||||||
|
'timestamp': datetime.now().isoformat(),
|
||||||
|
'analysis_summary': reason or '无法分析',
|
||||||
|
'signals': [],
|
||||||
|
'key_levels': {'support': [], 'resistance': []},
|
||||||
|
'error': reason
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_best_signal(self, result: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
从分析结果中获取最佳信号
|
||||||
|
|
||||||
|
Args:
|
||||||
|
result: analyze() 的返回结果
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
最佳信号,如果没有则返回 None
|
||||||
|
"""
|
||||||
|
signals = result.get('signals', [])
|
||||||
|
if not signals:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 按置信度排序
|
||||||
|
sorted_signals = sorted(signals, key=lambda x: x.get('confidence', 0), reverse=True)
|
||||||
|
return sorted_signals[0]
|
||||||
|
|
||||||
|
def format_signal_message(self, signal: Dict[str, Any], symbol: str) -> str:
|
||||||
|
"""
|
||||||
|
格式化信号消息(用于通知)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
signal: 信号数据
|
||||||
|
symbol: 交易对
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
格式化的消息文本
|
||||||
|
"""
|
||||||
|
type_map = {
|
||||||
|
'short_term': '短线',
|
||||||
|
'medium_term': '中线',
|
||||||
|
'long_term': '长线'
|
||||||
|
}
|
||||||
|
action_map = {
|
||||||
|
'buy': '做多',
|
||||||
|
'sell': '做空'
|
||||||
|
}
|
||||||
|
|
||||||
|
signal_type = type_map.get(signal['type'], signal['type'])
|
||||||
|
action = action_map.get(signal['action'], signal['action'])
|
||||||
|
grade = signal.get('grade', 'C')
|
||||||
|
confidence = signal.get('confidence', 0)
|
||||||
|
|
||||||
|
# 等级图标
|
||||||
|
grade_icon = {'A': '⭐⭐⭐', 'B': '⭐⭐', 'C': '⭐', 'D': ''}.get(grade, '')
|
||||||
|
|
||||||
|
message = f"""📊 {symbol} {signal_type}信号
|
||||||
|
|
||||||
|
方向: {action}
|
||||||
|
等级: {grade} {grade_icon}
|
||||||
|
置信度: {confidence}%
|
||||||
|
|
||||||
|
入场价: ${signal.get('entry_price', 0):,.2f}
|
||||||
|
止损价: ${signal.get('stop_loss', 0):,.2f}
|
||||||
|
止盈价: ${signal.get('take_profit', 0):,.2f}
|
||||||
|
|
||||||
|
📝 分析理由:
|
||||||
|
{signal.get('reason', '无')}
|
||||||
|
|
||||||
|
⚠️ 风险提示:
|
||||||
|
{signal.get('risk_warning', '请注意风险控制')}"""
|
||||||
|
|
||||||
|
return message
|
||||||
272
backend/app/services/news_service.py
Normal file
272
backend/app/services/news_service.py
Normal file
@ -0,0 +1,272 @@
|
|||||||
|
"""
|
||||||
|
新闻舆情服务 - 获取加密货币相关新闻
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
import html
|
||||||
|
import aiohttp
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
from typing import List, Dict, Any, Optional
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from app.utils.logger import logger
|
||||||
|
|
||||||
|
|
||||||
|
class NewsService:
|
||||||
|
"""新闻舆情服务"""
|
||||||
|
|
||||||
|
# 律动快讯 RSS
|
||||||
|
BLOCKBEATS_RSS = "https://api.theblockbeats.news/v2/rss/newsflash"
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""初始化新闻服务"""
|
||||||
|
self._cache: List[Dict[str, Any]] = []
|
||||||
|
self._cache_time: Optional[datetime] = None
|
||||||
|
self._cache_duration = timedelta(minutes=5) # 缓存5分钟
|
||||||
|
logger.info("新闻舆情服务初始化完成")
|
||||||
|
|
||||||
|
async def get_latest_news(self, limit: int = 20) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
获取最新新闻
|
||||||
|
|
||||||
|
Args:
|
||||||
|
limit: 获取数量
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
新闻列表
|
||||||
|
"""
|
||||||
|
# 检查缓存
|
||||||
|
if self._cache and self._cache_time:
|
||||||
|
if datetime.now() - self._cache_time < self._cache_duration:
|
||||||
|
return self._cache[:limit]
|
||||||
|
|
||||||
|
try:
|
||||||
|
news = await self._fetch_blockbeats_news()
|
||||||
|
self._cache = news
|
||||||
|
self._cache_time = datetime.now()
|
||||||
|
return news[:limit]
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"获取新闻失败: {e}")
|
||||||
|
return self._cache[:limit] if self._cache else []
|
||||||
|
|
||||||
|
async def _fetch_blockbeats_news(self) -> List[Dict[str, Any]]:
|
||||||
|
"""获取律动快讯"""
|
||||||
|
news_list = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
async with session.get(self.BLOCKBEATS_RSS, timeout=10) as response:
|
||||||
|
if response.status != 200:
|
||||||
|
logger.error(f"获取律动快讯失败: HTTP {response.status}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
content = await response.text()
|
||||||
|
|
||||||
|
# 解析 XML
|
||||||
|
root = ET.fromstring(content)
|
||||||
|
channel = root.find('channel')
|
||||||
|
|
||||||
|
if channel is None:
|
||||||
|
return []
|
||||||
|
|
||||||
|
for item in channel.findall('item'):
|
||||||
|
title_elem = item.find('title')
|
||||||
|
desc_elem = item.find('description')
|
||||||
|
pub_date_elem = item.find('pubDate')
|
||||||
|
link_elem = item.find('link')
|
||||||
|
|
||||||
|
if title_elem is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 提取标题
|
||||||
|
title = self._clean_cdata(title_elem.text or '')
|
||||||
|
|
||||||
|
# 提取描述(去除 HTML 标签)
|
||||||
|
description = ''
|
||||||
|
if desc_elem is not None and desc_elem.text:
|
||||||
|
description = self._clean_html(self._clean_cdata(desc_elem.text))
|
||||||
|
|
||||||
|
# 解析时间
|
||||||
|
pub_time = None
|
||||||
|
if pub_date_elem is not None and pub_date_elem.text:
|
||||||
|
pub_time = self._parse_rss_date(self._clean_cdata(pub_date_elem.text))
|
||||||
|
|
||||||
|
# 链接
|
||||||
|
link = ''
|
||||||
|
if link_elem is not None and link_elem.text:
|
||||||
|
link = self._clean_cdata(link_elem.text)
|
||||||
|
|
||||||
|
news_list.append({
|
||||||
|
'title': title,
|
||||||
|
'description': description[:500], # 限制长度
|
||||||
|
'time': pub_time,
|
||||||
|
'time_str': pub_time.strftime('%m-%d %H:%M') if pub_time else '',
|
||||||
|
'link': link,
|
||||||
|
'source': '律动BlockBeats'
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.info(f"获取到 {len(news_list)} 条律动快讯")
|
||||||
|
return news_list
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"解析律动快讯失败: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def _clean_cdata(self, text: str) -> str:
|
||||||
|
"""清理 CDATA 标记"""
|
||||||
|
if not text:
|
||||||
|
return ''
|
||||||
|
# 移除 CDATA 包装
|
||||||
|
text = re.sub(r'<!\[CDATA\[(.*?)\]\]>', r'\1', text, flags=re.DOTALL)
|
||||||
|
return text.strip()
|
||||||
|
|
||||||
|
def _clean_html(self, text: str) -> str:
|
||||||
|
"""清理 HTML 标签"""
|
||||||
|
if not text:
|
||||||
|
return ''
|
||||||
|
# 移除 HTML 标签
|
||||||
|
text = re.sub(r'<[^>]+>', '', text)
|
||||||
|
# 解码 HTML 实体
|
||||||
|
text = html.unescape(text)
|
||||||
|
# 清理多余空白
|
||||||
|
text = re.sub(r'\s+', ' ', text)
|
||||||
|
return text.strip()
|
||||||
|
|
||||||
|
def _parse_rss_date(self, date_str: str) -> Optional[datetime]:
|
||||||
|
"""解析 RSS 日期格式"""
|
||||||
|
if not date_str:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# RSS 日期格式: "Sat, 07 Feb 2026 00:30:33 +0800"
|
||||||
|
formats = [
|
||||||
|
'%a, %d %b %Y %H:%M:%S %z',
|
||||||
|
'%a, %d %b %Y %H:%M:%S',
|
||||||
|
'%Y-%m-%d %H:%M:%S'
|
||||||
|
]
|
||||||
|
|
||||||
|
for fmt in formats:
|
||||||
|
try:
|
||||||
|
return datetime.strptime(date_str, fmt)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def filter_relevant_news(self, news_list: List[Dict[str, Any]],
|
||||||
|
symbols: List[str] = None,
|
||||||
|
hours: int = 4) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
过滤相关新闻
|
||||||
|
|
||||||
|
Args:
|
||||||
|
news_list: 新闻列表
|
||||||
|
symbols: 关注的交易对(如 ['BTCUSDT', 'ETHUSDT'])
|
||||||
|
hours: 只保留最近几小时的新闻
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
过滤后的新闻
|
||||||
|
"""
|
||||||
|
if not news_list:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# 时间过滤
|
||||||
|
cutoff_time = datetime.now() - timedelta(hours=hours)
|
||||||
|
filtered = []
|
||||||
|
|
||||||
|
# 关键词映射
|
||||||
|
symbol_keywords = {
|
||||||
|
'BTCUSDT': ['比特币', 'BTC', 'Bitcoin'],
|
||||||
|
'ETHUSDT': ['以太坊', 'ETH', 'Ethereum'],
|
||||||
|
'BNBUSDT': ['BNB', 'Binance'],
|
||||||
|
'SOLUSDT': ['SOL', 'Solana'],
|
||||||
|
}
|
||||||
|
|
||||||
|
# 通用关键词(影响整体市场)
|
||||||
|
market_keywords = [
|
||||||
|
'市场', '行情', '反弹', '下跌', '暴跌', '暴涨', '清算',
|
||||||
|
'资金费率', '多单', '空单', '杠杆', '爆仓',
|
||||||
|
'美联储', 'Fed', '利率', '通胀',
|
||||||
|
'监管', 'SEC', 'ETF',
|
||||||
|
'鲸鱼', '巨鲸', '大户',
|
||||||
|
'交易所', 'Binance', 'Coinbase'
|
||||||
|
]
|
||||||
|
|
||||||
|
for news in news_list:
|
||||||
|
# 时间过滤
|
||||||
|
if news.get('time'):
|
||||||
|
# 处理带时区的时间
|
||||||
|
news_time = news['time']
|
||||||
|
if news_time.tzinfo:
|
||||||
|
news_time = news_time.replace(tzinfo=None)
|
||||||
|
if news_time < cutoff_time:
|
||||||
|
continue
|
||||||
|
|
||||||
|
title = news.get('title', '')
|
||||||
|
desc = news.get('description', '')
|
||||||
|
content = title + ' ' + desc
|
||||||
|
|
||||||
|
# 检查是否与关注的交易对相关
|
||||||
|
is_relevant = False
|
||||||
|
|
||||||
|
if symbols:
|
||||||
|
for symbol in symbols:
|
||||||
|
keywords = symbol_keywords.get(symbol, [])
|
||||||
|
for kw in keywords:
|
||||||
|
if kw.lower() in content.lower():
|
||||||
|
is_relevant = True
|
||||||
|
news['related_symbol'] = symbol
|
||||||
|
break
|
||||||
|
if is_relevant:
|
||||||
|
break
|
||||||
|
|
||||||
|
# 检查是否包含市场关键词
|
||||||
|
if not is_relevant:
|
||||||
|
for kw in market_keywords:
|
||||||
|
if kw.lower() in content.lower():
|
||||||
|
is_relevant = True
|
||||||
|
news['related_symbol'] = 'MARKET'
|
||||||
|
break
|
||||||
|
|
||||||
|
if is_relevant:
|
||||||
|
filtered.append(news)
|
||||||
|
|
||||||
|
return filtered
|
||||||
|
|
||||||
|
def format_news_for_llm(self, news_list: List[Dict[str, Any]],
|
||||||
|
max_items: int = 10) -> str:
|
||||||
|
"""
|
||||||
|
格式化新闻供 LLM 分析
|
||||||
|
|
||||||
|
Args:
|
||||||
|
news_list: 新闻列表
|
||||||
|
max_items: 最大条数
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
格式化的新闻文本
|
||||||
|
"""
|
||||||
|
if not news_list:
|
||||||
|
return "暂无相关新闻"
|
||||||
|
|
||||||
|
lines = ["## 最新市场新闻\n"]
|
||||||
|
|
||||||
|
for i, news in enumerate(news_list[:max_items], 1):
|
||||||
|
time_str = news.get('time_str', '')
|
||||||
|
title = news.get('title', '')
|
||||||
|
desc = news.get('description', '')[:200] # 限制描述长度
|
||||||
|
|
||||||
|
lines.append(f"### {i}. [{time_str}] {title}")
|
||||||
|
if desc:
|
||||||
|
lines.append(f"{desc}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
# 全局实例
|
||||||
|
_news_service: Optional[NewsService] = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_news_service() -> NewsService:
|
||||||
|
"""获取新闻服务实例"""
|
||||||
|
global _news_service
|
||||||
|
if _news_service is None:
|
||||||
|
_news_service = NewsService()
|
||||||
|
return _news_service
|
||||||
@ -364,6 +364,74 @@
|
|||||||
border-color: #ff4444;
|
border-color: #ff4444;
|
||||||
color: #ff4444;
|
color: #ff4444;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 持仓汇总 */
|
||||||
|
.position-summary {
|
||||||
|
display: flex;
|
||||||
|
gap: 24px;
|
||||||
|
padding: 16px 20px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-value {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-value.positive {
|
||||||
|
color: #00ff41;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-value.negative {
|
||||||
|
color: #ff4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 现价样式 */
|
||||||
|
.current-price {
|
||||||
|
font-family: monospace;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-price.price-up {
|
||||||
|
color: #00ff41;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-price.price-down {
|
||||||
|
color: #ff4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 警告价格(接近止损) */
|
||||||
|
.warning-price {
|
||||||
|
color: #ff4444;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 成功价格(接近止盈) */
|
||||||
|
.success-price {
|
||||||
|
color: #00ff41;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 盈亏小字 */
|
||||||
|
.pnl small {
|
||||||
|
font-size: 11px;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@ -438,6 +506,25 @@
|
|||||||
|
|
||||||
<!-- 活跃订单 -->
|
<!-- 活跃订单 -->
|
||||||
<div v-if="activeTab === 'active'">
|
<div v-if="activeTab === 'active'">
|
||||||
|
<!-- 持仓汇总 -->
|
||||||
|
<div v-if="activeOrders.length > 0" class="position-summary">
|
||||||
|
<div class="summary-item">
|
||||||
|
<span class="summary-label">持仓数量</span>
|
||||||
|
<span class="summary-value">{{ activeOrders.length }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="summary-item">
|
||||||
|
<span class="summary-label">总仓位</span>
|
||||||
|
<span class="summary-value">${{ totalPosition.toFixed(2) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="summary-item">
|
||||||
|
<span class="summary-label">浮动盈亏</span>
|
||||||
|
<span class="summary-value" :class="totalUnrealizedPnl >= 0 ? 'positive' : 'negative'">
|
||||||
|
{{ totalUnrealizedPnl >= 0 ? '+' : '' }}${{ totalUnrealizedPnl.toFixed(2) }}
|
||||||
|
({{ totalUnrealizedPnlPercent >= 0 ? '+' : '' }}{{ totalUnrealizedPnlPercent.toFixed(2) }}%)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-if="loading" class="loading">加载中...</div>
|
<div v-if="loading" class="loading">加载中...</div>
|
||||||
<div v-else-if="activeOrders.length === 0" class="empty-state">
|
<div v-else-if="activeOrders.length === 0" class="empty-state">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||||
@ -453,6 +540,8 @@
|
|||||||
<th>方向</th>
|
<th>方向</th>
|
||||||
<th>等级</th>
|
<th>等级</th>
|
||||||
<th>入场价</th>
|
<th>入场价</th>
|
||||||
|
<th>现价</th>
|
||||||
|
<th>浮动盈亏</th>
|
||||||
<th>止损</th>
|
<th>止损</th>
|
||||||
<th>止盈</th>
|
<th>止盈</th>
|
||||||
<th>仓位</th>
|
<th>仓位</th>
|
||||||
@ -467,8 +556,28 @@
|
|||||||
<td><span class="side-badge" :class="order.side">{{ order.side === 'long' ? '做多' : '做空' }}</span></td>
|
<td><span class="side-badge" :class="order.side">{{ order.side === 'long' ? '做多' : '做空' }}</span></td>
|
||||||
<td><span class="grade-badge" :class="order.signal_grade">{{ order.signal_grade }}</span></td>
|
<td><span class="grade-badge" :class="order.signal_grade">{{ order.signal_grade }}</span></td>
|
||||||
<td>${{ order.entry_price?.toLocaleString() }}</td>
|
<td>${{ order.entry_price?.toLocaleString() }}</td>
|
||||||
<td>${{ order.stop_loss?.toLocaleString() }}</td>
|
<td>
|
||||||
<td>${{ order.take_profit?.toLocaleString() }}</td>
|
<span class="current-price" :class="getPriceChangeClass(order)">
|
||||||
|
${{ getCurrentPrice(order.symbol)?.toLocaleString() || '-' }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="pnl" :class="getUnrealizedPnl(order).pnl >= 0 ? 'positive' : 'negative'">
|
||||||
|
{{ getUnrealizedPnl(order).pnl >= 0 ? '+' : '' }}{{ getUnrealizedPnl(order).percent.toFixed(2) }}%
|
||||||
|
<br>
|
||||||
|
<small>(${{ getUnrealizedPnl(order).pnl >= 0 ? '+' : '' }}{{ getUnrealizedPnl(order).pnl.toFixed(2) }})</small>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span :class="isNearStopLoss(order) ? 'warning-price' : ''">
|
||||||
|
${{ order.stop_loss?.toLocaleString() }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span :class="isNearTakeProfit(order) ? 'success-price' : ''">
|
||||||
|
${{ order.take_profit?.toLocaleString() }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
<td>${{ order.quantity }}</td>
|
<td>${{ order.quantity }}</td>
|
||||||
<td>{{ formatTime(order.opened_at) }}</td>
|
<td>{{ formatTime(order.opened_at) }}</td>
|
||||||
<td>
|
<td>
|
||||||
@ -611,10 +720,10 @@
|
|||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
this.refreshData();
|
this.refreshData();
|
||||||
// 每10秒自动刷新
|
// 每3秒自动刷新(实时价格更新)
|
||||||
this.refreshInterval = setInterval(() => {
|
this.refreshInterval = setInterval(() => {
|
||||||
this.refreshData();
|
this.refreshData();
|
||||||
}, 10000);
|
}, 3000);
|
||||||
},
|
},
|
||||||
beforeUnmount() {
|
beforeUnmount() {
|
||||||
if (this.refreshInterval) {
|
if (this.refreshInterval) {
|
||||||
@ -719,6 +828,82 @@
|
|||||||
'closed_manual': '手动平仓'
|
'closed_manual': '手动平仓'
|
||||||
};
|
};
|
||||||
return map[status] || status;
|
return map[status] || status;
|
||||||
|
},
|
||||||
|
|
||||||
|
// 获取当前价格
|
||||||
|
getCurrentPrice(symbol) {
|
||||||
|
return this.latestPrices[symbol] || null;
|
||||||
|
},
|
||||||
|
|
||||||
|
// 计算未实现盈亏
|
||||||
|
getUnrealizedPnl(order) {
|
||||||
|
const currentPrice = this.getCurrentPrice(order.symbol);
|
||||||
|
if (!currentPrice || !order.entry_price) {
|
||||||
|
return { pnl: 0, percent: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
let pnlPercent;
|
||||||
|
if (order.side === 'long') {
|
||||||
|
pnlPercent = ((currentPrice - order.entry_price) / order.entry_price) * 100;
|
||||||
|
} else {
|
||||||
|
pnlPercent = ((order.entry_price - currentPrice) / order.entry_price) * 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pnlAmount = (order.quantity || 0) * pnlPercent / 100;
|
||||||
|
|
||||||
|
return {
|
||||||
|
pnl: pnlAmount,
|
||||||
|
percent: pnlPercent
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
// 获取价格变化样式类
|
||||||
|
getPriceChangeClass(order) {
|
||||||
|
const currentPrice = this.getCurrentPrice(order.symbol);
|
||||||
|
if (!currentPrice || !order.entry_price) return '';
|
||||||
|
|
||||||
|
if (order.side === 'long') {
|
||||||
|
return currentPrice >= order.entry_price ? 'price-up' : 'price-down';
|
||||||
|
} else {
|
||||||
|
return currentPrice <= order.entry_price ? 'price-up' : 'price-down';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 检查是否接近止损(距离止损 < 1%)
|
||||||
|
isNearStopLoss(order) {
|
||||||
|
const currentPrice = this.getCurrentPrice(order.symbol);
|
||||||
|
if (!currentPrice || !order.stop_loss) return false;
|
||||||
|
|
||||||
|
const distance = Math.abs(currentPrice - order.stop_loss) / currentPrice;
|
||||||
|
return distance < 0.01;
|
||||||
|
},
|
||||||
|
|
||||||
|
// 检查是否接近止盈(距离止盈 < 1%)
|
||||||
|
isNearTakeProfit(order) {
|
||||||
|
const currentPrice = this.getCurrentPrice(order.symbol);
|
||||||
|
if (!currentPrice || !order.take_profit) return false;
|
||||||
|
|
||||||
|
const distance = Math.abs(currentPrice - order.take_profit) / currentPrice;
|
||||||
|
return distance < 0.01;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
// 总仓位
|
||||||
|
totalPosition() {
|
||||||
|
return this.activeOrders.reduce((sum, order) => sum + (order.quantity || 0), 0);
|
||||||
|
},
|
||||||
|
|
||||||
|
// 总浮动盈亏
|
||||||
|
totalUnrealizedPnl() {
|
||||||
|
return this.activeOrders.reduce((sum, order) => {
|
||||||
|
return sum + this.getUnrealizedPnl(order).pnl;
|
||||||
|
}, 0);
|
||||||
|
},
|
||||||
|
|
||||||
|
// 总浮动盈亏百分比
|
||||||
|
totalUnrealizedPnlPercent() {
|
||||||
|
if (this.totalPosition === 0) return 0;
|
||||||
|
return (this.totalUnrealizedPnl / this.totalPosition) * 100;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}).mount('#app');
|
}).mount('#app');
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user