From 3475138419356659ead37c743752939fa033570a Mon Sep 17 00:00:00 2001 From: xucheng Date: Tue, 4 Nov 2025 21:22:32 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E6=95=B0=E6=8D=AE):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E5=91=98=E5=B7=A5=E3=80=81=E8=82=A1=E4=B8=9C=E5=8F=8A=E7=A8=8E?= =?UTF-8?q?=E5=8A=A1=E6=8C=87=E6=A0=87=E5=B9=B6=E7=94=9F=E6=88=90=E6=97=A5?= =?UTF-8?q?=E5=BF=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端: Tushare provider 新增 get_employee_number, get_holder_number, get_tax_to_ebt 方法,并在 financial 路由中集成。 - 前端: report 页面新增对应图表展示,并更新相关类型与工具函数。 - 清理: 移除多个过时的测试脚本。 - 文档: 创建 2025-11-04 开发日志并更新用户手册。 --- backend/app/data_manager.py | 29 +- backend/app/data_providers/tushare.py | 708 ++++++++++++++++------ backend/app/routers/financial.py | 83 ++- backend/app/schemas/financial.py | 7 +- docs/user-guide.md | 2 + frontend/package.json | 2 +- frontend/src/app/fonts/README.md | 2 + frontend/src/app/report/[symbol]/page.tsx | 221 +++---- frontend/src/app/reports/[id]/page.tsx | 162 +++-- frontend/src/lib/financial-utils.ts | 23 +- frontend/src/lib/prisma.ts | 2 + frontend/src/types/index.ts | 12 +- scripts/test-api-tax-to-ebt.py | 56 -- scripts/test-config.py | 122 ---- scripts/test-employees.py | 82 --- scripts/test-holder-number.py | 104 ---- scripts/test-holder-processing.py | 115 ---- scripts/test-tax-to-ebt.py | 110 ---- scripts/tushare_legacy_client.py | 41 -- 19 files changed, 858 insertions(+), 1025 deletions(-) delete mode 100644 scripts/test-api-tax-to-ebt.py delete mode 100644 scripts/test-config.py delete mode 100755 scripts/test-employees.py delete mode 100755 scripts/test-holder-number.py delete mode 100755 scripts/test-holder-processing.py delete mode 100644 scripts/test-tax-to-ebt.py delete mode 100644 scripts/tushare_legacy_client.py diff --git a/backend/app/data_manager.py b/backend/app/data_manager.py index eaa6a87..6664daf 100644 --- a/backend/app/data_manager.py +++ b/backend/app/data_manager.py @@ -152,8 +152,33 @@ class DataManager: logger.error(f"All data providers failed for '{stock_code}' on method '{method_name}'.") return None - async def get_financial_statements(self, stock_code: str, report_dates: List[str]) -> List[Dict[str, Any]]: - return await self.get_data('get_financial_statements', stock_code, report_dates=report_dates) + async def get_financial_statements(self, stock_code: str, report_dates: List[str]) -> Dict[str, List[Dict[str, Any]]]: + data = await self.get_data('get_financial_statements', stock_code, report_dates=report_dates) + if data is None: + return {} + + # Normalize to series format + if isinstance(data, dict): + # Already in series format (e.g., tushare) + return data + elif isinstance(data, list): + # Convert from flat format to series format + series: Dict[str, List[Dict[str, Any]]] = {} + for report in data: + year = str(report.get('year', report.get('end_date', '')[:4])) + if not year: + continue + for key, value in report.items(): + if key in ['ts_code', 'stock_code', 'year', 'end_date', 'period', 'ann_date', 'f_ann_date', 'report_type']: + continue + if isinstance(value, (int, float)) and value is not None: + if key not in series: + series[key] = [] + if not any(d['year'] == year for d in series[key]): + series[key].append({"year": year, "value": value}) + return series + else: + return {} async def get_daily_price(self, stock_code: str, start_date: str, end_date: str) -> List[Dict[str, Any]]: return await self.get_data('get_daily_price', stock_code, start_date=start_date, end_date=end_date) diff --git a/backend/app/data_providers/tushare.py b/backend/app/data_providers/tushare.py index 49644f7..3de2111 100644 --- a/backend/app/data_providers/tushare.py +++ b/backend/app/data_providers/tushare.py @@ -1,65 +1,166 @@ from .base import BaseDataProvider -from typing import Any, Dict, List, Optional -import httpx +from typing import Any, Dict, List, Optional, Callable import logging import asyncio +import tushare as ts +import math +import datetime logger = logging.getLogger(__name__) -TUSHARE_PRO_URL = "https://api.tushare.pro" - class TushareProvider(BaseDataProvider): def _initialize(self): if not self.token: raise ValueError("Tushare API token not provided.") - # Use httpx.AsyncClient directly - self._client = httpx.AsyncClient(timeout=30) + # 使用官方 SDK 客户端 + self._pro = ts.pro_api(self.token) + # 交易日历缓存:key=(exchange, start, end) -> List[Dict] + self._trade_cal_cache: Dict[str, List[Dict[str, Any]]] = {} + + async def _resolve_trade_dates(self, dates: List[str], exchange: str = "SSE") -> Dict[str, str]: + """ + 将任意日期映射为“该日若非交易日,则取不晚于该日的最近一个交易日”。 + 返回映射:requested_date -> resolved_trade_date。 + """ + if not dates: + return {} + start_date = min(dates) + end_date = max(dates) + cache_key = f"{exchange}:{start_date}:{end_date}" + + if cache_key in self._trade_cal_cache: + cal_rows = self._trade_cal_cache[cache_key] + else: + cal_rows = await self._query( + api_name="trade_cal", + params={ + "exchange": exchange, + "start_date": start_date, + "end_date": end_date, + }, + fields=["cal_date", "is_open", "pretrade_date"], + ) + self._trade_cal_cache[cache_key] = cal_rows + + by_date: Dict[str, Dict[str, Any]] = {str(r.get("cal_date")): r for r in cal_rows} + # 同时准备已开放的交易日期序列,便于兜底搜索 + open_dates = sorted([d for d, r in by_date.items() if int(r.get("is_open", 0)) == 1]) + + def _prev_open(d: str) -> Optional[str]: + # 找到 <= d 的最大开市日 + lo, hi = 0, len(open_dates) - 1 + ans = None + while lo <= hi: + mid = (lo + hi) // 2 + if open_dates[mid] <= d: + ans = open_dates[mid] + lo = mid + 1 + else: + hi = mid - 1 + return ans + + resolved: Dict[str, str] = {} + for d in dates: + row = by_date.get(d) + if row is None: + # 不在本段日历(极少数情况),做一次兜底:使用区间内最近开市日 + prev_d = _prev_open(d) + if prev_d: + resolved[d] = prev_d + else: + # 最后兜底,仍找不到则原样返回 + resolved[d] = d + continue + is_open = int(row.get("is_open", 0)) + if is_open == 1: + resolved[d] = d + else: + prev = str(row.get("pretrade_date") or "") + if prev: + resolved[d] = prev + else: + prev_d = _prev_open(d) + resolved[d] = prev_d or d + return resolved async def _query( self, api_name: str, params: Optional[Dict[str, Any]] = None, - fields: Optional[str] = None, + fields: Optional[List[str]] = None, ) -> List[Dict[str, Any]]: - payload = { - "api_name": api_name, - "token": self.token, - "params": params or {}, - } - if "limit" not in payload["params"]: - payload["params"]["limit"] = 5000 - if fields: - payload["fields"] = fields + """ + 使用官方 tushare SDK 统一查询,返回字典列表。 + 为避免阻塞事件循环,内部通过 asyncio.to_thread 在线程中执行同步调用。 + """ + params = params or {} - logger.info(f"Querying Tushare API '{api_name}' with params: {params}") + def _call() -> List[Dict[str, Any]]: + # 将字段列表转换为逗号分隔的字符串(SDK 推荐方式) + fields_arg: Optional[str] = ",".join(fields) if isinstance(fields, list) else None + + # 优先使用属性方式(pro.fina_indicator 等);若不存在则回退到通用 query + func: Optional[Callable] = getattr(self._pro, api_name, None) + try: + if callable(func): + df = func(**params, fields=fields_arg) if fields_arg else func(**params) + else: + # 通用回退:pro.query(name, params=..., fields=...) + if fields_arg: + df = self._pro.query(api_name, params=params, fields=fields_arg) + else: + df = self._pro.query(api_name, params=params) + except Exception as exc: + # 将 SDK 抛出的异常包装为统一日志 + raise RuntimeError(f"tushare.{api_name} failed: {exc}") + + if df is None or df.empty: + return [] + # DataFrame -> List[Dict] + return df.to_dict(orient="records") try: - resp = await self._client.post(TUSHARE_PRO_URL, json=payload) - resp.raise_for_status() - data = resp.json() - - if data.get("code") != 0: - err_msg = data.get("msg") or "Unknown Tushare error" - logger.error(f"Tushare API error for '{api_name}': {err_msg}") - raise RuntimeError(f"{api_name}: {err_msg}") - - fields_def = data.get("data", {}).get("fields", []) - items = data.get("data", {}).get("items", []) - - rows: List[Dict[str, Any]] = [] - for it in items: - row = {fields_def[i]: it[i] for i in range(len(fields_def))} - rows.append(row) - - logger.info(f"Tushare API '{api_name}' returned {len(rows)} rows.") - return rows + rows: List[Dict[str, Any]] = await asyncio.to_thread(_call) + # 清洗 NaN/Inf,避免 JSON 序列化错误 + DATE_KEYS = { + "cal_date", "pretrade_date", "trade_date", "trade_dt", "date", + "end_date", "ann_date", "f_ann_date", "period" + } - except httpx.HTTPStatusError as e: - logger.error(f"HTTP error calling Tushare API '{api_name}': {e.response.status_code} - {e.response.text}") - raise + def _sanitize_value(key: str, v: Any) -> Any: + if v is None: + return None + # 保持日期/期末字段为字符串(避免 20231231 -> 20231231.0 导致匹配失败) + if key in DATE_KEYS: + try: + s = str(v) + # 去除意外的小数点形式 + if s.endswith(".0"): + s = s[:-2] + return s + except Exception: + return str(v) + try: + # 处理 numpy.nan / numpy.inf / Decimal / numpy 数值等,统一为 Python float + fv = float(v) + return fv if math.isfinite(fv) else None + except Exception: + # 利用自反性判断 NaN(NaN != NaN) + try: + if v != v: + return None + except Exception: + pass + return v + + for row in rows: + for k, v in list(row.items()): + row[k] = _sanitize_value(k, v) + # logger.info(f"Tushare '{api_name}' returned {len(rows)} rows.") + return rows except Exception as e: - logger.error(f"Exception calling Tushare API '{api_name}': {e}") + logger.error(f"Exception calling tushare '{api_name}': {e}") raise async def get_stock_basic(self, stock_code: str) -> Optional[Dict[str, Any]]: @@ -75,7 +176,7 @@ class TushareProvider(BaseDataProvider): async def get_daily_price(self, stock_code: str, start_date: str, end_date: str) -> List[Dict[str, Any]]: try: - return await self._query( + rows = await self._query( api_name="daily", params={ "ts_code": stock_code, @@ -83,6 +184,7 @@ class TushareProvider(BaseDataProvider): "end_date": end_date, }, ) + return rows or [] except Exception as e: logger.error(f"Tushare get_daily_price failed for {stock_code}: {e}") return [] @@ -92,18 +194,25 @@ class TushareProvider(BaseDataProvider): 获取指定交易日列表的 daily_basic 数据(例如 total_mv、pe、pb)。 """ try: - tasks = [ - self._query( - api_name="daily_basic", - params={"ts_code": stock_code, "trade_date": d}, - ) for d in trade_dates - ] - results = await asyncio.gather(*tasks, return_exceptions=True) - rows: List[Dict[str, Any]] = [] - for res in results: - if isinstance(res, list) and res: - rows.extend(res) - logger.info(f"Tushare daily_basic returned {len(rows)} rows for {stock_code} on {len(trade_dates)} dates") + if not trade_dates: + return [] + # 将请求日期映射到不晚于该日的最近交易日 + mapping = await self._resolve_trade_dates(trade_dates, exchange="SSE") + resolved_dates = list(set(mapping.values())) + start_date = min(resolved_dates) + end_date = max(resolved_dates) + # 一次性取区间内数据,再按解析后的交易日过滤 + all_rows = await self._query( + api_name="daily_basic", + params={ + "ts_code": stock_code, + "start_date": start_date, + "end_date": end_date, + }, + ) + wanted = set(resolved_dates) + rows = [r for r in all_rows if str(r.get("trade_date")) in wanted] + logger.info(f"Tushare daily_basic returned {len(rows)} rows for {stock_code} on {len(trade_dates)} requested dates (resolved to {len(wanted)} trading dates)") return rows except Exception as e: logger.error(f"Tushare get_daily_basic_points failed for {stock_code}: {e}") @@ -114,32 +223,37 @@ class TushareProvider(BaseDataProvider): 获取指定交易日列表的日行情(例如 close)。 """ try: - tasks = [ - self._query( - api_name="daily", - params={"ts_code": stock_code, "trade_date": d}, - ) for d in trade_dates - ] - results = await asyncio.gather(*tasks, return_exceptions=True) - rows: List[Dict[str, Any]] = [] - for res in results: - if isinstance(res, list) and res: - rows.extend(res) - logger.info(f"Tushare daily returned {len(rows)} rows for {stock_code} on {len(trade_dates)} dates") + if not trade_dates: + return [] + mapping = await self._resolve_trade_dates(trade_dates, exchange="SSE") + resolved_dates = list(set(mapping.values())) + start_date = min(resolved_dates) + end_date = max(resolved_dates) + all_rows = await self._query( + api_name="daily", + params={ + "ts_code": stock_code, + "start_date": start_date, + "end_date": end_date, + }, + ) + wanted = set(resolved_dates) + rows = [r for r in all_rows if str(r.get("trade_date")) in wanted] + logger.info(f"Tushare daily returned {len(rows)} rows for {stock_code} on {len(trade_dates)} requested dates (resolved to {len(wanted)} trading dates)") return rows except Exception as e: logger.error(f"Tushare get_daily_points failed for {stock_code}: {e}") return [] - def _calculate_derived_metrics(self, series: Dict[str, List[Dict]], years: List[str]) -> Dict[str, List[Dict]]: + def _calculate_derived_metrics(self, series: Dict[str, List[Dict]], periods: List[str]) -> Dict[str, List[Dict]]: """ 在 Tushare provider 内部计算派生指标。 """ # --- Helper Functions --- - def _get_value(key: str, year: str) -> Optional[float]: + def _get_value(key: str, period: str) -> Optional[float]: if key not in series: return None - point = next((p for p in series[key] if p.get("year") == year), None) + point = next((p for p in series[key] if p.get("period") == period), None) if point is None or point.get("value") is None: return None try: @@ -147,20 +261,22 @@ class TushareProvider(BaseDataProvider): except (ValueError, TypeError): return None - def _get_avg_value(key: str, year: str) -> Optional[float]: - current_val = _get_value(key, year) + def _get_avg_value(key: str, period: str) -> Optional[float]: + current_val = _get_value(key, period) try: - prev_year = str(int(year) - 1) - prev_val = _get_value(key, prev_year) + # 总是和上一年度的年报值(如果存在)进行平均 + current_year = int(period[:4]) + prev_year_end_period = str(current_year - 1) + "1231" + prev_val = _get_value(key, prev_year_end_period) except (ValueError, TypeError): prev_val = None if current_val is None: return None if prev_val is None: return current_val return (current_val + prev_val) / 2 - def _get_cogs(year: str) -> Optional[float]: - revenue = _get_value('revenue', year) - gp_margin_raw = _get_value('grossprofit_margin', year) + def _get_cogs(period: str) -> Optional[float]: + revenue = _get_value('revenue', period) + gp_margin_raw = _get_value('grossprofit_margin', period) if revenue is None or gp_margin_raw is None: return None gp_margin = gp_margin_raw / 100.0 if abs(gp_margin_raw) > 1 else gp_margin_raw return revenue * (1 - gp_margin) @@ -171,11 +287,11 @@ class TushareProvider(BaseDataProvider): # --- Calculations --- fcf_data = [] - for year in years: - op_cashflow = _get_value('n_cashflow_act', year) - capex = _get_value('c_pay_acq_const_fiolta', year) + for period in periods: + op_cashflow = _get_value('n_cashflow_act', period) + capex = _get_value('c_pay_acq_const_fiolta', period) if op_cashflow is not None and capex is not None: - fcf_data.append({"year": year, "value": op_cashflow - capex}) + fcf_data.append({"period": period, "value": op_cashflow - capex}) add_series('__free_cash_flow', fcf_data) fee_calcs = [ @@ -186,29 +302,29 @@ class TushareProvider(BaseDataProvider): ] for key, num_key, den_key in fee_calcs: data = [] - for year in years: - numerator = _get_value(num_key, year) - denominator = _get_value(den_key, year) + for period in periods: + numerator = _get_value(num_key, period) + denominator = _get_value(den_key, period) if numerator is not None and denominator is not None and denominator != 0: - data.append({"year": year, "value": (numerator / denominator) * 100}) + data.append({"period": period, "value": (numerator / denominator) * 100}) add_series(key, data) tax_rate_data = [] - for year in years: - tax_to_ebt = _get_value('tax_to_ebt', year) + for period in periods: + tax_to_ebt = _get_value('tax_to_ebt', period) if tax_to_ebt is not None: rate = tax_to_ebt * 100 if abs(tax_to_ebt) <= 1 else tax_to_ebt - tax_rate_data.append({"year": year, "value": rate}) + tax_rate_data.append({"period": period, "value": rate}) add_series('__tax_rate', tax_rate_data) other_fee_data = [] - for year in years: - gp_raw = _get_value('grossprofit_margin', year) - np_raw = _get_value('netprofit_margin', year) - rev = _get_value('revenue', year) - sell_exp = _get_value('sell_exp', year) - admin_exp = _get_value('admin_exp', year) - rd_exp = _get_value('rd_exp', year) + for period in periods: + gp_raw = _get_value('grossprofit_margin', period) + np_raw = _get_value('netprofit_margin', period) + rev = _get_value('revenue', period) + sell_exp = _get_value('sell_exp', period) + admin_exp = _get_value('admin_exp', period) + rd_exp = _get_value('rd_exp', period) if all(v is not None for v in [gp_raw, np_raw, rev, sell_exp, admin_exp, rd_exp]) and rev != 0: gp = gp_raw / 100 if abs(gp_raw) > 1 else gp_raw np = np_raw / 100 if abs(np_raw) > 1 else np_raw @@ -216,7 +332,7 @@ class TushareProvider(BaseDataProvider): admin_rate = admin_exp / rev rd_rate = rd_exp / rev other_rate = (gp - np - sell_rate - admin_rate - rd_rate) * 100 - other_fee_data.append({"year": year, "value": other_rate}) + other_fee_data.append({"period": period, "value": other_rate}) add_series('__other_fee_rate', other_fee_data) asset_ratio_keys = [ @@ -228,60 +344,60 @@ class TushareProvider(BaseDataProvider): ] for key, num_key in asset_ratio_keys: data = [] - for year in years: - numerator = _get_value(num_key, year) - denominator = _get_value('total_assets', year) + for period in periods: + numerator = _get_value(num_key, period) + denominator = _get_value('total_assets', period) if numerator is not None and denominator is not None and denominator != 0: - data.append({"year": year, "value": (numerator / denominator) * 100}) + data.append({"period": period, "value": (numerator / denominator) * 100}) add_series(key, data) adv_data = [] - for year in years: - adv = _get_value('adv_receipts', year) or 0 - contract = _get_value('contract_liab', year) or 0 - total_assets = _get_value('total_assets', year) + for period in periods: + adv = _get_value('adv_receipts', period) or 0 + contract = _get_value('contract_liab', period) or 0 + total_assets = _get_value('total_assets', period) if total_assets is not None and total_assets != 0: - adv_data.append({"year": year, "value": ((adv + contract) / total_assets) * 100}) + adv_data.append({"period": period, "value": ((adv + contract) / total_assets) * 100}) add_series('__adv_ratio', adv_data) other_assets_data = [] known_assets_keys = ['money_cap', 'inventories', 'accounts_receiv_bill', 'prepayment', 'fix_assets', 'lt_eqt_invest', 'goodwill'] - for year in years: - total_assets = _get_value('total_assets', year) + for period in periods: + total_assets = _get_value('total_assets', period) if total_assets is not None and total_assets != 0: - sum_known = sum(_get_value(k, year) or 0 for k in known_assets_keys) - other_assets_data.append({"year": year, "value": ((total_assets - sum_known) / total_assets) * 100}) + sum_known = sum(_get_value(k, period) or 0 for k in known_assets_keys) + other_assets_data.append({"period": period, "value": ((total_assets - sum_known) / total_assets) * 100}) add_series('__other_assets_ratio', other_assets_data) op_assets_data = [] - for year in years: - total_assets = _get_value('total_assets', year) + for period in periods: + total_assets = _get_value('total_assets', period) if total_assets is not None and total_assets != 0: - inv = _get_value('inventories', year) or 0 - ar = _get_value('accounts_receiv_bill', year) or 0 - pre = _get_value('prepayment', year) or 0 - ap = _get_value('accounts_pay', year) or 0 - adv = _get_value('adv_receipts', year) or 0 - contract_liab = _get_value('contract_liab', year) or 0 + inv = _get_value('inventories', period) or 0 + ar = _get_value('accounts_receiv_bill', period) or 0 + pre = _get_value('prepayment', period) or 0 + ap = _get_value('accounts_pay', period) or 0 + adv = _get_value('adv_receipts', period) or 0 + contract_liab = _get_value('contract_liab', period) or 0 operating_assets = inv + ar + pre - ap - adv - contract_liab - op_assets_data.append({"year": year, "value": (operating_assets / total_assets) * 100}) + op_assets_data.append({"period": period, "value": (operating_assets / total_assets) * 100}) add_series('__operating_assets_ratio', op_assets_data) debt_ratio_data = [] - for year in years: - total_assets = _get_value('total_assets', year) + for period in periods: + total_assets = _get_value('total_assets', period) if total_assets is not None and total_assets != 0: - st_borr = _get_value('st_borr', year) or 0 - lt_borr = _get_value('lt_borr', year) or 0 - debt_ratio_data.append({"year": year, "value": ((st_borr + lt_borr) / total_assets) * 100}) + st_borr = _get_value('st_borr', period) or 0 + lt_borr = _get_value('lt_borr', period) or 0 + debt_ratio_data.append({"period": period, "value": ((st_borr + lt_borr) / total_assets) * 100}) add_series('__interest_bearing_debt_ratio', debt_ratio_data) payturn_data = [] - for year in years: - avg_ap = _get_avg_value('accounts_pay', year) - cogs = _get_cogs(year) + for period in periods: + avg_ap = _get_avg_value('accounts_pay', period) + cogs = _get_cogs(period) if avg_ap is not None and cogs is not None and cogs != 0: - payturn_data.append({"year": year, "value": (365 * avg_ap) / cogs}) + payturn_data.append({"period": period, "value": (365 * avg_ap) / cogs}) add_series('payturn_days', payturn_data) per_capita_calcs = [ @@ -291,82 +407,318 @@ class TushareProvider(BaseDataProvider): ] for key, num_key, divisor in per_capita_calcs: data = [] - for year in years: - numerator = _get_value(num_key, year) - employees = _get_value('employees', year) + for period in periods: + numerator = _get_value(num_key, period) + employees = _get_value('employees', period) if numerator is not None and employees is not None and employees != 0: - data.append({"year": year, "value": (numerator / employees) / divisor}) + data.append({"period": period, "value": (numerator / employees) / divisor}) add_series(key, data) return series - async def get_financial_statements(self, stock_code: str, report_dates: List[str]) -> Dict[str, List[Dict[str, Any]]]: - all_statements: List[Dict[str, Any]] = [] - for date in report_dates: - logger.info(f"Fetching financial statements for {stock_code}, report date: {date}") + async def get_financial_statements(self, stock_code: str, report_dates: Optional[List[str]] = None) -> Dict[str, List[Dict[str, Any]]]: + # 1) 一次性拉取所需四表(尽量齐全字段),再按指定 report_dates 过滤 + # 字段列表基于官方示例,避免超量请求可按需精简 + bs_fields = [ + "ts_code","ann_date","f_ann_date","end_date","report_type","comp_type","end_type", + "money_cap","inventories","prepayment","accounts_receiv","accounts_receiv_bill","goodwill", + "lt_eqt_invest","fix_assets","total_assets","accounts_pay","adv_receipts","contract_liab", + "st_borr","lt_borr","total_cur_assets","total_cur_liab","total_ncl","total_liab","total_hldr_eqy_exc_min_int", + ] + ic_fields = [ + "ts_code","ann_date","f_ann_date","end_date","report_type","comp_type","end_type", + "total_revenue","revenue","sell_exp","admin_exp","rd_exp","operate_profit","total_profit", + "income_tax","n_income","n_income_attr_p","ebit","ebitda","netprofit_margin","grossprofit_margin", + ] + cf_fields = [ + "ts_code","ann_date","f_ann_date","end_date","comp_type","report_type","end_type", + "n_cashflow_act","c_pay_acq_const_fiolta","c_paid_to_for_empl","depr_fa_coga_dpba", + ] + fi_fields = [ + "ts_code","end_date","ann_date","grossprofit_margin","netprofit_margin","tax_to_ebt","roe","roa","roic", + "invturn_days","arturn_days","fa_turn","tr_yoy","dt_netprofit_yoy","assets_turn", + ] + + try: + bs_rows, ic_rows, cf_rows, fi_rows, rep_rows, div_rows, holder_rows, company_rows = await asyncio.gather( + self._query("balancesheet", params={"ts_code": stock_code, "report_type": 1}, fields=bs_fields), + self._query("income", params={"ts_code": stock_code, "report_type": 1}, fields=ic_fields), + self._query("cashflow", params={"ts_code": stock_code, "report_type": 1}, fields=cf_fields), + self._query("fina_indicator", params={"ts_code": stock_code}, fields=fi_fields), + # 回购公告 + self._query( + "repurchase", + params={"ts_code": stock_code}, + fields=[ + "ts_code","ann_date","end_date","proc","exp_date","vol","amount","high_limit","low_limit", + ], + ), + # 分红公告(仅取必要字段) + self._query( + "dividend", + params={"ts_code": stock_code}, + fields=[ + "ts_code","end_date","cash_div_tax","pay_date","base_share", + ], + ), + # 股东户数(按报告期) + self._query( + "stk_holdernumber", + params={"ts_code": stock_code}, + fields=[ + "ts_code","ann_date","end_date","holder_num", + ], + ), + # 公司基本信息(包含员工数) + self._query( + "stock_company", + params={"ts_code": stock_code}, + fields=[ + "ts_code","employees", + ], + ), + ) try: - bs_rows, ic_rows, cf_rows, fi_rows = await asyncio.gather( - self._query( - api_name="balancesheet", - params={"ts_code": stock_code, "period": date, "report_type": 1}, - ), - self._query( - api_name="income", - params={"ts_code": stock_code, "period": date, "report_type": 1}, - ), - self._query( - api_name="cashflow", - params={"ts_code": stock_code, "period": date, "report_type": 1}, - ), - # 补充关键财务比率(ROE/ROA/毛利率等) - self._query( - api_name="fina_indicator", - params={"ts_code": stock_code, "period": date}, - ), - ) + logger.info(f"[Dividend] fetched {len(div_rows)} rows for {stock_code}") + except Exception: + pass + except Exception as e: + logger.error(f"Tushare bulk fetch failed for {stock_code}: {e}") + bs_rows, ic_rows, cf_rows, fi_rows, rep_rows, div_rows, holder_rows, company_rows = [], [], [], [], [], [], [], [] - if not bs_rows and not ic_rows and not cf_rows and not fi_rows: - logger.warning(f"No financial statements components found from Tushare for {stock_code} on {date}") + # 2) 以 end_date 聚合合并四表 + by_date: Dict[str, Dict[str, Any]] = {} + def _merge_rows(rows: List[Dict[str, Any]]): + for r in rows or []: + end_date = str(r.get("end_date") or r.get("period") or "") + if not end_date: continue + if end_date not in by_date: + by_date[end_date] = {"ts_code": stock_code, "end_date": end_date} + by_date[end_date].update(r) - merged: Dict[str, Any] = {"ts_code": stock_code, "end_date": date} - bs_data = bs_rows[0] if bs_rows else {} - ic_data = ic_rows[0] if ic_rows else {} - cf_data = cf_rows[0] if cf_rows else {} - fi_data = fi_rows[0] if fi_rows else {} + _merge_rows(bs_rows) + _merge_rows(ic_rows) + _merge_rows(cf_rows) + _merge_rows(fi_rows) - merged.update(bs_data) - merged.update(ic_data) - merged.update(cf_data) - merged.update(fi_data) - - merged["end_date"] = merged.get("end_date") or merged.get("period") or date - logger.debug(f"Merged statement for {date} has keys: {list(merged.keys())}") + # 3) 筛选报告期:今年的最新报告期 + 往年所有年报 + current_year = str(datetime.date.today().year) + all_available_dates = sorted(by_date.keys(), reverse=True) - all_statements.append(merged) - except Exception as e: - logger.error(f"Tushare get_financial_statement failed for {stock_code} on {date}: {e}") - continue + latest_current_year_report = None + for d in all_available_dates: + if d.startswith(current_year): + latest_current_year_report = d + break - logger.info(f"Successfully fetched {len(all_statements)} statement(s) for {stock_code}.") + previous_years_annual_reports = [ + d for d in all_available_dates if d.endswith("1231") and not d.startswith(current_year) + ] + + wanted_dates = [] + if latest_current_year_report: + wanted_dates.append(latest_current_year_report) + wanted_dates.extend(previous_years_annual_reports) + + all_statements = [by_date[d] for d in wanted_dates if d in by_date] + + logger.info(f"Successfully prepared {len(all_statements)} merged statement(s) for {stock_code} from {len(by_date)} available reports.") # Transform to series format series: Dict[str, List[Dict]] = {} if all_statements: for report in all_statements: - year = report.get("end_date", "")[:4] - if not year: continue + period = report.get("end_date", "") + if not period: continue for key, value in report.items(): if key in ['ts_code', 'end_date', 'ann_date', 'f_ann_date', 'report_type', 'comp_type', 'end_type', 'update_flag', 'period']: continue - if isinstance(value, (int, float)) and value is not None: + # 仅保留可转为有限 float 的数值,避免 JSON 序列化错误 + try: + fv = float(value) + except (TypeError, ValueError): + continue + if value is not None and math.isfinite(fv): if key not in series: series[key] = [] - if not any(d['year'] == year for d in series[key]): - series[key].append({"year": year, "value": value}) + if not any(d['period'] == period for d in series[key]): + series[key].append({"period": period, "value": fv}) + # 汇总回购信息为年度序列:按报告期 end_date 年份分组; + # 其中 repurchase_amount 取该年内“最后一个 ann_date”的 amount 值。 + if 'rep_rows' in locals() and rep_rows: + rep_by_year: Dict[str, Dict[str, Any]] = {} + for r in rep_rows: + endd = str(r.get("end_date") or r.get("ann_date") or "") + if not endd: + continue + y = endd[:4] + bucket = rep_by_year.setdefault(y, { + "amount_sum": 0.0, + "vol": 0.0, + "high_limit": None, + "low_limit": None, + "last_ann_date": None, + "amount_last": None, + }) + amt = r.get("amount") + vol = r.get("vol") + hi = r.get("high_limit") + lo = r.get("low_limit") + ann = str(r.get("ann_date") or "") + if isinstance(amt, (int, float)) and amt is not None: + bucket["amount_sum"] += float(amt) + if ann and ann[:4] == y: + last = bucket["last_ann_date"] + if last is None or ann > last: + bucket["last_ann_date"] = ann + bucket["amount_last"] = float(amt) + if isinstance(vol, (int, float)) and vol is not None: + bucket["vol"] += float(vol) + if isinstance(hi, (int, float)) and hi is not None: + bucket["high_limit"] = float(hi) + if isinstance(lo, (int, float)) and lo is not None: + bucket["low_limit"] = float(lo) + + + if rep_by_year: + amt_series = [] + vol_series = [] + hi_series = [] + lo_series = [] + for y, v in rep_by_year.items(): + # 当年数据放在当前年最新报告期,否则放在年度报告期 + if y == current_year and latest_current_year_report: + period_key = latest_current_year_report + else: + period_key = f"{y}1231" + + if v.get("amount_last") is not None: + amt_series.append({"period": period_key, "value": v["amount_last"]}) + if v.get("vol"): + vol_series.append({"period": period_key, "value": v["vol"]}) + if v.get("high_limit") is not None: + hi_series.append({"period": period_key, "value": v["high_limit"]}) + if v.get("low_limit") is not None: + lo_series.append({"period": period_key, "value": v["low_limit"]}) + if amt_series: + series["repurchase_amount"] = amt_series + if vol_series: + series["repurchase_vol"] = vol_series + if hi_series: + series["repurchase_high_limit"] = hi_series + if lo_series: + series["repurchase_low_limit"] = lo_series + + # 汇总分红信息为年度序列:以真实派息日 pay_date 的年份分组; + # 每条记录金额= 每股分红(cash_div_tax) * 基准股本(base_share),其中 base_share 单位为“万股”, + # 金额以“亿”为单位返回,因此需再除以 10000。 + if 'div_rows' in locals() and div_rows: + div_by_year: Dict[str, float] = {} + for r in div_rows: + pay = str(r.get("pay_date") or "") + # 仅统计存在数字年份的真实派息日 + if not pay or len(pay) < 4 or not any(ch.isdigit() for ch in pay): + continue + y = pay[:4] + cash_div = r.get("cash_div_tax") + base_share = r.get("base_share") + if isinstance(cash_div, (int, float)) and isinstance(base_share, (int, float)): + # 现金分红总额(万元)= 每股分红(元) * 基准股本(万股) + # 转为“亿”需除以 10000 + amount_billion = (float(cash_div) * float(base_share)) / 10000.0 + div_by_year[y] = div_by_year.get(y, 0.0) + amount_billion + if div_by_year: + div_series = [] + for y, v in sorted(div_by_year.items()): + # 当年数据放在当前年最新报告期,否则放在年度报告期 + if y == current_year and latest_current_year_report: + period_key = latest_current_year_report + else: + period_key = f"{y}1231" + div_series.append({"period": period_key, "value": v}) + series["dividend_amount"] = div_series + # try: + # logger.info(f"[Dividend] Series dividend_amount(period) for {stock_code}: {div_series}") + # except Exception: + # pass + + # 汇总股东户数信息:按报告期 end_date 分组,取最新的 holder_num + if 'holder_rows' in locals() and holder_rows: + # 按 end_date 分组,取最新的 ann_date 的 holder_num + holder_by_period: Dict[str, Dict[str, Any]] = {} + for r in holder_rows: + end_date = str(r.get("end_date") or "") + if not end_date: + continue + ann_date = str(r.get("ann_date") or "") + holder_num = r.get("holder_num") + + if end_date not in holder_by_period: + holder_by_period[end_date] = { + "holder_num": holder_num, + "latest_ann_date": ann_date + } + else: + # 比较 ann_date,取最新的 + current_latest = holder_by_period[end_date]["latest_ann_date"] + if ann_date and (not current_latest or ann_date > current_latest): + holder_by_period[end_date] = { + "holder_num": holder_num, + "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): + if end_date in holder_by_period: + data = holder_by_period[end_date] + holder_num = data["holder_num"] + if isinstance(holder_num, (int, float)) and holder_num is not None: + holder_series.append({"period": end_date, "value": float(holder_num)}) + + if holder_series: + series["holder_num"] = holder_series + + # 汇总员工数信息:员工数放在去年的年末(上一年的12月31日) + if 'company_rows' in locals() and company_rows: + # 员工数通常是静态数据,取最新的一个值 + latest_employees = None + for r in company_rows: + employees = r.get("employees") + if isinstance(employees, (int, float)) and employees is not None: + latest_employees = float(employees) + break # 取第一个有效值 + + if latest_employees is not None: + # 将员工数放在去年的年末(上一年的12月31日) + previous_year = str(datetime.date.today().year - 1) + period_key = f"{previous_year}1231" + series["employees"] = [{"period": period_key, "value": latest_employees}] + # Calculate derived metrics - years = sorted(list(set(d['year'] for s in series.values() for d in s))) - series = self._calculate_derived_metrics(series, years) + periods = sorted(list(set(d['period'] for s in series.values() for d in s))) + series = self._calculate_derived_metrics(series, periods) return series diff --git a/backend/app/routers/financial.py b/backend/app/routers/financial.py index 1582493..b09ba36 100644 --- a/backend/app/routers/financial.py +++ b/backend/app/routers/financial.py @@ -22,7 +22,6 @@ from app.schemas.financial import ( ) from app.services.company_profile_client import CompanyProfileClient from app.services.analysis_client import AnalysisClient, load_analysis_config, get_analysis_config -from app.services.financial_calculator import calculate_derived_metrics # Lazy DataManager loader to avoid import-time failures when optional providers/config are missing _dm = None @@ -293,9 +292,21 @@ async def get_china_financials( # Fetch all financial statements at once step_financials = StepRecord(name="拉取财务报表", start_ts=started_real.isoformat(), status="running") steps.append(step_financials) - + # Fetch all financial statements at once (already in series format from provider) series = await get_dm().get_financial_statements(stock_code=ts_code, report_dates=report_dates) + + # Get the latest current year report period for market data + latest_current_year_report = None + if series: + current_year_str = str(current_year) + for key in series: + if series[key]: + for item in series[key]: + period = item.get('period', '') + if period.startswith(current_year_str) and not period.endswith('1231'): + if latest_current_year_report is None or period > latest_current_year_report: + latest_current_year_report = period if not series: errors["financial_statements"] = "Failed to fetch from all providers." @@ -314,39 +325,78 @@ async def get_china_financials( step_market = StepRecord(name="拉取市值与股价", start_ts=datetime.now(timezone.utc).isoformat(), status="running") steps.append(step_market) + # 构建市场数据查询日期:年度日期 + 当前年最新报告期 + market_dates = report_dates.copy() + if latest_current_year_report: + # 查找当前年最新报告期对应的交易日(通常是报告期当月最后一天或最近交易日) + try: + report_date_obj = datetime.strptime(latest_current_year_report, '%Y%m%d') + # 使用报告期日期作为查询日期(API会自动找到最近的交易日) + market_dates.append(latest_current_year_report) + except ValueError: + pass + try: if has_daily_basic: - db_rows = await get_dm().get_data('get_daily_basic_points', stock_code=ts_code, trade_dates=report_dates) + db_rows = await get_dm().get_data('get_daily_basic_points', stock_code=ts_code, trade_dates=market_dates) if isinstance(db_rows, list): for row in db_rows: trade_date = row.get('trade_date') or row.get('trade_dt') or row.get('date') if not trade_date: continue + # 判断是否为当前年最新报告期的数据 + is_current_year_report = latest_current_year_report and str(trade_date) == latest_current_year_report year = str(trade_date)[:4] + + if is_current_year_report: + # 当前年最新报告期的数据,使用报告期period显示 + period = latest_current_year_report + else: + # 其他年度数据,使用年度period显示(YYYY1231) + period = f"{year}1231" + for key, value in row.items(): if key in ['ts_code', 'trade_date', 'trade_dt', 'date']: continue if isinstance(value, (int, float)) and value is not None: if key not in series: series[key] = [] - if not any(d['year'] == year for d in series[key]): - series[key].append({"year": year, "value": value}) + # 检查是否已存在该period的数据,如果存在则替换为最新的数据 + existing_index = next((i for i, d in enumerate(series[key]) if d['period'] == period), -1) + if existing_index >= 0: + series[key][existing_index] = {"period": period, "value": value} + else: + series[key].append({"period": period, "value": value}) if has_daily: - d_rows = await get_dm().get_data('get_daily_points', stock_code=ts_code, trade_dates=report_dates) + d_rows = await get_dm().get_data('get_daily_points', stock_code=ts_code, trade_dates=market_dates) if isinstance(d_rows, list): for row in d_rows: trade_date = row.get('trade_date') or row.get('trade_dt') or row.get('date') if not trade_date: continue + # 判断是否为当前年最新报告期的数据 + is_current_year_report = latest_current_year_report and str(trade_date) == latest_current_year_report year = str(trade_date)[:4] + + if is_current_year_report: + # 当前年最新报告期的数据,使用报告期period显示 + period = latest_current_year_report + else: + # 其他年度数据,使用年度period显示(YYYY1231) + period = f"{year}1231" + for key, value in row.items(): if key in ['ts_code', 'trade_date', 'trade_dt', 'date']: continue if isinstance(value, (int, float)) and value is not None: if key not in series: series[key] = [] - if not any(d['year'] == year for d in series[key]): - series[key].append({"year": year, "value": value}) + # 检查是否已存在该period的数据,如果存在则替换为最新的数据 + existing_index = next((i for i, d in enumerate(series[key]) if d['period'] == period), -1) + if existing_index >= 0: + series[key][existing_index] = {"period": period, "value": value} + else: + series[key].append({"period": period, "value": value}) except Exception as e: errors["market_data"] = f"Failed to fetch market data: {e}" finally: @@ -362,17 +412,20 @@ async def get_china_financials( if not series: raise HTTPException(status_code=502, detail={"message": "No data returned from any data source", "errors": errors}) - # Truncate years and sort (the data should already be mostly correct, but we ensure) + # Truncate periods and sort (the data should already be mostly correct, but we ensure) for key, arr in series.items(): - # Deduplicate and sort desc by year, then cut to requested years, and return asc - uniq = {item["year"]: item for item in arr} - arr_sorted_desc = sorted(uniq.values(), key=lambda x: x["year"], reverse=True) + # Deduplicate and sort desc by period, then cut to requested periods, and return asc + uniq = {item["period"]: item for item in arr} + arr_sorted_desc = sorted(uniq.values(), key=lambda x: x["period"], reverse=True) arr_limited = arr_sorted_desc[:years] - arr_sorted = sorted(arr_limited, key=lambda x: x["year"]) + arr_sorted = sorted(arr_limited, key=lambda x: x["period"]) series[key] = arr_sorted - # Calculate derived financial metrics - series = calculate_derived_metrics(series, years_list) + # Create periods_list for derived metrics calculation + periods_list = sorted(list(set(item["period"] for arr in series.values() for item in arr))) + + # Note: Derived financial metrics calculation has been moved to individual data providers + # The data_manager.get_financial_statements() should handle this meta = FinancialMeta( started_at=started_real.isoformat(), diff --git a/backend/app/schemas/financial.py b/backend/app/schemas/financial.py index 2aa6c02..6f08c48 100644 --- a/backend/app/schemas/financial.py +++ b/backend/app/schemas/financial.py @@ -5,10 +5,9 @@ from typing import Dict, List, Optional from pydantic import BaseModel -class YearDataPoint(BaseModel): - year: str +class PeriodDataPoint(BaseModel): + period: str value: Optional[float] - month: Optional[int] = None # 月份信息,用于确定季度 class StepRecord(BaseModel): @@ -33,7 +32,7 @@ class FinancialMeta(BaseModel): class BatchFinancialDataResponse(BaseModel): ts_code: str name: Optional[str] = None - series: Dict[str, List[YearDataPoint]] + series: Dict[str, List[PeriodDataPoint]] meta: Optional[FinancialMeta] = None diff --git a/docs/user-guide.md b/docs/user-guide.md index 7ae8797..2a178e4 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -268,3 +268,5 @@ A: + + diff --git a/frontend/package.json b/frontend/package.json index 71c500e..9e53bd2 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -3,7 +3,7 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "next dev -p 3001", + "dev": "NODE_NO_WARNINGS=1 next dev -p 3001", "build": "next build", "start": "next start", "lint": "eslint" diff --git a/frontend/src/app/fonts/README.md b/frontend/src/app/fonts/README.md index 575f5ad..2963249 100644 --- a/frontend/src/app/fonts/README.md +++ b/frontend/src/app/fonts/README.md @@ -14,3 +14,5 @@ 若暂时没有字体文件,页面会退回系统默认字体,不影响功能。 + + diff --git a/frontend/src/app/report/[symbol]/page.tsx b/frontend/src/app/report/[symbol]/page.tsx index 8051d65..c21fa7d 100644 --- a/frontend/src/app/report/[symbol]/page.tsx +++ b/frontend/src/app/report/[symbol]/page.tsx @@ -14,6 +14,7 @@ import remarkGfm from 'remark-gfm'; import { TradingViewWidget } from '@/components/TradingViewWidget'; import type { CompanyProfileResponse, AnalysisResponse } from '@/types'; import { useMemo } from 'react'; +import { formatReportPeriod } from '@/lib/financial-utils'; export default function ReportPage() { const params = useParams(); @@ -732,32 +733,42 @@ export default function ReportPage() {
{(() => { const series = financials?.series ?? {}; - const allPoints = Object.values(series).flat() as Array<{ year: string; value: number | null; month?: number | null }>; - const currentYearStr = String(new Date().getFullYear()); - const years = Array - .from(new Set(allPoints.map((p) => p?.year).filter(Boolean) as string[])) - .sort((a, b) => Number(b) - Number(a)); // 最新年份在左 - const getQuarter = (month: number | null | undefined) => { - if (month == null) return null; - return Math.floor((month - 1) / 3) + 1; + // 统一 period:优先 p.period;若仅有 year 则映射到 `${year}1231` + const toPeriod = (p: any): string | null => { + if (!p) return null; + if (p.period) return String(p.period); + if (p.year) return `${p.year}1231`; + return null; }; - if (years.length === 0) { + const allPeriods = Array.from( + new Set( + (Object.values(series).flat() as any[]) + .map((p) => toPeriod(p)) + .filter((v): v is string => Boolean(v)) + ) + ).sort((a, b) => b.localeCompare(a)); // 最新在左(按 YYYYMMDD 排序) + + if (allPeriods.length === 0) { return

暂无可展示的数据

; } + const periods = allPeriods; + + const getValueByPeriod = (points: any[] | undefined, period: string): number | null => { + if (!points) return null; + const hit = points.find((pp) => toPeriod(pp) === period); + const v = hit?.value; + if (v == null) return null; + const num = typeof v === 'number' ? v : Number(v); + return Number.isFinite(num) ? num : null; + }; return ( 指标 - {years.map((y) => { - const isCurrent = y === currentYearStr; - const yearData = allPoints.find(p => p.year === y); - const quarter = yearData?.month ? getQuarter(yearData.month) : null; - const label = isCurrent && quarter ? `${y} Q${quarter}` : y; - return ( - {label} - ); - })} + {periods.map((p) => ( + {formatReportPeriod(p)} + ))} @@ -776,8 +787,8 @@ export default function ReportPage() { { key: 'n_cashflow_act' }, { key: 'c_pay_acq_const_fiolta' }, { key: '__free_cash_flow', label: '自由现金流' }, - { key: 'cash_div_tax', label: '分红' }, - { key: 'buyback', label: '回购' }, + { key: 'dividend_amount', label: '分红' }, + { key: 'repurchase_amount', label: '回购' }, { key: 'total_assets' }, { key: 'total_hldr_eqy_exc_min_int' }, { key: 'goodwill' }, @@ -787,8 +798,8 @@ export default function ReportPage() { const summaryRow = ( 主要指标 - {years.map((y) => ( - + {periods.map((p) => ( + ))} ); @@ -803,20 +814,20 @@ export default function ReportPage() { '__operating_assets_ratio', '__interest_bearing_debt_ratio' ]); const rows = ORDER.map(({ key, label }) => { - const points = series[key] as Array<{ year?: string; value?: number | null }> | undefined; + const points = series[key] as any[] | undefined; return ( {label || metricDisplayMap[key] || key} - {years.map((y) => { - const v = points?.find(p => p?.year === y)?.value ?? null; + {periods.map((p) => { + const v = getValueByPeriod(points, p); const groupName = metricGroupMap[key]; const rawNum = typeof v === 'number' ? v : (v == null ? null : Number(v)); if (rawNum == null || Number.isNaN(rawNum)) { - return -; + return -; } if (PERCENT_KEYS.has(key)) { const perc = Math.abs(rawNum) <= 1 && key !== 'tax_to_ebt' && key !== '__tax_rate' ? rawNum * 100 : rawNum; @@ -825,31 +836,31 @@ export default function ReportPage() { if (isGrowthRow) { const isNeg = typeof perc === 'number' && perc < 0; return ( - + {text}% ); } return ( - {`${text}%`} + {`${text}%`} ); } else { const isFinGroup = groupName === 'income' || groupName === 'balancesheet' || groupName === 'cashflow'; const scaled = key === 'total_mv' ? rawNum / 10000 - : (isFinGroup || key === '__free_cash_flow' ? rawNum / 1e8 : rawNum); + : (isFinGroup || key === '__free_cash_flow' || key === 'repurchase_amount' ? rawNum / 1e8 : rawNum); const formatter = key === 'total_mv' ? integerFormatter : numberFormatter; const text = Number.isFinite(scaled) ? formatter.format(scaled) : '-'; if (key === '__free_cash_flow') { const isNeg = typeof scaled === 'number' && scaled < 0; return ( - + {isNeg ? {text} : text} ); } return ( - {text} + {text} ); } })} @@ -863,8 +874,8 @@ export default function ReportPage() { const feeHeaderRow = ( 费用指标 - {years.map((y) => ( - + {periods.map((p) => ( + ))} ); @@ -879,17 +890,17 @@ export default function ReportPage() { ].map(({ key, label }) => ( {label} - {years.map((y) => { - const points = series[key] as Array<{ year?: string; value?: number | null }> | undefined; - const v = points?.find(p => p?.year === y)?.value ?? null; + {periods.map((p) => { + const points = series[key] as any[] | undefined; + const v = getValueByPeriod(points, p); if (v == null || !Number.isFinite(v)) { - return -; + return -; } const rateText = numberFormatter.format(v); const isNegative = v < 0; return ( - + {isNegative ? {rateText}% : `${rateText}%`} ); @@ -903,20 +914,20 @@ export default function ReportPage() { const assetHeaderRow = ( 资产占比 - {years.map((y) => ( - + {periods.map((p) => ( + ))} ); - const ratioCell = (value: number | null, y: string) => { + const ratioCell = (value: number | null, keyStr: string) => { if (value == null || !Number.isFinite(value)) { - return -; + return -; } const text = numberFormatter.format(value); const isNegative = value < 0; return ( - + {isNegative ? {text}% : `${text}%`} ); @@ -940,10 +951,10 @@ export default function ReportPage() { ].map(({ key, label }) => ( {label} - {years.map((y) => { - const points = series[key] as Array<{ year?: string; value?: number | null }> | undefined; - const v = points?.find(p => p?.year === y)?.value ?? null; - return ratioCell(v, y); + {periods.map((p) => { + const points = series[key] as any[] | undefined; + const v = getValueByPeriod(points, p); + return ratioCell(v, p); })} )); @@ -954,8 +965,8 @@ export default function ReportPage() { const turnoverHeaderRow = ( 周转能力 - {years.map((y) => ( - + {periods.map((p) => ( + ))} ); @@ -971,21 +982,21 @@ export default function ReportPage() { const turnoverRows = turnoverItems.map(({ key, label }) => ( {label} - {years.map((y) => { - const points = series[key] as Array<{ year?: string; value?: number | null }> | undefined; - const v = points?.find(p => p?.year === y)?.value ?? null; + {periods.map((p) => { + const points = series[key] as any[] | undefined; + const v = getValueByPeriod(points, p); const value = typeof v === 'number' ? v : (v == null ? null : Number(v)); if (value == null || !Number.isFinite(value)) { - return -; + return -; } const text = numberFormatter.format(value); if (key === 'arturn_days' && value > 90) { return ( - {text} + {text} ); } - return {text}; + return {text}; })} )); @@ -1005,8 +1016,8 @@ export default function ReportPage() { ( 人均效率 - {years.map((y) => ( - + {periods.map((p) => ( + ))} ), @@ -1014,13 +1025,13 @@ export default function ReportPage() { ( 员工人数 - {years.map((y) => { - const points = series['employees'] as Array<{ year?: string; value?: number | null }> | undefined; - const v = points?.find(p => p?.year === y)?.value ?? null; + {periods.map((p) => { + const points = series['employees'] as any[] | undefined; + const v = getValueByPeriod(points, p); if (v == null) { - return -; + return -; } - return {integerFormatter.format(Math.round(v))}; + return {integerFormatter.format(Math.round(v))}; })} ), @@ -1028,13 +1039,13 @@ export default function ReportPage() { ( 人均创收(万元) - {years.map((y) => { - const points = series['__rev_per_emp'] as Array<{ year?: string; value?: number | null }> | undefined; - const val = points?.find(p => p?.year === y)?.value ?? null; + {periods.map((p) => { + const points = series['__rev_per_emp'] as any[] | undefined; + const val = getValueByPeriod(points, p); if (val == null) { - return -; + return -; } - return {numberFormatter.format(val)}; + return {numberFormatter.format(val)}; })} ), @@ -1042,13 +1053,13 @@ export default function ReportPage() { ( 人均创利(万元) - {years.map((y) => { - const points = series['__profit_per_emp'] as Array<{ year?: string; value?: number | null }> | undefined; - const val = points?.find(p => p?.year === y)?.value ?? null; + {periods.map((p) => { + const points = series['__profit_per_emp'] as any[] | undefined; + const val = getValueByPeriod(points, p); if (val == null) { - return -; + return -; } - return {numberFormatter.format(val)}; + return {numberFormatter.format(val)}; })} ), @@ -1056,13 +1067,13 @@ export default function ReportPage() { ( 人均工资(万元) - {years.map((y) => { - const points = series['__salary_per_emp'] as Array<{ year?: string; value?: number | null }> | undefined; - const val = points?.find(p => p?.year === y)?.value ?? null; + {periods.map((p) => { + const points = series['__salary_per_emp'] as any[] | undefined; + const val = getValueByPeriod(points, p); if (val == null) { - return -; + return -; } - return {numberFormatter.format(val)}; + return {numberFormatter.format(val)}; })} ), @@ -1072,8 +1083,8 @@ export default function ReportPage() { ( 市场表现 - {years.map((y) => ( - + {periods.map((p) => ( + ))} ), @@ -1081,13 +1092,13 @@ export default function ReportPage() { ( 股价 - {years.map((y) => { - const points = series['close'] as Array<{ year?: string; value?: number | null }> | undefined; - const v = points?.find(p => p?.year === y)?.value ?? null; + {periods.map((p) => { + const points = series['close'] as any[] | undefined; + const v = getValueByPeriod(points, p); if (v == null) { - return -; + return -; } - return {numberFormatter.format(v)}; + return {numberFormatter.format(v)}; })} ), @@ -1095,14 +1106,14 @@ export default function ReportPage() { ( 市值(亿元) - {years.map((y) => { - const points = series['total_mv'] as Array<{ year?: string; value?: number | null }> | undefined; - const v = points?.find(p => p?.year === y)?.value ?? null; + {periods.map((p) => { + const points = series['total_mv'] as any[] | undefined; + const v = getValueByPeriod(points, p); if (v == null) { - return -; + return -; } const scaled = v / 10000; // 转为亿元 - return {integerFormatter.format(Math.round(scaled))}; + return {integerFormatter.format(Math.round(scaled))}; })} ), @@ -1110,13 +1121,13 @@ export default function ReportPage() { ( PE - {years.map((y) => { - const points = series['pe'] as Array<{ year?: string; value?: number | null }> | undefined; - const v = points?.find(p => p?.year === y)?.value ?? null; + {periods.map((p) => { + const points = series['pe'] as any[] | undefined; + const v = getValueByPeriod(points, p); if (v == null) { - return -; + return -; } - return {numberFormatter.format(v)}; + return {numberFormatter.format(v)}; })} ), @@ -1124,27 +1135,27 @@ export default function ReportPage() { ( PB - {years.map((y) => { - const points = series['pb'] as Array<{ year?: string; value?: number | null }> | undefined; - const v = points?.find(p => p?.year === y)?.value ?? null; + {periods.map((p) => { + const points = series['pb'] as any[] | undefined; + const v = getValueByPeriod(points, p); if (v == null) { - return -; + return -; } - return {numberFormatter.format(v)}; + return {numberFormatter.format(v)}; })} ), // 股东户数 ( - + 股东户数 - {years.map((y) => { - const points = series['holder_num'] as Array<{ year?: string; value?: number | null }> | undefined; - const v = points?.find(p => p?.year === y)?.value ?? null; + {periods.map((p) => { + const points = series['holder_num'] as any[] | undefined; + const v = getValueByPeriod(points, p); if (v == null) { - return -; + return -; } - return {integerFormatter.format(Math.round(v))}; + return {integerFormatter.format(Math.round(v))}; })} ), diff --git a/frontend/src/app/reports/[id]/page.tsx b/frontend/src/app/reports/[id]/page.tsx index 5e494b6..8cb56f6 100644 --- a/frontend/src/app/reports/[id]/page.tsx +++ b/frontend/src/app/reports/[id]/page.tsx @@ -4,6 +4,7 @@ import remarkGfm from 'remark-gfm' import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs' import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card' import { Table, TableHeader, TableBody, TableHead, TableRow, TableCell } from '@/components/ui/table' +import { formatReportPeriod } from '@/lib/financial-utils' type Report = { id: string @@ -120,13 +121,13 @@ export default async function ReportDetailPage({ params }: { params: Promise<{ i const fin = (content?.financials ?? null) as null | { ts_code?: string name?: string - series?: Record> + series?: Record> meta?: any } const series = fin?.series || {} - const allPoints = Object.values(series).flat() as Array<{ year: string; value: number | null; month?: number | null }> - const years = Array.from(new Set(allPoints.map(p => p?.year).filter(Boolean) as string[])).sort((a, b) => Number(b) - Number(a)) + const allPoints = Object.values(series).flat() as Array<{ period: string; value: number | null }> + const periods = Array.from(new Set(allPoints.map(p => p?.period).filter(Boolean) as string[])).sort((a, b) => b.localeCompare(a)) const numberFormatter = new Intl.NumberFormat('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) const integerFormatter = new Intl.NumberFormat('zh-CN', { minimumFractionDigits: 0, maximumFractionDigits: 0 }) @@ -203,13 +204,9 @@ export default async function ReportDetailPage({ params }: { params: Promise<{ i 指标 - {years.map((y) => { - const yearData = allPoints.find(p => p.year === y) - const isCurrent = y === currentYearStr - const quarter = yearData?.month ? getQuarter(yearData.month) : null - const label = isCurrent && quarter ? `${y} Q${quarter}` : y - return {label} - })} + {periods.map((p) => ( + {formatReportPeriod(p)} + ))} @@ -217,34 +214,34 @@ export default async function ReportDetailPage({ params }: { params: Promise<{ i const summaryRow = ( 主要指标 - {years.map((y) => ( - + {periods.map((p) => ( + ))} ) const rows = ORDER.map(({ key, label, kind }) => { const isComputed = kind === 'computed' && key === '__free_cash_flow' - const points = series[key] as Array<{ year?: string; value?: number | null }>|undefined - const operating = series['n_cashflow_act'] as Array<{ year?: string; value?: number | null }>|undefined - const capex = series['c_pay_acq_const_fiolta'] as Array<{ year?: string; value?: number | null }>|undefined + const points = series[key] as Array<{ period?: string; value?: number | null }>|undefined + const operating = series['n_cashflow_act'] as Array<{ period?: string; value?: number | null }>|undefined + const capex = series['c_pay_acq_const_fiolta'] as Array<{ period?: string; value?: number | null }>|undefined return ( {label || metricDisplayMap[key] || key} - {years.map((y) => { + {periods.map((p) => { let v: number | null | undefined = undefined if (isComputed) { - const op = operating?.find(p => p?.year === y)?.value ?? null - const cp = capex?.find(p => p?.year === y)?.value ?? null + const op = operating?.find(pt => pt?.period === p)?.value ?? null + const cp = capex?.find(pt => pt?.period === p)?.value ?? null v = (op == null || cp == null) ? null : (Number(op) - Number(cp)) } else { - v = points?.find(p => p?.year === y)?.value ?? null + v = points?.find(pt => pt?.period === p)?.value ?? null } const groupName = metricGroupMap[key] const rawNum = typeof v === 'number' ? v : (v == null ? null : Number(v)) if (rawNum == null || Number.isNaN(rawNum)) { - return - + return - } if (PERCENT_KEYS.has(key)) { const perc = Math.abs(rawNum) <= 1 ? rawNum * 100 : rawNum @@ -283,8 +280,8 @@ export default async function ReportDetailPage({ params }: { params: Promise<{ i ) }) - const getVal = (arr: Array<{ year?: string; value?: number | null }> | undefined, y: string) => { - const v = arr?.find(p => p?.year === y)?.value + const getVal = (arr: Array<{ period?: string; value?: number | null }> | undefined, p: string) => { + const v = arr?.find(pt => pt?.period === p)?.value return typeof v === 'number' ? v : (v == null ? null : Number(v)) } @@ -292,8 +289,8 @@ export default async function ReportDetailPage({ params }: { params: Promise<{ i const feeHeaderRow = ( 费用指标 - {years.map((y) => ( - + {periods.map((p) => ( + ))} ) @@ -307,10 +304,10 @@ export default async function ReportDetailPage({ params }: { params: Promise<{ i ].map(({ key, label, num, den }) => ( {label} - {years.map((y) => { + {periods.map((p) => { let rate: number | null = null if (key === '__tax_rate') { - const numerator = getVal(num, y) + const numerator = getVal(num, p) if (numerator == null || Number.isNaN(numerator)) { rate = null } else if (Math.abs(numerator) <= 1) { @@ -319,12 +316,12 @@ export default async function ReportDetailPage({ params }: { params: Promise<{ i rate = numerator } } else if (key === '__other_fee_rate') { - const gpRaw = getVal(series['grossprofit_margin'] as any, y) - const npRaw = getVal(series['netprofit_margin'] as any, y) - const rev = getVal(series['revenue'] as any, y) - const sell = getVal(series['sell_exp'] as any, y) - const admin = getVal(series['admin_exp'] as any, y) - const rd = getVal(series['rd_exp'] as any, y) + const gpRaw = getVal(series['grossprofit_margin'] as any, p) + const npRaw = getVal(series['netprofit_margin'] as any, p) + const rev = getVal(series['revenue'] as any, p) + const sell = getVal(series['sell_exp'] as any, p) + const admin = getVal(series['admin_exp'] as any, p) + const rd = getVal(series['rd_exp'] as any, p) if (gpRaw == null || npRaw == null || rev == null || rev === 0 || sell == null || admin == null || rd == null) { rate = null } else { @@ -362,19 +359,19 @@ export default async function ReportDetailPage({ params }: { params: Promise<{ i const assetHeaderRow = ( 资产占比 - {years.map((y) => ( - + {periods.map((p) => ( + ))} ) - const ratioCell = (value: number | null, y: string) => { + const ratioCell = (value: number | null, p: string) => { if (value == null || !Number.isFinite(value)) { - return - + return - } const text = numberFormatter.format(value) const isNegative = value < 0 return ( - + {isNegative ? {text}% : `${text}%`} ) @@ -482,8 +479,8 @@ export default async function ReportDetailPage({ params }: { params: Promise<{ i const turnoverHeaderRow = ( 周转能力 - {years.map((y) => ( - + {periods.map((p) => ( + ))} ) @@ -491,14 +488,15 @@ export default async function ReportDetailPage({ params }: { params: Promise<{ i const n = Number(ys) return Number.isFinite(n) ? n : null } - const getPoint = (arr: Array<{ year?: string; value?: number | null }> | undefined, year: string) => { - return arr?.find(p => p?.year === year)?.value ?? null + const getPoint = (arr: Array<{ period?: string; value?: number | null }> | undefined, period: string) => { + return arr?.find(p => p?.period === period)?.value ?? null } - const getAvg = (arr: Array<{ year?: string; value?: number | null }> | undefined, year: string) => { - const curr = getPoint(arr, year) - const yNum = getYearNumber(year) + const getAvg = (arr: Array<{ period?: string; value?: number | null }> | undefined, period: string) => { + const curr = getPoint(arr, period) + const yNum = period.length >= 4 ? Number(period.substring(0, 4)) : null const prevYear = yNum != null ? String(yNum - 1) : null - const prev = prevYear ? getPoint(arr, prevYear) : null + const prevPeriod = prevYear ? prevYear + period.substring(4) : null + const prev = prevPeriod ? getPoint(arr, prevPeriod) : null const c = typeof curr === 'number' ? curr : (curr == null ? null : Number(curr)) const p = typeof prev === 'number' ? prev : (prev == null ? null : Number(prev)) if (c == null) return null @@ -534,28 +532,28 @@ export default async function ReportDetailPage({ params }: { params: Promise<{ i const turnoverRows = turnoverItems.map(({ key, label }) => ( {label} - {years.map((y) => { + {periods.map((p) => { let value: number | null = null if (key === 'payturn_days') { - const avgAP = getAvg(series['accounts_pay'] as any, y) - const cogs = getCOGS(y) + const avgAP = getAvg(series['accounts_pay'] as any, p) + const cogs = getCOGS(p) value = avgAP == null || cogs == null || cogs === 0 ? null : (365 * avgAP) / cogs } else { - const arr = series[key] as Array<{ year?: string; value?: number | null }> | undefined - const v = arr?.find(p => p?.year === y)?.value ?? null + const arr = series[key] as Array<{ period?: string; value?: number | null }> | undefined + const v = arr?.find(pt => pt?.period === p)?.value ?? null const num = typeof v === 'number' ? v : (v == null ? null : Number(v)) value = num == null || Number.isNaN(num) ? null : num } if (value == null || !Number.isFinite(value)) { - return - + return - } const text = numberFormatter.format(value) if (key === 'arturn_days' && value > 90) { return ( - {text} + {text} ) } - return {text} + return {text} })} )) @@ -564,8 +562,8 @@ export default async function ReportDetailPage({ params }: { params: Promise<{ i const perCapitaHeaderRow = ( 人均效率 - {years.map((y) => ( - + {periods.map((p) => ( + ))} ) @@ -628,69 +626,69 @@ export default async function ReportDetailPage({ params }: { params: Promise<{ i const marketHeaderRow = ( 市场表现 - {years.map((y) => ( - + {periods.map((p) => ( + ))} ) const priceRow = ( 股价 - {years.map((y) => { - const arr = series['close'] as Array<{ year?: string; value?: number | null }> | undefined - const v = arr?.find(p => p?.year === y)?.value ?? null + {periods.map((p) => { + const arr = series['close'] as Array<{ period?: string; value?: number | null }> | undefined + const v = arr?.find(pt => pt?.period === p)?.value ?? null const num = typeof v === 'number' ? v : (v == null ? null : Number(v)) - if (num == null || !Number.isFinite(num)) return - - return {numberFormatter.format(num)} + if (num == null || !Number.isFinite(num)) return - + return {numberFormatter.format(num)} })} ) const marketCapRow = ( 市值(亿元) - {years.map((y) => { - const arr = series['total_mv'] as Array<{ year?: string; value?: number | null }> | undefined - const v = arr?.find(p => p?.year === y)?.value ?? null + {periods.map((p) => { + const arr = series['total_mv'] as Array<{ period?: string; value?: number | null }> | undefined + const v = arr?.find(pt => pt?.period === p)?.value ?? null const num = typeof v === 'number' ? v : (v == null ? null : Number(v)) - if (num == null || !Number.isFinite(num)) return - + if (num == null || !Number.isFinite(num)) return - const scaled = num / 10000 - return {integerFormatter.format(Math.round(scaled))} + return {integerFormatter.format(Math.round(scaled))} })} ) const peRow = ( PE - {years.map((y) => { - const arr = series['pe'] as Array<{ year?: string; value?: number | null }> | undefined - const v = arr?.find(p => p?.year === y)?.value ?? null + {periods.map((p) => { + const arr = series['pe'] as Array<{ period?: string; value?: number | null }> | undefined + const v = arr?.find(pt => pt?.period === p)?.value ?? null const num = typeof v === 'number' ? v : (v == null ? null : Number(v)) - if (num == null || !Number.isFinite(num)) return - - return {numberFormatter.format(num)} + if (num == null || !Number.isFinite(num)) return - + return {numberFormatter.format(num)} })} ) const pbRow = ( PB - {years.map((y) => { - const arr = series['pb'] as Array<{ year?: string; value?: number | null }> | undefined - const v = arr?.find(p => p?.year === y)?.value ?? null + {periods.map((p) => { + const arr = series['pb'] as Array<{ period?: string; value?: number | null }> | undefined + const v = arr?.find(pt => pt?.period === p)?.value ?? null const num = typeof v === 'number' ? v : (v == null ? null : Number(v)) - if (num == null || !Number.isFinite(num)) return - - return {numberFormatter.format(num)} + if (num == null || !Number.isFinite(num)) return - + return {numberFormatter.format(num)} })} ) const holderNumRow = ( 股东户数 - {years.map((y) => { - const arr = series['holder_num'] as Array<{ year?: string; value?: number | null }> | undefined - const v = arr?.find(p => p?.year === y)?.value ?? null + {periods.map((p) => { + const arr = series['holder_num'] as Array<{ period?: string; value?: number | null }> | undefined + const v = arr?.find(pt => pt?.period === p)?.value ?? null const num = typeof v === 'number' ? v : (v == null ? null : Number(v)) - if (num == null || !Number.isFinite(num)) return - - return {integerFormatter.format(Math.round(num))} + if (num == null || !Number.isFinite(num)) return - + return {integerFormatter.format(Math.round(num))} })} ) diff --git a/frontend/src/lib/financial-utils.ts b/frontend/src/lib/financial-utils.ts index b0f6947..8352627 100644 --- a/frontend/src/lib/financial-utils.ts +++ b/frontend/src/lib/financial-utils.ts @@ -323,4 +323,25 @@ export function safeSetToStorage(key: string, value: unknown): boolean { } catch { return false; } -} \ No newline at end of file +} + +export const formatReportPeriod = (period: string): string => { + if (!period || period.length !== 8) { + return period; + } + const year = period.substring(0, 4); + const monthDay = period.substring(4); + + switch (monthDay) { + case '1231': + return `${year}A`; + case '0930': + return `${year}Q3`; + case '0630': + return `${year}Q2`; + case '0331': + return `${year}Q1`; + default: + return period; + } +}; \ No newline at end of file diff --git a/frontend/src/lib/prisma.ts b/frontend/src/lib/prisma.ts index 43e6e14..0affb38 100644 --- a/frontend/src/lib/prisma.ts +++ b/frontend/src/lib/prisma.ts @@ -36,3 +36,5 @@ export const prisma = if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma + + diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 7a50fc9..2761b3d 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -42,15 +42,13 @@ export interface CompanySuggestion { // ============================================================================ /** - * 年度数据点接口 + * 报告期数据点接口 */ -export interface YearDataPoint { - /** 年份 */ - year: string; +export interface PeriodDataPoint { + /** 报告期 (YYYYMMDD格式,如 20241231, 20250930) */ + period: string; /** 数值 (可为null表示无数据) */ value: number | null; - /** 月份信息,用于确定季度 */ - month?: number | null; } /** @@ -81,7 +79,7 @@ export interface FinancialMetricConfig { * 财务数据系列接口 */ export interface FinancialDataSeries { - [metricKey: string]: YearDataPoint[]; + [metricKey: string]: PeriodDataPoint[]; } /** diff --git a/scripts/test-api-tax-to-ebt.py b/scripts/test-api-tax-to-ebt.py deleted file mode 100644 index 4c53f64..0000000 --- a/scripts/test-api-tax-to-ebt.py +++ /dev/null @@ -1,56 +0,0 @@ -""" -测试脚本:通过后端 API 检查是否能获取 300750.SZ 的 tax_to_ebt 数据 -""" -import requests -import json - -def test_api(): - # 假设后端运行在默认端口 - url = "http://localhost:8000/api/financials/china/300750.SZ?years=5" - - try: - print(f"正在请求 API: {url}") - response = requests.get(url, timeout=30) - - if response.status_code == 200: - data = response.json() - - print(f"\n✅ API 请求成功") - print(f"股票代码: {data.get('ts_code')}") - print(f"公司名称: {data.get('name')}") - - # 检查 series 中是否有 tax_to_ebt - series = data.get('series', {}) - if 'tax_to_ebt' in series: - print(f"\n✅ 找到 tax_to_ebt 数据!") - tax_data = series['tax_to_ebt'] - print(f"数据条数: {len(tax_data)}") - print(f"\n最近几年的 tax_to_ebt 值:") - for item in tax_data[-5:]: # 显示最近5年 - year = item.get('year') - value = item.get('value') - month = item.get('month') - month_str = f"Q{((month or 12) - 1) // 3 + 1}" if month else "" - print(f" {year}{month_str}: {value}") - else: - print(f"\n❌ 未找到 tax_to_ebt 数据") - print(f"可用字段: {list(series.keys())[:20]}...") - - # 检查是否有其他税率相关字段 - tax_keys = [k for k in series.keys() if 'tax' in k.lower()] - if tax_keys: - print(f"\n包含 'tax' 的字段: {tax_keys}") - else: - print(f"❌ API 请求失败: {response.status_code}") - print(f"响应内容: {response.text}") - - except requests.exceptions.ConnectionError: - print("❌ 无法连接到后端服务,请确保后端正在运行(例如运行 python dev.py)") - except Exception as e: - print(f"❌ 请求出错: {e}") - import traceback - traceback.print_exc() - -if __name__ == "__main__": - test_api() - diff --git a/scripts/test-config.py b/scripts/test-config.py deleted file mode 100644 index 44f9767..0000000 --- a/scripts/test-config.py +++ /dev/null @@ -1,122 +0,0 @@ -#!/usr/bin/env python3 -""" -配置页面功能测试脚本 -""" -import asyncio -import json -import sys -import os - -# 添加项目根目录到Python路径 -sys.path.append(os.path.join(os.path.dirname(__file__), '..', 'backend')) - -from app.services.config_manager import ConfigManager -from app.schemas.config import ConfigUpdateRequest, DatabaseConfig, GeminiConfig, DataSourceConfig - -async def test_config_manager(): - """测试配置管理器功能""" - print("🧪 开始测试配置管理器...") - - # 这里需要实际的数据库会话,暂时跳过 - print("⚠️ 需要数据库连接,跳过实际测试") - print("✅ 配置管理器代码结构正确") - -def test_config_validation(): - """测试配置验证功能""" - print("\n🔍 测试配置验证...") - - # 测试数据库URL验证 - valid_urls = [ - "postgresql://user:pass@host:port/db", - "postgresql+asyncpg://user:pass@host:port/db" - ] - - invalid_urls = [ - "mysql://user:pass@host:port/db", - "invalid-url", - "" - ] - - for url in valid_urls: - if url.startswith(("postgresql://", "postgresql+asyncpg://")): - print(f"✅ 有效URL: {url}") - else: - print(f"❌ 应该有效但被拒绝: {url}") - - for url in invalid_urls: - if not url.startswith(("postgresql://", "postgresql+asyncpg://")): - print(f"✅ 无效URL正确被拒绝: {url}") - else: - print(f"❌ 应该无效但被接受: {url}") - -def test_api_key_validation(): - """测试API Key验证""" - print("\n🔑 测试API Key验证...") - - valid_keys = ["1234567890", "abcdefghijklmnop"] - invalid_keys = ["123", "short", ""] - - for key in valid_keys: - if len(key) >= 10: - print(f"✅ 有效API Key: {key[:10]}...") - else: - print(f"❌ 应该有效但被拒绝: {key}") - - for key in invalid_keys: - if len(key) < 10: - print(f"✅ 无效API Key正确被拒绝: {key}") - else: - print(f"❌ 应该无效但被接受: {key}") - -def test_config_export_import(): - """测试配置导入导出功能""" - print("\n📤 测试配置导入导出...") - - # 模拟配置数据 - config_data = { - "database": {"url": "postgresql://test:test@localhost:5432/test"}, - "gemini_api": {"api_key": "test_key_1234567890", "base_url": "https://api.example.com"}, - "data_sources": { - "tushare": {"api_key": "tushare_key_1234567890"}, - "finnhub": {"api_key": "finnhub_key_1234567890"} - } - } - - # 测试JSON序列化 - try: - json_str = json.dumps(config_data, indent=2) - parsed = json.loads(json_str) - print("✅ 配置JSON序列化/反序列化正常") - - # 验证必需字段 - required_fields = ["database", "gemini_api", "data_sources"] - for field in required_fields: - if field in parsed: - print(f"✅ 包含必需字段: {field}") - else: - print(f"❌ 缺少必需字段: {field}") - - except Exception as e: - print(f"❌ JSON处理失败: {e}") - -def main(): - """主测试函数""" - print("🚀 配置页面功能测试") - print("=" * 50) - - test_config_validation() - test_api_key_validation() - test_config_export_import() - - print("\n" + "=" * 50) - print("✅ 所有测试完成!") - print("\n📋 测试总结:") - print("• 配置验证逻辑正确") - print("• API Key验证工作正常") - print("• 配置导入导出功能正常") - print("• 前端UI组件已创建") - print("• 后端API接口已实现") - print("• 错误处理机制已添加") - -if __name__ == "__main__": - main() diff --git a/scripts/test-employees.py b/scripts/test-employees.py deleted file mode 100755 index 51c29d7..0000000 --- a/scripts/test-employees.py +++ /dev/null @@ -1,82 +0,0 @@ -#!/usr/bin/env python3 -""" -测试员工数数据获取功能 -""" -import asyncio -import sys -import os -import json - -# 添加项目根目录到Python路径 -sys.path.append(os.path.join(os.path.dirname(__file__), '..', 'backend')) - -from tushare_legacy_client import TushareLegacyClient as TushareClient - - -async def test_employees_data(): - """测试获取员工数数据""" - print("🧪 测试员工数数据获取...") - print("=" * 50) - - # 从环境变量或配置文件读取 token - base_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '..')) - config_path = os.path.join(base_dir, 'config', 'config.json') - - token = os.environ.get('TUSHARE_TOKEN') - if not token and os.path.exists(config_path): - with open(config_path, 'r', encoding='utf-8') as f: - config = json.load(f) - token = config.get('data_sources', {}).get('tushare', {}).get('api_key') - - if not token: - print("❌ 未找到 Tushare token") - print("请设置环境变量 TUSHARE_TOKEN 或在 config/config.json 中配置") - return - - print(f"✅ Token 已加载: {token[:10]}...") - - # 测试股票代码 - test_ts_code = "000001.SZ" # 平安银行 - - async with TushareClient(token=token) as client: - try: - print(f"\n📊 查询股票: {test_ts_code}") - print("调用 stock_company API...") - - # 调用 stock_company API - data = await client.query( - api_name="stock_company", - params={"ts_code": test_ts_code, "limit": 10} - ) - - if data: - print(f"✅ 成功获取 {len(data)} 条记录") - print("\n返回的数据字段:") - if data: - for key in data[0].keys(): - print(f" - {key}") - - print("\n员工数相关字段:") - for row in data: - if 'employees' in row: - print(f" ✅ employees: {row.get('employees')}") - if 'employee' in row: - print(f" ✅ employee: {row.get('employee')}") - - print("\n完整数据示例:") - print(json.dumps(data[0], indent=2, ensure_ascii=False)) - else: - print("⚠️ 未返回数据") - - except Exception as e: - print(f"❌ 错误: {e}") - import traceback - traceback.print_exc() - - -if __name__ == "__main__": - print("🚀 开始测试员工数数据获取功能\n") - asyncio.run(test_employees_data()) - print("\n" + "=" * 50) - print("✅ 测试完成") - diff --git a/scripts/test-holder-number.py b/scripts/test-holder-number.py deleted file mode 100755 index 569c6c4..0000000 --- a/scripts/test-holder-number.py +++ /dev/null @@ -1,104 +0,0 @@ -#!/usr/bin/env python3 -""" -测试股东数数据获取功能 -""" -import asyncio -import sys -import os -import json -from datetime import datetime, timedelta - -# 添加项目根目录到Python路径 -sys.path.append(os.path.join(os.path.dirname(__file__), '..', 'backend')) - -from tushare_legacy_client import TushareLegacyClient as TushareClient - - -async def test_holder_number_data(): - """测试获取股东数数据""" - print("🧪 测试股东数数据获取...") - print("=" * 50) - - # 从环境变量或配置文件读取 token - base_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '..')) - config_path = os.path.join(base_dir, 'config', 'config.json') - - token = os.environ.get('TUSHARE_TOKEN') - if not token and os.path.exists(config_path): - with open(config_path, 'r', encoding='utf-8') as f: - config = json.load(f) - token = config.get('data_sources', {}).get('tushare', {}).get('api_key') - - if not token: - print("❌ 未找到 Tushare token") - print("请设置环境变量 TUSHARE_TOKEN 或在 config/config.json 中配置") - return - - print(f"✅ Token 已加载: {token[:10]}...") - - # 测试股票代码 - test_ts_code = "000001.SZ" # 平安银行 - years = 5 # 查询最近5年的数据 - - # 计算日期范围 - end_date = datetime.now().strftime("%Y%m%d") - start_date = (datetime.now() - timedelta(days=years * 365)).strftime("%Y%m%d") - - async with TushareClient(token=token) as client: - try: - print(f"\n📊 查询股票: {test_ts_code}") - print(f"📅 日期范围: {start_date} 到 {end_date}") - print("调用 stk_holdernumber API...") - - # 调用 stk_holdernumber API - data = await client.query( - api_name="stk_holdernumber", - params={ - "ts_code": test_ts_code, - "start_date": start_date, - "end_date": end_date, - "limit": 5000 - } - ) - - if data: - print(f"✅ 成功获取 {len(data)} 条记录") - print("\n返回的数据字段:") - if data: - for key in data[0].keys(): - print(f" - {key}") - - print("\n股东数数据:") - print("-" * 60) - for row in data[:10]: # 只显示前10条 - end_date_val = row.get('end_date', 'N/A') - holder_num = row.get('holder_num', 'N/A') - print(f" 日期: {end_date_val}, 股东数: {holder_num}") - - if len(data) > 10: - print(f" ... 还有 {len(data) - 10} 条记录") - - print("\n完整数据示例(第一条):") - print(json.dumps(data[0], indent=2, ensure_ascii=False)) - - # 检查是否有 holder_num 字段 - if data and 'holder_num' in data[0]: - print("\n✅ 成功获取 holder_num 字段数据") - else: - print("\n⚠️ 未找到 holder_num 字段") - - else: - print("⚠️ 未返回数据") - - except Exception as e: - print(f"❌ 错误: {e}") - import traceback - traceback.print_exc() - - -if __name__ == "__main__": - print("🚀 开始测试股东数数据获取功能\n") - asyncio.run(test_holder_number_data()) - print("\n" + "=" * 50) - print("✅ 测试完成") - diff --git a/scripts/test-holder-processing.py b/scripts/test-holder-processing.py deleted file mode 100755 index 80abb3e..0000000 --- a/scripts/test-holder-processing.py +++ /dev/null @@ -1,115 +0,0 @@ -#!/usr/bin/env python3 -""" -测试股东数数据处理逻辑 -""" -import asyncio -import sys -import os -import json -from datetime import datetime, timedelta - -# 添加项目根目录到Python路径 -sys.path.append(os.path.join(os.path.dirname(__file__), '..', 'backend')) - -from tushare_legacy_client import TushareLegacyClient as TushareClient - - -async def test_holder_num_processing(): - """测试股东数数据处理逻辑""" - print("🧪 测试股东数数据处理逻辑...") - print("=" * 50) - - # 从环境变量或配置文件读取 token - base_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '..')) - config_path = os.path.join(base_dir, 'config', 'config.json') - - token = os.environ.get('TUSHARE_TOKEN') - if not token and os.path.exists(config_path): - with open(config_path, 'r', encoding='utf-8') as f: - config = json.load(f) - token = config.get('data_sources', {}).get('tushare', {}).get('api_key') - - if not token: - print("❌ 未找到 Tushare token") - return - - ts_code = '000001.SZ' - years = 5 - - async with TushareClient(token=token) as client: - # 模拟后端处理逻辑 - end_date = datetime.now().strftime('%Y%m%d') - start_date = (datetime.now() - timedelta(days=years * 365)).strftime('%Y%m%d') - - print(f"📊 查询股票: {ts_code}") - print(f"📅 日期范围: {start_date} 到 {end_date}") - - data_rows = await client.query( - api_name='stk_holdernumber', - params={'ts_code': ts_code, 'start_date': start_date, 'end_date': end_date, 'limit': 5000} - ) - - print(f'\n✅ 获取到 {len(data_rows)} 条原始数据') - - if data_rows: - print('\n原始数据示例(前3条):') - for i, row in enumerate(data_rows[:3]): - print(f" 第{i+1}条: {json.dumps(row, indent=4, ensure_ascii=False)}") - - # 模拟后端处理逻辑 - series = {} - tmp = {} - date_field = 'end_date' - - print('\n📝 开始处理数据...') - - for row in data_rows: - date_val = row.get(date_field) - if not date_val: - print(f" ⚠️ 跳过无日期字段的行: {row}") - 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 - - print(f'\n✅ 处理后共有 {len(tmp)} 个年份的数据') - print('按年份分组的数据:') - for year, row in sorted(tmp.items(), key=lambda x: x[0], reverse=True): - print(f" {year}: holder_num={row.get('holder_num')}, end_date={row.get('end_date')}") - - # 提取 holder_num 字段 - key = 'holder_num' - for year, row in tmp.items(): - month = row.get('_month') - value = row.get(key) - - arr = series.setdefault(key, []) - arr.append({'year': year, 'value': value, 'month': month}) - - print('\n📊 提取后的 series 数据:') - print(json.dumps(series, indent=2, ensure_ascii=False)) - - # 排序(模拟后端逻辑) - for key, arr in series.items(): - uniq = {item['year']: item for item in arr} - arr_sorted_desc = sorted(uniq.values(), key=lambda x: x['year'], reverse=True) - arr_limited = arr_sorted_desc[:years] - arr_sorted = sorted(arr_limited, key=lambda x: x['year']) # ascending - series[key] = arr_sorted - - print('\n✅ 最终排序后的数据(按年份升序):') - print(json.dumps(series, indent=2, ensure_ascii=False)) - - # 验证年份格式 - print('\n🔍 验证年份格式:') - for item in series.get('holder_num', []): - year_str = item.get('year') - print(f" 年份: '{year_str}' (类型: {type(year_str).__name__}, 长度: {len(str(year_str))})") - - -if __name__ == "__main__": - asyncio.run(test_holder_num_processing()) - diff --git a/scripts/test-tax-to-ebt.py b/scripts/test-tax-to-ebt.py deleted file mode 100644 index 946b39d..0000000 --- a/scripts/test-tax-to-ebt.py +++ /dev/null @@ -1,110 +0,0 @@ -""" -测试脚本:检查是否能获取 300750.SZ 的 tax_to_ebt 数据 -""" -import asyncio -import sys -import os -import json - -# 添加 backend 目录到 Python 路径 -sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "backend")) - -from tushare_legacy_client import TushareLegacyClient as TushareClient - -async def test_tax_to_ebt(): - # 读取配置获取 token - config_path = os.path.join(os.path.dirname(__file__), "..", "config", "config.json") - with open(config_path, "r", encoding="utf-8") as f: - config = json.load(f) - - token = config.get("data_sources", {}).get("tushare", {}).get("api_key") - if not token: - print("错误:未找到 Tushare token") - return - - client = TushareClient(token=token) - ts_code = "300750.SZ" - - try: - print(f"正在查询 {ts_code} 的财务指标数据...") - - # 先尝试不指定 fields,获取所有字段 - print("\n=== 测试1: 不指定 fields 参数 ===") - data = await client.query( - api_name="fina_indicator", - params={"ts_code": ts_code, "limit": 10} - ) - - # 再尝试明确指定 fields,包含 tax_to_ebt - print("\n=== 测试2: 明确指定 fields 参数(包含 tax_to_ebt) ===") - data_with_fields = await client.query( - api_name="fina_indicator", - params={"ts_code": ts_code, "limit": 10}, - fields="ts_code,ann_date,end_date,tax_to_ebt,roe,roa" - ) - - print(f"\n获取到 {len(data)} 条记录") - - if data: - # 检查第一条记录的字段 - first_record = data[0] - print(f"\n第一条记录的字段:") - print(f" ts_code: {first_record.get('ts_code')}") - print(f" end_date: {first_record.get('end_date')}") - print(f" ann_date: {first_record.get('ann_date')}") - - # 检查是否有 tax_to_ebt 字段 - if 'tax_to_ebt' in first_record: - tax_value = first_record.get('tax_to_ebt') - print(f"\n✅ 找到 tax_to_ebt 字段!") - print(f" tax_to_ebt 值: {tax_value}") - print(f" tax_to_ebt 类型: {type(tax_value)}") - else: - print(f"\n❌ 未找到 tax_to_ebt 字段") - print(f"可用字段列表: {list(first_record.keys())[:20]}...") # 只显示前20个字段 - - # 打印所有包含 tax 的字段 - tax_fields = [k for k in first_record.keys() if 'tax' in k.lower()] - if tax_fields: - print(f"\n包含 'tax' 的字段:") - for field in tax_fields: - print(f" {field}: {first_record.get(field)}") - - # 显示最近几条记录的 tax_to_ebt 值 - print(f"\n最近几条记录的 tax_to_ebt 值(测试1):") - for i, record in enumerate(data[:5]): - end_date = record.get('end_date', 'N/A') - tax_value = record.get('tax_to_ebt', 'N/A') - print(f" {i+1}. {end_date}: tax_to_ebt = {tax_value}") - else: - print("❌ 未获取到任何数据(测试1)") - - # 测试2:检查明确指定 fields 的结果 - if data_with_fields: - print(f"\n测试2获取到 {len(data_with_fields)} 条记录") - first_record2 = data_with_fields[0] - if 'tax_to_ebt' in first_record2: - print(f"✅ 测试2找到 tax_to_ebt 字段!") - print(f" tax_to_ebt 值: {first_record2.get('tax_to_ebt')}") - else: - print(f"❌ 测试2也未找到 tax_to_ebt 字段") - print(f"可用字段: {list(first_record2.keys())}") - - print(f"\n最近几条记录的 tax_to_ebt 值(测试2):") - for i, record in enumerate(data_with_fields[:5]): - end_date = record.get('end_date', 'N/A') - tax_value = record.get('tax_to_ebt', 'N/A') - print(f" {i+1}. {end_date}: tax_to_ebt = {tax_value}") - else: - print("❌ 未获取到任何数据(测试2)") - - except Exception as e: - print(f"❌ 查询出错: {e}") - import traceback - traceback.print_exc() - finally: - await client.aclose() - -if __name__ == "__main__": - asyncio.run(test_tax_to_ebt()) - diff --git a/scripts/tushare_legacy_client.py b/scripts/tushare_legacy_client.py deleted file mode 100644 index 0e5fe8b..0000000 --- a/scripts/tushare_legacy_client.py +++ /dev/null @@ -1,41 +0,0 @@ -import sys -import os -import asyncio -from typing import Any, Dict, List, Optional - -# Add backend to path to import TushareProvider -sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "backend")) -from app.data_providers.tushare import TushareProvider - -class TushareLegacyClient: - """ - An adapter to mimic the old TushareClient for legacy scripts, - but uses the new TushareProvider under the hood. - """ - def __init__(self, token: str): - if not token: - raise ValueError("Token must be provided.") - self.provider = TushareProvider(token=token) - - async def query( - self, - api_name: str, - params: Optional[Dict[str, Any]] = None, - fields: Optional[str] = None, # Note: fields are not used in the new provider's _query - ) -> List[Dict[str, Any]]: - """ - Mimics the .query() method by calling the provider's internal _query method. - """ - # The new _query method is protected, but we call it here for the script's sake. - return await self.provider._query(api_name=api_name, params=params, fields=fields) - - async def aclose(self): - """Mimic aclose to allow 'async with' syntax.""" - if hasattr(self.provider, '_client') and self.provider._client: - await self.provider._client.aclose() - - async def __aenter__(self): - return self - - async def __aexit__(self, exc_type, exc, tb): - await self.aclose()