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()