""" Backtest服务 负责策略回测的执行、结果解析和AI策略生成 独立于实盘交易服务 (TradingService) """ from typing import Dict, Any, List, Optional, Tuple import subprocess import json import os import logging import re import uuid import asyncio from datetime import datetime from app.config import settings from app.cache import get_redis from app.metrics import backtest_queue_length, backtest_tasks_total logger = logging.getLogger(__name__) class BacktestService: """回测服务类""" def __init__(self): """初始化回测服务""" self.freqtrade_path = settings.FREQTRADE_PATH self.redis = get_redis() async def run_backtest( self, strategy_code: str, pairs: List[str], timerange: str, timeframe: str = "1h", initial_balance: float = 10000, user_id: Optional[str] = None, ) -> Dict[str, Any]: """执行策略回测(提交 Celery 异步任务)""" try: # 生成任务ID task_id = str(uuid.uuid4()) # 从用户代码中提取第一个继承自 IStrategy 的策略类名 strategy_name = self._extract_strategy_class_name(strategy_code) if not strategy_name: return { "task_id": task_id, "status": "failed", "error": "未在策略代码中找到继承自 IStrategy 的策略类,请检查是否存在类似 `class MyStrategy(IStrategy):` 的定义。", "error_code": "STRATEGY_VALIDATION_ERROR", } # 保存策略代码到文件 strategy_file = await self._save_strategy(task_id, strategy_code, strategy_name) # 验证策略代码 is_valid, error = await self._validate_strategy(strategy_file) if not is_valid: return { "task_id": task_id, "status": "failed", "error": f"策略代码验证失败: {error}", "error_code": "STRATEGY_VALIDATION_ERROR", } # 保存任务状态到Redis await self._save_task_status( task_id, { "status": "pending", "pairs": pairs, "timerange": timerange, "timeframe": timeframe, "message": "回测任务已创建,等待执行", "created_at": datetime.now().isoformat(), }, ) # 记录回测任务提交指标 backtest_tasks_total.labels(status="submitted").inc() backtest_queue_length.inc() # 提交 Celery 异步任务 # 注意:此处引入 task 避免循环依赖 from app.tasks.legacy_tasks import run_backtest_task run_backtest_task.delay( task_id=task_id, strategy_file=strategy_file, strategy_name=strategy_name, pairs=pairs, timerange=timerange, timeframe=timeframe, initial_balance=initial_balance, user_id=user_id, ) return { "task_id": task_id, "status": "pending", "message": "回测任务已提交,正在排队执行", } except Exception as e: logger.error("Error in run_backtest: %s", e, exc_info=True) return { "task_id": task_id if "task_id" in locals() else "", "status": "failed", "error": str(e), "error_code": "INTERNAL_ERROR", } def execute_backtest_job( self, strategy_file: str, pairs: List[str], timerange: str, timeframe: str, initial_balance: float ) -> Dict[str, Any]: """ [Worker端调用] 执行 Freqtrade 回测子进程 """ try: # 解析策略名 with open(strategy_file, "r", encoding="utf-8") as f: code = f.read() strategy_name = self._extract_strategy_class_name(code) or os.path.basename(strategy_file).replace(".py", "") cmd = [ "freqtrade", "backtesting", "--strategy", strategy_name, "--timerange", timerange, "--timeframe", timeframe, "--pairs", *pairs, "--starting-balance", str(initial_balance), "--export", "trades", "--export-filename", f"backtest_{strategy_name}.json" ] logger.info(f"Executing: {' '.join(cmd)}") result = subprocess.run( cmd, cwd=self.freqtrade_path, capture_output=True, text=True, timeout=600 # 10分钟超时 ) if result.returncode != 0: raise Exception(f"回测失败 (Code {result.returncode}): {result.stderr}") # 解析结果文件 result_dir = os.path.join(self.freqtrade_path, "user_data", "backtest_results") result_file = os.path.join(result_dir, f"backtest-result-{strategy_name}.json") if os.path.exists(result_file): try: with open(result_file, "r", encoding="utf-8") as f: raw_data = json.load(f) return self._parse_freqtrade_result(raw_data) except Exception as parse_error: logger.error(f"Error parsing Freqtrade result file {result_file}: {parse_error}") else: logger.warning(f"Freqtrade result file not found: {result_file}") # 返回空结果 return self._get_empty_result() except subprocess.TimeoutExpired: raise Exception("回测超时(超过10分钟)") except Exception as e: logger.error(f"Error executing backtest job: {e}", exc_info=True) raise # --- AI Strategy Generation --- async def generate_strategy_code( self, description: str, indicators: List[str] = None ) -> str: """生成策略代码 (使用 AI 动态生成)""" from langchain_openai import ChatOpenAI from langchain_core.prompts import ChatPromptTemplate from langchain_core.output_parsers import StrOutputParser if not settings.DEEPSEEK_API_KEY: logger.warning("DEEPSEEK_API_KEY not set, falling back to template.") return self._get_fallback_strategy_template() try: from app.services.model_registry import get_model_registry # 使用 ModelRegistry 获取 backtest 专用模型 model_registry = get_model_registry() backtest_model = model_registry.get("ai.model.backtest", settings.DEEPSEEK_MODEL) llm = ChatOpenAI( model=backtest_model, openai_api_base=settings.DEEPSEEK_API_BASE, openai_api_key=settings.DEEPSEEK_API_KEY, temperature=0.7, max_tokens=4096, ) indicators_str = ", ".join(indicators) if indicators else "常用技术指标(RSI, MACD, Bollinger Bands)" system_prompt = """你是一个专业的加密货币量化交易策略专家,精通 Freqtrade 框架。 你的任务是根据用户的描述,编写一个完整的、可运行的 Freqtrade 策略代码。 要求: 1. 必须继承 `IStrategy` 类。 2. 必须包含 `minimal_roi`, `stoploss`, `timeframe` 等必要属性。 3. 必须实现 `populate_indicators`, `populate_entry_trend`, `populate_exit_trend` 方法。 4. 代码必须符合 Python 语法规范,并包含必要的 import 语句。 5. 只返回 Python 代码,不要包含 Markdown 代码块标记,也不要包含其他文字。 """ user_prompt = f""" 请根据以下要求生成 Freqtrade 策略: 描述:{description} 建议指标:{indicators_str} """ prompt = ChatPromptTemplate.from_messages([ ("system", system_prompt), ("user", user_prompt), ]) chain = prompt | llm | StrOutputParser() logger.info(f"Generating strategy code for: {description}") code = await chain.ainvoke({}) code = code.replace("```python", "").replace("```", "").strip() if "class" not in code or "(IStrategy)" not in code: return self._get_fallback_strategy_template() return code except Exception as e: logger.error(f"Error generating strategy: {e}", exc_info=True) return self._get_fallback_strategy_template() # --- Internals --- async def _save_strategy(self, task_id: str, strategy_code: str, strategy_name: str) -> str: """保存策略代码到文件""" strategy_dir = os.path.join(self.freqtrade_path, "user_data", "strategies") os.makedirs(strategy_dir, exist_ok=True) strategy_file = os.path.join(strategy_dir, f"{strategy_name}_{task_id}.py") # 使用线程池进行文件IO def _write(): with open(strategy_file, "w", encoding="utf-8") as f: f.write(strategy_code) await asyncio.to_thread(_write) return strategy_file def _extract_strategy_class_name(self, strategy_code: str) -> Optional[str]: """提取策略类名""" pattern = r"^\s*class\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(([^)]*)\)\s*:" for line in strategy_code.splitlines(): match = re.match(pattern, line) if match: class_name, bases_str = match.group(1), match.group(2) if "IStrategy" in bases_str: return class_name return None async def _validate_strategy(self, strategy_file: str) -> Tuple[bool, Optional[str]]: """验证策略语法""" try: def _compile(): with open(strategy_file, "r", encoding="utf-8") as f: compile(f.read(), strategy_file, "exec") await asyncio.to_thread(_compile) return True, None except Exception as e: return False, str(e) def _parse_freqtrade_result(self, raw_data: Dict[str, Any]) -> Dict[str, Any]: """解析 Freqtrade 结果""" try: strategy_dict = raw_data.get("strategy") or {} if not strategy_dict: return self._get_empty_result() first_key = next(iter(strategy_dict)) s_data = strategy_dict.get(first_key, {}) return { "total_trades": s_data.get("trades", 0), "winning_trades": s_data.get("wins", 0), "losing_trades": s_data.get("losses", 0), "win_rate": (s_data.get("wins", 0) / s_data.get("trades", 1) * 100) if s_data.get("trades") else 0.0, "total_profit": s_data.get("profit_total", 0.0), "total_profit_percent": s_data.get("profit_total_pct", 0.0), "sharpe_ratio": s_data.get("sharpe"), "max_drawdown": s_data.get("max_drawdown_abs"), "max_drawdown_percent": s_data.get("max_drawdown_pct"), "trades": raw_data.get("trades", []), } except Exception: return self._get_empty_result() def _get_empty_result(self) -> Dict[str, Any]: return { "total_trades": 0, "winning_trades": 0, "losing_trades": 0, "win_rate": 0.0, "total_profit": 0.0, "total_profit_percent": 0.0, "sharpe_ratio": None, "max_drawdown": None, "trades": [] } async def _save_task_status(self, task_id: str, status: Dict[str, Any]) -> None: self.redis.set(f"backtest:task:{task_id}", status, ttl=86400) async def get_task_status(self, task_id: str) -> Optional[Dict[str, Any]]: return self.redis.get(f"backtest:task:{task_id}") def _get_fallback_strategy_template(self) -> str: return '''from freqtrade.strategy import IStrategy from pandas import DataFrame import talib.abstract as ta class GeneratedStrategy(IStrategy): minimal_roi = {"0": 0.1, "30": 0.05} stoploss = -0.05 timeframe = '1h' def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame: dataframe['rsi'] = ta.RSI(dataframe, timeperiod=14) return dataframe def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: dataframe.loc[(dataframe['rsi'] < 30), 'enter_long'] = 1 return dataframe def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: dataframe.loc[(dataframe['rsi'] > 70), 'exit_long'] = 1 return dataframe ''' # 全局实例 backtest_service = BacktestService() def get_backtest_service() -> BacktestService: return backtest_service