diff --git a/backend/app/routers/config.py b/backend/app/routers/config.py index 37d794e..0c3846f 100644 --- a/backend/app/routers/config.py +++ b/backend/app/routers/config.py @@ -28,11 +28,14 @@ async def test_config( config_manager: ConfigManager = Depends(get_config_manager) ): """Test a specific configuration (e.g., database connection).""" - # The test logic will be implemented in a subsequent step inside the ConfigManager - # For now, we return a placeholder response. - # test_result = await config_manager.test_config( - # test_request.config_type, - # test_request.config_data - # ) - # return test_result - raise HTTPException(status_code=501, detail="Not Implemented") + try: + test_result = await config_manager.test_config( + test_request.config_type, + test_request.config_data + ) + return test_result + except Exception as e: + return ConfigTestResponse( + success=False, + message=f"测试失败: {str(e)}" + ) diff --git a/backend/app/routers/financial.py b/backend/app/routers/financial.py index 2bd00b8..d45b31d 100644 --- a/backend/app/routers/financial.py +++ b/backend/app/routers/financial.py @@ -4,7 +4,7 @@ API router for financial data (Tushare for China market) import json import os import time -from datetime import datetime, timezone +from datetime import datetime, timezone, timedelta from typing import Dict, List from fastapi import APIRouter, HTTPException, Query @@ -12,9 +12,18 @@ from fastapi.responses import StreamingResponse import os from app.core.config import settings -from app.schemas.financial import BatchFinancialDataResponse, FinancialConfigResponse, FinancialMeta, StepRecord, CompanyProfileResponse +from app.schemas.financial import ( + BatchFinancialDataResponse, + FinancialConfigResponse, + FinancialMeta, + StepRecord, + CompanyProfileResponse, + AnalysisResponse, + AnalysisConfigResponse +) from app.services.tushare_client import TushareClient from app.services.company_profile_client import CompanyProfileClient +from app.services.analysis_client import AnalysisClient, load_analysis_config, get_analysis_config router = APIRouter() @@ -23,6 +32,7 @@ router = APIRouter() REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..")) FINANCIAL_CONFIG_PATH = os.path.join(REPO_ROOT, "config", "financial-tushare.json") BASE_CONFIG_PATH = os.path.join(REPO_ROOT, "config", "config.json") +ANALYSIS_CONFIG_PATH = os.path.join(REPO_ROOT, "config", "analysis-config.json") def _load_json(path: str) -> Dict: @@ -35,6 +45,177 @@ def _load_json(path: str) -> Dict: return {} +@router.post("/china/{ts_code}/analysis", response_model=List[AnalysisResponse]) +async def generate_full_analysis( + ts_code: str, + company_name: str = Query(None, description="Company name for better context"), +): + """ + Generate a full analysis report by orchestrating multiple analysis modules + based on dependencies defined in the configuration. + """ + import logging + logger = logging.getLogger(__name__) + + logger.info(f"[API] Full analysis requested for {ts_code}") + + # Load base and analysis configurations + base_cfg = _load_json(BASE_CONFIG_PATH) + llm_provider = base_cfg.get("llm", {}).get("provider", "gemini") + llm_config = base_cfg.get("llm", {}).get(llm_provider, {}) + + api_key = llm_config.get("api_key") + base_url = llm_config.get("base_url") + + if not api_key: + logger.error(f"[API] API key for {llm_provider} not configured") + raise HTTPException( + status_code=500, + detail=f"API key for {llm_provider} not configured." + ) + + analysis_config_full = load_analysis_config() + modules_config = analysis_config_full.get("analysis_modules", {}) + if not modules_config: + raise HTTPException(status_code=404, detail="Analysis modules configuration not found.") + + # --- Dependency Resolution (Topological Sort) --- + def topological_sort(graph): + in_degree = {u: 0 for u in graph} + for u in graph: + for v in graph[u]: + in_degree[v] += 1 + + queue = [u for u in graph if in_degree[u] == 0] + sorted_order = [] + + while queue: + u = queue.pop(0) + sorted_order.append(u) + for v in graph.get(u, []): + in_degree[v] -= 1 + if in_degree[v] == 0: + queue.append(v) + + if len(sorted_order) == len(graph): + return sorted_order + else: + # Detect cycles and provide a meaningful error + cycles = [] + visited = set() + path = [] + + def find_cycle_util(node): + visited.add(node) + path.append(node) + for neighbor in graph.get(node, []): + if neighbor in path: + cycle_start_index = path.index(neighbor) + cycles.append(path[cycle_start_index:] + [neighbor]) + return + if neighbor not in visited: + find_cycle_util(neighbor) + path.pop() + + for node in graph: + if node not in visited: + find_cycle_util(node) + + return None, cycles + + + # Build dependency graph + dependency_graph = { + name: config.get("dependencies", []) + for name, config in modules_config.items() + } + + # Invert graph for topological sort (from dependency to dependent) + adj_list = {u: [] for u in dependency_graph} + for u, dependencies in dependency_graph.items(): + for dep in dependencies: + if dep in adj_list: + adj_list[dep].append(u) + + sorted_modules, cycle = topological_sort(adj_list) + if not sorted_modules: + raise HTTPException( + status_code=400, + detail=f"Circular dependency detected in analysis modules configuration. Cycle: {cycle}" + ) + + # --- Fetch common data (company name, financial data) --- + # This logic is duplicated, could be refactored into a helper + financial_data = None + if not company_name: + logger.info(f"[API] Fetching company name for {ts_code}") + try: + token = base_cfg.get("data_sources", {}).get("tushare", {}).get("api_key") + if token: + tushare_client = TushareClient(token=token) + basic_data = await tushare_client.query(api_name="stock_basic", params={"ts_code": ts_code}, fields="ts_code,name") + if basic_data: + company_name = basic_data[0].get("name", ts_code) + logger.info(f"[API] Got company name: {company_name}") + except Exception as e: + logger.warning(f"Failed to get company name, proceeding with ts_code. Error: {e}") + company_name = ts_code + + # --- Execute modules in order --- + analysis_results = [] + completed_modules_content = {} + + for module_type in sorted_modules: + module_config = modules_config[module_type] + logger.info(f"[Orchestrator] Starting analysis for module: {module_type}") + + client = AnalysisClient( + api_key=api_key, + base_url=base_url, + model=module_config.get("model", "gemini-1.5-flash") + ) + + # Gather context from completed dependencies + context = { + dep: completed_modules_content.get(dep, "") + for dep in module_config.get("dependencies", []) + } + + result = await client.generate_analysis( + analysis_type=module_type, + company_name=company_name, + ts_code=ts_code, + prompt_template=module_config.get("prompt_template", ""), + financial_data=financial_data, + context=context, + ) + + response = AnalysisResponse( + ts_code=ts_code, + company_name=company_name, + analysis_type=module_type, + content=result.get("content", ""), + model=result.get("model", module_config.get("model")), + tokens=result.get("tokens", {}), + elapsed_ms=result.get("elapsed_ms", 0), + success=result.get("success", False), + error=result.get("error") + ) + + analysis_results.append(response) + + if response.success: + completed_modules_content[module_type] = response.content + else: + # If a module fails, subsequent dependent modules will get an empty string for its context. + # This prevents total failure but may affect quality. + completed_modules_content[module_type] = f"Error: Analysis for {module_type} failed." + logger.error(f"[Orchestrator] Module {module_type} failed: {response.error}") + + logger.info(f"[API] Full analysis for {ts_code} completed.") + return analysis_results + + @router.get("/config", response_model=FinancialConfigResponse) async def get_financial_config(): data = _load_json(FINANCIAL_CONFIG_PATH) @@ -86,14 +267,16 @@ async def get_china_financials( series: Dict[str, List[Dict]] = {} # Helper to store year-value pairs while keeping most recent per year - def _merge_year_value(key: str, year: str, value): + def _merge_year_value(key: str, year: str, value, month: int = None): arr = series.setdefault(key, []) # upsert by year for item in arr: if item["year"] == year: item["value"] = value + if month is not None: + item["month"] = month return - arr.append({"year": year, "value": value}) + arr.append({"year": year, "value": value, "month": month}) # Query each API group we care errors: Dict[str, str] = {} @@ -107,39 +290,96 @@ async def get_china_financials( current_action = step.name if not metrics: continue - api_name = metrics[0].get("api") or group_name - fields = list({m.get("tushareParam") for m in metrics if m.get("tushareParam")}) - if not fields: - continue - - date_field = "end_date" if group_name in ("fina_indicator", "income", "balancesheet", "cashflow") else "trade_date" - try: - data_rows = await client.query(api_name=api_name, params={"ts_code": ts_code, "limit": 5000}, fields=None) - api_calls_total += 1 - api_calls_by_group[group_name] = api_calls_by_group.get(group_name, 0) + 1 - except Exception as e: - step.status = "error" - step.error = str(e) - step.end_ts = datetime.now(timezone.utc).isoformat() - step.duration_ms = int((time.perf_counter_ns() - started) / 1_000_000) - errors[group_name] = str(e) - continue - - tmp: Dict[str, Dict] = {} - for row in data_rows: - date_val = row.get(date_field) - if not date_val: - continue - year = str(date_val)[:4] - existing = tmp.get(year) - if existing is None or str(row.get(date_field)) > str(existing.get(date_field)): - tmp[year] = row + + # 按 API 分组 metrics(处理 unknown 组中有多个不同 API 的情况) + api_groups_dict: Dict[str, List[Dict]] = {} for metric in metrics: - key = metric.get("tushareParam") - if not key: + api = metric.get("api") or group_name + if api: # 跳过空 API + if api not in api_groups_dict: + api_groups_dict[api] = [] + api_groups_dict[api].append(metric) + + # 对每个 API 分别处理 + for api_name, api_metrics in api_groups_dict.items(): + fields = [m.get("tushareParam") for m in api_metrics if m.get("tushareParam")] + if not fields: continue - for year, row in tmp.items(): - _merge_year_value(key, year, row.get(key)) + + date_field = "end_date" if group_name in ("fina_indicator", "income", "balancesheet", "cashflow") else "trade_date" + + # 构建 API 参数 + params = {"ts_code": ts_code, "limit": 5000} + + # 对于需要日期范围的 API(如 stk_holdernumber),添加日期参数 + if api_name == "stk_holdernumber": + # 计算日期范围:从 years 年前到现在 + end_date = datetime.now().strftime("%Y%m%d") + start_date = (datetime.now() - timedelta(days=years * 365)).strftime("%Y%m%d") + params["start_date"] = start_date + params["end_date"] = end_date + # stk_holdernumber 返回的日期字段通常是 end_date + date_field = "end_date" + + # 对于非时间序列 API(如 stock_company),标记为静态数据 + is_static_data = api_name == "stock_company" + + # 构建 fields 字符串:包含日期字段和所有需要的指标字段 + # 确保日期字段存在,因为我们需要用它来确定年份 + fields_list = list(fields) + if date_field not in fields_list: + fields_list.insert(0, date_field) + # 对于 fina_indicator 等 API,通常还需要 ts_code 和 ann_date + if api_name in ("fina_indicator", "income", "balancesheet", "cashflow"): + for req_field in ["ts_code", "ann_date"]: + if req_field not in fields_list: + fields_list.insert(0, req_field) + fields_str = ",".join(fields_list) + + try: + data_rows = await client.query(api_name=api_name, params=params, fields=fields_str) + api_calls_total += 1 + api_calls_by_group[group_name] = api_calls_by_group.get(group_name, 0) + 1 + except Exception as e: + # 记录错误但继续处理其他 API + error_key = f"{group_name}_{api_name}" + errors[error_key] = str(e) + continue + + tmp: Dict[str, Dict] = {} + current_year = datetime.now().strftime("%Y") + + for row in data_rows: + if is_static_data: + # 对于静态数据(如 stock_company),使用当前年份 + # 只处理第一行数据,因为静态数据通常只有一行 + if current_year not in tmp: + year = current_year + month = None + tmp[year] = row + tmp[year]['_month'] = month + # 跳过后续行 + continue + else: + # 对于时间序列数据,按日期字段处理 + date_val = row.get(date_field) + if not date_val: + continue + year = str(date_val)[:4] + month = int(str(date_val)[4:6]) if len(str(date_val)) >= 6 else None + existing = tmp.get(year) + if existing is None or str(row.get(date_field)) > str(existing.get(date_field)): + tmp[year] = row + tmp[year]['_month'] = month + + for metric in api_metrics: + key = metric.get("tushareParam") + if not key: + continue + for year, row in tmp.items(): + month = row.get('_month') + _merge_year_value(key, year, row.get(key), month) + step.status = "done" step.end_ts = datetime.now(timezone.utc).isoformat() step.duration_ms = int((time.perf_counter_ns() - started) / 1_000_000) @@ -188,17 +428,24 @@ async def get_company_profile( # Load config base_cfg = _load_json(BASE_CONFIG_PATH) - gemini_cfg = base_cfg.get("llm", {}).get("gemini", {}) - api_key = gemini_cfg.get("api_key") + llm_provider = base_cfg.get("llm", {}).get("provider", "gemini") + llm_config = base_cfg.get("llm", {}).get(llm_provider, {}) + + api_key = llm_config.get("api_key") + base_url = llm_config.get("base_url") # Will be None if not set, handled by client if not api_key: - logger.error("[API] Gemini API key not configured") + logger.error(f"[API] API key for {llm_provider} not configured") raise HTTPException( status_code=500, - detail="Gemini API key not configured. Set config.json llm.gemini.api_key" + detail=f"API key for {llm_provider} not configured." ) - client = CompanyProfileClient(api_key=api_key) + client = CompanyProfileClient( + api_key=api_key, + base_url=base_url, + model="gemini-1.5-flash" + ) # Get company name from ts_code if not provided if not company_name: @@ -247,3 +494,200 @@ async def get_company_profile( success=result.get("success", False), error=result.get("error") ) + + +@router.get("/analysis-config", response_model=AnalysisConfigResponse) +async def get_analysis_config_endpoint(): + """Get analysis configuration""" + config = load_analysis_config() + return AnalysisConfigResponse(analysis_modules=config.get("analysis_modules", {})) + + +@router.put("/analysis-config", response_model=AnalysisConfigResponse) +async def update_analysis_config_endpoint(analysis_config: AnalysisConfigResponse): + """Update analysis configuration""" + import logging + logger = logging.getLogger(__name__) + + try: + # 保存到文件 + config_data = { + "analysis_modules": analysis_config.analysis_modules + } + + with open(ANALYSIS_CONFIG_PATH, "w", encoding="utf-8") as f: + json.dump(config_data, f, ensure_ascii=False, indent=2) + + logger.info(f"[API] Analysis config updated successfully") + return AnalysisConfigResponse(analysis_modules=analysis_config.analysis_modules) + except Exception as e: + logger.error(f"[API] Failed to update analysis config: {e}") + raise HTTPException( + status_code=500, + detail=f"Failed to update analysis config: {str(e)}" + ) + + +@router.get("/china/{ts_code}/analysis/{analysis_type}", response_model=AnalysisResponse) +async def generate_analysis( + ts_code: str, + analysis_type: str, + company_name: str = Query(None, description="Company name for better context"), +): + """ + Generate analysis for a company using Gemini AI + Supported analysis types: + - fundamental_analysis (基本面分析) + - bull_case (看涨分析) + - bear_case (看跌分析) + - market_analysis (市场分析) + - news_analysis (新闻分析) + - trading_analysis (交易分析) + - insider_institutional (内部人与机构动向分析) + - final_conclusion (最终结论) + """ + import logging + logger = logging.getLogger(__name__) + + logger.info(f"[API] Analysis requested for {ts_code}, type: {analysis_type}") + + # Load config + base_cfg = _load_json(BASE_CONFIG_PATH) + llm_provider = base_cfg.get("llm", {}).get("provider", "gemini") + llm_config = base_cfg.get("llm", {}).get(llm_provider, {}) + + api_key = llm_config.get("api_key") + base_url = llm_config.get("base_url") + + if not api_key: + logger.error(f"[API] API key for {llm_provider} not configured") + raise HTTPException( + status_code=500, + detail=f"API key for {llm_provider} not configured." + ) + + # Get analysis configuration + analysis_cfg = get_analysis_config(analysis_type) + if not analysis_cfg: + raise HTTPException( + status_code=404, + detail=f"Analysis type '{analysis_type}' not found in configuration" + ) + + model = analysis_cfg.get("model", "gemini-2.5-flash") + prompt_template = analysis_cfg.get("prompt_template", "") + + if not prompt_template: + raise HTTPException( + status_code=500, + detail=f"Prompt template not found for analysis type '{analysis_type}'" + ) + + # Get company name from ts_code if not provided + financial_data = None + if not company_name: + logger.info(f"[API] Fetching company name and financial data for {ts_code}") + try: + token = ( + os.environ.get("TUSHARE_TOKEN") + or settings.TUSHARE_TOKEN + or base_cfg.get("data_sources", {}).get("tushare", {}).get("api_key") + ) + if token: + tushare_client = TushareClient(token=token) + basic_data = await tushare_client.query(api_name="stock_basic", params={"ts_code": ts_code}, fields="ts_code,name") + if basic_data and len(basic_data) > 0: + company_name = basic_data[0].get("name", ts_code) + logger.info(f"[API] Got company name: {company_name}") + + # Try to get financial data for context + try: + fin_cfg = _load_json(FINANCIAL_CONFIG_PATH) + api_groups = fin_cfg.get("api_groups", {}) + + # Get financial data summary for context + series: Dict[str, List[Dict]] = {} + for group_name, metrics in api_groups.items(): + if not metrics: + continue + api_groups_dict: Dict[str, List[Dict]] = {} + for metric in metrics: + api = metric.get("api") or group_name + if api: + if api not in api_groups_dict: + api_groups_dict[api] = [] + api_groups_dict[api].append(metric) + + for api_name, api_metrics in api_groups_dict.items(): + fields = [m.get("tushareParam") for m in api_metrics if m.get("tushareParam")] + if not fields: + continue + + date_field = "end_date" if group_name in ("fina_indicator", "income", "balancesheet", "cashflow") else "trade_date" + + params = {"ts_code": ts_code, "limit": 500} + fields_list = list(fields) + if date_field not in fields_list: + fields_list.insert(0, date_field) + if api_name in ("fina_indicator", "income", "balancesheet", "cashflow"): + for req_field in ["ts_code", "ann_date"]: + if req_field not in fields_list: + fields_list.insert(0, req_field) + fields_str = ",".join(fields_list) + + try: + data_rows = await tushare_client.query(api_name=api_name, params=params, fields=fields_str) + if data_rows: + # Get latest year's data + latest_row = data_rows[0] if data_rows else {} + for metric in api_metrics: + key = metric.get("tushareParam") + if key and key in latest_row: + if key not in series: + series[key] = [] + series[key].append({ + "year": latest_row.get(date_field, "")[:4] if latest_row.get(date_field) else str(datetime.now().year), + "value": latest_row.get(key) + }) + except Exception: + pass + + financial_data = {"series": series} + except Exception as e: + logger.warning(f"[API] Failed to get financial data: {e}") + financial_data = None + else: + company_name = ts_code + else: + company_name = ts_code + except Exception as e: + logger.warning(f"[API] Failed to get company name: {e}") + company_name = ts_code + + logger.info(f"[API] Generating {analysis_type} for {company_name}") + + # Initialize analysis client with configured model + client = AnalysisClient(api_key=api_key, base_url=base_url, model=model) + + # Generate analysis + result = await client.generate_analysis( + analysis_type=analysis_type, + company_name=company_name, + ts_code=ts_code, + prompt_template=prompt_template, + financial_data=financial_data + ) + + logger.info(f"[API] Analysis generation completed, success={result.get('success')}") + + return AnalysisResponse( + ts_code=ts_code, + company_name=company_name, + analysis_type=analysis_type, + content=result.get("content", ""), + model=result.get("model", model), + tokens=result.get("tokens", {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}), + elapsed_ms=result.get("elapsed_ms", 0), + success=result.get("success", False), + error=result.get("error") + ) diff --git a/backend/app/schemas/config.py b/backend/app/schemas/config.py index 6f4f823..298e0c2 100644 --- a/backend/app/schemas/config.py +++ b/backend/app/schemas/config.py @@ -7,8 +7,8 @@ from pydantic import BaseModel, Field class DatabaseConfig(BaseModel): url: str = Field(..., description="数据库连接URL") -class GeminiConfig(BaseModel): - api_key: str = Field(..., description="Gemini API Key") +class NewApiConfig(BaseModel): + api_key: str = Field(..., description="New API Key") base_url: Optional[str] = None class DataSourceConfig(BaseModel): @@ -16,12 +16,12 @@ class DataSourceConfig(BaseModel): class ConfigResponse(BaseModel): database: DatabaseConfig - gemini_api: GeminiConfig + new_api: NewApiConfig data_sources: Dict[str, DataSourceConfig] class ConfigUpdateRequest(BaseModel): database: Optional[DatabaseConfig] = None - gemini_api: Optional[GeminiConfig] = None + new_api: Optional[NewApiConfig] = None data_sources: Optional[Dict[str, DataSourceConfig]] = None class ConfigTestRequest(BaseModel): diff --git a/backend/app/schemas/financial.py b/backend/app/schemas/financial.py index 88ae52b..2aa6c02 100644 --- a/backend/app/schemas/financial.py +++ b/backend/app/schemas/financial.py @@ -8,6 +8,7 @@ from pydantic import BaseModel class YearDataPoint(BaseModel): year: str value: Optional[float] + month: Optional[int] = None # 月份信息,用于确定季度 class StepRecord(BaseModel): @@ -55,3 +56,19 @@ class CompanyProfileResponse(BaseModel): elapsed_ms: int success: bool = True error: Optional[str] = None + + +class AnalysisResponse(BaseModel): + ts_code: str + company_name: Optional[str] = None + analysis_type: str + content: str + model: str + tokens: TokenUsage + elapsed_ms: int + success: bool = True + error: Optional[str] = None + + +class AnalysisConfigResponse(BaseModel): + analysis_modules: Dict[str, Dict] diff --git a/backend/app/services/analysis_client.py b/backend/app/services/analysis_client.py new file mode 100644 index 0000000..dca85a7 --- /dev/null +++ b/backend/app/services/analysis_client.py @@ -0,0 +1,155 @@ +""" +Generic Analysis Client for various analysis types using an OpenAI-compatible API +""" +import time +import json +import os +from typing import Dict, Optional +import openai +import string + + +class AnalysisClient: + """Generic client for generating various types of analysis using an OpenAI-compatible API""" + + def __init__(self, api_key: str, base_url: str, model: str): + """Initialize OpenAI client with API key, base URL, and model""" + self.client = openai.AsyncOpenAI(api_key=api_key, base_url=base_url) + self.model_name = model + + async def generate_analysis( + self, + analysis_type: str, + company_name: str, + ts_code: str, + prompt_template: str, + financial_data: Optional[Dict] = None, + context: Optional[Dict] = None + ) -> Dict: + """ + Generate analysis using OpenAI-compatible API (non-streaming) + + Args: + analysis_type: Type of analysis (e.g., "fundamental_analysis") + company_name: Company name + ts_code: Stock code + prompt_template: Prompt template with placeholders + financial_data: Optional financial data for context + context: Optional dictionary with results from previous analyses + + Returns: + Dict with analysis content and metadata + """ + start_time = time.perf_counter_ns() + + # Build prompt from template + prompt = self._build_prompt( + prompt_template, + company_name, + ts_code, + financial_data, + context + ) + + # Call OpenAI-compatible API + try: + response = await self.client.chat.completions.create( + model=self.model_name, + messages=[{"role": "user", "content": prompt}], + ) + + content = response.choices[0].message.content if response.choices else "" + usage = response.usage + + elapsed_ms = int((time.perf_counter_ns() - start_time) / 1_000_000) + + return { + "content": content, + "model": self.model_name, + "tokens": { + "prompt_tokens": usage.prompt_tokens if usage else 0, + "completion_tokens": usage.completion_tokens if usage else 0, + "total_tokens": usage.total_tokens if usage else 0, + } if usage else {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}, + "elapsed_ms": elapsed_ms, + "success": True, + "analysis_type": analysis_type, + } + except Exception as e: + elapsed_ms = int((time.perf_counter_ns() - start_time) / 1_000_000) + return { + "content": "", + "model": self.model_name, + "tokens": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}, + "elapsed_ms": elapsed_ms, + "success": False, + "error": str(e), + "analysis_type": analysis_type, + } + + def _build_prompt( + self, + prompt_template: str, + company_name: str, + ts_code: str, + financial_data: Optional[Dict] = None, + context: Optional[Dict] = None + ) -> str: + """Build prompt from template by replacing placeholders""" + + # Start with base placeholders + placeholders = { + "company_name": company_name, + "ts_code": ts_code, + } + + # Add financial data if provided + financial_data_str = "" + if financial_data: + try: + financial_data_str = json.dumps(financial_data, ensure_ascii=False, indent=2) + except Exception: + financial_data_str = str(financial_data) + placeholders["financial_data"] = financial_data_str + + # Add context from previous analysis steps + if context: + placeholders.update(context) + + # Replace placeholders in template + # Use a custom formatter to handle missing keys gracefully + class SafeFormatter(string.Formatter): + def get_value(self, key, args, kwargs): + if isinstance(key, str): + return kwargs.get(key, f"{{{key}}}") + else: + return super().get_value(key, args, kwargs) + + formatter = SafeFormatter() + prompt = formatter.format(prompt_template, **placeholders) + + return prompt + + +def load_analysis_config() -> Dict: + """Load analysis configuration from JSON file""" + # Get project root: backend/app/services -> project_root/config/analysis-config.json + project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..")) + config_path = os.path.join(project_root, "config", "analysis-config.json") + + if not os.path.exists(config_path): + return {} + + try: + with open(config_path, "r", encoding="utf-8") as f: + return json.load(f) + except Exception: + return {} + + +def get_analysis_config(analysis_type: str) -> Optional[Dict]: + """Get configuration for a specific analysis type""" + config = load_analysis_config() + modules = config.get("analysis_modules", {}) + return modules.get(analysis_type) + diff --git a/backend/app/services/company_profile_client.py b/backend/app/services/company_profile_client.py index 31d8b6d..676fc5d 100644 --- a/backend/app/services/company_profile_client.py +++ b/backend/app/services/company_profile_client.py @@ -1,16 +1,16 @@ """ -Google Gemini API Client for company profile generation +OpenAI-compatible API Client for company profile generation """ import time from typing import Dict, List, Optional -import google.generativeai as genai +import openai class CompanyProfileClient: - def __init__(self, api_key: str): - """Initialize Gemini client with API key""" - genai.configure(api_key=api_key) - self.model = genai.GenerativeModel("gemini-2.5-flash") + def __init__(self, api_key: str, base_url: str, model: str = "gemini-1.5-flash"): + """Initialize OpenAI client with API key, base_url and model""" + self.client = openai.AsyncOpenAI(api_key=api_key, base_url=base_url) + self.model_name = model async def generate_profile( self, @@ -19,7 +19,7 @@ class CompanyProfileClient: financial_data: Optional[Dict] = None ) -> Dict: """ - Generate company profile using Gemini API (non-streaming) + Generate company profile using OpenAI-compatible API (non-streaming) Args: company_name: Company name @@ -34,29 +34,26 @@ class CompanyProfileClient: # Build prompt prompt = self._build_prompt(company_name, ts_code, financial_data) - # Call Gemini API (using sync API in async context) + # Call OpenAI-compatible API try: - # Run synchronous API call in executor - import asyncio - loop = asyncio.get_event_loop() - response = await loop.run_in_executor( - None, - lambda: self.model.generate_content(prompt) + response = await self.client.chat.completions.create( + model=self.model_name, + messages=[{"role": "user", "content": prompt}], ) - # Get token usage - usage_metadata = response.usage_metadata if hasattr(response, 'usage_metadata') else None - + content = response.choices[0].message.content if response.choices else "" + usage = response.usage + elapsed_ms = int((time.perf_counter_ns() - start_time) / 1_000_000) return { - "content": response.text, - "model": "gemini-2.5-flash", + "content": content, + "model": self.model_name, "tokens": { - "prompt_tokens": usage_metadata.prompt_token_count if usage_metadata else 0, - "completion_tokens": usage_metadata.candidates_token_count if usage_metadata else 0, - "total_tokens": usage_metadata.total_token_count if usage_metadata else 0, - } if usage_metadata else {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}, + "prompt_tokens": usage.prompt_tokens if usage else 0, + "completion_tokens": usage.completion_tokens if usage else 0, + "total_tokens": usage.total_tokens if usage else 0, + } if usage else {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}, "elapsed_ms": elapsed_ms, "success": True, } @@ -64,7 +61,7 @@ class CompanyProfileClient: elapsed_ms = int((time.perf_counter_ns() - start_time) / 1_000_000) return { "content": "", - "model": "gemini-2.5-flash", + "model": self.model_name, "tokens": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}, "elapsed_ms": elapsed_ms, "success": False, diff --git a/backend/app/services/config_manager.py b/backend/app/services/config_manager.py index 283baed..27c7af3 100644 --- a/backend/app/services/config_manager.py +++ b/backend/app/services/config_manager.py @@ -3,13 +3,16 @@ Configuration Management Service """ import json import os +import asyncio from typing import Any, Dict +import asyncpg +import httpx from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.future import select from app.models.system_config import SystemConfig -from app.schemas.config import ConfigResponse, ConfigUpdateRequest, DatabaseConfig, GeminiConfig, DataSourceConfig +from app.schemas.config import ConfigResponse, ConfigUpdateRequest, DatabaseConfig, NewApiConfig, DataSourceConfig, ConfigTestResponse class ConfigManager: """Manages system configuration by merging a static JSON file with dynamic settings from the database.""" @@ -17,8 +20,10 @@ class ConfigManager: def __init__(self, db_session: AsyncSession, config_path: str = None): self.db = db_session if config_path is None: - # Default path: backend/ -> project_root/ -> config/config.json - project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) + # Default path: backend/app/services -> project_root/config/config.json + # __file__ = backend/app/services/config_manager.py + # go up three levels to project root + project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..")) self.config_path = os.path.join(project_root, "config", "config.json") else: self.config_path = config_path @@ -34,12 +39,19 @@ class ConfigManager: return {} async def _load_dynamic_config_from_db(self) -> Dict[str, Any]: - """Loads dynamic configuration overrides from the database.""" - db_configs = {} - result = await self.db.execute(select(SystemConfig)) - for record in result.scalars().all(): - db_configs[record.config_key] = record.config_value - return db_configs + """Loads dynamic configuration overrides from the database. + + 当数据库表尚未创建(如开发环境未运行迁移)时,优雅降级为返回空覆盖配置,避免接口 500。 + """ + try: + db_configs: Dict[str, Any] = {} + result = await self.db.execute(select(SystemConfig)) + for record in result.scalars().all(): + db_configs[record.config_key] = record.config_value + return db_configs + except Exception: + # 表不存在或其他数据库错误时,忽略动态配置覆盖 + return {} def _merge_configs(self, base: Dict[str, Any], overrides: Dict[str, Any]) -> Dict[str, Any]: """Deeply merges the override config into the base config.""" @@ -57,9 +69,12 @@ class ConfigManager: merged_config = self._merge_configs(base_config, db_config) + # 兼容两种位置:优先使用 new_api,其次回退到 llm.new_api + new_api_src = merged_config.get("new_api") or merged_config.get("llm", {}).get("new_api", {}) + return ConfigResponse( database=DatabaseConfig(**merged_config.get("database", {})), - gemini_api=GeminiConfig(**merged_config.get("llm", {}).get("gemini", {})), + new_api=NewApiConfig(**(new_api_src or {})), data_sources={ k: DataSourceConfig(**v) for k, v in merged_config.get("data_sources", {}).items() @@ -68,20 +83,222 @@ class ConfigManager: async def update_config(self, config_update: ConfigUpdateRequest) -> ConfigResponse: """Updates configuration in the database and returns the new merged config.""" - update_dict = config_update.dict(exclude_unset=True) - - for key, value in update_dict.items(): - existing_config = await self.db.get(SystemConfig, key) - if existing_config: - # Merge with existing DB value before updating - if isinstance(existing_config.config_value, dict) and isinstance(value, dict): - merged_value = self._merge_configs(existing_config.config_value, value) - existing_config.config_value = merged_value + try: + update_dict = config_update.dict(exclude_unset=True) + + # 验证配置数据 + self._validate_config_data(update_dict) + + for key, value in update_dict.items(): + existing_config = await self.db.get(SystemConfig, key) + if existing_config: + # Merge with existing DB value before updating + if isinstance(existing_config.config_value, dict) and isinstance(value, dict): + merged_value = self._merge_configs(existing_config.config_value, value) + existing_config.config_value = merged_value + else: + existing_config.config_value = value else: - existing_config.config_value = value - else: - new_config = SystemConfig(config_key=key, config_value=value) - self.db.add(new_config) + new_config = SystemConfig(config_key=key, config_value=value) + self.db.add(new_config) + + await self.db.commit() + return await self.get_config() + except Exception as e: + await self.db.rollback() + raise e + + def _validate_config_data(self, config_data: Dict[str, Any]) -> None: + """Validate configuration data before saving.""" + if "database" in config_data: + db_config = config_data["database"] + if "url" in db_config: + url = db_config["url"] + if not url.startswith(("postgresql://", "postgresql+asyncpg://")): + raise ValueError("数据库URL必须以 postgresql:// 或 postgresql+asyncpg:// 开头") - await self.db.commit() - return await self.get_config() + if "new_api" in config_data: + new_api_config = config_data["new_api"] + if "api_key" in new_api_config and len(new_api_config["api_key"]) < 10: + raise ValueError("New API Key长度不能少于10个字符") + if "base_url" in new_api_config and new_api_config["base_url"]: + base_url = new_api_config["base_url"] + if not base_url.startswith(("http://", "https://")): + raise ValueError("New API Base URL必须以 http:// 或 https:// 开头") + + if "data_sources" in config_data: + for source_name, source_config in config_data["data_sources"].items(): + if "api_key" in source_config and len(source_config["api_key"]) < 10: + raise ValueError(f"{source_name} API Key长度不能少于10个字符") + + async def test_config(self, config_type: str, config_data: Dict[str, Any]) -> ConfigTestResponse: + """Test a specific configuration.""" + try: + if config_type == "database": + return await self._test_database(config_data) + elif config_type == "new_api": + return await self._test_new_api(config_data) + elif config_type == "tushare": + return await self._test_tushare(config_data) + elif config_type == "finnhub": + return await self._test_finnhub(config_data) + else: + return ConfigTestResponse( + success=False, + message=f"不支持的配置类型: {config_type}" + ) + except Exception as e: + return ConfigTestResponse( + success=False, + message=f"测试失败: {str(e)}" + ) + + async def _test_database(self, config_data: Dict[str, Any]) -> ConfigTestResponse: + """Test database connection.""" + db_url = config_data.get("url") + if not db_url: + return ConfigTestResponse( + success=False, + message="数据库URL不能为空" + ) + + try: + # 解析数据库URL + if db_url.startswith("postgresql+asyncpg://"): + db_url = db_url.replace("postgresql+asyncpg://", "postgresql://") + + # 测试连接 + conn = await asyncpg.connect(db_url) + await conn.close() + + return ConfigTestResponse( + success=True, + message="数据库连接成功" + ) + except Exception as e: + return ConfigTestResponse( + success=False, + message=f"数据库连接失败: {str(e)}" + ) + + async def _test_new_api(self, config_data: Dict[str, Any]) -> ConfigTestResponse: + """Test New API (OpenAI-compatible) connection.""" + api_key = config_data.get("api_key") + base_url = config_data.get("base_url") + + if not api_key or not base_url: + return ConfigTestResponse( + success=False, + message="New API Key和Base URL均不能为空" + ) + + try: + async with httpx.AsyncClient(timeout=10.0) as client: + # Test API availability by listing models + response = await client.get( + f"{base_url.rstrip('/')}/models", + headers={"Authorization": f"Bearer {api_key}"} + ) + + if response.status_code == 200: + return ConfigTestResponse( + success=True, + message="New API连接成功" + ) + else: + return ConfigTestResponse( + success=False, + message=f"New API测试失败: HTTP {response.status_code} - {response.text}" + ) + except Exception as e: + return ConfigTestResponse( + success=False, + message=f"New API连接失败: {str(e)}" + ) + + async def _test_tushare(self, config_data: Dict[str, Any]) -> ConfigTestResponse: + """Test Tushare API connection.""" + api_key = config_data.get("api_key") + + if not api_key: + return ConfigTestResponse( + success=False, + message="Tushare API Key不能为空" + ) + + try: + async with httpx.AsyncClient(timeout=10.0) as client: + # 测试API可用性 + response = await client.post( + "http://api.tushare.pro", + json={ + "api_name": "stock_basic", + "token": api_key, + "params": {"list_status": "L"}, + "fields": "ts_code" + } + ) + + if response.status_code == 200: + data = response.json() + if data.get("code") == 0: + return ConfigTestResponse( + success=True, + message="Tushare API连接成功" + ) + else: + return ConfigTestResponse( + success=False, + message=f"Tushare API错误: {data.get('msg', '未知错误')}" + ) + else: + return ConfigTestResponse( + success=False, + message=f"Tushare API测试失败: HTTP {response.status_code}" + ) + except Exception as e: + return ConfigTestResponse( + success=False, + message=f"Tushare API连接失败: {str(e)}" + ) + + async def _test_finnhub(self, config_data: Dict[str, Any]) -> ConfigTestResponse: + """Test Finnhub API connection.""" + api_key = config_data.get("api_key") + + if not api_key: + return ConfigTestResponse( + success=False, + message="Finnhub API Key不能为空" + ) + + try: + async with httpx.AsyncClient(timeout=10.0) as client: + # 测试API可用性 + response = await client.get( + f"https://finnhub.io/api/v1/quote", + params={"symbol": "AAPL", "token": api_key} + ) + + if response.status_code == 200: + data = response.json() + if "c" in data: # 检查是否有价格数据 + return ConfigTestResponse( + success=True, + message="Finnhub API连接成功" + ) + else: + return ConfigTestResponse( + success=False, + message="Finnhub API响应格式错误" + ) + else: + return ConfigTestResponse( + success=False, + message=f"Finnhub API测试失败: HTTP {response.status_code}" + ) + except Exception as e: + return ConfigTestResponse( + success=False, + message=f"Finnhub API连接失败: {str(e)}" + ) diff --git a/backend/requirements.txt b/backend/requirements.txt index 6b1eeaa..eab13e3 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,8 +1,9 @@ fastapi==0.115.0 uvicorn[standard]==0.30.6 -httpx==0.27.2 +httpx==0.27.0 pydantic-settings==2.5.2 SQLAlchemy==2.0.36 aiosqlite==0.20.0 alembic==1.13.3 -google-generativeai==0.8.3 +openai==1.37.0 +asyncpg diff --git a/config/analysis-config.json b/config/analysis-config.json new file mode 100644 index 0000000..a80f5a8 --- /dev/null +++ b/config/analysis-config.json @@ -0,0 +1,60 @@ +{ + "analysis_modules": { + "company_profile": { + "name": "公司简介", + "model": "gemini-2.5-flash", + "prompt_template": "您是一位专业的证券市场分析师。请为公司 {company_name} (股票代码: {ts_code}) 生成一份详细且专业的公司介绍。开头不要自我介绍,直接开始正文。正文用MarkDown输出,尽量说明信息来源,用斜体显示信息来源。在生成内容时,请严格遵循以下要求并采用清晰、结构化的格式:\n\n1. **公司概览**:\n * 简要介绍公司的性质、核心业务领域及其在行业中的定位。\n * 提炼并阐述公司的核心价值理念。\n\n2. **主营业务**:\n * 详细描述公司主要的**产品或服务**。\n * **重要提示**:如果能获取到公司最新的官方**年报**或**财务报告**,请从中提取各主要产品/服务线的**收入金额**和其占公司总收入的**百分比**。请**明确标注数据来源**(例如:\"数据来源于XX年年度报告\")。\n * **严格禁止**编造或估算任何财务数据。若无法找到公开、准确的财务数据,请**不要**在这一点中提及具体金额或比例,仅描述业务内容。\n\n3. **发展历程**:\n * 以时间线或关键事件的形式,概述公司自成立以来的主要**里程碑事件**、重大发展阶段、战略转型或重要成就。\n\n4. **核心团队**:\n * 介绍公司**主要管理层和核心技术团队成员**。\n * 对于每位核心成员,提供其**职务、主要工作履历、教育背景**。\n * 如果公开可查,可补充其**出生年份**。\n\n5. **供应链**:\n * 描述公司的**主要原材料、部件或服务来源**。\n * 如果公开信息中包含,请列出**主要供应商名称**,并**明确其在总采购金额中的大致占比**。若无此数据,则仅描述采购模式。\n\n6. **主要客户及销售模式**:\n * 阐明公司的**销售模式**(例如:直销、经销、线上销售、代理等)。\n * 列出公司的**主要客户群体**或**代表性大客户**。\n * 如果公开信息中包含,请标明**主要客户(或前五大客户)的销售额占公司总销售额的比例**。若无此数据,则仅描述客户类型。\n\n7. **未来展望**:\n * 基于公司**公开的官方声明、管理层访谈或战略规划**,总结公司未来的发展方向、战略目标、重点项目或市场预期。请确保此部分内容有可靠的信息来源支持。" + }, + "fundamental_analysis": { + "name": "基本面分析", + "model": "gemini-2.5-flash", + "prompt_template": "# 角色\n你是一位专注于长期价值投资的顶级证券分析师,擅长从基本面出发,对公司进行深入、全面的分析。你的分析报告以客观、严谨、逻辑清晰、数据详实著称。\n# 任务\n为公司 {company_name} (股票代码: {ts_code}) 生成一份全面、专业、结构化的投资分析报告。\n# 输出要求\n直接开始:不要进行任何自我介绍或客套话,直接输出报告正文。\nMarkdown格式:使用清晰的多级Markdown标题(如 ## 和 ###)来组织报告结构。\n专业口吻:保持客观、中立、分析性的专业语调。\n信息缺失处理:如果某些信息在公开渠道无法获取,请明确指出“相关信息未公开披露”或类似说明。\n\n# 报告核心结构与分析要点\n一、 公司基本面分析 (Fundamental Analysis)\n1.1 护城河与核心竞争力\n公司通过何种独有优势(如品牌、技术、成本、网络效应、牌照等)获取超额利润?\n该护城河是在增强、维持还是在削弱?请提供论据。\n1.2 管理层与公司治理\n管理能力:管理层过往的战略决策和执行能力如何?是否有卓越的业界声誉?\n股东回报:管理层及大股东是否珍惜股权价值?(分析历史上的增持/减持行为、分红派息政策、是否存在损害小股东利益的体外资产等)\n激励与目标:公司的经营目标是长期主义还是短期化?管理层的激励机制(如股权激励、考核指标)是否与长期战略目标一致?\n1.3 企业文化与财务政策\n公司是否有独特且可观察到的企业文化?(例如:创新文化、成本控制文化等)\n公司的财务政策(如资本结构、现金流管理、投资策略)与同行业公司相比有何显著特点?是激进还是保守?\n1.4 发展历程与战略规划\n梳理公司发展史上的关键事件、重大业务转型或里程碑。\n公司是否有清晰的长期战略目标(未来5-10年)?计划成为一家什么样的企业?\n二、 业务与市场分析 (Business & Market Analysis)\n2.1 产品与客户价值\n公司为客户提供什么核心产品/服务?其核心价值点是什么?客户为何选择公司的产品而非竞争对手的?\n产品的更新迭代是颠覆性的还是渐进积累型的?分析产品历年的产量、价格及销量变化,并探讨其背后的驱动因素。\n2.2 市场需求与景气度\n客户所处行业的需求是趋势性的高增长,还是周期性波动?或是两者结合?当前处于何种阶段?\n目标客户群体的经营状况和现金流是否健康?\n2.3 议价能力与客户关系\n公司对下游客户的议价能力强弱如何?(结合应收账款周转天数、账龄结构、毛利率等数据进行佐证)\n公司与核心客户的关系是否稳定?客户对公司的评价如何(例如:客户忠诚度、满意度)?\n三、 竞争格局分析 (Competitive Landscape Analysis)\n3.1 竞争对手画像\n列出公司的主要竞争对手,并分析各自的优势与劣势。\n公司的竞争对手是在增多还是减少?行业进入壁垒是在增高还是降低?\n是否存在潜在的跨界竞争者?\n四、 供应链与外部关系 (Supply Chain & External Relations)\n4.1 供应链议价能力\n公司对上游供应商的议价能力如何?(结合应付账款周转天数、采购成本控制等数据进行佐证)\n核心供应商的经营是否稳定?供应链是否存在集中度过高的风险?\n4.2 金融机构关系与融资需求\n公司与金融机构的关系如何?融资渠道是否通畅?\n公司未来的发展是否依赖于大规模的债务或股权融资?\n五、 监管环境与政策风险 (Regulatory Environment & Policy Risks)\n公司所处行业是否存在重要的监管部门?主要的监管政策有哪些?\n监管政策是否稳定?未来可能发生哪些重大变化?对公司有何潜在影响?\n公司是否具备影响或适应监管政策变化的能力?" + }, + "bull_case": { + "name": "看涨分析", + "model": "gemini-2.5-flash", + "dependencies": [], + "prompt_template": "#### # 角色\n你是一位顶级的成长股投资分析师,拥有敏锐的洞察力,尤其擅长**挖掘市场尚未充分认识到的潜在价值**和**判断长期行业趋势**。你的任务是为目标公司构建一个令人信服的、由证据支持的看涨论述(Bull Case)。\n\n#### # 任务\n为公司 **{company_name}** (股票代码: **{ts_code}**) 生成一份深入的看涨分析报告。报告的核心是论证该公司拥有被市场低估的隐藏资产、持续加深的护城河,并且其所处行业将迎来至少3年以上的景气周期。\n\n#### # 输出要求\n1. **直奔主题**:直接开始分析,无需引言。\n2. **Markdown格式**:使用清晰的标题结构来组织你的论点。\n3. **数据与来源**:所有关键论点都必须有数据、事实或合理的逻辑推演作为支撑。请用*斜体*注明信息来源(如:*来源:公司2023年投资者交流纪要* 或 *来源:中信证券行业研报*)。\n4. **聚焦看涨逻辑**:报告内容应完全围绕支撑看涨观点的论据展开,暂时忽略风险和负面因素。\n5. **前瞻性视角**:分析应侧重于未来3-5年的发展潜力,而不仅仅是回顾历史。\n6. **信息缺失处理**:如果某些推论需要的数据无法公开获取,可以基于现有信息进行合理的逻辑推测,并明确标注“(此为基于...的推测)”。\n\n---\n\n### # 看涨核心论证框架\n\n## 一、 深度挖掘:公司的隐藏资产与未被市场充分定价的价值\n\n### 1.1 资产负债表之外的价值 (Off-Balance Sheet Value)\n- **无形资产**:公司是否拥有未被充分计价的核心技术专利、软件著作权、特许经营权或强大的品牌价值?请量化或举例说明其潜在商业价值。\n- **数据资产**:公司是否积累了具有巨大潜在价值的用户或行业数据?这些数据未来可能的变现途径是什么?\n\n### 1.2 低估的实体或股权资产 (Undervalued Physical or Equity Assets)\n- **土地/物业重估**:公司持有的土地、房产等固定资产,其当前市场公允价值是否远超账面价值?\n- **子公司/投资价值**:公司旗下是否有快速增长但未被市场充分关注的子公司或有价值的长期股权投资?分析其独立估值的潜力。\n\n### 1.3 运营中的“隐形冠军” (Operational \"Hidden Champions\")\n- 公司是否存在独特的、难以复制的生产工艺、供应链管理能力或运营效率优势,而这些优势尚未完全体现在当前的利润率中?\n\n## 二、 护城河的加深:竞争优势的动态强化分析\n\n### 2.1 护城河的动态演变:是静态还是在拓宽?\n- 论证公司的核心护城河(例如:网络效应、转换成本、成本优势、技术壁垒)在未来几年将如何被**强化**而非削弱。请提供具体证据(如:研发投入的持续增长、客户续约率的提升、市场份额的扩大等)。\n\n### 2.2 技术与创新壁垒的领先优势\n- 公司的研发投入和创新产出,如何确保其在未来3-5年内保持对竞争对手的技术代差或领先地位?\n- 是否有即将商业化的“杀手级”新产品或新技术?\n\n### 2.3 品牌与客户粘性的正反馈循环\n- 公司的品牌价值或客户关系如何形成一个正反馈循环(即:强品牌带来高议价能力 -> 高利润投入研发/营销 -> 品牌更强)?\n- 客户为何难以转向竞争对手?分析其高昂的转换成本。\n\n## 三、 长期景气度:行业未来3年以上的持续增长动力\n\n### 3.1 长期需求驱动力(Demand-Side Drivers)\n- 驱动行业增长的核心动力是短期的周期性复苏,还是长期的结构性变迁(如:技术革命、消费升级、国产替代、政策驱动)?请深入论证。\n- 行业的市场渗透率是否仍有巨大提升空间?分析未来市场规模(TAM)的扩张潜力。\n\n### 3.2 供给侧格局优化(Supply-Side Dynamics)\n- 行业供给侧是否出现集中度提升、落后产能出清的趋势?这是否意味着龙头企业的定价权和盈利能力将持续增强?\n- 行业的进入壁垒是否在显著提高(如:技术、资金、资质壁垒),从而限制新竞争者的涌入?\n\n### 3.3 关键催化剂(Key Catalysts)\n- 未来1-2年内,是否存在可以显著提升公司估值或盈利的潜在催化剂事件(如:新产品发布、重要政策落地、海外市场突破等)?" + }, + "bear_case": { + "name": "看跌分析", + "model": "gemini-2.5-flash", + "dependencies": [], + "prompt_template": "#### # 角色\n你是一位经验丰富的风险控制分析师和审慎的价值投资者,以“能看到别人看不到的风险”而闻名。你的核心任务是**进行压力测试**,识别出公司潜在的、可能导致价值毁灭的重大风险点,并评估其在最坏情况下的价值底线。\n\n#### # 任务\n为公司 **{company_name}** (股票代码: **{ts_code}**) 生成一份审慎的看跌分析报告(Bear Case)。报告需要深入探讨可能侵蚀公司护城河的因素、被市场忽视的潜在风险、行业可能面临的逆风,并对公司的价值底线进行评估。\n\n#### # 输出要求\n1. **直奔主题**:直接开始风险分析,无需引言。\n2. **Markdown格式**:使用清晰的标题结构组织风险论点。\n3. **证据驱动**:所有风险点都必须基于事实、数据或严谨的逻辑推演。请用*斜体*注明信息来源(如:*来源:竞争对手2023年财报* 或 *来源:行业监管政策草案*)。\n4. **聚焦看跌逻辑**:报告应完全围绕看跌观点展开,旨在识别和放大潜在的负面因素。\n5. **底线思维**:分析的核心是评估“事情最坏能到什么程度”,并判断公司的安全边际。\n6. **信息缺失处理**:对于难以量化的风险(如管理层风险),进行定性分析和逻辑阐述。\n\n---\n\n### # 看跌核心论证框架\n\n## 一、 护城河的侵蚀:竞争优势的脆弱性分析 (Moat Erosion: Vulnerability of Competitive Advantages)\n\n### 1.1 现有护城河的潜在威胁\n- 公司的核心护城河(技术、品牌、成本等)是否面临被颠覆的风险?(例如:新技术的出现、竞争对手的模仿或价格战)\n- 客户的转换成本是否真的足够高?是否存在某些因素(如行业标准化)可能降低客户的转换壁垒?\n\n### 1.2 竞争格局的恶化\n- 是否有新的、强大的“跨界”竞争者进入市场?\n- 行业是否从“蓝海”变为“红海”?分析导致竞争加剧的因素(如:产能过剩、产品同质化)。\n- 竞争对手的哪些战略举动可能对公司构成致命打击?\n\n## 二、 隐藏的负债与风险:资产负债表之外的“地雷” (Hidden Liabilities & Risks: Off-Balance Sheet \"Mines\")\n\n### 2.1 潜在的财务风险\n- 公司是否存在大量的或有负债、对外担保或未入表的债务?\n- 公司的现金流健康状况是否脆弱?分析其经营现金流能否覆盖资本开支和债务利息,尤其是在收入下滑的情况下。\n- 应收账款或存货是否存在潜在的暴雷风险?(分析其账龄、周转率和减值计提的充分性)\n\n### 2.2 运营与管理风险\n- 公司是否对单一供应商、单一客户或单一市场存在过度依赖?\n- 公司是否存在“关键人物风险”?创始团队或核心技术人员的离开会对公司造成多大影响?\n- 公司的企业文化或治理结构是否存在可能导致重大决策失误的缺陷?\n\n## 三、 行业逆风与最坏情况分析 (Industry Headwinds & Worst-Case Scenario)\n\n### 3.1 行业天花板与需求逆转\n- 行业渗透率是否已接近饱和?未来的增长空间是否被高估?\n- 驱动行业增长的核心因素是否可持续?是否存在可能导致需求突然逆转的黑天鹅事件(如:政策突变、技术路线改变、消费者偏好转移)?\n\n### 3.2 价值链上的压力传导\n- 上游供应商的议价能力是否在增强,从而挤压公司利润空间?\n- 下游客户的需求是否在萎缩,或者客户的财务状况是否在恶化?\n\n### 3.3 最坏情况压力测试 (Worst-Case Stress Test)\n- **情景假设**:假设行业需求下滑30%,或主要竞争对手发起价格战,公司的收入、利润和现金流会受到多大冲击?\n- **破产风险评估**:在这种极端情况下,公司是否有足够的现金储备和融资能力来度过危机?公司的生存底线在哪里?\n\n### 3.4 价值底线评估:清算价值分析 (Bottom-Line Valuation: Liquidation Value Analysis)\n- **核心假设**:在公司被迫停止经营并清算的极端情况下,其资产的真实变现价值是多少?\n- **资产逐项折价**:请对资产负债表中的主要科目进行折价估算。例如:\n - *现金及等价物*:按100%计算。\n - *应收账款*:根据账龄和客户质量,估计一个合理的回收率(如50%-80%)。\n - *存货*:根据存货类型(原材料、产成品)和市场状况,估计一个变现折扣(如30%-70%)。\n - *固定资产(厂房、设备)*:估计其二手市场的变现价值,通常远低于账面净值。\n - *无形资产/商誉*:大部分在清算时价值归零。\n- **负债计算**:公司的总负债(包括所有表内及表外负债)需要被优先偿还。\n- **清算价值估算**:计算**(折价后的总资产 - 总负债)/ 总股本**,得出每股清算价值。这是公司价值的绝对底线。\n\n## 四、 估值陷阱分析 (Valuation Trap Analysis)\n\n### 4.1 增长预期的证伪\n- 当前的高估值是否隐含了过于乐观的增长预期?论证这些预期为何可能无法实现。\n- 市场是否忽略了公司盈利能力的周期性,而将其误判为长期成长性?\n\n### 4.2 资产质量重估\n- 公司的资产(尤其是商誉、无形资产)是否存在大幅减值的风险?\n- 公司的真实盈利能力(扣除非经常性损益后)是否低于报表利润?\n" + }, + "market_analysis": { + "name": "市场分析", + "model": "gemini-2.5-flash", + "prompt_template": "#### # 角色\n你是一位顶级的市场策略分析师,精通行为金融学,对市场情绪和投资者心理有深刻的洞察。你擅长从海量的新闻、研报和市场数据中,提炼出当前市场对特定公司的核心看法、主要分歧点,并预判可能导致情绪反转的关键驱动因素。\n\n#### # 任务\n为公司 **{company_name}** (股票代码: **{ts_code}**) 生成一份当前的市场情绪分析报告。报告应聚焦于解读市场参与者当下的想法,而不是对公司基本面进行独立研究。\n\n#### # 输出要求\n1. **基于近期信息**:分析必须基**最近1-3个月**的公开新闻、分析师评论、社交媒体讨论和市场数据。\n2. **引用新闻来源**:在提到具体事件或观点时,必须用*斜体*注明新闻或信息来源。\n3. **客观呈现分歧**:清晰、中立地展示市场上多空双方的观点,而不是偏向任何一方。\n4. **聚焦“预期差”**:分析的核心是找出市场预期与公司现实之间可能存在的差距。\n5. **Markdown格式**:使用清晰的标题结构组织报告。\n\n---\n\n### # 市场情绪分析框架\n\n## 一、 当前市场主流叙事与估值定位 (Current Market Narrative & Valuation Positioning)\n\n### 1.1 市场的主流故事线是什么?\n- 综合近期(1-3个月内)的新闻报道和券商研报,当前市场在为这家公司讲述一个什么样的“故事”?是“困境反转”、“AI赋能”、“周期复苏”还是“增长放缓”?\n- 这个主流故事线是在近期被强化了,还是开始出现动摇?\n\n### 1.2 当前估值反映了什么预期?\n- 公司当前的估值水平(如市盈率P/E、市净率P/B)在历史和行业中处于什么位置(高位、中位、低位)?\n- 这个估值水平背后,市场“计价”了什么样的增长率、利润率或成功预期?*(例如:市场普遍预期其新业务明年将贡献30%的收入增长)*\n\n## 二、 情绪分歧点:多空双方的核心博弈 (Points of Disagreement: The Core Bull vs. Bear Debate)\n\n### 2.1 关键分歧一:[例如:新产品的市场前景]\n- **看多者认为**:[陈述看多方的核心理由,并引用支持性新闻或数据]\n- **看空者认为**:[陈述看空方的核心理由,并引用支持性新闻或数据]\n\n### 2.2 关键分歧二:[例如:监管政策的影响]\n- **看多者认为**:[陈述看多方的核心理由,并引用支持性新闻或数据]\n- **看空者认为**:[陈述看空方的核心理由,并引用支持性新闻或数据]\n\n### 2.3 市场资金的态度\n- 近期是否有知名的机构投资者在增持或减持?\n- 股票的卖空比例是否有显著变化?这反映了什么情绪?\n\n## 三、 情绪变化的潜在驱动力 (Potential Drivers of Sentiment Change)\n\n### 3.1 近期(未来1-3个月)的关键催化剂\n- 列出未来短期内可能打破当前市场情绪平衡的关键事件。(例如:即将发布的财报、行业重要会议、新产品发布会、重要的宏观数据公布等)\n- 这些事件的结果将如何分别验证或证伪当前多/空双方的逻辑?\n\n### 3.2 识别“预期差”\n- 当前市场最可能“过度乐观”的点是什么?\n- 当前市场最可能“过度悲观”的点是什么?\n- 未来什么样的信息出现,会最大程度地修复这种预期差,并引发股价剧烈波动?\n" + }, + "news_analysis": { + "name": "新闻分析", + "model": "gemini-2.5-flash", + "prompt_template": "#### # 角色\n你是一位嗅觉极其敏锐的金融新闻分析师,专注于事件驱动投资策略。你擅长从看似孤立的新闻事件中,解读其深层含义,并精准预判其对公司股价可能造成的催化作用和潜在的拐点。\n\n#### # 任务\n为公司 **{company_name}** (股票代码: **{ts_code}**) 生成一份股价催化剂与拐点预判报告。报告需要梳理近期相关新闻,并基于这些信息,识别出未来可能导致股价发生重大变化的正面及负面催化剂。\n\n#### # 输出要求\n1. **聚焦近期新闻**:分析应主要基于**最近1-2个月**的公司公告、行业新闻、政策文件及权威媒体报道。\n2. **明确时间线**:尽可能为潜在的催化剂事件标注一个预期的时间窗口(例如:“预计在Q4财报发布时”、“未来一个月内”)。\n3. **量化影响**:对于每个催化剂,不仅要定性判断(利好/利空),还要尝试分析其可能的影响级别(重大/中等/轻微)。\n4. **提供观察信号**:为每个预判的拐点,提供需要密切观察的关键信号或数据验证点。\n5. **Markdown格式**:使用清晰的标题结构。\n6. **引用来源**:关键信息需用*斜体*注明来源。\n\n---\n\n### # 股价催化剂与拐点分析框架\n\n## 一、 近期关键新闻梳理与解读 (Recent Key News Flow & Interpretation)\n\n- **新闻事件1:[日期] [新闻标题]**\n - *来源:[例如:公司官网公告 / 彭博社]*\n - **事件概述**:[简要概括新闻内容]\n - **市场初步反应**:[事件发生后,股价和成交量有何变化?]\n - **深层解读**:[该新闻是孤立事件,还是某个趋势的延续?它暗示了公司基本面的何种变化?]\n- **新闻事件2:[日期] [新闻标题]**\n - ... (以此类推)\n\n## 二、 正面催化剂预判 (Potential Positive Catalysts)\n\n### 2.1 确定性较高的催化剂 (High-Probability Catalysts)\n- **催化剂名称**:[例如:新一代产品发布]\n- **预期时间窗口**:[例如:预计在下个月的行业大会上]\n- **触发逻辑**:[为什么这件事会成为股价的正面驱动力?它会如何改善市场预期?]\n- **需观察的信号**:[需要看到什么具体信息(如产品性能参数、预订单数量)才能确认催化剂的有效性?]\n\n### 2.2 潜在的“黑天鹅”式利好 (Potential \"Black Swan\" Positives)\n- **催化剂名称**:[例如:意外获得海外市场准入 / 竞争对手出现重大失误]\n- **触发逻辑**:[描述这种小概率但影响巨大的利好事件及其可能性]\n- **需观察的信号**:[哪些先行指标或行业动态可能预示着这种事件的发生?]\n\n## 三、 负面催化剂预判 (Potential Negative Catalysts)\n\n### 3.1 确定性较高的风险 (High-Probability Risks)\n- **催化剂名称**:[例如:关键专利到期 / 主要客户合同续约谈判]\n- **预期时间窗口**:[例如:本季度末]\n- **触发逻辑**:[为什么这件事可能对股价造成负面冲击?]\n- **需观察的信号**:[需要关注哪些数据或公告来判断风险是否会兑现?]\n\n### 3.2 潜在的“黑天鹅”式风险 (Potential \"Black Swan\" Risks)\n- **催化剂名称**:[例如:突发性的行业监管收紧 / 供应链“断链”风险]\n- **触发逻辑**:[描述这种小概率但影响巨大的风险事件]\n- **需观察的信号**:[哪些蛛丝马迹可能预示着风险的临近?]\n\n## 四、 综合预判:下一个股价拐点 (Synthesis: The Next Inflection Point)\n\n- **核心博弈点**:综合以上分析,当前市场最关注、最可能率先发生的多空催化剂是什么?\n- **拐点预测**:基于当前信息,下一个可能改变股价趋势的关键时间点或事件最有可能是什么?\n- **关键验证指标**:在那个拐点到来之前,我们应该把注意力集中在哪个/哪些最关键的数据或信息上?\n" + }, + "trading_analysis": { + "name": "交易分析", + "model": "gemini-2.5-flash", + "prompt_template": "#### # 角色\n你是一位经验丰富的专业交易员,擅长将技术分析、市场赔率计算与基本面催化剂结合起来,制定高胜率的交易策略。你的决策核心是评估“风险回报比”,并寻找“基本面和资金面”可能形成共振(双击)的交易机会。\n\n#### # 任务\n为公司 **{company_name}** (股票代码: **{ts_code}**) 生成一份可执行的交易分析报告。报告需要深入分析当前股价走势,评估潜在的上涨空间与风险,并判断其是否具备形成“戴维斯双击”式上涨的潜力。\n\n#### # 输出要求\n1. **图表导向**:分析应基于对价格图表(K线)、成交量和关键技术指标(如均线、MACD、RSI)的解读。\n2. **量化赔率**:明确计算并展示风险回报比(赔率),作为是否值得参与交易的核心依据。\n3. **明确信号**:给出清晰、无歧义的入场、止损和止盈信号。\n4. **客观中立**:只基于当前的市场数据和图表信号进行分析,避免主观臆测。\n5. **Markdown格式**:使用清晰的标题结构。\n\n---\n\n### # 交易策略分析框架\n\n## 一、 当前价格走势与结构分析 (Current Price Action & Structure Analysis)\n\n### 1.1 趋势与动能\n- **当前趋势**:股价目前处于明确的上升、下降还是盘整趋势中?(*参考:关键均线系统,如MA20, MA60, MA120的排列状态*)\n- **关键水平**:当前最重要的支撑位和阻力位分别在哪里?这些是历史高低点、均线位置还是成交密集区?\n- **量价关系**:近期的成交量与价格波动是否匹配?是否存在“价升量增”的健康上涨或“价跌量增”的恐慌抛售?\n\n### 1.2 图表形态\n- 近期是否形成了关键的K线形态?(例如:突破性阳线、反转信号)\n- 是否存在经典的图表形态?(例如:头肩底、W底、收敛三角形、箱体震荡)\n\n## 二、 市场体量与赔率计算 (Market Capacity & Risk/Reward Calculation)\n\n### 2.1 上涨空间评估 (Upside Potential)\n- 如果向上突破关键阻力位,下一个或几个现实的**目标价位**在哪里?(*参考:前期高点、斐波那契扩展位、形态测量目标*)\n- **潜在回报率**:从当前价格到主要目标价位的潜在上涨百分比是多少?\n\n### 2.2 风险评估与止损设置 (Downside Risk & Stop-Loss)\n- 如果交易逻辑被证伪,一个清晰、有效的**止损价位**应该设在哪里?(*参考:关键支撑位下方、上升趋势线下方*)\n- **潜在风险率**:从当前价格到止损价位的潜在下跌百分比是多少?\n\n### 2.3 赔率分析 (Risk/Reward Ratio)\n- 计算**风险回报比**(= 潜在回报率 / 潜在风险率)。这个比率是否具有吸引力?(*专业交易者通常要求至少大于 2:1 或 3:1*)\n- **市场体量**:该股的日均成交额是否足够大,能够容纳计划中的资金进出而不会造成显著的冲击成本?\n\n## 三、 增长路径:“双击”可能性评估 (Growth Path: \"Dual-Click\" Potential)\n\n### 3.1 基本面驱动力 (Fundamental Momentum)\n- 近期是否有或将要有**基本面催化剂**来支撑股价上涨?(*参考《股价催化剂分析》的结论,如:超预期的财报、新产品成功、行业政策利好*)\n- 这个基本面利好是能提供“一次性”的脉冲,还是能开启一个“持续性”的盈利增长周期?\n\n### 3.2 资金面驱动力 (Capital Momentum)\n- 是否有证据表明**增量资金**正在流入?(*参考:成交量的持续放大、机构投资者的增持报告、龙虎榜数据*)\n- 该股所属的板块或赛道,当前是否受到市场主流资金的青睐?\n\n### 3.3 “双击”可能性综合评估\n- 综合来看,公司出现“**业绩超预期(基本面)+ 估值提升(资金面)**”双击局面的可能性有多大?\n- 触发“双击”的关键信号可能是什么?(例如:在发布亮眼财报后,股价以放量涨停的方式突破关键阻力位)\n\n## 四、 交易计划总结 (Actionable Trading Plan)\n\n- **入场信号**:[具体的入场条件。例如:日线收盘价站上 {阻力位A} 并且成交量放大至 {数值X} 以上]\n- **止损策略**:[具体的止损条件。例如:日线收盘价跌破 {支撑位B}]\n- **止盈策略**:[具体的目标位和操作。例如:在 {目标位C} 止盈50%,剩余仓位跟踪止盈]\n- **仓位管理**:[基于赔率和确定性,建议的初始仓位是多少?]\n" + }, + "insider_institutional": { + "name": "内部人与机构动向分析", + "model": "gemini-2.5-flash", + "prompt_template": "#### # 角色\n你是一位专注于追踪“聪明钱”动向的顶级数据分析师。你对解读上市公司内部人(高管、大股东)的交易行为和机构投资者的持仓变化具有丰富的经验,能够从纷繁的数据中识别出预示未来股价走向的关键信号。\n\n#### # 任务\n为公司 **{company_name}** (股票代码: **{ts_code}**) 生成一份关于内部人与机构投资者动向的深度分析报告。报告需覆盖**最近6-12个月**的数据,并解读这些“聪明钱”的行为可能暗示的公司前景。\n\n#### # 输出要求\n1. **数据驱动**:分析必须基于公开的、可验证的数据(如交易所披露的内部人交易记录、基金公司的持仓报告如13F文件等)。\n2. **聚焦近期**:重点分析最近6-12个月的动向,以捕捉最新的趋势变化。\n3. **深度解读,而非罗列**:不仅要呈现数据,更要深入分析交易行为背后的动机。例如,区分主动的公开市场增持与被动的股权激励,分析机构的“新进”与“清仓”。\n4. **结合股价**:将内部人和机构的动向与同期的股价走势相结合,分析是否存在“低位吸筹”或“高位派发”的迹象。\n5. **Markdown格式**:使用清晰的标题结构。\n6. **引用来源**:*在分析时需注明数据来源类型,如:来源:Q3季度机构持仓报告*。\n\n---\n\n### # 内部人与机构动向分析框架\n\n## 一、 内部人动向分析 (Insider Activity Analysis)\n\n### 1.1 核心高管交易 (Key Executive Transactions)\n- **公开市场买卖**:近6-12个月,公司的核心高管(CEO, CFO等)是否有在公开市场**主动买入**或**卖出**自家股票?\n- **交易动机解读**:\n - **买入**:买入的金额、次数以及当时股价所处的位置?(*通常,高管在股价下跌后主动增持,被视为强烈的看多信号*)\n - **卖出**:是出于个人资金需求(如纳税)的一次性小额卖出,还是持续、大量的减持?是否在股价历史高位附近减持?\n- **期权行权**:高管行使期权后,是选择继续持有股票,还是立即在市场卖出?\n\n### 1.2 大股东与董事会成员动向 (Major Shareholder & Director Activity)\n- 持股5%以上的大股东或董事会成员,近期的整体趋势是增持还是减持?\n- 是否存在关键股东(如创始人、战略投资者)的持股比例发生重大变化?\n\n### 1.3 内部人持股的总体趋势\n- 综合来看,内部人近半年的行为释放了什么样的集体信号?是信心增强、信心减弱,还是无明显趋势?\n\n## 二、 机构投资者动向分析 (Institutional Investor Activity Analysis)\n\n### 2.1 机构持股的总体变化\n- **持股比例**:机构投资者的总持股占流通股的比例,在最近几个季度是上升还是下降?\n- **股东数量**:持有该公司股票的机构总数是在增加还是减少?(*数量增加通常意味着市场关注度的提升*)\n\n### 2.2 顶级机构的进出 (Top-Tier Institution Moves)\n- **十大机构股东**:当前最大的机构股东有哪些?在最近一个报告期,它们是“增持”、“减持”、“新进”还是“清仓”?\n- **“聪明钱”的踪迹**:是否有以长期价值投资著称的知名基金(如高瓴、景林、Fidelity等)新进入了股东名单,或者大幅增持?\n- 反之,是否有顶级机构在清仓式卖出?\n\n### 2.3 机构观点的“一致性”\n- 从机构的整体行为来看,市场主流机构对该公司的看法是趋于一致(大家都在买或都在卖),还是存在巨大分歧?\n\n## 三、 综合研判:“聪明钱”的信号 (Synthesized Verdict: The \"Smart Money\" Signal)\n\n### 3.1 信号的一致性与背离\n- 内部人和机构投资者的行动方向是否一致?(*例如:内部人增持的同时,顶级机构也在建仓,这是一个极强的看多信号*)\n- “聪明钱”的动向是否与当前市场情绪或股价走势相背离?(*例如:在散户普遍悲观、股价下跌时,内部人和机构却在持续买入*)\n\n### 3.2 最终结论\n- 综合来看,在未来3-6个月,来自“聪明钱”的资金流向是可能成为股价的**顺风**(Tailwind)还是**逆风**(Headwind)?\n" + }, + "final_conclusion": { + "name": "最终结论", + "model": "gemini-2.5-flash", + "prompt_template": "#### # 角色\n你是一位顶级的基金公司首席投资官(CIO),你的工作不是进行初步研究,而是听取旗下所有分析师(基本面、宏观、技术、新闻、数据等)的报告后,做出最终的、高质量的投资决策。你必须能够穿透信息的迷雾,抓住主要矛盾,并给出明确的行动指令。\n\n#### # 任务\n基于以下七个维度的分析报告(由你的团队提供),为公司 **{company_name}** (股票代码: **{ts_code}**) 形成一份最终的投资决策备忘录。\n\n- **基本面分析**: `{fundamental_analysis}`\n- **看涨分析**: `{bull_case}`\n- **看跌分析**: `{bear_case}`\n- **市场情绪分析**: `{market_analysis}`\n- **新闻催化剂分析**: `{news_analysis}`\n- **交易策略分析**: `{trading_analysis}`\n- **内部人与机构动向**: `{insider_institutional}`\n\n#### # 输出要求\n1. **全局视角**:必须将所有输入信息融会贯通,形成一个逻辑自洽的、立体的投资论点。\n2. **抓住核心**:聚焦于识别当前局面的“核心矛盾”和最大的“预期差”。\n3. **决策导向**:结论必须是明确的、可执行的,并包含对“时机”和“价值”的量化评估。\n4. **精炼语言**:使用专业、果断、直击要害的语言。\n5. **Markdown格式**:使用清晰的标题结构。\n\n---\n\n### # 最终投资决策备忘录\n\n## 一、 核心矛盾与预期差 (Core Contradiction & Expectation Gap)\n\n- **当前的核心矛盾是什么?** 综合所有分析,当前多空双方争论的、最核心的、最关键的一个问题是什么?(例如:是“高估值下的成长故事”与“宏观逆风下的业绩担忧”之间的矛盾?还是“革命性产品”与“商业化落地不确定性”之间的矛盾?)\n- **最大的预期差在哪里?** 我们认为市场在哪一个关键点上可能犯了最大的错误?是我们比市场更乐观,还是更悲观?具体体现在哪个方面?\n\n## 二、 拐点的临近度与关键信号 (Proximity to Inflection Point & Key Signals)\n\n- **拐点是否临近?** 能够解决上述“核心矛盾”的关键催化剂事件,是否即将发生?(参考新闻和催化剂分析)\n- **我们需要验证什么?** 在拐点到来之前,我们需要密切跟踪和验证的、最关键的1-2个数据或信号是什么?(例如:是新产品的预订单数量,还是下一个季度的毛利率指引?)\n\n## 三、 综合投资论点 (Synthesized Investment Thesis)\n\n- **质量与价值(基本面 & 看跌风险)**:这家公司的“质量”如何?它的护城河是否足够深厚,能够在最坏的情况下提供足够的安全边际(清算价值)?\n- **成长与赔率(看涨 & 交易分析)**:如果看涨逻辑兑现,潜在的回报空间有多大?当前的交易结构是否提供了有吸引力的风险回报比?\n- **情绪与资金(市场情绪 & 聪明钱)**:当前的市场情绪是助力还是阻力?“聪明钱”的流向是在支持还是反对我们的判断?\n- **时机与催化剂(新闻分析)**:现在是合适的扣动扳机的时间点吗?还是需要等待某个关键催化剂的出现?\n\n## 四、 最终决策与评级 (Final Decision & Rating)\n\n- **投资结论**:[明确给出:**买入 / 增持 / 观望 / 减持 / 卖出**]\n- **核心投资逻辑**:[用一句话总结本次决策的核心理由]\n\n- **值得参与度评分**:**[请打分, 1-10分]**\n - *(评分标准:1-3分=机会不佳;4-6分=值得观察;7-8分=良好机会,建议配置;9-10分=极佳机会,应重点配置)*\n\n- **关注时间维度**:**[请选择:紧急 / 中期 / 长期]**\n - *(评级标准:**紧急**=关键拐点预计在1个月内;**中期**=关键拐点预计在1-6个月;**长期**=需要持续跟踪6个月以上)*\n", + "dependencies": [ + "fundamental_analysis", + "bull_case", + "bear_case", + "market_analysis", + "news_analysis", + "trading_analysis", + "insider_institutional" + ] + } + } +} \ No newline at end of file diff --git a/config/config.json b/config/config.json index 1b9bcbb..cdacdc3 100644 --- a/config/config.json +++ b/config/config.json @@ -1,9 +1,13 @@ { "llm": { - "provider": "gemini", + "provider": "new_api", "gemini": { "base_url": "", - "api_key": "AIzaSyCe4KpiRWFU3hnP-iwWvDR28ZCEzFnN0x0" + "api_key": "YOUR_GEMINI_API_KEY" + }, + "new_api": { + "base_url": "http://192.168.3.214:3000/v1", + "api_key": "sk-DdTTQ5fdU1aFW6gnYxSNYDgFsVQg938zUcmY4vaB7oPtcNs7" } }, "data_sources": { diff --git a/config/financial-tushare.json b/config/financial-tushare.json index e6c3d1d..fea9625 100644 --- a/config/financial-tushare.json +++ b/config/financial-tushare.json @@ -41,7 +41,8 @@ "cashflow": [ { "displayText": "经营净现金流", "tushareParam": "n_cashflow_act", "api": "cashflow" }, { "displayText": "资本开支", "tushareParam": "c_pay_acq_const_fiolta", "api": "cashflow" }, - { "displayText": "折旧费用", "tushareParam": "depr_fa_coga_dpba", "api": "cashflow" } + { "displayText": "折旧费用", "tushareParam": "depr_fa_coga_dpba", "api": "cashflow" }, + { "displayText": "支付给职工以及为职工支付的现金", "tushareParam": "c_paid_to_for_empl", "api": "cashflow" } ], "daily_basic": [ { "displayText": "PB", "tushareParam": "pb", "api": "daily_basic" }, diff --git a/docs/design.md b/docs/design.md index 47996dc..a128cd5 100644 --- a/docs/design.md +++ b/docs/design.md @@ -22,12 +22,13 @@ ### 2.1. 架构概述 -系统采用前后端分离的现代化Web架构: +系统采用前后端分离的现代化Web架构,并通过前端API代理到后端: -- **前端 (Frontend)**:基于React (Next.js) 的单页面应用 (SPA),负责用户界面和交互逻辑。它通过RESTful API与后端通信。 -- **后端 (Backend)**:基于Python FastAPI框架的异步API服务,负责处理所有业务逻辑、数据操作和与外部服务的集成。 -- **数据库 (Database)**:采用PostgreSQL作为关系型数据库,存储所有持久化数据。 -- **异步任务队列**: 利用FastAPI的`BackgroundTasks`处理耗时的报告生成任务,避免阻塞API请求,并允许实时进度追踪。 +- **前端 (Frontend)**:基于 React (Next.js App Router) 的应用,负责用户界面与交互。前端通过 Next.js 内置的 API 路由作为代理,转发请求到后端(`NEXT_PUBLIC_BACKEND_URL` 可配置,默认 `http://127.0.0.1:8000/api`)。 +- **前端 API 代理**:`/frontend/src/app/api/**` 下的路由将前端请求转发至后端对应路径,统一处理`Content-Type`与状态码。 +- **后端 (Backend)**:基于 Python FastAPI 的异步 API 服务,负责财务数据聚合、AI生成、配置管理与分析配置管理。 +- **数据库 (Database)**:使用 PostgreSQL(已具备模型与迁移脚手架)。当前 MVP 未落地“报告持久化”,主要以即时查询/生成返回为主;后续迭代将启用。 +- **异步任务**:当前 MVP 版本以“同步请求-响应”为主,不使用流式SSE或队列;后续可评估以 SSE/队列增强体验。 ![System Architecture Diagram](https://i.imgur.com/example.png) @@ -35,12 +36,14 @@ | 层次 | 技术 | 理由 | | :--- | :--- | :--- | -| **前端** | React (Next.js), TypeScript, Shadcn/UI | 提供优秀的开发体验、类型安全、高性能的服务端渲染(SSR)和丰富的UI组件库。 | +| **前端** | React (Next.js App Router), TypeScript, Shadcn/UI, SWR | App Router 简化路由与服务能力,SWR 负责数据获取与缓存。 | | **后端** | Python, FastAPI, SQLAlchemy (Async) | 异步框架带来高并发性能,Python拥有强大的数据处理和AI生态,SQLAlchemy提供强大的ORM能力。 | | **数据库** | PostgreSQL | 功能强大、稳定可靠的开源关系型数据库,支持JSONB等高级数据类型,适合存储结构化报告数据。 | | **数据源** | Tushare API, Yahoo Finance | Tushare提供全面的中国市场数据,Yahoo Finance作为其他市场的补充,易于集成。 | | **AI模型** | Google Gemini | 强大的大语言模型,能够根据指令生成高质量的业务分析内容。 | +注:当前实现通过 `config/financial-tushare.json` 配置需要拉取的 Tushare 指标分组,通过 `config/analysis-config.json` 配置各分析模块名称、模型与 Prompt 模板。 + ## 3. 后端设计 (Backend Design) ### 3.1. 核心服务设计 @@ -54,32 +57,44 @@ - **ConfigManager (配置管理器)**: 负责从`config.json`或数据库中加载、更新和验证系统配置(如API密钥、数据库URL)。 - **ProgressTracker (进度追踪器)**: 负责在报告生成的每个阶段更新状态、耗时和Token使用情况,并提供给前端查询。 -### 3.2. 异步任务处理 +### 3.2. 任务执行模型(MVP) -报告生成是一个耗时操作,将通过FastAPI的`BackgroundTasks`在后台执行。当用户请求生成新报告时,API会立即返回一个报告ID和“生成中”的状态,并将生成任务添加到后台队列。前端可以通过该ID轮询或使用WebSocket/SSE来获取实时进度。 +当前 MVP 以“按需生成/查询”的同步调用为主: -### 3.3. API 端点设计 +- 财务数据:请求到达后端即刻聚合最新数据并返回,包含元信息(耗时、步骤、API 调用统计)。 +- 公司简介与各分析模块:到达后端实时调用 Gemini 生成,返回内容与 Token/耗时指标。 +- 前端在同一页面内顺序执行“公司简介 → 各分析模块”,并以本地状态记录执行轨迹,不依赖 SSE/队列。 -后端将提供以下RESTful API端点: +### 3.3. API 端点设计(当前实现) + +后端 FastAPI 实现的主要端点(被前端 Next.js API 代理转发): | Method | Endpoint | 描述 | 请求体/参数 | 响应体 | | :--- | :--- | :--- | :--- | :--- | -| `POST` | `/api/reports` | 创建或获取报告。如果报告已存在,返回现有报告;否则,启动后台任务生成新报告。 | `symbol`, `market` | `ReportResponse` | -| `POST` | `/api/reports/regenerate` | 强制重新生成报告。 | `symbol`, `market` | `ReportResponse` | -| `GET` | `/api/reports/{report_id}` | 获取特定报告的详细内容,包括所有分析模块。 | `report_id` (UUID) | `ReportResponse` | -| `GET` | `/api/reports` | 获取报告列表,支持分页和筛选。 | `skip`, `limit`, `status` | `List[ReportResponse]` | -| `GET` | `/api/progress/stream/{report_id}` | (SSE) 实时流式传输报告生成进度。 | `report_id` (UUID) | `ProgressResponse` (Stream) | -| `GET` | `/api/config` | 获取当前系统所有配置。 | - | `ConfigResponse` | -| `PUT` | `/api/config` | 更新系统配置。 | `ConfigUpdateRequest` | `ConfigResponse` | -| `POST`| `/api/config/test` | 测试特定配置的有效性(如数据库连接)。 | `ConfigTestRequest` | `ConfigTestResponse` | +| `GET` | `/api/financials/config` | 获取财务指标分组配置 | - | `FinancialConfigResponse` | +| `GET` | `/api/financials/china/{ts_code}` | 聚合中国市场财务数据(按年汇总,含元信息与步骤) | `years` | `BatchFinancialDataResponse` | +| `GET` | `/api/financials/china/{ts_code}/company-profile` | 生成公司简介(Gemini,同步) | `company_name?` | `CompanyProfileResponse` | +| `GET` | `/api/financials/analysis-config` | 获取分析模块配置 | - | `AnalysisConfigResponse` | +| `PUT` | `/api/financials/analysis-config` | 更新分析模块配置 | `AnalysisConfigResponse` | `AnalysisConfigResponse` | +| `POST` | `/api/financials/china/{ts_code}/analysis` | 生成完整的分析报告(根据依赖关系编排) | `company_name?` | `List[AnalysisResponse]` | +| `GET` | `/api/config` | 获取系统配置 | - | `ConfigResponse` | +| `PUT` | `/api/config` | 更新系统配置 | `ConfigUpdateRequest` | `ConfigResponse` | +| `POST` | `/api/config/test` | 测试配置有效性(数据库/Gemini/Tushare 等) | `ConfigTestRequest` | `ConfigTestResponse` | + +说明:前端对应的代理路径如下(示例): + +- 前端请求 `/api/financials/china/...` → 代理到后端 `${BACKEND_BASE}/financials/china/...` +- 前端请求 `/api/config`、`/api/config/test` → 代理到后端 `${BACKEND_BASE}/config`、`${BACKEND_BASE}/config/test` ## 4. 数据库设计 ### 4.1. 数据模型 (Schema) +【规划中】以下表结构为后续“报告持久化与历史管理”功能的设计草案,当前 MVP 未启用: + **1. `reports` (报告表)** -存储报告的元数据。 +用于存储报告的元数据。 | 字段名 | 类型 | 描述 | 示例 | | :--- | :--- | :--- | :--- | @@ -135,24 +150,76 @@ ## 5. 前端设计 (Frontend Design) -### 5.1. 组件设计 +### 5.1. 组件设计(当前实现) -- **`StockInputForm`**: 首页的核心组件,包含证券代码输入框和交易市场选择器。 -- **`ReportPage`**: 报告的主页面,根据报告状态显示历史报告、进度追踪器或完整的分析模块。 -- **`ProgressTracker`**: 实时进度组件,通过订阅SSE或定时轮询来展示报告生成的步骤、状态和耗时。 -- **`ModuleNavigator`**: 报告页面的侧边栏或顶部导航,允许用户在不同的分析模块间切换。 -- **`ModuleViewer`**: 用于展示单个分析模块内容的组件,能渲染从`content` (JSONB)字段解析出的文本、图表和表格。 -- **`ConfigPage`**: 系统配置页面,提供表单来修改和测试数据库、API密钥等配置。 +- **`ReportPage` (`frontend/src/app/report/[symbol]/page.tsx`)**:报告主页面,基于 Tabs 展示:股价图表、财务数据表格、公司简介、各分析模块、执行详情。 + - 中国市场 `ts_code` 规范化:支持纯6位数字自动推断交易所(0/3→SZ,6→SH)。 + - “公司简介 → 各分析模块”按顺序串行执行,带状态、耗时与 Token 统计,可单项重试。 + - 财务数据表按年列展示,包含主要指标、费用率、资产占比、周转能力、人均效率、市场表现等分组;含多项计算型指标与高亮规则(如 ROE/ROIC>12%)。 +- **`TradingViewWidget`**:嵌入式股价图表,展示传入 `symbol` 的行情。 +- **`ConfigPage` (`frontend/src/app/config/page.tsx`)**:配置中心,支持数据库/Gemini/Tushare/Finnhub 配置查看、保存与测试;支持分析模块名称、模型、Prompt 模板的在线编辑与保存。 +- **前端 API 代理**:`/frontend/src/app/api/**` 路由用于转发到后端,解耦部署与本地开发。 -### 5.2. 页面与路由 +### 5.2. 页面与路由(当前实现) -- `/`: 首页,展示`StockInputForm`。 -- `/report/{symbol}`: 报告页面,动态路由,根据查询参数(如`market`)加载`ReportPage`。 -- `/report/{symbol}/{moduleId}`: 模块详情页,展示特定分析模块的内容。 -- `/config`: 系统配置页面,展示`ConfigPage`。 +- `/`: 入口页。 +- `/report/[symbol]?market=china|cn|...`: 报告页面(当前主要支持中国市场)。 +- `/config`: 配置中心。 +- `/docs`, `/logs`, `/reports`, `/query`: 辅助页面(如存在)。 -### 5.3. 状态管理 +### 5.3. 状态管理(当前实现) -- 使用Zustand或React Context进行全局状态管理,主要管理用户信息、系统配置和当前的报告状态。 -- 组件内部状态将使用React的`useState`和`useReducer`。 -- 使用React Query或SWR来管理API数据获取、缓存和同步,简化数据获取逻辑并提升用户体验。 +- **数据获取**:使用 SWR(`useSWR`)封装于 `frontend/src/hooks/useApi.ts` 中,提供 `useChinaFinancials`、`useFinancialConfig`、`useAnalysisConfig`、`useConfig` 等。 +- **全局配置**:使用 Zustand `useConfigStore` 存放系统配置与加载状态。 +- **页面局部状态**:`ReportPage` 使用 `useState/useMemo/useRef` 管理任务队列、当前任务、计时器、执行记录与各模块的内容/错误/耗时/Token。 + +注:当前无 SSE;进度条和“执行详情”来自本地状态与后端返回的元信息(财务数据接口含步骤与耗时统计)。 + +### 5.4. 指标与显示规范(要点) + +- 百分比字段支持 0–1 与百分数两种输入,自动规范化显示为 `%`。 +- 金额类(损益/资产负债/现金流)按“亿元/亿股”等进行缩放显示,市值按“亿元”整数化展示。 +- 计算项示例:自由现金流 = 经营现金流 − 资本开支;费用率=费用/收入;其他费用率=毛利率−净利率−销售−管理−研发。 +- 高亮规则:例如 ROE/ROIC>12% 标绿色背景,增长率为负标红等。 + +### 5.5. 分析模块编排 + +系统的核心能力之一是允许分析模块之间传递信息,即一个模块可以利用前序模块的分析结果作为上下文。这通过模块间的**依赖关系(Dependencies)**实现。 + +- **编排方式**: + - 前端不再独立、依次请求每个分析模块,而是通过调用一个统一的编排端点 `POST /api/financials/china/{ts_code}/analysis` 来发起一次完整的报告生成任务。 + - 后端编排器会读取 `config/analysis-config.json` 中定义的模块依赖关系,通过**拓扑排序**算法智能地计算出最优执行顺序,确保被依赖的模块总是先于依赖它的模块执行。 + +- **依赖配置与上下文注入**: + - **如何配置**:在前端的“配置中心”页面,可以为每个分析模块通过复选框勾选其需要依赖的其他模块。保存后,这将在 `analysis-config.json` 对应模块下生成一个 `dependencies` 数组,例如: + ```json + "bull_case": { + "name": "看涨分析", + "dependencies": ["fundamental_analysis"], + "prompt_template": "基于以下基本面分析:\n{fundamental_analysis}\n\n请生成看涨分析报告。" + } + ``` + - **如何工作**:当后端执行时,它会先运行 `fundamental_analysis` 模块,然后将其生成的**全部文本结果**,完整地替换掉 `bull_case` 模块提示词模板中的 `{fundamental_analysis}` 占位符。这样,AI在执行看涨分析时,就已经获得了充分的上下文。 + +- **重要:未配置依赖但使用占位符的后果** + - 如果您**没有**在配置中为一个模块(如 `bull_case`)勾选其对另一个模块(如 `fundamental_analysis`)的依赖,但依然在其提示词模板中使用了对应的占位符(`{fundamental_analysis}`),系统**不会报错**,但会发生以下情况: + 1. **执行顺序不保证**:编排器认为这两个模块独立,`fundamental_analysis` 不一定会在 `bull_case` 之前运行。 + 2. **上下文不会被注入**:即使前序模块先运行完,由于 `dependencies` 列表为空,系统也不会进行占位符替换。 + - **最终结果**:该占位符将作为**纯文本**(例如字符串 `"{fundamental_analysis}"`)被原封不动地包含在提示词中并发送给大语言模型。这通常会导致分析质量下降,因为模型会收到一个它无法理解的指令片段,但得益于后端的安全机制(`SafeFormatter`),整个流程不会因缺少数据而崩溃。 + - **结论**:**必须通过配置页面的复选框明确声明依赖关系**,才能激活模块间的上下文传递。 + +- **失败处理**: + - 当前的编排流程中,如果一个模块执行失败,依赖于它的后续模块在生成提示词时,会收到一段表示错误的文本(例如 `"Error: Analysis for a_module_failed."`)作为上下文,而不是空字符串。这可以防止后续模块因缺少信息而生成质量过低的内容,同时也为调试提供了线索。 + - 原有的单项重试功能已由全局的“重新运行完整分析”按钮取代,以适应新的编排模式。 + +### 5.6. 股票代码规范化(中国市场) + +- 输入为 6 位数字时自动添加交易所后缀:首位 `6`→`.SH`,`0/3`→`.SZ`;已有后缀将转为大写保留。 +## 6. 与最初规划的差异与后续计划 + +- **报告持久化/历史列表/再生成功能**:目前未实现,仍按需生成并即时展示;后续将启用数据库表 `reports`、`analysis_modules` 与 `progress_tracking` 完成闭环。 +- **SSE/队列化执行**:当前为前端串行请求 + 本地状态记录;后续可引入 SSE 或任务队列以提供更丝滑的进度与并发控制。 +- **多市场支持**:当前聚焦中国市场(Tushare);后续将补充美股等数据源与规则。 +- **权限与用户体系**:MVP 暂无;后续纳入登录、权限与审计。 + +本设计文档已对“当前实现”与“后续规划”进行了清晰标注,便于开发与验收。 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 29596d6..92623d0 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,7 @@ "name": "frontend", "version": "0.1.0", "dependencies": { + "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-navigation-menu": "^1.2.14", "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-slot": "^1.2.3", @@ -1049,6 +1050,36 @@ } } }, + "node_modules/@radix-ui/react-checkbox": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.3.tgz", + "integrity": "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-collection": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", diff --git a/frontend/package.json b/frontend/package.json index 2b47445..26e579a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,6 +9,7 @@ "lint": "eslint" }, "dependencies": { + "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-navigation-menu": "^1.2.14", "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-slot": "^1.2.3", diff --git a/frontend/src/app/config/page.tsx b/frontend/src/app/config/page.tsx index 98268b3..4f3b349 100644 --- a/frontend/src/app/config/page.tsx +++ b/frontend/src/app/config/page.tsx @@ -1,161 +1,652 @@ 'use client'; import { useState, useEffect } from 'react'; -import { useConfig, updateConfig, testConfig } from '@/hooks/useApi'; +import { useConfig, updateConfig, testConfig, useAnalysisConfig, updateAnalysisConfig } from '@/hooks/useApi'; import { useConfigStore, SystemConfig } from '@/stores/useConfigStore'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Label } from "@/components/ui/label"; +import { Separator } from "@/components/ui/separator"; +import { Checkbox } from "@/components/ui/checkbox"; +import type { AnalysisConfigResponse } from '@/types'; export default function ConfigPage() { // 从 Zustand store 获取全局状态 const { config, loading, error, setConfig } = useConfigStore(); // 使用 SWR hook 加载初始配置 useConfig(); + + // 加载分析配置 + const { data: analysisConfig, mutate: mutateAnalysisConfig } = useAnalysisConfig(); // 本地表单状态 const [dbUrl, setDbUrl] = useState(''); - const [geminiApiKey, setGeminiApiKey] = useState(''); + const [newApiApiKey, setNewApiApiKey] = useState(''); + const [newApiBaseUrl, setNewApiBaseUrl] = useState(''); const [tushareApiKey, setTushareApiKey] = useState(''); - + const [finnhubApiKey, setFinnhubApiKey] = useState(''); + + // 分析配置的本地状态 + const [localAnalysisConfig, setLocalAnalysisConfig] = useState>({}); + + // 分析配置保存状态 + const [savingAnalysis, setSavingAnalysis] = useState(false); + const [analysisSaveMessage, setAnalysisSaveMessage] = useState(''); + // 测试结果状态 - const [dbTestResult, setDbTestResult] = useState<{ success: boolean; message: string } | null>(null); - const [geminiTestResult, setGeminiTestResult] = useState<{ success: boolean; message: string } | null>(null); - + const [testResults, setTestResults] = useState>({}); + // 保存状态 const [saving, setSaving] = useState(false); const [saveMessage, setSaveMessage] = useState(''); - + + // 初始化分析配置的本地状态 useEffect(() => { - if (config) { - setDbUrl(config.database?.url || ''); - // API Keys 不回显 + if (analysisConfig?.analysis_modules) { + setLocalAnalysisConfig(analysisConfig.analysis_modules); } - }, [config]); + }, [analysisConfig]); + + // 更新分析配置中的某个字段 + const updateAnalysisField = (type: string, field: 'name' | 'model' | 'prompt_template', value: string) => { + setLocalAnalysisConfig(prev => ({ + ...prev, + [type]: { + ...prev[type], + [field]: value + } + })); + }; + + // 更新分析模块的依赖 + const updateAnalysisDependencies = (type: string, dependency: string, checked: boolean) => { + setLocalAnalysisConfig(prev => { + const currentConfig = prev[type]; + const currentDeps = currentConfig.dependencies || []; + + const newDeps = checked + ? [...currentDeps, dependency] + // 移除依赖,并去重 + : currentDeps.filter(d => d !== dependency); + + return { + ...prev, + [type]: { + ...currentConfig, + dependencies: [...new Set(newDeps)] // 确保唯一性 + } + }; + }); + }; + + // 保存分析配置 + const handleSaveAnalysisConfig = async () => { + setSavingAnalysis(true); + setAnalysisSaveMessage('保存中...'); + + try { + const updated = await updateAnalysisConfig({ + analysis_modules: localAnalysisConfig + }); + await mutateAnalysisConfig(updated); + setAnalysisSaveMessage('保存成功!'); + } catch (e: any) { + setAnalysisSaveMessage(`保存失败: ${e.message}`); + } finally { + setSavingAnalysis(false); + setTimeout(() => setAnalysisSaveMessage(''), 5000); + } + }; + + const validateConfig = () => { + const errors: string[] = []; + + // 验证数据库URL格式 + if (dbUrl && !dbUrl.match(/^postgresql(\+asyncpg)?:\/\/.+/)) { + errors.push('数据库URL格式不正确,应为 postgresql://user:pass@host:port/dbname'); + } + + // 验证New API Base URL格式 + if (newApiBaseUrl && !newApiBaseUrl.match(/^https?:\/\/.+/)) { + errors.push('New API Base URL格式不正确,应为 http:// 或 https:// 开头'); + } + + // 验证API Key长度(基本检查) + if (newApiApiKey && newApiApiKey.length < 10) { + errors.push('New API Key长度过短'); + } + + if (tushareApiKey && tushareApiKey.length < 10) { + errors.push('Tushare API Key长度过短'); + } + + if (finnhubApiKey && finnhubApiKey.length < 10) { + errors.push('Finnhub API Key长度过短'); + } + + return errors; + }; const handleSave = async () => { + // 验证配置 + const validationErrors = validateConfig(); + if (validationErrors.length > 0) { + setSaveMessage(`配置验证失败: ${validationErrors.join(', ')}`); + return; + } + setSaving(true); setSaveMessage('保存中...'); - const newConfig: Partial = { - database: { url: dbUrl }, - gemini_api: { api_key: geminiApiKey }, - data_sources: { - tushare: { api_key: tushareApiKey }, - }, - }; + const newConfig: Partial = {}; + + // 只更新有值的字段 + if (dbUrl) { + newConfig.database = { url: dbUrl }; + } + + if (newApiApiKey || newApiBaseUrl) { + newConfig.new_api = { + api_key: newApiApiKey || config?.new_api?.api_key || '', + base_url: newApiBaseUrl || config?.new_api?.base_url || undefined, + }; + } + + if (tushareApiKey || finnhubApiKey) { + newConfig.data_sources = { + ...config?.data_sources, + ...(tushareApiKey && { tushare: { api_key: tushareApiKey } }), + ...(finnhubApiKey && { finnhub: { api_key: finnhubApiKey } }), + }; + } try { const updated = await updateConfig(newConfig); setConfig(updated); // 更新全局状态 setSaveMessage('保存成功!'); - setGeminiApiKey(''); // 清空敏感字段输入 + // 清空敏感字段输入 + setNewApiApiKey(''); setTushareApiKey(''); + setFinnhubApiKey(''); } catch (e: any) { setSaveMessage(`保存失败: ${e.message}`); } finally { setSaving(false); - setTimeout(() => setSaveMessage(''), 3000); + setTimeout(() => setSaveMessage(''), 5000); } }; - const handleTestDb = async () => { - const result = await testConfig('database', { url: dbUrl }); - setDbTestResult(result); + const handleTest = async (type: string, data: any) => { + try { + const result = await testConfig(type, data); + setTestResults(prev => ({ ...prev, [type]: result })); + } catch (e: any) { + setTestResults(prev => ({ + ...prev, + [type]: { success: false, message: e.message } + })); + } }; - const handleTestGemini = async () => { - const result = await testConfig('gemini', { api_key: geminiApiKey || config?.gemini_api.api_key }); - setGeminiTestResult(result); + const handleTestDb = () => { + handleTest('database', { url: dbUrl }); }; - if (loading) return
Loading...
; - if (error) return
Error loading config: {error}
; + const handleTestNewApi = () => { + handleTest('new_api', { + api_key: newApiApiKey || config?.new_api?.api_key, + base_url: newApiBaseUrl || config?.new_api?.base_url + }); + }; + + const handleTestTushare = () => { + handleTest('tushare', { api_key: tushareApiKey || config?.data_sources?.tushare?.api_key }); + }; + + const handleTestFinnhub = () => { + handleTest('finnhub', { api_key: finnhubApiKey || config?.data_sources?.finnhub?.api_key }); + }; + + const handleReset = () => { + setDbUrl(''); + setNewApiApiKey(''); + setNewApiBaseUrl(''); + setTushareApiKey(''); + setFinnhubApiKey(''); + setTestResults({}); + setSaveMessage(''); + }; + + const handleExportConfig = () => { + if (!config) return; + + const configToExport = { + database: config.database, + new_api: config.new_api, + data_sources: config.data_sources, + export_time: new Date().toISOString(), + version: "1.0" + }; + + const blob = new Blob([JSON.stringify(configToExport, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `config-backup-${new Date().toISOString().split('T')[0]}.json`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + }; + + const handleImportConfig = (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file) return; + + const reader = new FileReader(); + reader.onload = (e) => { + try { + const importedConfig = JSON.parse(e.target?.result as string); + + // 验证导入的配置格式 + if (importedConfig.database?.url) { + setDbUrl(importedConfig.database.url); + } + if (importedConfig.new_api?.base_url) { + setNewApiBaseUrl(importedConfig.new_api.base_url); + } + + setSaveMessage('配置导入成功,请检查并保存'); + } catch (error) { + setSaveMessage('配置文件格式错误,导入失败'); + } + }; + reader.readAsText(file); + }; + + if (loading) return ( +
+
+
+

加载配置中...

+
+
+ ); + + if (error) return ( +
+
+
⚠️
+

加载配置失败: {error}

+
+
+ ); return ( -
+
-

配置中心

-

- 管理系统配置,包括数据库、API密钥等。敏感密钥不回显,留空表示保持现值。 +

配置中心

+

+ 管理系统配置,包括数据库连接、API密钥等。敏感信息不回显,留空表示保持当前值。

- - - 数据库配置 - PostgreSQL 连接设置 - - -
- - setDbUrl(e.target.value)} - placeholder="postgresql+asyncpg://user:pass@host:port/dbname" - className="flex-1" - /> - -
- {dbTestResult && ( - - {dbTestResult.message} - + + + 数据库 + AI服务 + 数据源 + 分析配置 + 系统 + + + + + + 数据库配置 + PostgreSQL 数据库连接设置 + + +
+ +
+ setDbUrl(e.target.value)} + placeholder="postgresql+asyncpg://user:password@host:port/database" + className="flex-1" + /> + +
+ {testResults.database && ( + + {testResults.database.message} + + )} +
+
+
+
+ + + + + AI 服务配置 + New API 设置 (兼容 OpenAI 格式) + + +
+ +
+ setNewApiApiKey(e.target.value)} + placeholder="留空表示保持当前值" + className="flex-1" + /> + +
+ {testResults.new_api && ( + + {testResults.new_api.message} + + )} +
+ +
+ + setNewApiBaseUrl(e.target.value)} + placeholder="例如: http://localhost:3000/v1" + className="flex-1" + /> +
+
+
+
+ + + + + 数据源配置 + 外部数据源 API 设置 + + +
+
+ +

中国股票数据源

+
+ setTushareApiKey(e.target.value)} + placeholder="留空表示保持当前值" + className="flex-1" + /> + +
+ {testResults.tushare && ( + + {testResults.tushare.message} + + )} +
+ + + +
+ +

全球金融市场数据源

+
+ setFinnhubApiKey(e.target.value)} + placeholder="留空表示保持当前值" + className="flex-1" + /> + +
+ {testResults.finnhub && ( + + {testResults.finnhub.message} + + )} +
+
+
+
+
+ + + + + 分析模块配置 + 配置各个分析模块的模型和提示词 + + + {Object.entries(localAnalysisConfig).map(([type, config]) => { + const otherModuleKeys = Object.keys(localAnalysisConfig).filter(key => key !== type); + + return ( +
+
+

{config.name || type}

+ {type} +
+ +
+ + updateAnalysisField(type, 'name', e.target.value)} + placeholder="分析模块显示名称" + /> +
+ +
+ + updateAnalysisField(type, 'model', e.target.value)} + placeholder="例如: gemini-1.5-pro" + /> +

+ 在 AI 服务中配置的模型名称 +

+
+ +
+ +
+ {otherModuleKeys.map(depKey => ( +
+ { + updateAnalysisDependencies(type, depKey, !!checked); + }} + /> + +
+ ))} +
+

+ 选择此模块在生成时需要依赖的其他模块。选中的模块结果将通过占位符注入提示词模板。 +

+
+ +
+ +