feat(数据): 新增员工、股东及税务指标并生成日志

- 后端: Tushare provider 新增 get_employee_number, get_holder_number, get_tax_to_ebt 方法,并在 financial 路由中集成。
- 前端: report 页面新增对应图表展示,并更新相关类型与工具函数。
- 清理: 移除多个过时的测试脚本。
- 文档: 创建 2025-11-04 开发日志并更新用户手册。
This commit is contained in:
xucheng 2025-11-04 21:22:32 +08:00
parent 3ffb30696b
commit 3475138419
19 changed files with 858 additions and 1025 deletions

View File

@ -152,8 +152,33 @@ class DataManager:
logger.error(f"All data providers failed for '{stock_code}' on method '{method_name}'.") logger.error(f"All data providers failed for '{stock_code}' on method '{method_name}'.")
return None return None
async def get_financial_statements(self, stock_code: str, report_dates: List[str]) -> List[Dict[str, Any]]: async def get_financial_statements(self, stock_code: str, report_dates: List[str]) -> Dict[str, List[Dict[str, Any]]]:
return await self.get_data('get_financial_statements', stock_code, report_dates=report_dates) 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]]: 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) return await self.get_data('get_daily_price', stock_code, start_date=start_date, end_date=end_date)

View File

@ -1,65 +1,166 @@
from .base import BaseDataProvider from .base import BaseDataProvider
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional, Callable
import httpx
import logging import logging
import asyncio import asyncio
import tushare as ts
import math
import datetime
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
TUSHARE_PRO_URL = "https://api.tushare.pro"
class TushareProvider(BaseDataProvider): class TushareProvider(BaseDataProvider):
def _initialize(self): def _initialize(self):
if not self.token: if not self.token:
raise ValueError("Tushare API token not provided.") raise ValueError("Tushare API token not provided.")
# Use httpx.AsyncClient directly # 使用官方 SDK 客户端
self._client = httpx.AsyncClient(timeout=30) 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( async def _query(
self, self,
api_name: str, api_name: str,
params: Optional[Dict[str, Any]] = None, params: Optional[Dict[str, Any]] = None,
fields: Optional[str] = None, fields: Optional[List[str]] = None,
) -> List[Dict[str, Any]]: ) -> List[Dict[str, Any]]:
payload = { """
"api_name": api_name, 使用官方 tushare SDK 统一查询返回字典列表
"token": self.token, 为避免阻塞事件循环内部通过 asyncio.to_thread 在线程中执行同步调用
"params": params or {}, """
} params = params or {}
if "limit" not in payload["params"]:
payload["params"]["limit"] = 5000
if fields:
payload["fields"] = fields
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: try:
resp = await self._client.post(TUSHARE_PRO_URL, json=payload) rows: List[Dict[str, Any]] = await asyncio.to_thread(_call)
resp.raise_for_status() # 清洗 NaN/Inf避免 JSON 序列化错误
data = resp.json() DATE_KEYS = {
"cal_date", "pretrade_date", "trade_date", "trade_dt", "date",
"end_date", "ann_date", "f_ann_date", "period"
}
if data.get("code") != 0: def _sanitize_value(key: str, v: Any) -> Any:
err_msg = data.get("msg") or "Unknown Tushare error" if v is None:
logger.error(f"Tushare API error for '{api_name}': {err_msg}") return None
raise RuntimeError(f"{api_name}: {err_msg}") # 保持日期/期末字段为字符串(避免 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:
# 利用自反性判断 NaNNaN != NaN
try:
if v != v:
return None
except Exception:
pass
return v
fields_def = data.get("data", {}).get("fields", []) for row in rows:
items = data.get("data", {}).get("items", []) for k, v in list(row.items()):
row[k] = _sanitize_value(k, v)
rows: List[Dict[str, Any]] = [] # logger.info(f"Tushare '{api_name}' returned {len(rows)} rows.")
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 return rows
except httpx.HTTPStatusError as e:
logger.error(f"HTTP error calling Tushare API '{api_name}': {e.response.status_code} - {e.response.text}")
raise
except Exception as e: except Exception as e:
logger.error(f"Exception calling Tushare API '{api_name}': {e}") logger.error(f"Exception calling tushare '{api_name}': {e}")
raise raise
async def get_stock_basic(self, stock_code: str) -> Optional[Dict[str, Any]]: 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]]: async def get_daily_price(self, stock_code: str, start_date: str, end_date: str) -> List[Dict[str, Any]]:
try: try:
return await self._query( rows = await self._query(
api_name="daily", api_name="daily",
params={ params={
"ts_code": stock_code, "ts_code": stock_code,
@ -83,6 +184,7 @@ class TushareProvider(BaseDataProvider):
"end_date": end_date, "end_date": end_date,
}, },
) )
return rows or []
except Exception as e: except Exception as e:
logger.error(f"Tushare get_daily_price failed for {stock_code}: {e}") logger.error(f"Tushare get_daily_price failed for {stock_code}: {e}")
return [] return []
@ -92,18 +194,25 @@ class TushareProvider(BaseDataProvider):
获取指定交易日列表的 daily_basic 数据例如 total_mvpepb 获取指定交易日列表的 daily_basic 数据例如 total_mvpepb
""" """
try: try:
tasks = [ if not trade_dates:
self._query( 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", api_name="daily_basic",
params={"ts_code": stock_code, "trade_date": d}, params={
) for d in trade_dates "ts_code": stock_code,
] "start_date": start_date,
results = await asyncio.gather(*tasks, return_exceptions=True) "end_date": end_date,
rows: List[Dict[str, Any]] = [] },
for res in results: )
if isinstance(res, list) and res: wanted = set(resolved_dates)
rows.extend(res) 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)} dates") 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 return rows
except Exception as e: except Exception as e:
logger.error(f"Tushare get_daily_basic_points failed for {stock_code}: {e}") logger.error(f"Tushare get_daily_basic_points failed for {stock_code}: {e}")
@ -114,32 +223,37 @@ class TushareProvider(BaseDataProvider):
获取指定交易日列表的日行情例如 close 获取指定交易日列表的日行情例如 close
""" """
try: try:
tasks = [ if not trade_dates:
self._query( 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", api_name="daily",
params={"ts_code": stock_code, "trade_date": d}, params={
) for d in trade_dates "ts_code": stock_code,
] "start_date": start_date,
results = await asyncio.gather(*tasks, return_exceptions=True) "end_date": end_date,
rows: List[Dict[str, Any]] = [] },
for res in results: )
if isinstance(res, list) and res: wanted = set(resolved_dates)
rows.extend(res) 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)} dates") 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 return rows
except Exception as e: except Exception as e:
logger.error(f"Tushare get_daily_points failed for {stock_code}: {e}") logger.error(f"Tushare get_daily_points failed for {stock_code}: {e}")
return [] 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 内部计算派生指标 Tushare provider 内部计算派生指标
""" """
# --- Helper Functions --- # --- 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: if key not in series:
return None 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: if point is None or point.get("value") is None:
return None return None
try: try:
@ -147,20 +261,22 @@ class TushareProvider(BaseDataProvider):
except (ValueError, TypeError): except (ValueError, TypeError):
return None return None
def _get_avg_value(key: str, year: str) -> Optional[float]: def _get_avg_value(key: str, period: str) -> Optional[float]:
current_val = _get_value(key, year) current_val = _get_value(key, period)
try: 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): except (ValueError, TypeError):
prev_val = None prev_val = None
if current_val is None: return None if current_val is None: return None
if prev_val is None: return current_val if prev_val is None: return current_val
return (current_val + prev_val) / 2 return (current_val + prev_val) / 2
def _get_cogs(year: str) -> Optional[float]: def _get_cogs(period: str) -> Optional[float]:
revenue = _get_value('revenue', year) revenue = _get_value('revenue', period)
gp_margin_raw = _get_value('grossprofit_margin', year) gp_margin_raw = _get_value('grossprofit_margin', period)
if revenue is None or gp_margin_raw is None: return None 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 gp_margin = gp_margin_raw / 100.0 if abs(gp_margin_raw) > 1 else gp_margin_raw
return revenue * (1 - gp_margin) return revenue * (1 - gp_margin)
@ -171,11 +287,11 @@ class TushareProvider(BaseDataProvider):
# --- Calculations --- # --- Calculations ---
fcf_data = [] fcf_data = []
for year in years: for period in periods:
op_cashflow = _get_value('n_cashflow_act', year) op_cashflow = _get_value('n_cashflow_act', period)
capex = _get_value('c_pay_acq_const_fiolta', year) capex = _get_value('c_pay_acq_const_fiolta', period)
if op_cashflow is not None and capex is not None: 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) add_series('__free_cash_flow', fcf_data)
fee_calcs = [ fee_calcs = [
@ -186,29 +302,29 @@ class TushareProvider(BaseDataProvider):
] ]
for key, num_key, den_key in fee_calcs: for key, num_key, den_key in fee_calcs:
data = [] data = []
for year in years: for period in periods:
numerator = _get_value(num_key, year) numerator = _get_value(num_key, period)
denominator = _get_value(den_key, year) denominator = _get_value(den_key, period)
if numerator is not None and denominator is not None and denominator != 0: 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) add_series(key, data)
tax_rate_data = [] tax_rate_data = []
for year in years: for period in periods:
tax_to_ebt = _get_value('tax_to_ebt', year) tax_to_ebt = _get_value('tax_to_ebt', period)
if tax_to_ebt is not None: if tax_to_ebt is not None:
rate = tax_to_ebt * 100 if abs(tax_to_ebt) <= 1 else tax_to_ebt 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) add_series('__tax_rate', tax_rate_data)
other_fee_data = [] other_fee_data = []
for year in years: for period in periods:
gp_raw = _get_value('grossprofit_margin', year) gp_raw = _get_value('grossprofit_margin', period)
np_raw = _get_value('netprofit_margin', year) np_raw = _get_value('netprofit_margin', period)
rev = _get_value('revenue', year) rev = _get_value('revenue', period)
sell_exp = _get_value('sell_exp', year) sell_exp = _get_value('sell_exp', period)
admin_exp = _get_value('admin_exp', year) admin_exp = _get_value('admin_exp', period)
rd_exp = _get_value('rd_exp', year) 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: 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 gp = gp_raw / 100 if abs(gp_raw) > 1 else gp_raw
np = np_raw / 100 if abs(np_raw) > 1 else np_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 admin_rate = admin_exp / rev
rd_rate = rd_exp / rev rd_rate = rd_exp / rev
other_rate = (gp - np - sell_rate - admin_rate - rd_rate) * 100 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) add_series('__other_fee_rate', other_fee_data)
asset_ratio_keys = [ asset_ratio_keys = [
@ -228,60 +344,60 @@ class TushareProvider(BaseDataProvider):
] ]
for key, num_key in asset_ratio_keys: for key, num_key in asset_ratio_keys:
data = [] data = []
for year in years: for period in periods:
numerator = _get_value(num_key, year) numerator = _get_value(num_key, period)
denominator = _get_value('total_assets', year) denominator = _get_value('total_assets', period)
if numerator is not None and denominator is not None and denominator != 0: 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) add_series(key, data)
adv_data = [] adv_data = []
for year in years: for period in periods:
adv = _get_value('adv_receipts', year) or 0 adv = _get_value('adv_receipts', period) or 0
contract = _get_value('contract_liab', year) or 0 contract = _get_value('contract_liab', period) or 0
total_assets = _get_value('total_assets', year) total_assets = _get_value('total_assets', period)
if total_assets is not None and total_assets != 0: 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) add_series('__adv_ratio', adv_data)
other_assets_data = [] other_assets_data = []
known_assets_keys = ['money_cap', 'inventories', 'accounts_receiv_bill', 'prepayment', 'fix_assets', 'lt_eqt_invest', 'goodwill'] known_assets_keys = ['money_cap', 'inventories', 'accounts_receiv_bill', 'prepayment', 'fix_assets', 'lt_eqt_invest', 'goodwill']
for year in years: for period in periods:
total_assets = _get_value('total_assets', year) total_assets = _get_value('total_assets', period)
if total_assets is not None and total_assets != 0: 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) sum_known = sum(_get_value(k, period) or 0 for k in known_assets_keys)
other_assets_data.append({"year": year, "value": ((total_assets - sum_known) / total_assets) * 100}) other_assets_data.append({"period": period, "value": ((total_assets - sum_known) / total_assets) * 100})
add_series('__other_assets_ratio', other_assets_data) add_series('__other_assets_ratio', other_assets_data)
op_assets_data = [] op_assets_data = []
for year in years: for period in periods:
total_assets = _get_value('total_assets', year) total_assets = _get_value('total_assets', period)
if total_assets is not None and total_assets != 0: if total_assets is not None and total_assets != 0:
inv = _get_value('inventories', year) or 0 inv = _get_value('inventories', period) or 0
ar = _get_value('accounts_receiv_bill', year) or 0 ar = _get_value('accounts_receiv_bill', period) or 0
pre = _get_value('prepayment', year) or 0 pre = _get_value('prepayment', period) or 0
ap = _get_value('accounts_pay', year) or 0 ap = _get_value('accounts_pay', period) or 0
adv = _get_value('adv_receipts', year) or 0 adv = _get_value('adv_receipts', period) or 0
contract_liab = _get_value('contract_liab', year) or 0 contract_liab = _get_value('contract_liab', period) or 0
operating_assets = inv + ar + pre - ap - adv - contract_liab 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) add_series('__operating_assets_ratio', op_assets_data)
debt_ratio_data = [] debt_ratio_data = []
for year in years: for period in periods:
total_assets = _get_value('total_assets', year) total_assets = _get_value('total_assets', period)
if total_assets is not None and total_assets != 0: if total_assets is not None and total_assets != 0:
st_borr = _get_value('st_borr', year) or 0 st_borr = _get_value('st_borr', period) or 0
lt_borr = _get_value('lt_borr', year) or 0 lt_borr = _get_value('lt_borr', period) or 0
debt_ratio_data.append({"year": year, "value": ((st_borr + lt_borr) / total_assets) * 100}) debt_ratio_data.append({"period": period, "value": ((st_borr + lt_borr) / total_assets) * 100})
add_series('__interest_bearing_debt_ratio', debt_ratio_data) add_series('__interest_bearing_debt_ratio', debt_ratio_data)
payturn_data = [] payturn_data = []
for year in years: for period in periods:
avg_ap = _get_avg_value('accounts_pay', year) avg_ap = _get_avg_value('accounts_pay', period)
cogs = _get_cogs(year) cogs = _get_cogs(period)
if avg_ap is not None and cogs is not None and cogs != 0: 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) add_series('payturn_days', payturn_data)
per_capita_calcs = [ per_capita_calcs = [
@ -291,82 +407,318 @@ class TushareProvider(BaseDataProvider):
] ]
for key, num_key, divisor in per_capita_calcs: for key, num_key, divisor in per_capita_calcs:
data = [] data = []
for year in years: for period in periods:
numerator = _get_value(num_key, year) numerator = _get_value(num_key, period)
employees = _get_value('employees', year) employees = _get_value('employees', period)
if numerator is not None and employees is not None and employees != 0: 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) add_series(key, data)
return series return series
async def get_financial_statements(self, stock_code: str, report_dates: List[str]) -> Dict[str, List[Dict[str, Any]]]: async def get_financial_statements(self, stock_code: str, report_dates: Optional[List[str]] = None) -> Dict[str, List[Dict[str, Any]]]:
all_statements: List[Dict[str, Any]] = [] # 1) 一次性拉取所需四表(尽量齐全字段),再按指定 report_dates 过滤
for date in report_dates: # 字段列表基于官方示例,避免超量请求可按需精简
logger.info(f"Fetching financial statements for {stock_code}, report date: {date}") 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: try:
bs_rows, ic_rows, cf_rows, fi_rows = await asyncio.gather( 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( self._query(
api_name="balancesheet", "repurchase",
params={"ts_code": stock_code, "period": date, "report_type": 1}, params={"ts_code": stock_code},
fields=[
"ts_code","ann_date","end_date","proc","exp_date","vol","amount","high_limit","low_limit",
],
), ),
# 分红公告(仅取必要字段)
self._query( self._query(
api_name="income", "dividend",
params={"ts_code": stock_code, "period": date, "report_type": 1}, params={"ts_code": stock_code},
fields=[
"ts_code","end_date","cash_div_tax","pay_date","base_share",
],
), ),
# 股东户数(按报告期)
self._query( self._query(
api_name="cashflow", "stk_holdernumber",
params={"ts_code": stock_code, "period": date, "report_type": 1}, params={"ts_code": stock_code},
fields=[
"ts_code","ann_date","end_date","holder_num",
],
), ),
# 补充关键财务比率ROE/ROA/毛利率等) # 公司基本信息(包含员工数
self._query( self._query(
api_name="fina_indicator", "stock_company",
params={"ts_code": stock_code, "period": date}, params={"ts_code": stock_code},
fields=[
"ts_code","employees",
],
), ),
) )
try:
if not bs_rows and not ic_rows and not cf_rows and not fi_rows: logger.info(f"[Dividend] fetched {len(div_rows)} rows for {stock_code}")
logger.warning(f"No financial statements components found from Tushare for {stock_code} on {date}") except Exception:
continue pass
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 {}
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())}")
all_statements.append(merged)
except Exception as e: except Exception as e:
logger.error(f"Tushare get_financial_statement failed for {stock_code} on {date}: {e}") logger.error(f"Tushare bulk fetch failed for {stock_code}: {e}")
continue bs_rows, ic_rows, cf_rows, fi_rows, rep_rows, div_rows, holder_rows, company_rows = [], [], [], [], [], [], [], []
logger.info(f"Successfully fetched {len(all_statements)} statement(s) for {stock_code}.") # 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)
_merge_rows(bs_rows)
_merge_rows(ic_rows)
_merge_rows(cf_rows)
_merge_rows(fi_rows)
# 3) 筛选报告期:今年的最新报告期 + 往年所有年报
current_year = str(datetime.date.today().year)
all_available_dates = sorted(by_date.keys(), reverse=True)
latest_current_year_report = None
for d in all_available_dates:
if d.startswith(current_year):
latest_current_year_report = d
break
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 # Transform to series format
series: Dict[str, List[Dict]] = {} series: Dict[str, List[Dict]] = {}
if all_statements: if all_statements:
for report in all_statements: for report in all_statements:
year = report.get("end_date", "")[:4] period = report.get("end_date", "")
if not year: continue if not period: continue
for key, value in report.items(): 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']: if key in ['ts_code', 'end_date', 'ann_date', 'f_ann_date', 'report_type', 'comp_type', 'end_type', 'update_flag', 'period']:
continue 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: if key not in series:
series[key] = [] series[key] = []
if not any(d['year'] == year for d in series[key]): if not any(d['period'] == period for d in series[key]):
series[key].append({"year": year, "value": value}) 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 # Calculate derived metrics
years = sorted(list(set(d['year'] for s in series.values() for d in s))) periods = sorted(list(set(d['period'] for s in series.values() for d in s)))
series = self._calculate_derived_metrics(series, years) series = self._calculate_derived_metrics(series, periods)
return series return series

View File

@ -22,7 +22,6 @@ from app.schemas.financial import (
) )
from app.services.company_profile_client import CompanyProfileClient from app.services.company_profile_client import CompanyProfileClient
from app.services.analysis_client import AnalysisClient, load_analysis_config, get_analysis_config 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 # Lazy DataManager loader to avoid import-time failures when optional providers/config are missing
_dm = None _dm = None
@ -297,6 +296,18 @@ async def get_china_financials(
# Fetch all financial statements at once (already in series format from provider) # 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) 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: if not series:
errors["financial_statements"] = "Failed to fetch from all providers." 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") step_market = StepRecord(name="拉取市值与股价", start_ts=datetime.now(timezone.utc).isoformat(), status="running")
steps.append(step_market) 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: try:
if has_daily_basic: 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): if isinstance(db_rows, list):
for row in db_rows: for row in db_rows:
trade_date = row.get('trade_date') or row.get('trade_dt') or row.get('date') trade_date = row.get('trade_date') or row.get('trade_dt') or row.get('date')
if not trade_date: if not trade_date:
continue continue
# 判断是否为当前年最新报告期的数据
is_current_year_report = latest_current_year_report and str(trade_date) == latest_current_year_report
year = str(trade_date)[:4] 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(): for key, value in row.items():
if key in ['ts_code', 'trade_date', 'trade_dt', 'date']: if key in ['ts_code', 'trade_date', 'trade_dt', 'date']:
continue continue
if isinstance(value, (int, float)) and value is not None: if isinstance(value, (int, float)) and value is not None:
if key not in series: if key not in series:
series[key] = [] series[key] = []
if not any(d['year'] == year for d in series[key]): # 检查是否已存在该period的数据如果存在则替换为最新的数据
series[key].append({"year": year, "value": value}) 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: 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): if isinstance(d_rows, list):
for row in d_rows: for row in d_rows:
trade_date = row.get('trade_date') or row.get('trade_dt') or row.get('date') trade_date = row.get('trade_date') or row.get('trade_dt') or row.get('date')
if not trade_date: if not trade_date:
continue continue
# 判断是否为当前年最新报告期的数据
is_current_year_report = latest_current_year_report and str(trade_date) == latest_current_year_report
year = str(trade_date)[:4] 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(): for key, value in row.items():
if key in ['ts_code', 'trade_date', 'trade_dt', 'date']: if key in ['ts_code', 'trade_date', 'trade_dt', 'date']:
continue continue
if isinstance(value, (int, float)) and value is not None: if isinstance(value, (int, float)) and value is not None:
if key not in series: if key not in series:
series[key] = [] series[key] = []
if not any(d['year'] == year for d in series[key]): # 检查是否已存在该period的数据如果存在则替换为最新的数据
series[key].append({"year": year, "value": value}) 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: except Exception as e:
errors["market_data"] = f"Failed to fetch market data: {e}" errors["market_data"] = f"Failed to fetch market data: {e}"
finally: finally:
@ -362,17 +412,20 @@ async def get_china_financials(
if not series: if not series:
raise HTTPException(status_code=502, detail={"message": "No data returned from any data source", "errors": errors}) 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(): for key, arr in series.items():
# Deduplicate and sort desc by year, then cut to requested years, and return asc # Deduplicate and sort desc by period, then cut to requested periods, and return asc
uniq = {item["year"]: item for item in arr} uniq = {item["period"]: item for item in arr}
arr_sorted_desc = sorted(uniq.values(), key=lambda x: x["year"], reverse=True) arr_sorted_desc = sorted(uniq.values(), key=lambda x: x["period"], reverse=True)
arr_limited = arr_sorted_desc[:years] 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 series[key] = arr_sorted
# Calculate derived financial metrics # Create periods_list for derived metrics calculation
series = calculate_derived_metrics(series, years_list) 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( meta = FinancialMeta(
started_at=started_real.isoformat(), started_at=started_real.isoformat(),

View File

@ -5,10 +5,9 @@ from typing import Dict, List, Optional
from pydantic import BaseModel from pydantic import BaseModel
class YearDataPoint(BaseModel): class PeriodDataPoint(BaseModel):
year: str period: str
value: Optional[float] value: Optional[float]
month: Optional[int] = None # 月份信息,用于确定季度
class StepRecord(BaseModel): class StepRecord(BaseModel):
@ -33,7 +32,7 @@ class FinancialMeta(BaseModel):
class BatchFinancialDataResponse(BaseModel): class BatchFinancialDataResponse(BaseModel):
ts_code: str ts_code: str
name: Optional[str] = None name: Optional[str] = None
series: Dict[str, List[YearDataPoint]] series: Dict[str, List[PeriodDataPoint]]
meta: Optional[FinancialMeta] = None meta: Optional[FinancialMeta] = None

View File

@ -268,3 +268,5 @@ A:

View File

@ -3,7 +3,7 @@
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev -p 3001", "dev": "NODE_NO_WARNINGS=1 next dev -p 3001",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "eslint" "lint": "eslint"

View File

@ -14,3 +14,5 @@
若暂时没有字体文件,页面会退回系统默认字体,不影响功能。 若暂时没有字体文件,页面会退回系统默认字体,不影响功能。

View File

@ -14,6 +14,7 @@ import remarkGfm from 'remark-gfm';
import { TradingViewWidget } from '@/components/TradingViewWidget'; import { TradingViewWidget } from '@/components/TradingViewWidget';
import type { CompanyProfileResponse, AnalysisResponse } from '@/types'; import type { CompanyProfileResponse, AnalysisResponse } from '@/types';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { formatReportPeriod } from '@/lib/financial-utils';
export default function ReportPage() { export default function ReportPage() {
const params = useParams(); const params = useParams();
@ -732,32 +733,42 @@ export default function ReportPage() {
<div className="overflow-x-auto"> <div className="overflow-x-auto">
{(() => { {(() => {
const series = financials?.series ?? {}; const series = financials?.series ?? {};
const allPoints = Object.values(series).flat() as Array<{ year: string; value: number | null; month?: number | null }>; // 统一 period优先 p.period若仅有 year 则映射到 `${year}1231`
const currentYearStr = String(new Date().getFullYear()); const toPeriod = (p: any): string | null => {
const years = Array if (!p) return null;
.from(new Set(allPoints.map((p) => p?.year).filter(Boolean) as string[])) if (p.period) return String(p.period);
.sort((a, b) => Number(b) - Number(a)); // 最新年份在左 if (p.year) return `${p.year}1231`;
const getQuarter = (month: number | null | undefined) => { return null;
if (month == null) return null;
return Math.floor((month - 1) / 3) + 1;
}; };
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 <p className="text-sm text-muted-foreground"></p>; return <p className="text-sm text-muted-foreground"></p>;
} }
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 ( return (
<Table className="min-w-full text-sm"> <Table className="min-w-full text-sm">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead className="text-left p-2"></TableHead> <TableHead className="text-left p-2"></TableHead>
{years.map((y) => { {periods.map((p) => (
const isCurrent = y === currentYearStr; <TableHead key={p} className="text-right p-2">{formatReportPeriod(p)}</TableHead>
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 (
<TableHead key={y} className="text-right p-2">{label}</TableHead>
);
})}
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@ -776,8 +787,8 @@ export default function ReportPage() {
{ key: 'n_cashflow_act' }, { key: 'n_cashflow_act' },
{ key: 'c_pay_acq_const_fiolta' }, { key: 'c_pay_acq_const_fiolta' },
{ key: '__free_cash_flow', label: '自由现金流' }, { key: '__free_cash_flow', label: '自由现金流' },
{ key: 'cash_div_tax', label: '分红' }, { key: 'dividend_amount', label: '分红' },
{ key: 'buyback', label: '回购' }, { key: 'repurchase_amount', label: '回购' },
{ key: 'total_assets' }, { key: 'total_assets' },
{ key: 'total_hldr_eqy_exc_min_int' }, { key: 'total_hldr_eqy_exc_min_int' },
{ key: 'goodwill' }, { key: 'goodwill' },
@ -787,8 +798,8 @@ export default function ReportPage() {
const summaryRow = ( const summaryRow = (
<TableRow key="__main_metrics_row" className="bg-muted hover:bg-purple-100"> <TableRow key="__main_metrics_row" className="bg-muted hover:bg-purple-100">
<TableCell className="p-2 font-medium "></TableCell> <TableCell className="p-2 font-medium "></TableCell>
{years.map((y) => ( {periods.map((p) => (
<TableCell key={y} className="p-2"></TableCell> <TableCell key={p} className="p-2"></TableCell>
))} ))}
</TableRow> </TableRow>
); );
@ -803,20 +814,20 @@ export default function ReportPage() {
'__operating_assets_ratio', '__interest_bearing_debt_ratio' '__operating_assets_ratio', '__interest_bearing_debt_ratio'
]); ]);
const rows = ORDER.map(({ key, label }) => { 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 ( return (
<TableRow key={key} className="hover:bg-purple-100"> <TableRow key={key} className="hover:bg-purple-100">
<TableCell className="p-2 text-muted-foreground"> <TableCell className="p-2 text-muted-foreground">
{label || metricDisplayMap[key] || key} {label || metricDisplayMap[key] || key}
</TableCell> </TableCell>
{years.map((y) => { {periods.map((p) => {
const v = points?.find(p => p?.year === y)?.value ?? null; const v = getValueByPeriod(points, p);
const groupName = metricGroupMap[key]; const groupName = metricGroupMap[key];
const rawNum = typeof v === 'number' ? v : (v == null ? null : Number(v)); const rawNum = typeof v === 'number' ? v : (v == null ? null : Number(v));
if (rawNum == null || Number.isNaN(rawNum)) { if (rawNum == null || Number.isNaN(rawNum)) {
return <TableCell key={y} className="text-right p-2">-</TableCell>; return <TableCell key={p} className="text-right p-2">-</TableCell>;
} }
if (PERCENT_KEYS.has(key)) { if (PERCENT_KEYS.has(key)) {
const perc = Math.abs(rawNum) <= 1 && key !== 'tax_to_ebt' && key !== '__tax_rate' ? rawNum * 100 : rawNum; 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) { if (isGrowthRow) {
const isNeg = typeof perc === 'number' && perc < 0; const isNeg = typeof perc === 'number' && perc < 0;
return ( return (
<TableCell key={y} className="text-right p-2"> <TableCell key={p} className="text-right p-2">
<span className={isNeg ? 'text-red-600 bg-red-100 italic' : 'text-blue-600 italic'}>{text}%</span> <span className={isNeg ? 'text-red-600 bg-red-100 italic' : 'text-blue-600 italic'}>{text}%</span>
</TableCell> </TableCell>
); );
} }
return ( return (
<TableCell key={y} className="text-right p-2">{`${text}%`}</TableCell> <TableCell key={p} className="text-right p-2">{`${text}%`}</TableCell>
); );
} else { } else {
const isFinGroup = groupName === 'income' || groupName === 'balancesheet' || groupName === 'cashflow'; const isFinGroup = groupName === 'income' || groupName === 'balancesheet' || groupName === 'cashflow';
const scaled = key === 'total_mv' const scaled = key === 'total_mv'
? rawNum / 10000 ? 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 formatter = key === 'total_mv' ? integerFormatter : numberFormatter;
const text = Number.isFinite(scaled) ? formatter.format(scaled) : '-'; const text = Number.isFinite(scaled) ? formatter.format(scaled) : '-';
if (key === '__free_cash_flow') { if (key === '__free_cash_flow') {
const isNeg = typeof scaled === 'number' && scaled < 0; const isNeg = typeof scaled === 'number' && scaled < 0;
return ( return (
<TableCell key={y} className="text-right p-2"> <TableCell key={p} className="text-right p-2">
{isNeg ? <span className="text-red-600 bg-red-100">{text}</span> : text} {isNeg ? <span className="text-red-600 bg-red-100">{text}</span> : text}
</TableCell> </TableCell>
); );
} }
return ( return (
<TableCell key={y} className="text-right p-2">{text}</TableCell> <TableCell key={p} className="text-right p-2">{text}</TableCell>
); );
} }
})} })}
@ -863,8 +874,8 @@ export default function ReportPage() {
const feeHeaderRow = ( const feeHeaderRow = (
<TableRow key="__fee_metrics_row" className="bg-muted hover:bg-purple-100"> <TableRow key="__fee_metrics_row" className="bg-muted hover:bg-purple-100">
<TableCell className="p-2 font-medium "></TableCell> <TableCell className="p-2 font-medium "></TableCell>
{years.map((y) => ( {periods.map((p) => (
<TableCell key={y} className="p-2"></TableCell> <TableCell key={p} className="p-2"></TableCell>
))} ))}
</TableRow> </TableRow>
); );
@ -879,17 +890,17 @@ export default function ReportPage() {
].map(({ key, label }) => ( ].map(({ key, label }) => (
<TableRow key={key} className="hover:bg-purple-100"> <TableRow key={key} className="hover:bg-purple-100">
<TableCell className="p-2 text-muted-foreground">{label}</TableCell> <TableCell className="p-2 text-muted-foreground">{label}</TableCell>
{years.map((y) => { {periods.map((p) => {
const points = series[key] as Array<{ year?: string; value?: number | null }> | undefined; const points = series[key] as any[] | undefined;
const v = points?.find(p => p?.year === y)?.value ?? null; const v = getValueByPeriod(points, p);
if (v == null || !Number.isFinite(v)) { if (v == null || !Number.isFinite(v)) {
return <TableCell key={y} className="text-right p-2">-</TableCell>; return <TableCell key={p} className="text-right p-2">-</TableCell>;
} }
const rateText = numberFormatter.format(v); const rateText = numberFormatter.format(v);
const isNegative = v < 0; const isNegative = v < 0;
return ( return (
<TableCell key={y} className="text-right p-2"> <TableCell key={p} className="text-right p-2">
{isNegative ? <span className="text-red-600 bg-red-100">{rateText}%</span> : `${rateText}%`} {isNegative ? <span className="text-red-600 bg-red-100">{rateText}%</span> : `${rateText}%`}
</TableCell> </TableCell>
); );
@ -903,20 +914,20 @@ export default function ReportPage() {
const assetHeaderRow = ( const assetHeaderRow = (
<TableRow key="__asset_ratio_row" className="bg-muted hover:bg-purple-100"> <TableRow key="__asset_ratio_row" className="bg-muted hover:bg-purple-100">
<TableCell className="p-2 font-medium "></TableCell> <TableCell className="p-2 font-medium "></TableCell>
{years.map((y) => ( {periods.map((p) => (
<TableCell key={y} className="p-2"></TableCell> <TableCell key={p} className="p-2"></TableCell>
))} ))}
</TableRow> </TableRow>
); );
const ratioCell = (value: number | null, y: string) => { const ratioCell = (value: number | null, keyStr: string) => {
if (value == null || !Number.isFinite(value)) { if (value == null || !Number.isFinite(value)) {
return <TableCell key={y} className="text-right p-2">-</TableCell>; return <TableCell key={keyStr} className="text-right p-2">-</TableCell>;
} }
const text = numberFormatter.format(value); const text = numberFormatter.format(value);
const isNegative = value < 0; const isNegative = value < 0;
return ( return (
<TableCell key={y} className="text-right p-2"> <TableCell key={keyStr} className="text-right p-2">
{isNegative ? <span className="text-red-600 bg-red-100">{text}%</span> : `${text}%`} {isNegative ? <span className="text-red-600 bg-red-100">{text}%</span> : `${text}%`}
</TableCell> </TableCell>
); );
@ -940,10 +951,10 @@ export default function ReportPage() {
].map(({ key, label }) => ( ].map(({ key, label }) => (
<TableRow key={key} className={`hover:bg-purple-100 ${key === '__other_assets_ratio' ? 'bg-yellow-50' : ''}`}> <TableRow key={key} className={`hover:bg-purple-100 ${key === '__other_assets_ratio' ? 'bg-yellow-50' : ''}`}>
<TableCell className="p-2 text-muted-foreground">{label}</TableCell> <TableCell className="p-2 text-muted-foreground">{label}</TableCell>
{years.map((y) => { {periods.map((p) => {
const points = series[key] as Array<{ year?: string; value?: number | null }> | undefined; const points = series[key] as any[] | undefined;
const v = points?.find(p => p?.year === y)?.value ?? null; const v = getValueByPeriod(points, p);
return ratioCell(v, y); return ratioCell(v, p);
})} })}
</TableRow> </TableRow>
)); ));
@ -954,8 +965,8 @@ export default function ReportPage() {
const turnoverHeaderRow = ( const turnoverHeaderRow = (
<TableRow key="__turnover_row" className="bg-muted hover:bg-purple-100"> <TableRow key="__turnover_row" className="bg-muted hover:bg-purple-100">
<TableCell className="p-2 font-medium "></TableCell> <TableCell className="p-2 font-medium "></TableCell>
{years.map((y) => ( {periods.map((p) => (
<TableCell key={y} className="p-2"></TableCell> <TableCell key={p} className="p-2"></TableCell>
))} ))}
</TableRow> </TableRow>
); );
@ -971,21 +982,21 @@ export default function ReportPage() {
const turnoverRows = turnoverItems.map(({ key, label }) => ( const turnoverRows = turnoverItems.map(({ key, label }) => (
<TableRow key={key} className="hover:bg-purple-100"> <TableRow key={key} className="hover:bg-purple-100">
<TableCell className="p-2 text-muted-foreground">{label}</TableCell> <TableCell className="p-2 text-muted-foreground">{label}</TableCell>
{years.map((y) => { {periods.map((p) => {
const points = series[key] as Array<{ year?: string; value?: number | null }> | undefined; const points = series[key] as any[] | undefined;
const v = points?.find(p => p?.year === y)?.value ?? null; const v = getValueByPeriod(points, p);
const value = typeof v === 'number' ? v : (v == null ? null : Number(v)); const value = typeof v === 'number' ? v : (v == null ? null : Number(v));
if (value == null || !Number.isFinite(value)) { if (value == null || !Number.isFinite(value)) {
return <TableCell key={y} className="text-right p-2">-</TableCell>; return <TableCell key={p} className="text-right p-2">-</TableCell>;
} }
const text = numberFormatter.format(value); const text = numberFormatter.format(value);
if (key === 'arturn_days' && value > 90) { if (key === 'arturn_days' && value > 90) {
return ( return (
<TableCell key={y} className="text-right p-2 bg-red-100 text-red-600">{text}</TableCell> <TableCell key={p} className="text-right p-2 bg-red-100 text-red-600">{text}</TableCell>
); );
} }
return <TableCell key={y} className="text-right p-2">{text}</TableCell>; return <TableCell key={p} className="text-right p-2">{text}</TableCell>;
})} })}
</TableRow> </TableRow>
)); ));
@ -1005,8 +1016,8 @@ export default function ReportPage() {
( (
<TableRow key="__per_capita_row" className="bg-muted hover:bg-purple-100"> <TableRow key="__per_capita_row" className="bg-muted hover:bg-purple-100">
<TableCell className="p-2 font-medium "></TableCell> <TableCell className="p-2 font-medium "></TableCell>
{years.map((y) => ( {periods.map((p) => (
<TableCell key={y} className="p-2"></TableCell> <TableCell key={p} className="p-2"></TableCell>
))} ))}
</TableRow> </TableRow>
), ),
@ -1014,13 +1025,13 @@ export default function ReportPage() {
( (
<TableRow key="__employees_row" className="hover:bg-purple-100"> <TableRow key="__employees_row" className="hover:bg-purple-100">
<TableCell className="p-2 text-muted-foreground"></TableCell> <TableCell className="p-2 text-muted-foreground"></TableCell>
{years.map((y) => { {periods.map((p) => {
const points = series['employees'] as Array<{ year?: string; value?: number | null }> | undefined; const points = series['employees'] as any[] | undefined;
const v = points?.find(p => p?.year === y)?.value ?? null; const v = getValueByPeriod(points, p);
if (v == null) { if (v == null) {
return <TableCell key={y} className="text-right p-2">-</TableCell>; return <TableCell key={p} className="text-right p-2">-</TableCell>;
} }
return <TableCell key={y} className="text-right p-2">{integerFormatter.format(Math.round(v))}</TableCell>; return <TableCell key={p} className="text-right p-2">{integerFormatter.format(Math.round(v))}</TableCell>;
})} })}
</TableRow> </TableRow>
), ),
@ -1028,13 +1039,13 @@ export default function ReportPage() {
( (
<TableRow key="__rev_per_emp_row" className="hover:bg-purple-100"> <TableRow key="__rev_per_emp_row" className="hover:bg-purple-100">
<TableCell className="p-2 text-muted-foreground"></TableCell> <TableCell className="p-2 text-muted-foreground"></TableCell>
{years.map((y) => { {periods.map((p) => {
const points = series['__rev_per_emp'] as Array<{ year?: string; value?: number | null }> | undefined; const points = series['__rev_per_emp'] as any[] | undefined;
const val = points?.find(p => p?.year === y)?.value ?? null; const val = getValueByPeriod(points, p);
if (val == null) { if (val == null) {
return <TableCell key={y} className="text-right p-2">-</TableCell>; return <TableCell key={p} className="text-right p-2">-</TableCell>;
} }
return <TableCell key={y} className="text-right p-2">{numberFormatter.format(val)}</TableCell>; return <TableCell key={p} className="text-right p-2">{numberFormatter.format(val)}</TableCell>;
})} })}
</TableRow> </TableRow>
), ),
@ -1042,13 +1053,13 @@ export default function ReportPage() {
( (
<TableRow key="__profit_per_emp_row" className="hover:bg-purple-100"> <TableRow key="__profit_per_emp_row" className="hover:bg-purple-100">
<TableCell className="p-2 text-muted-foreground"></TableCell> <TableCell className="p-2 text-muted-foreground"></TableCell>
{years.map((y) => { {periods.map((p) => {
const points = series['__profit_per_emp'] as Array<{ year?: string; value?: number | null }> | undefined; const points = series['__profit_per_emp'] as any[] | undefined;
const val = points?.find(p => p?.year === y)?.value ?? null; const val = getValueByPeriod(points, p);
if (val == null) { if (val == null) {
return <TableCell key={y} className="text-right p-2">-</TableCell>; return <TableCell key={p} className="text-right p-2">-</TableCell>;
} }
return <TableCell key={y} className="text-right p-2">{numberFormatter.format(val)}</TableCell>; return <TableCell key={p} className="text-right p-2">{numberFormatter.format(val)}</TableCell>;
})} })}
</TableRow> </TableRow>
), ),
@ -1056,13 +1067,13 @@ export default function ReportPage() {
( (
<TableRow key="__salary_per_emp_row" className="hover:bg-purple-100"> <TableRow key="__salary_per_emp_row" className="hover:bg-purple-100">
<TableCell className="p-2 text-muted-foreground"></TableCell> <TableCell className="p-2 text-muted-foreground"></TableCell>
{years.map((y) => { {periods.map((p) => {
const points = series['__salary_per_emp'] as Array<{ year?: string; value?: number | null }> | undefined; const points = series['__salary_per_emp'] as any[] | undefined;
const val = points?.find(p => p?.year === y)?.value ?? null; const val = getValueByPeriod(points, p);
if (val == null) { if (val == null) {
return <TableCell key={y} className="text-right p-2">-</TableCell>; return <TableCell key={p} className="text-right p-2">-</TableCell>;
} }
return <TableCell key={y} className="text-right p-2">{numberFormatter.format(val)}</TableCell>; return <TableCell key={p} className="text-right p-2">{numberFormatter.format(val)}</TableCell>;
})} })}
</TableRow> </TableRow>
), ),
@ -1072,8 +1083,8 @@ export default function ReportPage() {
( (
<TableRow key="__market_perf_row" className="bg-muted hover:bg-purple-100"> <TableRow key="__market_perf_row" className="bg-muted hover:bg-purple-100">
<TableCell className="p-2 font-medium "></TableCell> <TableCell className="p-2 font-medium "></TableCell>
{years.map((y) => ( {periods.map((p) => (
<TableCell key={y} className="p-2"></TableCell> <TableCell key={p} className="p-2"></TableCell>
))} ))}
</TableRow> </TableRow>
), ),
@ -1081,13 +1092,13 @@ export default function ReportPage() {
( (
<TableRow key="__price_row" className="hover:bg-purple-100"> <TableRow key="__price_row" className="hover:bg-purple-100">
<TableCell className="p-2 text-muted-foreground"></TableCell> <TableCell className="p-2 text-muted-foreground"></TableCell>
{years.map((y) => { {periods.map((p) => {
const points = series['close'] as Array<{ year?: string; value?: number | null }> | undefined; const points = series['close'] as any[] | undefined;
const v = points?.find(p => p?.year === y)?.value ?? null; const v = getValueByPeriod(points, p);
if (v == null) { if (v == null) {
return <TableCell key={y} className="text-right p-2">-</TableCell>; return <TableCell key={p} className="text-right p-2">-</TableCell>;
} }
return <TableCell key={y} className="text-right p-2">{numberFormatter.format(v)}</TableCell>; return <TableCell key={p} className="text-right p-2">{numberFormatter.format(v)}</TableCell>;
})} })}
</TableRow> </TableRow>
), ),
@ -1095,14 +1106,14 @@ export default function ReportPage() {
( (
<TableRow key="__market_cap_row" className="hover:bg-purple-100"> <TableRow key="__market_cap_row" className="hover:bg-purple-100">
<TableCell className="p-2 text-muted-foreground">亿</TableCell> <TableCell className="p-2 text-muted-foreground">亿</TableCell>
{years.map((y) => { {periods.map((p) => {
const points = series['total_mv'] as Array<{ year?: string; value?: number | null }> | undefined; const points = series['total_mv'] as any[] | undefined;
const v = points?.find(p => p?.year === y)?.value ?? null; const v = getValueByPeriod(points, p);
if (v == null) { if (v == null) {
return <TableCell key={y} className="text-right p-2">-</TableCell>; return <TableCell key={p} className="text-right p-2">-</TableCell>;
} }
const scaled = v / 10000; // 转为亿元 const scaled = v / 10000; // 转为亿元
return <TableCell key={y} className="text-right p-2">{integerFormatter.format(Math.round(scaled))}</TableCell>; return <TableCell key={p} className="text-right p-2">{integerFormatter.format(Math.round(scaled))}</TableCell>;
})} })}
</TableRow> </TableRow>
), ),
@ -1110,13 +1121,13 @@ export default function ReportPage() {
( (
<TableRow key="__pe_row" className="hover:bg-purple-100"> <TableRow key="__pe_row" className="hover:bg-purple-100">
<TableCell className="p-2 text-muted-foreground">PE</TableCell> <TableCell className="p-2 text-muted-foreground">PE</TableCell>
{years.map((y) => { {periods.map((p) => {
const points = series['pe'] as Array<{ year?: string; value?: number | null }> | undefined; const points = series['pe'] as any[] | undefined;
const v = points?.find(p => p?.year === y)?.value ?? null; const v = getValueByPeriod(points, p);
if (v == null) { if (v == null) {
return <TableCell key={y} className="text-right p-2">-</TableCell>; return <TableCell key={p} className="text-right p-2">-</TableCell>;
} }
return <TableCell key={y} className="text-right p-2">{numberFormatter.format(v)}</TableCell>; return <TableCell key={p} className="text-right p-2">{numberFormatter.format(v)}</TableCell>;
})} })}
</TableRow> </TableRow>
), ),
@ -1124,27 +1135,27 @@ export default function ReportPage() {
( (
<TableRow key="__pb_row" className="hover:bg-purple-100"> <TableRow key="__pb_row" className="hover:bg-purple-100">
<TableCell className="p-2 text-muted-foreground">PB</TableCell> <TableCell className="p-2 text-muted-foreground">PB</TableCell>
{years.map((y) => { {periods.map((p) => {
const points = series['pb'] as Array<{ year?: string; value?: number | null }> | undefined; const points = series['pb'] as any[] | undefined;
const v = points?.find(p => p?.year === y)?.value ?? null; const v = getValueByPeriod(points, p);
if (v == null) { if (v == null) {
return <TableCell key={y} className="text-right p-2">-</TableCell>; return <TableCell key={p} className="text-right p-2">-</TableCell>;
} }
return <TableCell key={y} className="text-right p-2">{numberFormatter.format(v)}</TableCell>; return <TableCell key={p} className="text-right p-2">{numberFormatter.format(v)}</TableCell>;
})} })}
</TableRow> </TableRow>
), ),
// 股东户数 // 股东户数
( (
<TableRow key="__holder_num_row" className="hover:bg-purple-100"> <TableRow key="__holder_num_row" className="hover:bg紫-100 hover:bg-purple-100">
<TableCell className="p-2 text-muted-foreground"></TableCell> <TableCell className="p-2 text-muted-foreground"></TableCell>
{years.map((y) => { {periods.map((p) => {
const points = series['holder_num'] as Array<{ year?: string; value?: number | null }> | undefined; const points = series['holder_num'] as any[] | undefined;
const v = points?.find(p => p?.year === y)?.value ?? null; const v = getValueByPeriod(points, p);
if (v == null) { if (v == null) {
return <TableCell key={y} className="text-right p-2">-</TableCell>; return <TableCell key={p} className="text-right p-2">-</TableCell>;
} }
return <TableCell key={y} className="text-right p-2">{integerFormatter.format(Math.round(v))}</TableCell>; return <TableCell key={p} className="text-right p-2">{integerFormatter.format(Math.round(v))}</TableCell>;
})} })}
</TableRow> </TableRow>
), ),

View File

@ -4,6 +4,7 @@ import remarkGfm from 'remark-gfm'
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs' import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card' import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'
import { Table, TableHeader, TableBody, TableHead, TableRow, TableCell } from '@/components/ui/table' import { Table, TableHeader, TableBody, TableHead, TableRow, TableCell } from '@/components/ui/table'
import { formatReportPeriod } from '@/lib/financial-utils'
type Report = { type Report = {
id: string id: string
@ -120,13 +121,13 @@ export default async function ReportDetailPage({ params }: { params: Promise<{ i
const fin = (content?.financials ?? null) as null | { const fin = (content?.financials ?? null) as null | {
ts_code?: string ts_code?: string
name?: string name?: string
series?: Record<string, Array<{ year: string; value: number | null; month?: number | null }>> series?: Record<string, Array<{ period: string; value: number | null }>>
meta?: any meta?: any
} }
const series = fin?.series || {} const series = fin?.series || {}
const allPoints = Object.values(series).flat() as Array<{ year: string; value: number | null; month?: number | null }> const allPoints = Object.values(series).flat() as Array<{ period: string; value: 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 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 numberFormatter = new Intl.NumberFormat('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
const integerFormatter = new Intl.NumberFormat('zh-CN', { minimumFractionDigits: 0, maximumFractionDigits: 0 }) const integerFormatter = new Intl.NumberFormat('zh-CN', { minimumFractionDigits: 0, maximumFractionDigits: 0 })
@ -203,13 +204,9 @@ export default async function ReportDetailPage({ params }: { params: Promise<{ i
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead className="text-left p-2"></TableHead> <TableHead className="text-left p-2"></TableHead>
{years.map((y) => { {periods.map((p) => (
const yearData = allPoints.find(p => p.year === y) <TableHead key={p} className="text-right p-2">{formatReportPeriod(p)}</TableHead>
const isCurrent = y === currentYearStr ))}
const quarter = yearData?.month ? getQuarter(yearData.month) : null
const label = isCurrent && quarter ? `${y} Q${quarter}` : y
return <TableHead key={y} className="text-right p-2">{label}</TableHead>
})}
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@ -217,34 +214,34 @@ export default async function ReportDetailPage({ params }: { params: Promise<{ i
const summaryRow = ( const summaryRow = (
<TableRow key="__main_metrics_row" className="bg-muted hover:bg-purple-100"> <TableRow key="__main_metrics_row" className="bg-muted hover:bg-purple-100">
<TableCell className="p-2 font-medium "></TableCell> <TableCell className="p-2 font-medium "></TableCell>
{years.map((y) => ( {periods.map((p) => (
<TableCell key={y} className="p-2"></TableCell> <TableCell key={p} className="p-2"></TableCell>
))} ))}
</TableRow> </TableRow>
) )
const rows = ORDER.map(({ key, label, kind }) => { const rows = ORDER.map(({ key, label, kind }) => {
const isComputed = kind === 'computed' && key === '__free_cash_flow' const isComputed = kind === 'computed' && key === '__free_cash_flow'
const points = series[key] 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<{ year?: 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<{ year?: string; value?: number | null }>|undefined const capex = series['c_pay_acq_const_fiolta'] as Array<{ period?: string; value?: number | null }>|undefined
return ( return (
<TableRow key={key} className="hover:bg-purple-100"> <TableRow key={key} className="hover:bg-purple-100">
<TableCell className="p-2 text-muted-foreground">{label || metricDisplayMap[key] || key}</TableCell> <TableCell className="p-2 text-muted-foreground">{label || metricDisplayMap[key] || key}</TableCell>
{years.map((y) => { {periods.map((p) => {
let v: number | null | undefined = undefined let v: number | null | undefined = undefined
if (isComputed) { if (isComputed) {
const op = operating?.find(p => p?.year === y)?.value ?? null const op = operating?.find(pt => pt?.period === p)?.value ?? null
const cp = capex?.find(p => p?.year === y)?.value ?? null const cp = capex?.find(pt => pt?.period === p)?.value ?? null
v = (op == null || cp == null) ? null : (Number(op) - Number(cp)) v = (op == null || cp == null) ? null : (Number(op) - Number(cp))
} else { } else {
v = points?.find(p => p?.year === y)?.value ?? null v = points?.find(pt => pt?.period === p)?.value ?? null
} }
const groupName = metricGroupMap[key] const groupName = metricGroupMap[key]
const rawNum = typeof v === 'number' ? v : (v == null ? null : Number(v)) const rawNum = typeof v === 'number' ? v : (v == null ? null : Number(v))
if (rawNum == null || Number.isNaN(rawNum)) { if (rawNum == null || Number.isNaN(rawNum)) {
return <TableCell key={y} className="text-right p-2">-</TableCell> return <TableCell key={p} className="text-right p-2">-</TableCell>
} }
if (PERCENT_KEYS.has(key)) { if (PERCENT_KEYS.has(key)) {
const perc = Math.abs(rawNum) <= 1 ? rawNum * 100 : rawNum 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 getVal = (arr: Array<{ period?: string; value?: number | null }> | undefined, p: string) => {
const v = arr?.find(p => p?.year === y)?.value const v = arr?.find(pt => pt?.period === p)?.value
return typeof v === 'number' ? v : (v == null ? null : Number(v)) 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 = ( const feeHeaderRow = (
<TableRow key="__fee_metrics_row" className="bg-muted hover:bg-purple-100"> <TableRow key="__fee_metrics_row" className="bg-muted hover:bg-purple-100">
<TableCell className="p-2 font-medium "></TableCell> <TableCell className="p-2 font-medium "></TableCell>
{years.map((y) => ( {periods.map((p) => (
<TableCell key={y} className="p-2"></TableCell> <TableCell key={p} className="p-2"></TableCell>
))} ))}
</TableRow> </TableRow>
) )
@ -307,10 +304,10 @@ export default async function ReportDetailPage({ params }: { params: Promise<{ i
].map(({ key, label, num, den }) => ( ].map(({ key, label, num, den }) => (
<TableRow key={key} className="hover:bg-purple-100"> <TableRow key={key} className="hover:bg-purple-100">
<TableCell className="p-2 text-muted-foreground">{label}</TableCell> <TableCell className="p-2 text-muted-foreground">{label}</TableCell>
{years.map((y) => { {periods.map((p) => {
let rate: number | null = null let rate: number | null = null
if (key === '__tax_rate') { if (key === '__tax_rate') {
const numerator = getVal(num, y) const numerator = getVal(num, p)
if (numerator == null || Number.isNaN(numerator)) { if (numerator == null || Number.isNaN(numerator)) {
rate = null rate = null
} else if (Math.abs(numerator) <= 1) { } else if (Math.abs(numerator) <= 1) {
@ -319,12 +316,12 @@ export default async function ReportDetailPage({ params }: { params: Promise<{ i
rate = numerator rate = numerator
} }
} else if (key === '__other_fee_rate') { } else if (key === '__other_fee_rate') {
const gpRaw = getVal(series['grossprofit_margin'] as any, y) const gpRaw = getVal(series['grossprofit_margin'] as any, p)
const npRaw = getVal(series['netprofit_margin'] as any, y) const npRaw = getVal(series['netprofit_margin'] as any, p)
const rev = getVal(series['revenue'] as any, y) const rev = getVal(series['revenue'] as any, p)
const sell = getVal(series['sell_exp'] as any, y) const sell = getVal(series['sell_exp'] as any, p)
const admin = getVal(series['admin_exp'] as any, y) const admin = getVal(series['admin_exp'] as any, p)
const rd = getVal(series['rd_exp'] as any, y) const rd = getVal(series['rd_exp'] as any, p)
if (gpRaw == null || npRaw == null || rev == null || rev === 0 || sell == null || admin == null || rd == null) { if (gpRaw == null || npRaw == null || rev == null || rev === 0 || sell == null || admin == null || rd == null) {
rate = null rate = null
} else { } else {
@ -362,19 +359,19 @@ export default async function ReportDetailPage({ params }: { params: Promise<{ i
const assetHeaderRow = ( const assetHeaderRow = (
<TableRow key="__asset_ratio_row" className="bg-muted hover:bg-purple-100"> <TableRow key="__asset_ratio_row" className="bg-muted hover:bg-purple-100">
<TableCell className="p-2 font-medium "></TableCell> <TableCell className="p-2 font-medium "></TableCell>
{years.map((y) => ( {periods.map((p) => (
<TableCell key={y} className="p-2"></TableCell> <TableCell key={p} className="p-2"></TableCell>
))} ))}
</TableRow> </TableRow>
) )
const ratioCell = (value: number | null, y: string) => { const ratioCell = (value: number | null, p: string) => {
if (value == null || !Number.isFinite(value)) { if (value == null || !Number.isFinite(value)) {
return <TableCell key={y} className="text-right p-2">-</TableCell> return <TableCell key={p} className="text-right p-2">-</TableCell>
} }
const text = numberFormatter.format(value) const text = numberFormatter.format(value)
const isNegative = value < 0 const isNegative = value < 0
return ( return (
<TableCell key={y} className="text-right p-2"> <TableCell key={p} className="text-right p-2">
{isNegative ? <span className="text-red-600 bg-red-100">{text}%</span> : `${text}%`} {isNegative ? <span className="text-red-600 bg-red-100">{text}%</span> : `${text}%`}
</TableCell> </TableCell>
) )
@ -482,8 +479,8 @@ export default async function ReportDetailPage({ params }: { params: Promise<{ i
const turnoverHeaderRow = ( const turnoverHeaderRow = (
<TableRow key="__turnover_row" className="bg-muted hover:bg-purple-100"> <TableRow key="__turnover_row" className="bg-muted hover:bg-purple-100">
<TableCell className="p-2 font-medium "></TableCell> <TableCell className="p-2 font-medium "></TableCell>
{years.map((y) => ( {periods.map((p) => (
<TableCell key={y} className="p-2"></TableCell> <TableCell key={p} className="p-2"></TableCell>
))} ))}
</TableRow> </TableRow>
) )
@ -491,14 +488,15 @@ export default async function ReportDetailPage({ params }: { params: Promise<{ i
const n = Number(ys) const n = Number(ys)
return Number.isFinite(n) ? n : null return Number.isFinite(n) ? n : null
} }
const getPoint = (arr: Array<{ year?: string; value?: number | null }> | undefined, year: string) => { const getPoint = (arr: Array<{ period?: string; value?: number | null }> | undefined, period: string) => {
return arr?.find(p => p?.year === year)?.value ?? null return arr?.find(p => p?.period === period)?.value ?? null
} }
const getAvg = (arr: Array<{ year?: string; value?: number | null }> | undefined, year: string) => { const getAvg = (arr: Array<{ period?: string; value?: number | null }> | undefined, period: string) => {
const curr = getPoint(arr, year) const curr = getPoint(arr, period)
const yNum = getYearNumber(year) const yNum = period.length >= 4 ? Number(period.substring(0, 4)) : null
const prevYear = yNum != null ? String(yNum - 1) : 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 c = typeof curr === 'number' ? curr : (curr == null ? null : Number(curr))
const p = typeof prev === 'number' ? prev : (prev == null ? null : Number(prev)) const p = typeof prev === 'number' ? prev : (prev == null ? null : Number(prev))
if (c == null) return null if (c == null) return null
@ -534,28 +532,28 @@ export default async function ReportDetailPage({ params }: { params: Promise<{ i
const turnoverRows = turnoverItems.map(({ key, label }) => ( const turnoverRows = turnoverItems.map(({ key, label }) => (
<TableRow key={key} className="hover:bg-purple-100"> <TableRow key={key} className="hover:bg-purple-100">
<TableCell className="p-2 text-muted-foreground">{label}</TableCell> <TableCell className="p-2 text-muted-foreground">{label}</TableCell>
{years.map((y) => { {periods.map((p) => {
let value: number | null = null let value: number | null = null
if (key === 'payturn_days') { if (key === 'payturn_days') {
const avgAP = getAvg(series['accounts_pay'] as any, y) const avgAP = getAvg(series['accounts_pay'] as any, p)
const cogs = getCOGS(y) const cogs = getCOGS(p)
value = avgAP == null || cogs == null || cogs === 0 ? null : (365 * avgAP) / cogs value = avgAP == null || cogs == null || cogs === 0 ? null : (365 * avgAP) / cogs
} else { } else {
const arr = series[key] as Array<{ year?: string; value?: number | null }> | undefined const arr = series[key] as Array<{ period?: string; value?: number | null }> | undefined
const v = arr?.find(p => p?.year === y)?.value ?? null const v = arr?.find(pt => pt?.period === p)?.value ?? null
const num = typeof v === 'number' ? v : (v == null ? null : Number(v)) const num = typeof v === 'number' ? v : (v == null ? null : Number(v))
value = num == null || Number.isNaN(num) ? null : num value = num == null || Number.isNaN(num) ? null : num
} }
if (value == null || !Number.isFinite(value)) { if (value == null || !Number.isFinite(value)) {
return <TableCell key={y} className="text-right p-2">-</TableCell> return <TableCell key={p} className="text-right p-2">-</TableCell>
} }
const text = numberFormatter.format(value) const text = numberFormatter.format(value)
if (key === 'arturn_days' && value > 90) { if (key === 'arturn_days' && value > 90) {
return ( return (
<TableCell key={y} className="text-right p-2 bg-red-100 text-red-600">{text}</TableCell> <TableCell key={p} className="text-right p-2 bg-red-100 text-red-600">{text}</TableCell>
) )
} }
return <TableCell key={y} className="text-right p-2">{text}</TableCell> return <TableCell key={p} className="text-right p-2">{text}</TableCell>
})} })}
</TableRow> </TableRow>
)) ))
@ -564,8 +562,8 @@ export default async function ReportDetailPage({ params }: { params: Promise<{ i
const perCapitaHeaderRow = ( const perCapitaHeaderRow = (
<TableRow key="__per_capita_row" className="bg-muted hover:bg-purple-100"> <TableRow key="__per_capita_row" className="bg-muted hover:bg-purple-100">
<TableCell className="p-2 font-medium "></TableCell> <TableCell className="p-2 font-medium "></TableCell>
{years.map((y) => ( {periods.map((p) => (
<TableCell key={y} className="p-2"></TableCell> <TableCell key={p} className="p-2"></TableCell>
))} ))}
</TableRow> </TableRow>
) )
@ -628,69 +626,69 @@ export default async function ReportDetailPage({ params }: { params: Promise<{ i
const marketHeaderRow = ( const marketHeaderRow = (
<TableRow key="__market_perf_row" className="bg-muted hover:bg-purple-100"> <TableRow key="__market_perf_row" className="bg-muted hover:bg-purple-100">
<TableCell className="p-2 font-medium "></TableCell> <TableCell className="p-2 font-medium "></TableCell>
{years.map((y) => ( {periods.map((p) => (
<TableCell key={y} className="p-2"></TableCell> <TableCell key={p} className="p-2"></TableCell>
))} ))}
</TableRow> </TableRow>
) )
const priceRow = ( const priceRow = (
<TableRow key="__price_row" className="hover:bg-purple-100"> <TableRow key="__price_row" className="hover:bg-purple-100">
<TableCell className="p-2 text-muted-foreground"></TableCell> <TableCell className="p-2 text-muted-foreground"></TableCell>
{years.map((y) => { {periods.map((p) => {
const arr = series['close'] as Array<{ year?: string; value?: number | null }> | undefined const arr = series['close'] as Array<{ period?: string; value?: number | null }> | undefined
const v = arr?.find(p => p?.year === y)?.value ?? null const v = arr?.find(pt => pt?.period === p)?.value ?? null
const num = typeof v === 'number' ? v : (v == null ? null : Number(v)) const num = typeof v === 'number' ? v : (v == null ? null : Number(v))
if (num == null || !Number.isFinite(num)) return <TableCell key={y} className="text-right p-2">-</TableCell> if (num == null || !Number.isFinite(num)) return <TableCell key={p} className="text-right p-2">-</TableCell>
return <TableCell key={y} className="text-right p-2">{numberFormatter.format(num)}</TableCell> return <TableCell key={p} className="text-right p-2">{numberFormatter.format(num)}</TableCell>
})} })}
</TableRow> </TableRow>
) )
const marketCapRow = ( const marketCapRow = (
<TableRow key="__market_cap_row" className="hover:bg-purple-100"> <TableRow key="__market_cap_row" className="hover:bg-purple-100">
<TableCell className="p-2 text-muted-foreground">亿</TableCell> <TableCell className="p-2 text-muted-foreground">亿</TableCell>
{years.map((y) => { {periods.map((p) => {
const arr = series['total_mv'] as Array<{ year?: string; value?: number | null }> | undefined const arr = series['total_mv'] as Array<{ period?: string; value?: number | null }> | undefined
const v = arr?.find(p => p?.year === y)?.value ?? null const v = arr?.find(pt => pt?.period === p)?.value ?? null
const num = typeof v === 'number' ? v : (v == null ? null : Number(v)) const num = typeof v === 'number' ? v : (v == null ? null : Number(v))
if (num == null || !Number.isFinite(num)) return <TableCell key={y} className="text-right p-2">-</TableCell> if (num == null || !Number.isFinite(num)) return <TableCell key={p} className="text-right p-2">-</TableCell>
const scaled = num / 10000 const scaled = num / 10000
return <TableCell key={y} className="text-right p-2">{integerFormatter.format(Math.round(scaled))}</TableCell> return <TableCell key={p} className="text-right p-2">{integerFormatter.format(Math.round(scaled))}</TableCell>
})} })}
</TableRow> </TableRow>
) )
const peRow = ( const peRow = (
<TableRow key="__pe_row" className="hover:bg-purple-100"> <TableRow key="__pe_row" className="hover:bg-purple-100">
<TableCell className="p-2 text-muted-foreground">PE</TableCell> <TableCell className="p-2 text-muted-foreground">PE</TableCell>
{years.map((y) => { {periods.map((p) => {
const arr = series['pe'] as Array<{ year?: string; value?: number | null }> | undefined const arr = series['pe'] as Array<{ period?: string; value?: number | null }> | undefined
const v = arr?.find(p => p?.year === y)?.value ?? null const v = arr?.find(pt => pt?.period === p)?.value ?? null
const num = typeof v === 'number' ? v : (v == null ? null : Number(v)) const num = typeof v === 'number' ? v : (v == null ? null : Number(v))
if (num == null || !Number.isFinite(num)) return <TableCell key={y} className="text-right p-2">-</TableCell> if (num == null || !Number.isFinite(num)) return <TableCell key={p} className="text-right p-2">-</TableCell>
return <TableCell key={y} className="text-right p-2">{numberFormatter.format(num)}</TableCell> return <TableCell key={p} className="text-right p-2">{numberFormatter.format(num)}</TableCell>
})} })}
</TableRow> </TableRow>
) )
const pbRow = ( const pbRow = (
<TableRow key="__pb_row" className="hover:bg-purple-100"> <TableRow key="__pb_row" className="hover:bg-purple-100">
<TableCell className="p-2 text-muted-foreground">PB</TableCell> <TableCell className="p-2 text-muted-foreground">PB</TableCell>
{years.map((y) => { {periods.map((p) => {
const arr = series['pb'] as Array<{ year?: string; value?: number | null }> | undefined const arr = series['pb'] as Array<{ period?: string; value?: number | null }> | undefined
const v = arr?.find(p => p?.year === y)?.value ?? null const v = arr?.find(pt => pt?.period === p)?.value ?? null
const num = typeof v === 'number' ? v : (v == null ? null : Number(v)) const num = typeof v === 'number' ? v : (v == null ? null : Number(v))
if (num == null || !Number.isFinite(num)) return <TableCell key={y} className="text-right p-2">-</TableCell> if (num == null || !Number.isFinite(num)) return <TableCell key={p} className="text-right p-2">-</TableCell>
return <TableCell key={y} className="text-right p-2">{numberFormatter.format(num)}</TableCell> return <TableCell key={p} className="text-right p-2">{numberFormatter.format(num)}</TableCell>
})} })}
</TableRow> </TableRow>
) )
const holderNumRow = ( const holderNumRow = (
<TableRow key="__holder_num_row" className="hover:bg-purple-100"> <TableRow key="__holder_num_row" className="hover:bg-purple-100">
<TableCell className="p-2 text-muted-foreground"></TableCell> <TableCell className="p-2 text-muted-foreground"></TableCell>
{years.map((y) => { {periods.map((p) => {
const arr = series['holder_num'] as Array<{ year?: string; value?: number | null }> | undefined const arr = series['holder_num'] as Array<{ period?: string; value?: number | null }> | undefined
const v = arr?.find(p => p?.year === y)?.value ?? null const v = arr?.find(pt => pt?.period === p)?.value ?? null
const num = typeof v === 'number' ? v : (v == null ? null : Number(v)) const num = typeof v === 'number' ? v : (v == null ? null : Number(v))
if (num == null || !Number.isFinite(num)) return <TableCell key={y} className="text-right p-2">-</TableCell> if (num == null || !Number.isFinite(num)) return <TableCell key={p} className="text-right p-2">-</TableCell>
return <TableCell key={y} className="text-right p-2">{integerFormatter.format(Math.round(num))}</TableCell> return <TableCell key={p} className="text-right p-2">{integerFormatter.format(Math.round(num))}</TableCell>
})} })}
</TableRow> </TableRow>
) )

View File

@ -324,3 +324,24 @@ export function safeSetToStorage(key: string, value: unknown): boolean {
return false; return false;
} }
} }
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;
}
};

View File

@ -36,3 +36,5 @@ export const prisma =
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma

View File

@ -42,15 +42,13 @@ export interface CompanySuggestion {
// ============================================================================ // ============================================================================
/** /**
* *
*/ */
export interface YearDataPoint { export interface PeriodDataPoint {
/** 年份 */ /** 报告期 (YYYYMMDD格式如 20241231, 20250930) */
year: string; period: string;
/** 数值 (可为null表示无数据) */ /** 数值 (可为null表示无数据) */
value: number | null; value: number | null;
/** 月份信息,用于确定季度 */
month?: number | null;
} }
/** /**
@ -81,7 +79,7 @@ export interface FinancialMetricConfig {
* *
*/ */
export interface FinancialDataSeries { export interface FinancialDataSeries {
[metricKey: string]: YearDataPoint[]; [metricKey: string]: PeriodDataPoint[];
} }
/** /**

View File

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

View File

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

View File

@ -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("✅ 测试完成")

View File

@ -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("✅ 测试完成")

View File

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

View File

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

View File

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