diff --git a/backend/app/data_providers/finnhub.py b/backend/app/data_providers/finnhub.py index 645b7c5..f2e7c5e 100644 --- a/backend/app/data_providers/finnhub.py +++ b/backend/app/data_providers/finnhub.py @@ -102,7 +102,38 @@ class FinnhubProvider(BaseDataProvider): # e.g. 'AssetsTotal' -> 'total_assets' # This is a complex task and depends on the desired final schema. - return all_reports + # We will now normalize and calculate derived metrics + normalized_reports = [] + for report in all_reports: + normalized_report = { + "ts_code": report.get("ts_code"), + "end_date": report.get("end_date"), + # Balance Sheet + "total_assets": report.get("AssetsTotal"), + "total_liabilities": report.get("LiabilitiesTotal"), + "equity": report.get("StockholdersEquityTotal"), + # Income Statement + "revenue": report.get("RevenuesTotal"), + "net_income": report.get("NetIncomeLoss"), + "gross_profit": report.get("GrossProfit"), + # Cash Flow + "net_cash_flow_operating": report.get("NetCashFlowOperating"), + } + + # Calculate derived metrics + if normalized_report["revenue"] and normalized_report["revenue"] > 0: + normalized_report["gross_margin"] = (normalized_report["gross_profit"] / normalized_report["revenue"]) if normalized_report["gross_profit"] else None + normalized_report["net_margin"] = (normalized_report["net_income"] / normalized_report["revenue"]) if normalized_report["net_income"] else None + + if normalized_report["total_assets"] and normalized_report["total_assets"] > 0: + normalized_report["roa"] = (normalized_report["net_income"] / normalized_report["total_assets"]) if normalized_report["net_income"] else None + + if normalized_report["equity"] and normalized_report["equity"] > 0: + normalized_report["roe"] = (normalized_report["net_income"] / normalized_report["equity"]) if normalized_report["net_income"] else None + + normalized_reports.append(normalized_report) + + return normalized_reports except Exception as e: logger.error(f"Finnhub get_financial_statements failed for {stock_code}: {e}") diff --git a/backend/app/data_providers/tushare.py b/backend/app/data_providers/tushare.py index 3de2111..8d96155 100644 --- a/backend/app/data_providers/tushare.py +++ b/backend/app/data_providers/tushare.py @@ -669,29 +669,10 @@ class TushareProvider(BaseDataProvider): "latest_ann_date": ann_date } - # 筛选报告期:只取今年的最后一个报告期和往年的所有年报(12月31日) - holder_available_dates = sorted(holder_by_period.keys(), reverse=True) - - holder_wanted_dates = [] - - # 今年的最新报告期 - latest_current_year_holder = None - for d in holder_available_dates: - if d.startswith(current_year): - latest_current_year_holder = d - break - if latest_current_year_holder: - holder_wanted_dates.append(latest_current_year_holder) - - # 往年的所有年报(12月31日) - previous_years_holder_reports = [ - d for d in holder_available_dates if d.endswith("1231") and not d.startswith(current_year) - ] - holder_wanted_dates.extend(previous_years_holder_reports) - - # 生成系列数据,只包含筛选后的报告期 + # 使用与财务报表相同的报告期筛选逻辑 + # 股东户数应该与财务报表的报告期时间点对应 holder_series = [] - for end_date in sorted(holder_wanted_dates): + for end_date in wanted_dates: if end_date in holder_by_period: data = holder_by_period[end_date] holder_num = data["holder_num"] diff --git a/backend/app/main.py b/backend/app/main.py index ef53e7e..f777706 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -8,6 +8,7 @@ from fastapi.middleware.cors import CORSMiddleware from app.core.config import settings from app.routers.config import router as config_router from app.routers.financial import router as financial_router +from app.routers.orgs import router as orgs_router # Configure logging to ensure our app logs show up in development import sys @@ -50,6 +51,7 @@ app.add_middleware( # Routers app.include_router(config_router, prefix=f"{settings.API_V1_STR}/config", tags=["config"]) app.include_router(financial_router, prefix=f"{settings.API_V1_STR}/financials", tags=["financials"]) +app.include_router(orgs_router, prefix=f"{settings.API_V1_STR}/orgs", tags=["orgs"]) @app.get("/") async def root(): diff --git a/backend/app/routers/financial.py b/backend/app/routers/financial.py index b09ba36..d1529e2 100644 --- a/backend/app/routers/financial.py +++ b/backend/app/routers/financial.py @@ -18,7 +18,8 @@ from app.schemas.financial import ( StepRecord, CompanyProfileResponse, AnalysisResponse, - AnalysisConfigResponse + AnalysisConfigResponse, + TodaySnapshotResponse, ) from app.services.company_profile_client import CompanyProfileClient from app.services.analysis_client import AnalysisClient, load_analysis_config, get_analysis_config @@ -259,7 +260,7 @@ async def get_financial_config(): @router.get("/china/{ts_code}", response_model=BatchFinancialDataResponse) async def get_china_financials( ts_code: str, - years: int = Query(5, ge=1, le=15), + years: int = Query(10, ge=1, le=10), ): # Load metric config fin_cfg = _load_json(FINANCIAL_CONFIG_PATH) @@ -723,6 +724,82 @@ async def generate_analysis( ) +@router.get("/china/{ts_code}/snapshot", response_model=TodaySnapshotResponse) +async def get_today_snapshot(ts_code: str): + """ + 获取“昨日快照”(以上一个自然日为基准,映射为不晚于该日的最近交易日)的市场数据: + - 日期(trade_date) + - 收盘价(close) + - 市值(total_mv,返回原始万元单位值) + - 估值(pe、pb) + - 股息率(dv_ratio,单位%) + """ + try: + # 优先取公司名称(可选) + company_name = None + try: + basic = await get_dm().get_stock_basic(stock_code=ts_code) + if basic: + company_name = basic.get("name") + except Exception: + company_name = None + + # 以“昨天”为查询日期,provider 内部会解析为“不晚于该日的最近交易日” + base_dt = (datetime.now() - timedelta(days=1)).date() + base_str = base_dt.strftime("%Y%m%d") + + # 从 daily_basic 取主要字段,包含 close、pe、pb、dv_ratio、total_mv + rows = await get_dm().get_data( + 'get_daily_basic_points', + stock_code=ts_code, + trade_dates=[base_str] + ) + row = None + if isinstance(rows, list) and rows: + # get_daily_basic_points 返回每个交易日一条记录 + row = rows[0] + + trade_date = None + close = None + pe = None + pb = None + dv_ratio = None + total_mv = None + + if isinstance(row, dict): + trade_date = str(row.get('trade_date') or row.get('trade_dt') or row.get('date') or base_str) + close = row.get('close') + pe = row.get('pe') + pb = row.get('pb') + dv_ratio = row.get('dv_ratio') + total_mv = row.get('total_mv') + + # 若 close 缺失,兜底从 daily 取收盘价 + if close is None: + d_rows = await get_dm().get_data('get_daily_points', stock_code=ts_code, trade_dates=[base_str]) + if isinstance(d_rows, list) and d_rows: + d = d_rows[0] + close = d.get('close') + if trade_date is None: + trade_date = str(d.get('trade_date') or d.get('trade_dt') or d.get('date') or base_str) + + if trade_date is None: + trade_date = base_str + + return TodaySnapshotResponse( + ts_code=ts_code, + trade_date=trade_date, + name=company_name, + close=close, + pe=pe, + pb=pb, + dv_ratio=dv_ratio, + total_mv=total_mv, + ) + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to fetch snapshot: {e}") + + @router.get("/china/{ts_code}/analysis/{analysis_type}/stream") async def stream_analysis( ts_code: str, diff --git a/backend/app/routers/orgs.py b/backend/app/routers/orgs.py new file mode 100644 index 0000000..252bfcc --- /dev/null +++ b/backend/app/routers/orgs.py @@ -0,0 +1,143 @@ +import logging +import os +import json +from typing import Dict +from fastapi import APIRouter, BackgroundTasks, HTTPException + +# Lazy loader for DataManager +_dm = None +def get_dm(): + global _dm + if _dm is not None: + return _dm + try: + from app.data_manager import data_manager as real_dm + _dm = real_dm + return _dm + except Exception: + # Return a stub if the real one fails to import + class _StubDM: + async def get_stock_basic(self, stock_code: str): return None + async def get_financial_statements(self, stock_code: str, report_dates): return [] + _dm = _StubDM() + return _dm + +from app.services.analysis_client import AnalysisClient, load_analysis_config + +router = APIRouter() +logger = logging.getLogger(__name__) + +# Constants for config paths +REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..")) +BASE_CONFIG_PATH = os.path.join(REPO_ROOT, "config", "config.json") + +def _load_json(path: str) -> Dict: + if not os.path.exists(path): + return {} + try: + with open(path, "r", encoding="utf-8") as f: + return json.load(f) + except Exception: + return {} + +async def run_full_analysis(org_id: str): + """ + Asynchronous task to run a full analysis for a given stock. + This function is market-agnostic and relies on DataManager. + """ + logger.info(f"Starting full analysis task for {org_id}") + + # 1. Load 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 key for {llm_provider} not configured. Aborting analysis for {org_id}.") + return + + analysis_config_full = load_analysis_config() + modules_config = analysis_config_full.get("analysis_modules", {}) + if not modules_config: + logger.error(f"Analysis modules configuration not found. Aborting analysis for {org_id}.") + return + + # 2. Fetch basic company info (name) + try: + basic_data = await get_dm().get_stock_basic(stock_code=org_id) + company_name = basic_data.get("name", org_id) if basic_data else org_id + logger.info(f"Got company name for {org_id}: {company_name}") + except Exception as e: + logger.warning(f"Failed to get company name for {org_id}. Using org_id as name. Error: {e}") + company_name = org_id + + # 3. Fetch financial data + financial_data = None + try: + # You might want to make the date range configurable + from datetime import datetime + current_year = datetime.now().year + report_dates = [f"{year}1231" for year in range(current_year - 5, current_year)] + + financial_statements = await get_dm().get_financial_statements(stock_code=org_id, report_dates=report_dates) + if financial_statements: + financial_data = {"series": financial_statements} + logger.info(f"Successfully fetched financial statements for {org_id}") + else: + logger.warning(f"Could not fetch financial statements for {org_id}") + except Exception as e: + logger.error(f"Error fetching financial data for {org_id}: {e}") + + # 4. Execute analysis modules in order (simplified, assumes no complex dependencies for now) + # Note: A full implementation would need the topological sort from the financial router. + analysis_results = {} + for module_type, module_config in modules_config.items(): + logger.info(f"Running analysis module: {module_type} for {org_id}") + client = AnalysisClient( + api_key=api_key, + base_url=base_url, + model=module_config.get("model", "gemini-1.5-flash") + ) + + # Simplified context: use results from all previously completed modules + context = analysis_results.copy() + + result = await client.generate_analysis( + analysis_type=module_type, + company_name=company_name, + ts_code=org_id, + prompt_template=module_config.get("prompt_template", ""), + financial_data=financial_data, + context=context, + ) + + if result.get("success"): + analysis_results[module_type] = result.get("content", "") + logger.info(f"Module {module_type} for {org_id} completed successfully.") + else: + logger.error(f"Module {module_type} for {org_id} failed: {result.get('error')}") + # Store error message to avoid breaking dependencies that might handle missing data + analysis_results[module_type] = f"Error: Analysis for {module_type} failed." + + # 5. Save the final report + # TODO: Implement database logic to save the `analysis_results` to the report record. + logger.info(f"Full analysis for {org_id} finished. Results: {json.dumps(analysis_results, indent=2, ensure_ascii=False)}") + + +@router.post("/{market}/{org_id}/reports/generate") +async def trigger_report_generation(market: str, org_id: str, background_tasks: BackgroundTasks): + """ + Triggers a background task to generate a full financial report. + This endpoint is now market-agnostic. + """ + logger.info(f"Received report generation request for {org_id} in {market} market.") + + # TODO: Create a report record in the database with "generating" status here. + + background_tasks.add_task(run_full_analysis, org_id) + + logger.info(f"Queued analysis task for {org_id}.") + return {"queued": True, "market": market, "org_id": org_id} diff --git a/backend/app/schemas/financial.py b/backend/app/schemas/financial.py index 6f08c48..44d4e4b 100644 --- a/backend/app/schemas/financial.py +++ b/backend/app/schemas/financial.py @@ -71,3 +71,14 @@ class AnalysisResponse(BaseModel): class AnalysisConfigResponse(BaseModel): analysis_modules: Dict[str, Dict] + + +class TodaySnapshotResponse(BaseModel): + ts_code: str + trade_date: str + name: Optional[str] = None + close: Optional[float] = None + pe: Optional[float] = None + pb: Optional[float] = None + dv_ratio: Optional[float] = None + total_mv: Optional[float] = None diff --git a/config/analysis-config.json b/config/analysis-config.json index dc886b2..402b68e 100644 --- a/config/analysis-config.json +++ b/config/analysis-config.json @@ -2,23 +2,23 @@ "analysis_modules": { "company_profile": { "name": "公司简介", - "model": "qwen-flash", + "model": "qwen-flash-2025-07-28", "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": "qwen-flash", + "model": "qwen-flash-2025-07-28", "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": "qwen-flash", + "model": "qwen-flash-2025-07-28", "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": "qwen-flash", + "model": "qwen-flash-2025-07-28", "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" }, @@ -29,22 +29,22 @@ }, "news_analysis": { "name": "新闻分析", - "model": "qwen-flash", + "model": "qwen-flash-2025-07-28", "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": "qwen-flash", + "model": "qwen-flash-2025-07-28", "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": "qwen-flash", + "model": "qwen-flash-2025-07-28", "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": "qwen-flash", + "model": "qwen-flash-2025-07-28", "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", diff --git a/config/data_sources.yaml b/config/data_sources.yaml index 80506f5..abaa2ed 100644 --- a/config/data_sources.yaml +++ b/config/data_sources.yaml @@ -22,8 +22,8 @@ markets: - yfinance # yfinance can be a fallback US: # US Market priority: - - yfinance - finnhub + - yfinance HK: # Hong Kong Market priority: - yfinance diff --git a/docs/user-guide.md b/docs/user-guide.md index 2a178e4..35ec794 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -270,3 +270,5 @@ A: + + diff --git a/frontend/src/app/fonts/README.md b/frontend/src/app/fonts/README.md index 2963249..5c00bab 100644 --- a/frontend/src/app/fonts/README.md +++ b/frontend/src/app/fonts/README.md @@ -16,3 +16,5 @@ + + diff --git a/frontend/src/app/report/[symbol]/page.tsx b/frontend/src/app/report/[symbol]/page.tsx index c21fa7d..7d28f39 100644 --- a/frontend/src/app/report/[symbol]/page.tsx +++ b/frontend/src/app/report/[symbol]/page.tsx @@ -1,7 +1,7 @@ 'use client'; import { useParams, useSearchParams } from 'next/navigation'; -import { useChinaFinancials, useFinancialConfig, useAnalysisConfig, generateFullAnalysis } from '@/hooks/useApi'; +import { useChinaFinancials, useFinancialConfig, useAnalysisConfig, generateFullAnalysis, useChinaSnapshot } from '@/hooks/useApi'; import { Spinner } from '@/components/ui/spinner'; import { Button } from '@/components/ui/button'; import { CheckCircle, XCircle, RotateCw } from 'lucide-react'; @@ -22,6 +22,8 @@ export default function ReportPage() { const symbol = params.symbol as string; const market = (searchParams.get('market') || '').toLowerCase(); + const displayMarket = market === 'china' ? '中国' : market; + const isChina = market === 'china' || market === 'cn'; // 规范化中国市场 ts_code:若为6位数字或无后缀,自动推断交易所 @@ -39,6 +41,7 @@ export default function ReportPage() { })(); const { data: financials, error, isLoading } = useChinaFinancials(isChina ? normalizedTsCode : undefined, 10); + const { data: snapshot, error: snapshotError, isLoading: snapshotLoading } = useChinaSnapshot(isChina ? normalizedTsCode : undefined); const { data: financialConfig } = useFinancialConfig(); const { data: analysisConfig } = useAnalysisConfig(); @@ -271,6 +274,21 @@ export default function ReportPage() { return text; }; }, []); + + const removeTitleFromContent = useMemo(() => { + return (content: string, title: string): string => { + if (!content || !title) { + return content; + } + const lines = content.split('\n'); + // Trim and remove markdown from first line + const firstLine = (lines[0] || '').trim().replace(/^(#+\s*|\*\*|__)/, '').replace(/(\*\*|__)$/, '').trim(); + if (firstLine === title) { + return lines.slice(1).join('\n').trim(); + } + return content; + }; + }, []); const hasRunningTask = useMemo(() => { if (currentAnalysisTask !== null) return true; @@ -589,7 +607,7 @@ export default function ReportPage() {
交易市场: - {market} + {displayMarket}
公司名称: @@ -609,6 +627,94 @@ export default function ReportPage() { + {isChina && ( + + + 昨日快照 + + +
+
+ 日期: + + {snapshotLoading ? ( + 加载中... + ) : snapshot?.trade_date ? ( + `${snapshot.trade_date.slice(0,4)}-${snapshot.trade_date.slice(4,6)}-${snapshot.trade_date.slice(6,8)}` + ) : ( + - + )} + +
+ +
+ PB: + + {snapshotLoading ? ( + - + ) : snapshot?.pb != null ? ( + `${Number(snapshot.pb).toFixed(2)}` + ) : ( + - + )} + +
+ +
+ 股价: + + {snapshotLoading ? ( + - + ) : snapshot?.close != null ? ( + `${Number(snapshot.close).toFixed(2)}` + ) : ( + - + )} + +
+ +
+ PE: + + {snapshotLoading ? ( + - + ) : snapshot?.pe != null ? ( + `${Number(snapshot.pe).toFixed(2)}` + ) : ( + - + )} + +
+ +
+ 市值: + + {snapshotLoading ? ( + - + ) : snapshot?.total_mv != null ? ( + `${Math.round((snapshot.total_mv as number) / 10000).toLocaleString('zh-CN')} 亿元` + ) : ( + - + )} + +
+ +
+ 股息率: + + {snapshotLoading ? ( + - + ) : snapshot?.dv_ratio != null ? ( + `${Number(snapshot.dv_ratio).toFixed(2)}%` + ) : ( + - + )} + +
+
+
+
+ )} {isChina && ( @@ -740,9 +846,27 @@ export default function ReportPage() { if (p.year) return `${p.year}1231`; return null; }; + + const displayedKeys = [ + 'roe', 'roa', 'roic', 'grossprofit_margin', 'netprofit_margin', 'revenue', 'tr_yoy', 'n_income', + 'dt_netprofit_yoy', 'n_cashflow_act', 'c_pay_acq_const_fiolta', '__free_cash_flow', + 'dividend_amount', 'repurchase_amount', 'total_assets', 'total_hldr_eqy_exc_min_int', 'goodwill', + '__sell_rate', '__admin_rate', '__rd_rate', '__other_fee_rate', '__tax_rate', '__depr_ratio', + '__money_cap_ratio', '__inventories_ratio', '__ar_ratio', '__prepay_ratio', '__fix_assets_ratio', + '__lt_invest_ratio', '__goodwill_ratio', '__other_assets_ratio', '__ap_ratio', '__adv_ratio', + '__st_borr_ratio', '__lt_borr_ratio', '__operating_assets_ratio', '__interest_bearing_debt_ratio', + 'invturn_days', 'arturn_days', 'payturn_days', 'fa_turn', 'assets_turn', + 'employees', '__rev_per_emp', '__profit_per_emp', '__salary_per_emp', + 'close', 'total_mv', 'pe', 'pb', 'holder_num' + ]; + + const displayedSeries = Object.entries(series) + .filter(([key]) => displayedKeys.includes(key)) + .map(([, value]) => value); + const allPeriods = Array.from( new Set( - (Object.values(series).flat() as any[]) + (displayedSeries.flat() as any[]) .map((p) => toPeriod(p)) .filter((v): v is string => Boolean(v)) ) @@ -751,7 +875,7 @@ export default function ReportPage() { if (allPeriods.length === 0) { return

暂无可展示的数据

; } - const periods = allPeriods; + const periods = allPeriods.slice(0, 10); const getValueByPeriod = (points: any[] | undefined, period: string): number | null => { if (!points) return null; @@ -835,9 +959,40 @@ export default function ReportPage() { const isGrowthRow = key === 'tr_yoy' || key === 'dt_netprofit_yoy'; if (isGrowthRow) { const isNeg = typeof perc === 'number' && perc < 0; + const isHighGrowth = typeof perc === 'number' && perc > 30; + + let content = `${text}%`; + if (key === 'dt_netprofit_yoy' && typeof perc === 'number' && perc > 1000) { + content = `${(perc / 100).toFixed(1)}x`; + } + + let tableCellClassName = 'text-right p-2'; + let spanClassName = 'italic'; + + if (isNeg) { + tableCellClassName += ' bg-red-100'; + spanClassName += ' text-red-600'; + } else if (isHighGrowth) { + tableCellClassName += ' bg-green-100'; + spanClassName += ' text-green-800 font-bold'; + } else { + spanClassName += ' text-blue-600'; + } + return ( - - {text}% + + {content} + + ); + } + const isHighlighted = (key === 'roe' && typeof perc === 'number' && perc > 12.5) || + (key === 'grossprofit_margin' && typeof perc === 'number' && perc > 35) || + (key === 'netprofit_margin' && typeof perc === 'number' && perc > 15); + + if (isHighlighted) { + return ( + + {`${text}%`} ); } @@ -926,9 +1081,18 @@ export default function ReportPage() { } const text = numberFormatter.format(value); const isNegative = value < 0; + const isHighRatio = value > 30; + + let cellClassName = "text-right p-2"; + if (isHighRatio) { + cellClassName += " bg-red-100"; + } else if (isNegative) { + cellClassName += " bg-red-100"; + } + return ( - - {isNegative ? {text}% : `${text}%`} + + {isNegative ? {text}% : `${text}%`} ); }; @@ -1147,7 +1311,7 @@ export default function ReportPage() { ), // 股东户数 ( - + 股东户数 {periods.map((p) => { const points = series['holder_num'] as any[] | undefined; @@ -1174,10 +1338,11 @@ export default function ReportPage() { {/* 动态生成各个分析的TabsContent */} {analysisTypes.map(analysisType => { const state = analysisStates[analysisType] || { content: '', loading: false, error: null }; - const normalizedContent = normalizeMarkdown(state.content); const analysisName = analysisType === 'company_profile' ? '公司简介' : (analysisConfig?.analysis_modules[analysisType]?.name || analysisType); + const contentWithoutTitle = removeTitleFromContent(state.content, analysisName); + const normalizedContent = normalizeMarkdown(contentWithoutTitle); const modelName = analysisConfig?.analysis_modules[analysisType]?.model; return ( diff --git a/frontend/src/app/reports/[id]/page.tsx b/frontend/src/app/reports/[id]/page.tsx index 8cb56f6..9ca1d7f 100644 --- a/frontend/src/app/reports/[id]/page.tsx +++ b/frontend/src/app/reports/[id]/page.tsx @@ -162,7 +162,7 @@ export default async function ReportDetailPage({ params }: { params: Promise<{ i c_pay_acq_const_fiolta: 'cashflow', } - if (years.length === 0) { + if (periods.length === 0) { return (
暂无保存的财务数据。下次保存报告时会一并保存财务数据。 @@ -250,7 +250,7 @@ export default async function ReportDetailPage({ params }: { params: Promise<{ i if (isGrowthRow) { const isNeg = typeof perc === 'number' && perc < 0 return ( - + {text}% ) @@ -258,10 +258,10 @@ export default async function ReportDetailPage({ params }: { params: Promise<{ i if (key === 'roe' || key === 'roic') { const highlight = typeof perc === 'number' && perc > 12 return ( - {`${text}%`} + {`${text}%`} ) } - return {`${text}%`} + return {`${text}%`} } else { const isFinGroup = groupName === 'income' || groupName === 'balancesheet' || groupName === 'cashflow' const scaled = key === 'total_mv' ? rawNum / 10000 : (isFinGroup || isComputed ? rawNum / 1e8 : rawNum) @@ -270,10 +270,10 @@ export default async function ReportDetailPage({ params }: { params: Promise<{ i if (key === '__free_cash_flow') { const isNeg = typeof scaled === 'number' && scaled < 0 return ( - {isNeg ? {text} : text} + {isNeg ? {text} : text} ) } - return {text} + return {text} } })} @@ -333,8 +333,8 @@ export default async function ReportDetailPage({ params }: { params: Promise<{ i rate = gp - np - sellRate - adminRate - rdRate } } else { - const numerator = getVal(num, y) - const denominator = getVal(den, y) + const numerator = getVal(num, p) + const denominator = getVal(den, p) if (numerator == null || denominator == null || denominator === 0) { rate = null } else { @@ -342,12 +342,12 @@ export default async function ReportDetailPage({ params }: { params: Promise<{ i } } if (rate == null || !Number.isFinite(rate)) { - return - + return - } const rateText = numberFormatter.format(rate) const isNegative = rate < 0 return ( - + {isNegative ? {rateText}% : `${rateText}%`} ) @@ -377,101 +377,101 @@ export default async function ReportDetailPage({ params }: { params: Promise<{ i ) } const assetRows = [ - { key: '__money_cap_ratio', label: '现金占比', calc: (y: string) => { - const num = getVal(series['money_cap'] as any, y) - const den = getVal(series['total_assets'] as any, y) + { key: '__money_cap_ratio', label: '现金占比', calc: (p: string) => { + const num = getVal(series['money_cap'] as any, p) + const den = getVal(series['total_assets'] as any, p) return num == null || den == null || den === 0 ? null : (num / den) * 100 } }, - { key: '__inventories_ratio', label: '库存占比', calc: (y: string) => { - const num = getVal(series['inventories'] as any, y) - const den = getVal(series['total_assets'] as any, y) + { key: '__inventories_ratio', label: '库存占比', calc: (p: string) => { + const num = getVal(series['inventories'] as any, p) + const den = getVal(series['total_assets'] as any, p) return num == null || den == null || den === 0 ? null : (num / den) * 100 } }, - { key: '__ar_ratio', label: '应收款占比', calc: (y: string) => { - const num = getVal(series['accounts_receiv_bill'] as any, y) - const den = getVal(series['total_assets'] as any, y) + { key: '__ar_ratio', label: '应收款占比', calc: (p: string) => { + const num = getVal(series['accounts_receiv_bill'] as any, p) + const den = getVal(series['total_assets'] as any, p) return num == null || den == null || den === 0 ? null : (num / den) * 100 } }, - { key: '__prepay_ratio', label: '预付款占比', calc: (y: string) => { - const num = getVal(series['prepayment'] as any, y) - const den = getVal(series['total_assets'] as any, y) + { key: '__prepay_ratio', label: '预付款占比', calc: (p: string) => { + const num = getVal(series['prepayment'] as any, p) + const den = getVal(series['total_assets'] as any, p) return num == null || den == null || den === 0 ? null : (num / den) * 100 } }, - { key: '__fix_assets_ratio', label: '固定资产占比', calc: (y: string) => { - const num = getVal(series['fix_assets'] as any, y) - const den = getVal(series['total_assets'] as any, y) + { key: '__fix_assets_ratio', label: '固定资产占比', calc: (p: string) => { + const num = getVal(series['fix_assets'] as any, p) + const den = getVal(series['total_assets'] as any, p) return num == null || den == null || den === 0 ? null : (num / den) * 100 } }, - { key: '__lt_invest_ratio', label: '长期投资占比', calc: (y: string) => { - const num = getVal(series['lt_eqt_invest'] as any, y) - const den = getVal(series['total_assets'] as any, y) + { key: '__lt_invest_ratio', label: '长期投资占比', calc: (p: string) => { + const num = getVal(series['lt_eqt_invest'] as any, p) + const den = getVal(series['total_assets'] as any, p) return num == null || den == null || den === 0 ? null : (num / den) * 100 } }, - { key: '__goodwill_ratio', label: '商誉占比', calc: (y: string) => { - const num = getVal(series['goodwill'] as any, y) - const den = getVal(series['total_assets'] as any, y) + { key: '__goodwill_ratio', label: '商誉占比', calc: (p: string) => { + const num = getVal(series['goodwill'] as any, p) + const den = getVal(series['total_assets'] as any, p) return num == null || den == null || den === 0 ? null : (num / den) * 100 } }, - { key: '__other_assets_ratio', label: '其他资产占比', calc: (y: string) => { - const total = getVal(series['total_assets'] as any, y) + { key: '__other_assets_ratio', label: '其他资产占比', calc: (p: string) => { + const total = getVal(series['total_assets'] as any, p) if (total == null || total === 0) return null const parts = [ - getVal(series['money_cap'] as any, y) || 0, - getVal(series['inventories'] as any, y) || 0, - getVal(series['accounts_receiv_bill'] as any, y) || 0, - getVal(series['prepayment'] as any, y) || 0, - getVal(series['fix_assets'] as any, y) || 0, - getVal(series['lt_eqt_invest'] as any, y) || 0, - getVal(series['goodwill'] as any, y) || 0, + getVal(series['money_cap'] as any, p) || 0, + getVal(series['inventories'] as any, p) || 0, + getVal(series['accounts_receiv_bill'] as any, p) || 0, + getVal(series['prepayment'] as any, p) || 0, + getVal(series['fix_assets'] as any, p) || 0, + getVal(series['lt_eqt_invest'] as any, p) || 0, + getVal(series['goodwill'] as any, p) || 0, ] const sumKnown = parts.reduce((acc: number, v: number) => acc + v, 0) return ((total - sumKnown) / total) * 100 } }, - { key: '__ap_ratio', label: '应付款占比', calc: (y: string) => { - const num = getVal(series['accounts_pay'] as any, y) - const den = getVal(series['total_assets'] as any, y) + { key: '__ap_ratio', label: '应付款占比', calc: (p: string) => { + const num = getVal(series['accounts_pay'] as any, p) + const den = getVal(series['total_assets'] as any, p) return num == null || den == null || den === 0 ? null : (num / den) * 100 } }, - { key: '__adv_ratio', label: '预收款占比', calc: (y: string) => { - const adv = getVal(series['adv_receipts'] as any, y) || 0 - const contractLiab = getVal(series['contract_liab'] as any, y) || 0 + { key: '__adv_ratio', label: '预收款占比', calc: (p: string) => { + const adv = getVal(series['adv_receipts'] as any, p) || 0 + const contractLiab = getVal(series['contract_liab'] as any, p) || 0 const num = adv + contractLiab - const den = getVal(series['total_assets'] as any, y) + const den = getVal(series['total_assets'] as any, p) return den == null || den === 0 ? null : (num / den) * 100 } }, - { key: '__st_borr_ratio', label: '短期借款占比', calc: (y: string) => { - const num = getVal(series['st_borr'] as any, y) - const den = getVal(series['total_assets'] as any, y) + { key: '__st_borr_ratio', label: '短期借款占比', calc: (p: string) => { + const num = getVal(series['st_borr'] as any, p) + const den = getVal(series['total_assets'] as any, p) return num == null || den == null || den === 0 ? null : (num / den) * 100 } }, - { key: '__lt_borr_ratio', label: '长期借款占比', calc: (y: string) => { - const num = getVal(series['lt_borr'] as any, y) - const den = getVal(series['total_assets'] as any, y) + { key: '__lt_borr_ratio', label: '长期借款占比', calc: (p: string) => { + const num = getVal(series['lt_borr'] as any, p) + const den = getVal(series['total_assets'] as any, p) return num == null || den == null || den === 0 ? null : (num / den) * 100 } }, - { key: '__interest_bearing_debt_ratio', label: '有息负债率', calc: (y: string) => { - const total = getVal(series['total_assets'] as any, y) + { key: '__interest_bearing_debt_ratio', label: '有息负债率', calc: (p: string) => { + const total = getVal(series['total_assets'] as any, p) if (total == null || total === 0) return null - const st = getVal(series['st_borr'] as any, y) || 0 - const lt = getVal(series['lt_borr'] as any, y) || 0 + const st = getVal(series['st_borr'] as any, p) || 0 + const lt = getVal(series['lt_borr'] as any, p) || 0 return ((st + lt) / total) * 100 } }, - { key: '__operating_assets_ratio', label: '运营资产占比', calc: (y: string) => { - const total = getVal(series['total_assets'] as any, y) + { key: '__operating_assets_ratio', label: '运营资产占比', calc: (p: string) => { + const total = getVal(series['total_assets'] as any, p) if (total == null || total === 0) return null - const inv = getVal(series['inventories'] as any, y) || 0 - const ar = getVal(series['accounts_receiv_bill'] as any, y) || 0 - const pre = getVal(series['prepayment'] as any, y) || 0 - const ap = getVal(series['accounts_pay'] as any, y) || 0 - const adv = getVal(series['adv_receipts'] as any, y) || 0 - const contractLiab = getVal(series['contract_liab'] as any, y) || 0 + const inv = getVal(series['inventories'] as any, p) || 0 + const ar = getVal(series['accounts_receiv_bill'] as any, p) || 0 + const pre = getVal(series['prepayment'] as any, p) || 0 + const ap = getVal(series['accounts_pay'] as any, p) || 0 + const adv = getVal(series['adv_receipts'] as any, p) || 0 + const contractLiab = getVal(series['contract_liab'] as any, p) || 0 const operating = inv + ar + pre - ap - adv - contractLiab return (operating / total) * 100 } }, ].map(({ key, label, calc }) => ( {label} - {years.map((y) => ratioCell(calc(y), y))} + {periods.map((p) => ratioCell(calc(p), p))} )) @@ -570,54 +570,54 @@ export default async function ReportDetailPage({ params }: { params: Promise<{ i const employeesRow = ( 员工人数 - {years.map((y) => { - const v = getVal(series['employees'] as any, y) + {periods.map((p) => { + const v = getVal(series['employees'] as any, p) if (v == null || !Number.isFinite(v)) { - return - + return - } - return {integerFormatter.format(Math.round(v))} + return {integerFormatter.format(Math.round(v))} })} ) const revPerEmpRow = ( 人均创收(万元) - {years.map((y) => { - const rev = getVal(series['revenue'] as any, y) - const emp = getVal(series['employees'] as any, y) + {periods.map((p) => { + const rev = getVal(series['revenue'] as any, p) + const emp = getVal(series['employees'] as any, p) if (rev == null || emp == null || emp === 0) { - return - + return - } const val = (rev / emp) / 10000 - return {numberFormatter.format(val)} + return {numberFormatter.format(val)} })} ) const profitPerEmpRow = ( 人均创利(万元) - {years.map((y) => { - const prof = getVal(series['n_income'] as any, y) - const emp = getVal(series['employees'] as any, y) + {periods.map((p) => { + const prof = getVal(series['n_income'] as any, p) + const emp = getVal(series['employees'] as any, p) if (prof == null || emp == null || emp === 0) { - return - + return - } const val = (prof / emp) / 10000 - return {numberFormatter.format(val)} + return {numberFormatter.format(val)} })} ) const salaryPerEmpRow = ( 人均工资(万元) - {years.map((y) => { - const salaryPaid = getVal(series['c_paid_to_for_empl'] as any, y) - const emp = getVal(series['employees'] as any, y) + {periods.map((p) => { + const salaryPaid = getVal(series['c_paid_to_for_empl'] as any, p) + const emp = getVal(series['employees'] as any, p) if (salaryPaid == null || emp == null || emp === 0) { - return - + return - } const val = (salaryPaid / emp) / 10000 - return {numberFormatter.format(val)} + return {numberFormatter.format(val)} })} ) diff --git a/frontend/src/hooks/useApi.ts b/frontend/src/hooks/useApi.ts index 6dfcf3a..b9bd0e3 100644 --- a/frontend/src/hooks/useApi.ts +++ b/frontend/src/hooks/useApi.ts @@ -1,6 +1,6 @@ import useSWR from 'swr'; import { useConfigStore } from '@/stores/useConfigStore'; -import { BatchFinancialDataResponse, FinancialConfigResponse, AnalysisConfigResponse } from '@/types'; +import { BatchFinancialDataResponse, FinancialConfigResponse, AnalysisConfigResponse, TodaySnapshotResponse } from '@/types'; const fetcher = async (url: string) => { const res = await fetch(url); @@ -111,3 +111,16 @@ export async function generateFullAnalysis(tsCode: string, companyName: string) throw new Error('Invalid JSON response from server.'); } } + +export function useChinaSnapshot(ts_code?: string) { + return useSWR( + ts_code ? `/api/financials/china/${encodeURIComponent(ts_code)}/snapshot` : null, + fetcher, + { + revalidateOnFocus: false, + revalidateOnReconnect: false, + dedupingInterval: 120000, // 2分钟 + errorRetryCount: 1, + } + ); +} diff --git a/frontend/src/lib/prisma.ts b/frontend/src/lib/prisma.ts index 0affb38..149adb5 100644 --- a/frontend/src/lib/prisma.ts +++ b/frontend/src/lib/prisma.ts @@ -38,3 +38,5 @@ if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma + + diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 2761b3d..2c3bd62 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -195,6 +195,20 @@ export interface AnalysisConfigResponse { }>; } +/** + * 今日快照响应接口 + */ +export interface TodaySnapshotResponse { + ts_code: string; + trade_date: string; // YYYYMMDD + name?: string; + close?: number | null; + pe?: number | null; + pb?: number | null; + dv_ratio?: number | null; // % + total_mv?: number | null; // 万元 +} + // ============================================================================ // 表格相关类型 // ============================================================================ diff --git a/scripts/dev.sh b/scripts/dev.sh index 9996284..2dc3d3f 100755 --- a/scripts/dev.sh +++ b/scripts/dev.sh @@ -23,7 +23,9 @@ FRONTEND_PORT=3001 # Kill process using specified port kill_port() { local port=$1 + echo -e "${YELLOW}[DEBUG]${RESET} Checking port $port..." local pids=$(lsof -nP -ti tcp:"$port" 2>/dev/null || true) + echo -e "${YELLOW}[DEBUG]${RESET} Done checking port $port. PIDs: '$pids'" if [[ -n "$pids" ]]; then echo -e "${YELLOW}[CLEANUP]${RESET} Killing process(es) using port $port: $pids" echo "$pids" | xargs kill -9 2>/dev/null || true