diff --git a/backend/app/api/paper_trading.py b/backend/app/api/paper_trading.py index 6eaee36..5773b7c 100644 --- a/backend/app/api/paper_trading.py +++ b/backend/app/api/paper_trading.py @@ -214,6 +214,29 @@ async def get_statistics_by_symbol(): raise HTTPException(status_code=500, detail=str(e)) +@router.get("/daily-returns") +async def get_daily_returns( + days: int = Query(30, description="获取最近多少天的数据", ge=1, le=365) +): + """ + 获取每日收益率数据 + + - days: 获取最近多少天的数据,默认30天,最大365天 + """ + try: + service = get_paper_trading_service() + daily_returns = service.get_daily_returns(days=days) + + return { + "success": True, + "data": daily_returns, + "count": len(daily_returns) + } + except Exception as e: + logger.error(f"获取每日收益率失败: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + @router.get("/monitor/status") async def get_monitor_status(): """获取价格监控状态和实时价格""" diff --git a/backend/app/crypto_agent/llm_signal_analyzer.py b/backend/app/crypto_agent/llm_signal_analyzer.py index 5925031..ee017b2 100644 --- a/backend/app/crypto_agent/llm_signal_analyzer.py +++ b/backend/app/crypto_agent/llm_signal_analyzer.py @@ -1299,14 +1299,40 @@ class LLMSignalAnalyzer: } try: - # 尝试提取 JSON + json_str = None + + # 尝试多种方式提取 JSON + # 1. 尝试提取 ```json ... ``` 代码块 json_match = re.search(r'```json\s*([\s\S]*?)\s*```', response) if json_match: - json_str = json_match.group(1) + json_str = json_match.group(1).strip() + logger.debug(f"从 ```json 代码块提取 JSON,长度: {len(json_str)}") else: - # 尝试直接解析 - json_str = response + # 2. 尝试提取 ``` ... ``` 代码块(没有 json 标记) + code_match = re.search(r'```\s*([\s\S]*?)\s*```', response) + if code_match: + potential_json = code_match.group(1).strip() + # 检查是否像 JSON(以 { 开头) + if potential_json.startswith('{'): + json_str = potential_json + logger.debug(f"从 ``` 代码块提取 JSON,长度: {len(json_str)}") + # 3. 如果还没找到,尝试直接找到 { ... } 结构 + if not json_str: + brace_match = re.search(r'\{[\s\S]*\}', response) + if brace_match: + json_str = brace_match.group(0).strip() + logger.debug(f"从花括号提取 JSON,长度: {len(json_str)}") + + # 4. 最后尝试直接解析整个响应 + if not json_str: + json_str = response.strip() + logger.debug(f"直接使用整个响应作为 JSON,长度: {len(json_str)}") + + # 清理 JSON 字符串中的问题字符 + json_str = self._clean_json_string(json_str) + + # 解析 JSON parsed = json.loads(json_str) result['analysis_summary'] = parsed.get('analysis_summary', '') @@ -1320,12 +1346,92 @@ class LLMSignalAnalyzer: valid_signals.append(sig) result['signals'] = valid_signals - except json.JSONDecodeError: - logger.warning("LLM 响应不是有效 JSON,尝试提取关键信息") + logger.info(f"JSON 解析成功: {len(valid_signals)} 个有效信号") + + except json.JSONDecodeError as e: + logger.warning(f"LLM 响应不是有效 JSON: {e},尝试提取关键信息") + logger.debug(f"无法解析的 JSON 字符串: {json_str[:200] if json_str else response[:200]}...") + result['analysis_summary'] = self._extract_summary(response) + except Exception as e: + logger.error(f"解析响应时出错: {e}") 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 + + def _clean_json_string(self, json_str: str) -> str: + """清理 JSON 字符串中的问题字符""" + # 移除 BOM 标记 + json_str = json_str.strip('\ufeff') + + # 使用更健壮的方法:使用 json.JSONDecoder 的 raw_decode + # 但首先尝试简单的清理 + try: + # 方法1: 直接解析,如果成功就不需要清理 + json.loads(json_str) + return json_str + except json.JSONDecodeError: + pass + + # 方法2: 移除控制字符(保留换行、制表符等常见字符) + # 控制字符范围: 0x00-0x1F,除了 0x09(\t), 0x0A(\n), 0x0D(\r) + def remove_control_chars(s): + """移除字符串值中的控制字符""" + result = [] + in_string = False + escape = False + i = 0 + + while i < len(s): + char = s[i] + + if escape: + # 转义模式下,保留所有字符 + result.append(char) + escape = False + elif char == '\\': + # 开始转义 + result.append(char) + escape = True + elif char == '"' and (i == 0 or s[i-1] != '\\'): + # 字符串边界 + in_string = not in_string + result.append(char) + elif in_string: + # 在字符串内,检查是否为控制字符 + code = ord(char) + if code < 0x20 and code not in (0x09, 0x0A, 0x0D): + # 控制字符,跳过或替换 + if char == '\n': + result.append('\\n') + elif char == '\r': + result.append('\\r') + elif char == '\t': + result.append('\\t') + # 其他控制字符直接跳过 + else: + result.append(char) + else: + # 不在字符串内,保留所有字符(包括空格、换行等) + result.append(char) + + i += 1 + + return ''.join(result) + + json_str = remove_control_chars(json_str) + return json_str + def _validate_signal(self, signal: Dict[str, Any]) -> bool: """验证信号是否有效""" required_fields = ['type', 'action', 'confidence', 'grade', 'reason'] diff --git a/backend/app/services/multi_llm_service.py b/backend/app/services/multi_llm_service.py index 64fd888..710b562 100644 --- a/backend/app/services/multi_llm_service.py +++ b/backend/app/services/multi_llm_service.py @@ -40,12 +40,12 @@ class MultiLLMService: if '.' in api_key and len(api_key) > 10: self.clients['zhipu'] = ZhipuAI(api_key=api_key) self.model_info['zhipu'] = { - 'name': 'GLM-4', - 'model_id': 'glm-4', + 'name': 'GLM-4-Flash', + 'model_id': 'glm-4-flash', 'provider': 'zhipu', 'available': True } - logger.info("智谱AI初始化成功") + logger.info("智谱AI初始化成功 (使用模型: glm-4-flash)") except Exception as e: logger.error(f"智谱AI初始化失败: {e}") @@ -144,12 +144,16 @@ class MultiLLMService: # 智谱AI调用 # Zhipu对参数更严格,temperature范围是0.0-1.0 safe_temperature = max(0.0, min(1.0, temperature)) + logger.debug(f"智谱AI请求参数: model={model_id}, temperature={safe_temperature}, max_tokens={max_tokens}") + logger.debug(f"消息内容: {messages[-1]['content'][:200] if messages else 'empty'}...") + response = client.chat.completions.create( model=model_id, messages=messages, temperature=safe_temperature, max_tokens=max_tokens ) + logger.debug(f"智谱AI原始响应: {response}") elif provider == 'deepseek': # DeepSeek调用(OpenAI兼容) # DeepSeek对参数更严格,确保temperature在有效范围内 @@ -164,12 +168,46 @@ class MultiLLMService: logger.error(f"未知的模型提供商: {provider}") return None - if response.choices: - content = response.choices[0].message.content - logger.info(f"LLM响应成功,长度: {len(content) if content else 0}") - return content + # 详细日志记录响应结构 + logger.debug(f"响应对象类型: {type(response)}") + + if hasattr(response, 'choices') and response.choices: + choice = response.choices[0] + logger.debug(f"Choice类型: {type(choice)}, 索引: {getattr(choice, 'index', 'N/A')}") + + message = choice.message + logger.debug(f"Message类型: {type(message)}") + + # 智谱AI可能使用不同的字段 + content = getattr(message, 'content', None) + + if content and content.strip(): + logger.info(f"LLM响应成功,长度: {len(content)}") + return content + else: + logger.warning(f"LLM响应content为空或空白: content={repr(content)}") + # 尝试从其他字段获取内容(智谱AI可能使用不同字段) + for attr in ['reasoning_content', 'text', 'result']: + if hasattr(message, attr): + alt_content = getattr(message, attr) + if alt_content and alt_content.strip(): + logger.info(f"从 {attr} 获取内容,长度: {len(alt_content)}") + return alt_content + + # 打印完整的message对象用于调试 + logger.debug(f"完整Message对象: {message}") + if hasattr(message, '__dict__'): + logger.debug(f"Message属性: {message.__dict__}") + + return None else: - logger.warning("LLM响应中没有choices") + logger.warning(f"LLM响应中没有choices。响应: {response}") + # 检查是否有其他可能的响应格式 + if hasattr(response, 'content'): + content = response.content + if content and content.strip(): + logger.info(f"从response.content获取内容,长度: {len(content)}") + return content return None except Exception as e: diff --git a/backend/app/services/paper_trading_service.py b/backend/app/services/paper_trading_service.py index 587bf6a..ef42881 100644 --- a/backend/app/services/paper_trading_service.py +++ b/backend/app/services/paper_trading_service.py @@ -1500,6 +1500,122 @@ class PaperTradingService: logger.warning(f"获取 {symbol} 当前价格失败: {e}") return 0.0 + def get_daily_returns(self, days: int = 30) -> List[Dict[str, Any]]: + """ + 获取每日收益率数据 + + Args: + days: 获取最近多少天的数据,默认30天 + + Returns: + 每日收益率数据列表,格式: [ + { + 'date': '2024-01-15', + 'return_percent': 2.5, + 'return_amount': 250.0, + 'balance': 10250.0, + 'trades_count': 5, + 'winning_trades': 3, + 'losing_trades': 2 + }, + ... + ] + """ + db = db_service.get_session() + try: + from datetime import timedelta + from collections import defaultdict + + # 计算起始日期 + end_date = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0) + start_date = end_date - timedelta(days=days) + + # 获取所有已平仓订单(按日期范围) + closed_orders = db.query(PaperOrder).filter( + PaperOrder.status.in_([ + OrderStatus.CLOSED_TP, + OrderStatus.CLOSED_SL, + OrderStatus.CLOSED_BE, + OrderStatus.CLOSED_MANUAL + ]), + PaperOrder.closed_at >= start_date + ).order_by(PaperOrder.closed_at.asc()).all() + + # 获取初始余额(第一个订单前的余额) + initial_balance = self.initial_balance + current_balance = initial_balance + + # 按日期分组统计 + daily_stats = defaultdict(lambda: { + 'pnl': 0.0, + 'trades': [], + 'balance_start': initial_balance + }) + + # 记录每日开始余额 + daily_balance = {end_date: initial_balance} + + # 处理每个订单 + for order in closed_orders: + if order.closed_at: + # 获取订单日期(UTC 0点) + order_date = order.closed_at.replace(hour=0, minute=0, second=0, microsecond=0) + + if order_date not in daily_balance: + # 计算该日开始时的余额 + daily_balance[order_date] = current_balance + + # 更新当前余额 + current_balance += (order.pnl_amount or 0) + + # 累加每日统计 + daily_stats[order_date]['pnl'] += (order.pnl_amount or 0) + daily_stats[order_date]['trades'].append(order) + + # 构建结果 + results = [] + balance_running = initial_balance + + # 生成所有日期(包括没有交易的日期) + for i in range(days): + date = end_date - timedelta(days=days - 1 - i) + + # 获取该日的统计数据 + if date in daily_stats: + stats = daily_stats[date] + daily_pnl = stats['pnl'] + trades = stats['trades'] + + # 计算该日收益率 + balance_start = stats.get('balance_start', balance_running) + return_percent = (daily_pnl / balance_start * 100) if balance_start > 0 else 0 + balance_running += daily_pnl + + winning_count = len([t for t in trades if (t.pnl_amount or 0) > 0]) + losing_count = len([t for t in trades if (t.pnl_amount or 0) < 0]) + else: + # 没有交易的日期 + daily_pnl = 0 + return_percent = 0 + trades = [] + winning_count = 0 + losing_count = 0 + + results.append({ + 'date': date.strftime('%Y-%m-%d'), + 'return_percent': round(return_percent, 2), + 'return_amount': round(daily_pnl, 2), + 'balance': round(balance_running, 2), + 'trades_count': len(trades), + 'winning_trades': winning_count, + 'losing_trades': losing_count + }) + + return results + + finally: + db.close() + def reset_all_data(self) -> Dict[str, Any]: """ 重置所有模拟交易数据 diff --git a/backend/app/services/signal_database_service.py b/backend/app/services/signal_database_service.py index ae430fe..a173018 100644 --- a/backend/app/services/signal_database_service.py +++ b/backend/app/services/signal_database_service.py @@ -22,8 +22,9 @@ class SignalDatabaseService: def _ensure_tables(self): """确保表已创建""" try: - from app.models.database import Base, engine - Base.metadata.create_all(bind=engine) + from app.models.database import Base + # 使用 db_service 的 engine + Base.metadata.create_all(bind=self.db_service.engine) logger.info("交易信号表已创建") except Exception as e: logger.error(f"创建交易信号表失败: {e}") diff --git a/frontend/paper-trading.html b/frontend/paper-trading.html index ece0e9e..d58069b 100644 --- a/frontend/paper-trading.html +++ b/frontend/paper-trading.html @@ -942,6 +942,93 @@ .share-btn.copy:hover { background: rgba(255, 255, 255, 0.2); } + + /* 收益率图表样式 */ + .chart-container { + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: 4px; + padding: 20px; + margin-bottom: 20px; + } + + .chart-wrapper { + position: relative; + height: 400px; + width: 100%; + } + + .daily-returns-table { + width: 100%; + border-collapse: collapse; + background: var(--bg-secondary); + border: 1px solid var(--border); + } + + .daily-returns-table th, + .daily-returns-table td { + padding: 12px 16px; + text-align: center; + border-bottom: 1px solid var(--border); + } + + .daily-returns-table th { + background: var(--bg-primary); + color: var(--text-secondary); + font-weight: 400; + font-size: 12px; + text-transform: uppercase; + } + + .daily-returns-table td { + color: var(--text-primary); + font-size: 14px; + } + + .daily-returns-table tr:hover { + background: var(--bg-tertiary); + } + + .daily-returns-table .positive { + color: #00ff41; + } + + .daily-returns-table .negative { + color: #ff4444; + } + + .summary-chart-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 16px; + margin-bottom: 20px; + } + + .summary-chart-card { + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: 4px; + padding: 16px; + } + + .summary-chart-label { + font-size: 12px; + color: var(--text-secondary); + margin-bottom: 6px; + } + + .summary-chart-value { + font-size: 20px; + font-weight: 500; + } + + .summary-chart-value.positive { + color: #00ff41; + } + + .summary-chart-value.negative { + color: #ff4444; + } @@ -1025,6 +1112,9 @@ + @@ -1229,6 +1319,103 @@ + + +
+

每日收益率

+ + +
+
+
累计收益率
+
+ {{ totalReturn >= 0 ? '+' : '' }}{{ totalReturn.toFixed(2) }}% +
+
+
+
累计收益额
+
+ {{ totalReturnAmount >= 0 ? '+' : '' }}${{ totalReturnAmount.toFixed(2) }} +
+
+
+
盈利天数
+
{{ profitableDays }}
+
+
+
亏损天数
+
{{ losingDays }}
+
+
+
胜率(按天)
+
{{ dailyWinRate.toFixed(1) }}%
+
+
+
平均日收益
+
+ {{ avgDailyReturn >= 0 ? '+' : '' }}{{ avgDailyReturn.toFixed(2) }}% +
+
+
+ + +
+ +
+

净值曲线

+
+ +
+
+ + +
+

每日收益率 (%)

+
+ +
+
+
+ + +
加载中...
+
+ + + +

暂无收益率数据

+
+
+ + + + + + + + + + + + + + + + + + + + + + + +
日期收益率收益额余额交易数盈利亏损
{{ day.date }} + {{ day.return_percent >= 0 ? '+' : '' }}{{ day.return_percent }}% + + {{ day.return_amount >= 0 ? '+' : '' }}${{ day.return_amount.toFixed(2) }} + ${{ day.balance.toFixed(2) }}{{ day.trades_count }}{{ day.winning_trades }}{{ day.losing_trades }}
+
+
@@ -1287,6 +1474,8 @@ + +