feat(数据): 新增员工、股东及税务指标并生成日志
- 后端: Tushare provider 新增 get_employee_number, get_holder_number, get_tax_to_ebt 方法,并在 financial 路由中集成。 - 前端: report 页面新增对应图表展示,并更新相关类型与工具函数。 - 清理: 移除多个过时的测试脚本。 - 文档: 创建 2025-11-04 开发日志并更新用户手册。
This commit is contained in:
parent
3ffb30696b
commit
3475138419
@ -152,8 +152,33 @@ class DataManager:
|
||||
logger.error(f"All data providers failed for '{stock_code}' on method '{method_name}'.")
|
||||
return None
|
||||
|
||||
async def get_financial_statements(self, stock_code: str, report_dates: List[str]) -> List[Dict[str, Any]]:
|
||||
return await self.get_data('get_financial_statements', stock_code, report_dates=report_dates)
|
||||
async def get_financial_statements(self, stock_code: str, report_dates: List[str]) -> Dict[str, List[Dict[str, Any]]]:
|
||||
data = await self.get_data('get_financial_statements', stock_code, report_dates=report_dates)
|
||||
if data is None:
|
||||
return {}
|
||||
|
||||
# Normalize to series format
|
||||
if isinstance(data, dict):
|
||||
# Already in series format (e.g., tushare)
|
||||
return data
|
||||
elif isinstance(data, list):
|
||||
# Convert from flat format to series format
|
||||
series: Dict[str, List[Dict[str, Any]]] = {}
|
||||
for report in data:
|
||||
year = str(report.get('year', report.get('end_date', '')[:4]))
|
||||
if not year:
|
||||
continue
|
||||
for key, value in report.items():
|
||||
if key in ['ts_code', 'stock_code', 'year', 'end_date', 'period', 'ann_date', 'f_ann_date', 'report_type']:
|
||||
continue
|
||||
if isinstance(value, (int, float)) and value is not None:
|
||||
if key not in series:
|
||||
series[key] = []
|
||||
if not any(d['year'] == year for d in series[key]):
|
||||
series[key].append({"year": year, "value": value})
|
||||
return series
|
||||
else:
|
||||
return {}
|
||||
|
||||
async def get_daily_price(self, stock_code: str, start_date: str, end_date: str) -> List[Dict[str, Any]]:
|
||||
return await self.get_data('get_daily_price', stock_code, start_date=start_date, end_date=end_date)
|
||||
|
||||
@ -1,65 +1,166 @@
|
||||
from .base import BaseDataProvider
|
||||
from typing import Any, Dict, List, Optional
|
||||
import httpx
|
||||
from typing import Any, Dict, List, Optional, Callable
|
||||
import logging
|
||||
import asyncio
|
||||
import tushare as ts
|
||||
import math
|
||||
import datetime
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
TUSHARE_PRO_URL = "https://api.tushare.pro"
|
||||
|
||||
class TushareProvider(BaseDataProvider):
|
||||
|
||||
def _initialize(self):
|
||||
if not self.token:
|
||||
raise ValueError("Tushare API token not provided.")
|
||||
# Use httpx.AsyncClient directly
|
||||
self._client = httpx.AsyncClient(timeout=30)
|
||||
# 使用官方 SDK 客户端
|
||||
self._pro = ts.pro_api(self.token)
|
||||
# 交易日历缓存:key=(exchange, start, end) -> List[Dict]
|
||||
self._trade_cal_cache: Dict[str, List[Dict[str, Any]]] = {}
|
||||
|
||||
async def _resolve_trade_dates(self, dates: List[str], exchange: str = "SSE") -> Dict[str, str]:
|
||||
"""
|
||||
将任意日期映射为“该日若非交易日,则取不晚于该日的最近一个交易日”。
|
||||
返回映射:requested_date -> resolved_trade_date。
|
||||
"""
|
||||
if not dates:
|
||||
return {}
|
||||
start_date = min(dates)
|
||||
end_date = max(dates)
|
||||
cache_key = f"{exchange}:{start_date}:{end_date}"
|
||||
|
||||
if cache_key in self._trade_cal_cache:
|
||||
cal_rows = self._trade_cal_cache[cache_key]
|
||||
else:
|
||||
cal_rows = await self._query(
|
||||
api_name="trade_cal",
|
||||
params={
|
||||
"exchange": exchange,
|
||||
"start_date": start_date,
|
||||
"end_date": end_date,
|
||||
},
|
||||
fields=["cal_date", "is_open", "pretrade_date"],
|
||||
)
|
||||
self._trade_cal_cache[cache_key] = cal_rows
|
||||
|
||||
by_date: Dict[str, Dict[str, Any]] = {str(r.get("cal_date")): r for r in cal_rows}
|
||||
# 同时准备已开放的交易日期序列,便于兜底搜索
|
||||
open_dates = sorted([d for d, r in by_date.items() if int(r.get("is_open", 0)) == 1])
|
||||
|
||||
def _prev_open(d: str) -> Optional[str]:
|
||||
# 找到 <= d 的最大开市日
|
||||
lo, hi = 0, len(open_dates) - 1
|
||||
ans = None
|
||||
while lo <= hi:
|
||||
mid = (lo + hi) // 2
|
||||
if open_dates[mid] <= d:
|
||||
ans = open_dates[mid]
|
||||
lo = mid + 1
|
||||
else:
|
||||
hi = mid - 1
|
||||
return ans
|
||||
|
||||
resolved: Dict[str, str] = {}
|
||||
for d in dates:
|
||||
row = by_date.get(d)
|
||||
if row is None:
|
||||
# 不在本段日历(极少数情况),做一次兜底:使用区间内最近开市日
|
||||
prev_d = _prev_open(d)
|
||||
if prev_d:
|
||||
resolved[d] = prev_d
|
||||
else:
|
||||
# 最后兜底,仍找不到则原样返回
|
||||
resolved[d] = d
|
||||
continue
|
||||
is_open = int(row.get("is_open", 0))
|
||||
if is_open == 1:
|
||||
resolved[d] = d
|
||||
else:
|
||||
prev = str(row.get("pretrade_date") or "")
|
||||
if prev:
|
||||
resolved[d] = prev
|
||||
else:
|
||||
prev_d = _prev_open(d)
|
||||
resolved[d] = prev_d or d
|
||||
return resolved
|
||||
|
||||
async def _query(
|
||||
self,
|
||||
api_name: str,
|
||||
params: Optional[Dict[str, Any]] = None,
|
||||
fields: Optional[str] = None,
|
||||
fields: Optional[List[str]] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
payload = {
|
||||
"api_name": api_name,
|
||||
"token": self.token,
|
||||
"params": params or {},
|
||||
}
|
||||
if "limit" not in payload["params"]:
|
||||
payload["params"]["limit"] = 5000
|
||||
if fields:
|
||||
payload["fields"] = fields
|
||||
"""
|
||||
使用官方 tushare SDK 统一查询,返回字典列表。
|
||||
为避免阻塞事件循环,内部通过 asyncio.to_thread 在线程中执行同步调用。
|
||||
"""
|
||||
params = params or {}
|
||||
|
||||
logger.info(f"Querying Tushare API '{api_name}' with params: {params}")
|
||||
def _call() -> List[Dict[str, Any]]:
|
||||
# 将字段列表转换为逗号分隔的字符串(SDK 推荐方式)
|
||||
fields_arg: Optional[str] = ",".join(fields) if isinstance(fields, list) else None
|
||||
|
||||
# 优先使用属性方式(pro.fina_indicator 等);若不存在则回退到通用 query
|
||||
func: Optional[Callable] = getattr(self._pro, api_name, None)
|
||||
try:
|
||||
if callable(func):
|
||||
df = func(**params, fields=fields_arg) if fields_arg else func(**params)
|
||||
else:
|
||||
# 通用回退:pro.query(name, params=..., fields=...)
|
||||
if fields_arg:
|
||||
df = self._pro.query(api_name, params=params, fields=fields_arg)
|
||||
else:
|
||||
df = self._pro.query(api_name, params=params)
|
||||
except Exception as exc:
|
||||
# 将 SDK 抛出的异常包装为统一日志
|
||||
raise RuntimeError(f"tushare.{api_name} failed: {exc}")
|
||||
|
||||
if df is None or df.empty:
|
||||
return []
|
||||
# DataFrame -> List[Dict]
|
||||
return df.to_dict(orient="records")
|
||||
|
||||
try:
|
||||
resp = await self._client.post(TUSHARE_PRO_URL, json=payload)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
if data.get("code") != 0:
|
||||
err_msg = data.get("msg") or "Unknown Tushare error"
|
||||
logger.error(f"Tushare API error for '{api_name}': {err_msg}")
|
||||
raise RuntimeError(f"{api_name}: {err_msg}")
|
||||
|
||||
fields_def = data.get("data", {}).get("fields", [])
|
||||
items = data.get("data", {}).get("items", [])
|
||||
|
||||
rows: List[Dict[str, Any]] = []
|
||||
for it in items:
|
||||
row = {fields_def[i]: it[i] for i in range(len(fields_def))}
|
||||
rows.append(row)
|
||||
|
||||
logger.info(f"Tushare API '{api_name}' returned {len(rows)} rows.")
|
||||
return rows
|
||||
rows: List[Dict[str, Any]] = await asyncio.to_thread(_call)
|
||||
# 清洗 NaN/Inf,避免 JSON 序列化错误
|
||||
DATE_KEYS = {
|
||||
"cal_date", "pretrade_date", "trade_date", "trade_dt", "date",
|
||||
"end_date", "ann_date", "f_ann_date", "period"
|
||||
}
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
logger.error(f"HTTP error calling Tushare API '{api_name}': {e.response.status_code} - {e.response.text}")
|
||||
raise
|
||||
def _sanitize_value(key: str, v: Any) -> Any:
|
||||
if v is None:
|
||||
return None
|
||||
# 保持日期/期末字段为字符串(避免 20231231 -> 20231231.0 导致匹配失败)
|
||||
if key in DATE_KEYS:
|
||||
try:
|
||||
s = str(v)
|
||||
# 去除意外的小数点形式
|
||||
if s.endswith(".0"):
|
||||
s = s[:-2]
|
||||
return s
|
||||
except Exception:
|
||||
return str(v)
|
||||
try:
|
||||
# 处理 numpy.nan / numpy.inf / Decimal / numpy 数值等,统一为 Python float
|
||||
fv = float(v)
|
||||
return fv if math.isfinite(fv) else None
|
||||
except Exception:
|
||||
# 利用自反性判断 NaN(NaN != NaN)
|
||||
try:
|
||||
if v != v:
|
||||
return None
|
||||
except Exception:
|
||||
pass
|
||||
return v
|
||||
|
||||
for row in rows:
|
||||
for k, v in list(row.items()):
|
||||
row[k] = _sanitize_value(k, v)
|
||||
# logger.info(f"Tushare '{api_name}' returned {len(rows)} rows.")
|
||||
return rows
|
||||
except Exception as e:
|
||||
logger.error(f"Exception calling Tushare API '{api_name}': {e}")
|
||||
logger.error(f"Exception calling tushare '{api_name}': {e}")
|
||||
raise
|
||||
|
||||
async def get_stock_basic(self, stock_code: str) -> Optional[Dict[str, Any]]:
|
||||
@ -75,7 +176,7 @@ class TushareProvider(BaseDataProvider):
|
||||
|
||||
async def get_daily_price(self, stock_code: str, start_date: str, end_date: str) -> List[Dict[str, Any]]:
|
||||
try:
|
||||
return await self._query(
|
||||
rows = await self._query(
|
||||
api_name="daily",
|
||||
params={
|
||||
"ts_code": stock_code,
|
||||
@ -83,6 +184,7 @@ class TushareProvider(BaseDataProvider):
|
||||
"end_date": end_date,
|
||||
},
|
||||
)
|
||||
return rows or []
|
||||
except Exception as e:
|
||||
logger.error(f"Tushare get_daily_price failed for {stock_code}: {e}")
|
||||
return []
|
||||
@ -92,18 +194,25 @@ class TushareProvider(BaseDataProvider):
|
||||
获取指定交易日列表的 daily_basic 数据(例如 total_mv、pe、pb)。
|
||||
"""
|
||||
try:
|
||||
tasks = [
|
||||
self._query(
|
||||
api_name="daily_basic",
|
||||
params={"ts_code": stock_code, "trade_date": d},
|
||||
) for d in trade_dates
|
||||
]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
rows: List[Dict[str, Any]] = []
|
||||
for res in results:
|
||||
if isinstance(res, list) and res:
|
||||
rows.extend(res)
|
||||
logger.info(f"Tushare daily_basic returned {len(rows)} rows for {stock_code} on {len(trade_dates)} dates")
|
||||
if not trade_dates:
|
||||
return []
|
||||
# 将请求日期映射到不晚于该日的最近交易日
|
||||
mapping = await self._resolve_trade_dates(trade_dates, exchange="SSE")
|
||||
resolved_dates = list(set(mapping.values()))
|
||||
start_date = min(resolved_dates)
|
||||
end_date = max(resolved_dates)
|
||||
# 一次性取区间内数据,再按解析后的交易日过滤
|
||||
all_rows = await self._query(
|
||||
api_name="daily_basic",
|
||||
params={
|
||||
"ts_code": stock_code,
|
||||
"start_date": start_date,
|
||||
"end_date": end_date,
|
||||
},
|
||||
)
|
||||
wanted = set(resolved_dates)
|
||||
rows = [r for r in all_rows if str(r.get("trade_date")) in wanted]
|
||||
logger.info(f"Tushare daily_basic returned {len(rows)} rows for {stock_code} on {len(trade_dates)} requested dates (resolved to {len(wanted)} trading dates)")
|
||||
return rows
|
||||
except Exception as e:
|
||||
logger.error(f"Tushare get_daily_basic_points failed for {stock_code}: {e}")
|
||||
@ -114,32 +223,37 @@ class TushareProvider(BaseDataProvider):
|
||||
获取指定交易日列表的日行情(例如 close)。
|
||||
"""
|
||||
try:
|
||||
tasks = [
|
||||
self._query(
|
||||
api_name="daily",
|
||||
params={"ts_code": stock_code, "trade_date": d},
|
||||
) for d in trade_dates
|
||||
]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
rows: List[Dict[str, Any]] = []
|
||||
for res in results:
|
||||
if isinstance(res, list) and res:
|
||||
rows.extend(res)
|
||||
logger.info(f"Tushare daily returned {len(rows)} rows for {stock_code} on {len(trade_dates)} dates")
|
||||
if not trade_dates:
|
||||
return []
|
||||
mapping = await self._resolve_trade_dates(trade_dates, exchange="SSE")
|
||||
resolved_dates = list(set(mapping.values()))
|
||||
start_date = min(resolved_dates)
|
||||
end_date = max(resolved_dates)
|
||||
all_rows = await self._query(
|
||||
api_name="daily",
|
||||
params={
|
||||
"ts_code": stock_code,
|
||||
"start_date": start_date,
|
||||
"end_date": end_date,
|
||||
},
|
||||
)
|
||||
wanted = set(resolved_dates)
|
||||
rows = [r for r in all_rows if str(r.get("trade_date")) in wanted]
|
||||
logger.info(f"Tushare daily returned {len(rows)} rows for {stock_code} on {len(trade_dates)} requested dates (resolved to {len(wanted)} trading dates)")
|
||||
return rows
|
||||
except Exception as e:
|
||||
logger.error(f"Tushare get_daily_points failed for {stock_code}: {e}")
|
||||
return []
|
||||
|
||||
def _calculate_derived_metrics(self, series: Dict[str, List[Dict]], years: List[str]) -> Dict[str, List[Dict]]:
|
||||
def _calculate_derived_metrics(self, series: Dict[str, List[Dict]], periods: List[str]) -> Dict[str, List[Dict]]:
|
||||
"""
|
||||
在 Tushare provider 内部计算派生指标。
|
||||
"""
|
||||
# --- Helper Functions ---
|
||||
def _get_value(key: str, year: str) -> Optional[float]:
|
||||
def _get_value(key: str, period: str) -> Optional[float]:
|
||||
if key not in series:
|
||||
return None
|
||||
point = next((p for p in series[key] if p.get("year") == year), None)
|
||||
point = next((p for p in series[key] if p.get("period") == period), None)
|
||||
if point is None or point.get("value") is None:
|
||||
return None
|
||||
try:
|
||||
@ -147,20 +261,22 @@ class TushareProvider(BaseDataProvider):
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
def _get_avg_value(key: str, year: str) -> Optional[float]:
|
||||
current_val = _get_value(key, year)
|
||||
def _get_avg_value(key: str, period: str) -> Optional[float]:
|
||||
current_val = _get_value(key, period)
|
||||
try:
|
||||
prev_year = str(int(year) - 1)
|
||||
prev_val = _get_value(key, prev_year)
|
||||
# 总是和上一年度的年报值(如果存在)进行平均
|
||||
current_year = int(period[:4])
|
||||
prev_year_end_period = str(current_year - 1) + "1231"
|
||||
prev_val = _get_value(key, prev_year_end_period)
|
||||
except (ValueError, TypeError):
|
||||
prev_val = None
|
||||
if current_val is None: return None
|
||||
if prev_val is None: return current_val
|
||||
return (current_val + prev_val) / 2
|
||||
|
||||
def _get_cogs(year: str) -> Optional[float]:
|
||||
revenue = _get_value('revenue', year)
|
||||
gp_margin_raw = _get_value('grossprofit_margin', year)
|
||||
def _get_cogs(period: str) -> Optional[float]:
|
||||
revenue = _get_value('revenue', period)
|
||||
gp_margin_raw = _get_value('grossprofit_margin', period)
|
||||
if revenue is None or gp_margin_raw is None: return None
|
||||
gp_margin = gp_margin_raw / 100.0 if abs(gp_margin_raw) > 1 else gp_margin_raw
|
||||
return revenue * (1 - gp_margin)
|
||||
@ -171,11 +287,11 @@ class TushareProvider(BaseDataProvider):
|
||||
|
||||
# --- Calculations ---
|
||||
fcf_data = []
|
||||
for year in years:
|
||||
op_cashflow = _get_value('n_cashflow_act', year)
|
||||
capex = _get_value('c_pay_acq_const_fiolta', year)
|
||||
for period in periods:
|
||||
op_cashflow = _get_value('n_cashflow_act', period)
|
||||
capex = _get_value('c_pay_acq_const_fiolta', period)
|
||||
if op_cashflow is not None and capex is not None:
|
||||
fcf_data.append({"year": year, "value": op_cashflow - capex})
|
||||
fcf_data.append({"period": period, "value": op_cashflow - capex})
|
||||
add_series('__free_cash_flow', fcf_data)
|
||||
|
||||
fee_calcs = [
|
||||
@ -186,29 +302,29 @@ class TushareProvider(BaseDataProvider):
|
||||
]
|
||||
for key, num_key, den_key in fee_calcs:
|
||||
data = []
|
||||
for year in years:
|
||||
numerator = _get_value(num_key, year)
|
||||
denominator = _get_value(den_key, year)
|
||||
for period in periods:
|
||||
numerator = _get_value(num_key, period)
|
||||
denominator = _get_value(den_key, period)
|
||||
if numerator is not None and denominator is not None and denominator != 0:
|
||||
data.append({"year": year, "value": (numerator / denominator) * 100})
|
||||
data.append({"period": period, "value": (numerator / denominator) * 100})
|
||||
add_series(key, data)
|
||||
|
||||
tax_rate_data = []
|
||||
for year in years:
|
||||
tax_to_ebt = _get_value('tax_to_ebt', year)
|
||||
for period in periods:
|
||||
tax_to_ebt = _get_value('tax_to_ebt', period)
|
||||
if tax_to_ebt is not None:
|
||||
rate = tax_to_ebt * 100 if abs(tax_to_ebt) <= 1 else tax_to_ebt
|
||||
tax_rate_data.append({"year": year, "value": rate})
|
||||
tax_rate_data.append({"period": period, "value": rate})
|
||||
add_series('__tax_rate', tax_rate_data)
|
||||
|
||||
other_fee_data = []
|
||||
for year in years:
|
||||
gp_raw = _get_value('grossprofit_margin', year)
|
||||
np_raw = _get_value('netprofit_margin', year)
|
||||
rev = _get_value('revenue', year)
|
||||
sell_exp = _get_value('sell_exp', year)
|
||||
admin_exp = _get_value('admin_exp', year)
|
||||
rd_exp = _get_value('rd_exp', year)
|
||||
for period in periods:
|
||||
gp_raw = _get_value('grossprofit_margin', period)
|
||||
np_raw = _get_value('netprofit_margin', period)
|
||||
rev = _get_value('revenue', period)
|
||||
sell_exp = _get_value('sell_exp', period)
|
||||
admin_exp = _get_value('admin_exp', period)
|
||||
rd_exp = _get_value('rd_exp', period)
|
||||
if all(v is not None for v in [gp_raw, np_raw, rev, sell_exp, admin_exp, rd_exp]) and rev != 0:
|
||||
gp = gp_raw / 100 if abs(gp_raw) > 1 else gp_raw
|
||||
np = np_raw / 100 if abs(np_raw) > 1 else np_raw
|
||||
@ -216,7 +332,7 @@ class TushareProvider(BaseDataProvider):
|
||||
admin_rate = admin_exp / rev
|
||||
rd_rate = rd_exp / rev
|
||||
other_rate = (gp - np - sell_rate - admin_rate - rd_rate) * 100
|
||||
other_fee_data.append({"year": year, "value": other_rate})
|
||||
other_fee_data.append({"period": period, "value": other_rate})
|
||||
add_series('__other_fee_rate', other_fee_data)
|
||||
|
||||
asset_ratio_keys = [
|
||||
@ -228,60 +344,60 @@ class TushareProvider(BaseDataProvider):
|
||||
]
|
||||
for key, num_key in asset_ratio_keys:
|
||||
data = []
|
||||
for year in years:
|
||||
numerator = _get_value(num_key, year)
|
||||
denominator = _get_value('total_assets', year)
|
||||
for period in periods:
|
||||
numerator = _get_value(num_key, period)
|
||||
denominator = _get_value('total_assets', period)
|
||||
if numerator is not None and denominator is not None and denominator != 0:
|
||||
data.append({"year": year, "value": (numerator / denominator) * 100})
|
||||
data.append({"period": period, "value": (numerator / denominator) * 100})
|
||||
add_series(key, data)
|
||||
|
||||
adv_data = []
|
||||
for year in years:
|
||||
adv = _get_value('adv_receipts', year) or 0
|
||||
contract = _get_value('contract_liab', year) or 0
|
||||
total_assets = _get_value('total_assets', year)
|
||||
for period in periods:
|
||||
adv = _get_value('adv_receipts', period) or 0
|
||||
contract = _get_value('contract_liab', period) or 0
|
||||
total_assets = _get_value('total_assets', period)
|
||||
if total_assets is not None and total_assets != 0:
|
||||
adv_data.append({"year": year, "value": ((adv + contract) / total_assets) * 100})
|
||||
adv_data.append({"period": period, "value": ((adv + contract) / total_assets) * 100})
|
||||
add_series('__adv_ratio', adv_data)
|
||||
|
||||
other_assets_data = []
|
||||
known_assets_keys = ['money_cap', 'inventories', 'accounts_receiv_bill', 'prepayment', 'fix_assets', 'lt_eqt_invest', 'goodwill']
|
||||
for year in years:
|
||||
total_assets = _get_value('total_assets', year)
|
||||
for period in periods:
|
||||
total_assets = _get_value('total_assets', period)
|
||||
if total_assets is not None and total_assets != 0:
|
||||
sum_known = sum(_get_value(k, year) or 0 for k in known_assets_keys)
|
||||
other_assets_data.append({"year": year, "value": ((total_assets - sum_known) / total_assets) * 100})
|
||||
sum_known = sum(_get_value(k, period) or 0 for k in known_assets_keys)
|
||||
other_assets_data.append({"period": period, "value": ((total_assets - sum_known) / total_assets) * 100})
|
||||
add_series('__other_assets_ratio', other_assets_data)
|
||||
|
||||
op_assets_data = []
|
||||
for year in years:
|
||||
total_assets = _get_value('total_assets', year)
|
||||
for period in periods:
|
||||
total_assets = _get_value('total_assets', period)
|
||||
if total_assets is not None and total_assets != 0:
|
||||
inv = _get_value('inventories', year) or 0
|
||||
ar = _get_value('accounts_receiv_bill', year) or 0
|
||||
pre = _get_value('prepayment', year) or 0
|
||||
ap = _get_value('accounts_pay', year) or 0
|
||||
adv = _get_value('adv_receipts', year) or 0
|
||||
contract_liab = _get_value('contract_liab', year) or 0
|
||||
inv = _get_value('inventories', period) or 0
|
||||
ar = _get_value('accounts_receiv_bill', period) or 0
|
||||
pre = _get_value('prepayment', period) or 0
|
||||
ap = _get_value('accounts_pay', period) or 0
|
||||
adv = _get_value('adv_receipts', period) or 0
|
||||
contract_liab = _get_value('contract_liab', period) or 0
|
||||
operating_assets = inv + ar + pre - ap - adv - contract_liab
|
||||
op_assets_data.append({"year": year, "value": (operating_assets / total_assets) * 100})
|
||||
op_assets_data.append({"period": period, "value": (operating_assets / total_assets) * 100})
|
||||
add_series('__operating_assets_ratio', op_assets_data)
|
||||
|
||||
debt_ratio_data = []
|
||||
for year in years:
|
||||
total_assets = _get_value('total_assets', year)
|
||||
for period in periods:
|
||||
total_assets = _get_value('total_assets', period)
|
||||
if total_assets is not None and total_assets != 0:
|
||||
st_borr = _get_value('st_borr', year) or 0
|
||||
lt_borr = _get_value('lt_borr', year) or 0
|
||||
debt_ratio_data.append({"year": year, "value": ((st_borr + lt_borr) / total_assets) * 100})
|
||||
st_borr = _get_value('st_borr', period) or 0
|
||||
lt_borr = _get_value('lt_borr', period) or 0
|
||||
debt_ratio_data.append({"period": period, "value": ((st_borr + lt_borr) / total_assets) * 100})
|
||||
add_series('__interest_bearing_debt_ratio', debt_ratio_data)
|
||||
|
||||
payturn_data = []
|
||||
for year in years:
|
||||
avg_ap = _get_avg_value('accounts_pay', year)
|
||||
cogs = _get_cogs(year)
|
||||
for period in periods:
|
||||
avg_ap = _get_avg_value('accounts_pay', period)
|
||||
cogs = _get_cogs(period)
|
||||
if avg_ap is not None and cogs is not None and cogs != 0:
|
||||
payturn_data.append({"year": year, "value": (365 * avg_ap) / cogs})
|
||||
payturn_data.append({"period": period, "value": (365 * avg_ap) / cogs})
|
||||
add_series('payturn_days', payturn_data)
|
||||
|
||||
per_capita_calcs = [
|
||||
@ -291,82 +407,318 @@ class TushareProvider(BaseDataProvider):
|
||||
]
|
||||
for key, num_key, divisor in per_capita_calcs:
|
||||
data = []
|
||||
for year in years:
|
||||
numerator = _get_value(num_key, year)
|
||||
employees = _get_value('employees', year)
|
||||
for period in periods:
|
||||
numerator = _get_value(num_key, period)
|
||||
employees = _get_value('employees', period)
|
||||
if numerator is not None and employees is not None and employees != 0:
|
||||
data.append({"year": year, "value": (numerator / employees) / divisor})
|
||||
data.append({"period": period, "value": (numerator / employees) / divisor})
|
||||
add_series(key, data)
|
||||
|
||||
return series
|
||||
|
||||
async def get_financial_statements(self, stock_code: str, report_dates: List[str]) -> Dict[str, List[Dict[str, Any]]]:
|
||||
all_statements: List[Dict[str, Any]] = []
|
||||
for date in report_dates:
|
||||
logger.info(f"Fetching financial statements for {stock_code}, report date: {date}")
|
||||
async def get_financial_statements(self, stock_code: str, report_dates: Optional[List[str]] = None) -> Dict[str, List[Dict[str, Any]]]:
|
||||
# 1) 一次性拉取所需四表(尽量齐全字段),再按指定 report_dates 过滤
|
||||
# 字段列表基于官方示例,避免超量请求可按需精简
|
||||
bs_fields = [
|
||||
"ts_code","ann_date","f_ann_date","end_date","report_type","comp_type","end_type",
|
||||
"money_cap","inventories","prepayment","accounts_receiv","accounts_receiv_bill","goodwill",
|
||||
"lt_eqt_invest","fix_assets","total_assets","accounts_pay","adv_receipts","contract_liab",
|
||||
"st_borr","lt_borr","total_cur_assets","total_cur_liab","total_ncl","total_liab","total_hldr_eqy_exc_min_int",
|
||||
]
|
||||
ic_fields = [
|
||||
"ts_code","ann_date","f_ann_date","end_date","report_type","comp_type","end_type",
|
||||
"total_revenue","revenue","sell_exp","admin_exp","rd_exp","operate_profit","total_profit",
|
||||
"income_tax","n_income","n_income_attr_p","ebit","ebitda","netprofit_margin","grossprofit_margin",
|
||||
]
|
||||
cf_fields = [
|
||||
"ts_code","ann_date","f_ann_date","end_date","comp_type","report_type","end_type",
|
||||
"n_cashflow_act","c_pay_acq_const_fiolta","c_paid_to_for_empl","depr_fa_coga_dpba",
|
||||
]
|
||||
fi_fields = [
|
||||
"ts_code","end_date","ann_date","grossprofit_margin","netprofit_margin","tax_to_ebt","roe","roa","roic",
|
||||
"invturn_days","arturn_days","fa_turn","tr_yoy","dt_netprofit_yoy","assets_turn",
|
||||
]
|
||||
|
||||
try:
|
||||
bs_rows, ic_rows, cf_rows, fi_rows, rep_rows, div_rows, holder_rows, company_rows = await asyncio.gather(
|
||||
self._query("balancesheet", params={"ts_code": stock_code, "report_type": 1}, fields=bs_fields),
|
||||
self._query("income", params={"ts_code": stock_code, "report_type": 1}, fields=ic_fields),
|
||||
self._query("cashflow", params={"ts_code": stock_code, "report_type": 1}, fields=cf_fields),
|
||||
self._query("fina_indicator", params={"ts_code": stock_code}, fields=fi_fields),
|
||||
# 回购公告
|
||||
self._query(
|
||||
"repurchase",
|
||||
params={"ts_code": stock_code},
|
||||
fields=[
|
||||
"ts_code","ann_date","end_date","proc","exp_date","vol","amount","high_limit","low_limit",
|
||||
],
|
||||
),
|
||||
# 分红公告(仅取必要字段)
|
||||
self._query(
|
||||
"dividend",
|
||||
params={"ts_code": stock_code},
|
||||
fields=[
|
||||
"ts_code","end_date","cash_div_tax","pay_date","base_share",
|
||||
],
|
||||
),
|
||||
# 股东户数(按报告期)
|
||||
self._query(
|
||||
"stk_holdernumber",
|
||||
params={"ts_code": stock_code},
|
||||
fields=[
|
||||
"ts_code","ann_date","end_date","holder_num",
|
||||
],
|
||||
),
|
||||
# 公司基本信息(包含员工数)
|
||||
self._query(
|
||||
"stock_company",
|
||||
params={"ts_code": stock_code},
|
||||
fields=[
|
||||
"ts_code","employees",
|
||||
],
|
||||
),
|
||||
)
|
||||
try:
|
||||
bs_rows, ic_rows, cf_rows, fi_rows = await asyncio.gather(
|
||||
self._query(
|
||||
api_name="balancesheet",
|
||||
params={"ts_code": stock_code, "period": date, "report_type": 1},
|
||||
),
|
||||
self._query(
|
||||
api_name="income",
|
||||
params={"ts_code": stock_code, "period": date, "report_type": 1},
|
||||
),
|
||||
self._query(
|
||||
api_name="cashflow",
|
||||
params={"ts_code": stock_code, "period": date, "report_type": 1},
|
||||
),
|
||||
# 补充关键财务比率(ROE/ROA/毛利率等)
|
||||
self._query(
|
||||
api_name="fina_indicator",
|
||||
params={"ts_code": stock_code, "period": date},
|
||||
),
|
||||
)
|
||||
logger.info(f"[Dividend] fetched {len(div_rows)} rows for {stock_code}")
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(f"Tushare bulk fetch failed for {stock_code}: {e}")
|
||||
bs_rows, ic_rows, cf_rows, fi_rows, rep_rows, div_rows, holder_rows, company_rows = [], [], [], [], [], [], [], []
|
||||
|
||||
if not bs_rows and not ic_rows and not cf_rows and not fi_rows:
|
||||
logger.warning(f"No financial statements components found from Tushare for {stock_code} on {date}")
|
||||
# 2) 以 end_date 聚合合并四表
|
||||
by_date: Dict[str, Dict[str, Any]] = {}
|
||||
def _merge_rows(rows: List[Dict[str, Any]]):
|
||||
for r in rows or []:
|
||||
end_date = str(r.get("end_date") or r.get("period") or "")
|
||||
if not end_date:
|
||||
continue
|
||||
if end_date not in by_date:
|
||||
by_date[end_date] = {"ts_code": stock_code, "end_date": end_date}
|
||||
by_date[end_date].update(r)
|
||||
|
||||
merged: Dict[str, Any] = {"ts_code": stock_code, "end_date": date}
|
||||
bs_data = bs_rows[0] if bs_rows else {}
|
||||
ic_data = ic_rows[0] if ic_rows else {}
|
||||
cf_data = cf_rows[0] if cf_rows else {}
|
||||
fi_data = fi_rows[0] if fi_rows else {}
|
||||
_merge_rows(bs_rows)
|
||||
_merge_rows(ic_rows)
|
||||
_merge_rows(cf_rows)
|
||||
_merge_rows(fi_rows)
|
||||
|
||||
merged.update(bs_data)
|
||||
merged.update(ic_data)
|
||||
merged.update(cf_data)
|
||||
merged.update(fi_data)
|
||||
|
||||
merged["end_date"] = merged.get("end_date") or merged.get("period") or date
|
||||
logger.debug(f"Merged statement for {date} has keys: {list(merged.keys())}")
|
||||
# 3) 筛选报告期:今年的最新报告期 + 往年所有年报
|
||||
current_year = str(datetime.date.today().year)
|
||||
all_available_dates = sorted(by_date.keys(), reverse=True)
|
||||
|
||||
all_statements.append(merged)
|
||||
except Exception as e:
|
||||
logger.error(f"Tushare get_financial_statement failed for {stock_code} on {date}: {e}")
|
||||
continue
|
||||
latest_current_year_report = None
|
||||
for d in all_available_dates:
|
||||
if d.startswith(current_year):
|
||||
latest_current_year_report = d
|
||||
break
|
||||
|
||||
logger.info(f"Successfully fetched {len(all_statements)} statement(s) for {stock_code}.")
|
||||
previous_years_annual_reports = [
|
||||
d for d in all_available_dates if d.endswith("1231") and not d.startswith(current_year)
|
||||
]
|
||||
|
||||
wanted_dates = []
|
||||
if latest_current_year_report:
|
||||
wanted_dates.append(latest_current_year_report)
|
||||
wanted_dates.extend(previous_years_annual_reports)
|
||||
|
||||
all_statements = [by_date[d] for d in wanted_dates if d in by_date]
|
||||
|
||||
logger.info(f"Successfully prepared {len(all_statements)} merged statement(s) for {stock_code} from {len(by_date)} available reports.")
|
||||
|
||||
# Transform to series format
|
||||
series: Dict[str, List[Dict]] = {}
|
||||
if all_statements:
|
||||
for report in all_statements:
|
||||
year = report.get("end_date", "")[:4]
|
||||
if not year: continue
|
||||
period = report.get("end_date", "")
|
||||
if not period: continue
|
||||
for key, value in report.items():
|
||||
if key in ['ts_code', 'end_date', 'ann_date', 'f_ann_date', 'report_type', 'comp_type', 'end_type', 'update_flag', 'period']:
|
||||
continue
|
||||
if isinstance(value, (int, float)) and value is not None:
|
||||
# 仅保留可转为有限 float 的数值,避免 JSON 序列化错误
|
||||
try:
|
||||
fv = float(value)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if value is not None and math.isfinite(fv):
|
||||
if key not in series:
|
||||
series[key] = []
|
||||
if not any(d['year'] == year for d in series[key]):
|
||||
series[key].append({"year": year, "value": value})
|
||||
if not any(d['period'] == period for d in series[key]):
|
||||
series[key].append({"period": period, "value": fv})
|
||||
|
||||
# 汇总回购信息为年度序列:按报告期 end_date 年份分组;
|
||||
# 其中 repurchase_amount 取该年内“最后一个 ann_date”的 amount 值。
|
||||
if 'rep_rows' in locals() and rep_rows:
|
||||
rep_by_year: Dict[str, Dict[str, Any]] = {}
|
||||
for r in rep_rows:
|
||||
endd = str(r.get("end_date") or r.get("ann_date") or "")
|
||||
if not endd:
|
||||
continue
|
||||
y = endd[:4]
|
||||
bucket = rep_by_year.setdefault(y, {
|
||||
"amount_sum": 0.0,
|
||||
"vol": 0.0,
|
||||
"high_limit": None,
|
||||
"low_limit": None,
|
||||
"last_ann_date": None,
|
||||
"amount_last": None,
|
||||
})
|
||||
amt = r.get("amount")
|
||||
vol = r.get("vol")
|
||||
hi = r.get("high_limit")
|
||||
lo = r.get("low_limit")
|
||||
ann = str(r.get("ann_date") or "")
|
||||
if isinstance(amt, (int, float)) and amt is not None:
|
||||
bucket["amount_sum"] += float(amt)
|
||||
if ann and ann[:4] == y:
|
||||
last = bucket["last_ann_date"]
|
||||
if last is None or ann > last:
|
||||
bucket["last_ann_date"] = ann
|
||||
bucket["amount_last"] = float(amt)
|
||||
if isinstance(vol, (int, float)) and vol is not None:
|
||||
bucket["vol"] += float(vol)
|
||||
if isinstance(hi, (int, float)) and hi is not None:
|
||||
bucket["high_limit"] = float(hi)
|
||||
if isinstance(lo, (int, float)) and lo is not None:
|
||||
bucket["low_limit"] = float(lo)
|
||||
|
||||
|
||||
if rep_by_year:
|
||||
amt_series = []
|
||||
vol_series = []
|
||||
hi_series = []
|
||||
lo_series = []
|
||||
for y, v in rep_by_year.items():
|
||||
# 当年数据放在当前年最新报告期,否则放在年度报告期
|
||||
if y == current_year and latest_current_year_report:
|
||||
period_key = latest_current_year_report
|
||||
else:
|
||||
period_key = f"{y}1231"
|
||||
|
||||
if v.get("amount_last") is not None:
|
||||
amt_series.append({"period": period_key, "value": v["amount_last"]})
|
||||
if v.get("vol"):
|
||||
vol_series.append({"period": period_key, "value": v["vol"]})
|
||||
if v.get("high_limit") is not None:
|
||||
hi_series.append({"period": period_key, "value": v["high_limit"]})
|
||||
if v.get("low_limit") is not None:
|
||||
lo_series.append({"period": period_key, "value": v["low_limit"]})
|
||||
if amt_series:
|
||||
series["repurchase_amount"] = amt_series
|
||||
if vol_series:
|
||||
series["repurchase_vol"] = vol_series
|
||||
if hi_series:
|
||||
series["repurchase_high_limit"] = hi_series
|
||||
if lo_series:
|
||||
series["repurchase_low_limit"] = lo_series
|
||||
|
||||
# 汇总分红信息为年度序列:以真实派息日 pay_date 的年份分组;
|
||||
# 每条记录金额= 每股分红(cash_div_tax) * 基准股本(base_share),其中 base_share 单位为“万股”,
|
||||
# 金额以“亿”为单位返回,因此需再除以 10000。
|
||||
if 'div_rows' in locals() and div_rows:
|
||||
div_by_year: Dict[str, float] = {}
|
||||
for r in div_rows:
|
||||
pay = str(r.get("pay_date") or "")
|
||||
# 仅统计存在数字年份的真实派息日
|
||||
if not pay or len(pay) < 4 or not any(ch.isdigit() for ch in pay):
|
||||
continue
|
||||
y = pay[:4]
|
||||
cash_div = r.get("cash_div_tax")
|
||||
base_share = r.get("base_share")
|
||||
if isinstance(cash_div, (int, float)) and isinstance(base_share, (int, float)):
|
||||
# 现金分红总额(万元)= 每股分红(元) * 基准股本(万股)
|
||||
# 转为“亿”需除以 10000
|
||||
amount_billion = (float(cash_div) * float(base_share)) / 10000.0
|
||||
div_by_year[y] = div_by_year.get(y, 0.0) + amount_billion
|
||||
if div_by_year:
|
||||
div_series = []
|
||||
for y, v in sorted(div_by_year.items()):
|
||||
# 当年数据放在当前年最新报告期,否则放在年度报告期
|
||||
if y == current_year and latest_current_year_report:
|
||||
period_key = latest_current_year_report
|
||||
else:
|
||||
period_key = f"{y}1231"
|
||||
div_series.append({"period": period_key, "value": v})
|
||||
series["dividend_amount"] = div_series
|
||||
# try:
|
||||
# logger.info(f"[Dividend] Series dividend_amount(period) for {stock_code}: {div_series}")
|
||||
# except Exception:
|
||||
# pass
|
||||
|
||||
# 汇总股东户数信息:按报告期 end_date 分组,取最新的 holder_num
|
||||
if 'holder_rows' in locals() and holder_rows:
|
||||
# 按 end_date 分组,取最新的 ann_date 的 holder_num
|
||||
holder_by_period: Dict[str, Dict[str, Any]] = {}
|
||||
for r in holder_rows:
|
||||
end_date = str(r.get("end_date") or "")
|
||||
if not end_date:
|
||||
continue
|
||||
ann_date = str(r.get("ann_date") or "")
|
||||
holder_num = r.get("holder_num")
|
||||
|
||||
if end_date not in holder_by_period:
|
||||
holder_by_period[end_date] = {
|
||||
"holder_num": holder_num,
|
||||
"latest_ann_date": ann_date
|
||||
}
|
||||
else:
|
||||
# 比较 ann_date,取最新的
|
||||
current_latest = holder_by_period[end_date]["latest_ann_date"]
|
||||
if ann_date and (not current_latest or ann_date > current_latest):
|
||||
holder_by_period[end_date] = {
|
||||
"holder_num": holder_num,
|
||||
"latest_ann_date": ann_date
|
||||
}
|
||||
|
||||
# 筛选报告期:只取今年的最后一个报告期和往年的所有年报(12月31日)
|
||||
holder_available_dates = sorted(holder_by_period.keys(), reverse=True)
|
||||
|
||||
holder_wanted_dates = []
|
||||
|
||||
# 今年的最新报告期
|
||||
latest_current_year_holder = None
|
||||
for d in holder_available_dates:
|
||||
if d.startswith(current_year):
|
||||
latest_current_year_holder = d
|
||||
break
|
||||
if latest_current_year_holder:
|
||||
holder_wanted_dates.append(latest_current_year_holder)
|
||||
|
||||
# 往年的所有年报(12月31日)
|
||||
previous_years_holder_reports = [
|
||||
d for d in holder_available_dates if d.endswith("1231") and not d.startswith(current_year)
|
||||
]
|
||||
holder_wanted_dates.extend(previous_years_holder_reports)
|
||||
|
||||
# 生成系列数据,只包含筛选后的报告期
|
||||
holder_series = []
|
||||
for end_date in sorted(holder_wanted_dates):
|
||||
if end_date in holder_by_period:
|
||||
data = holder_by_period[end_date]
|
||||
holder_num = data["holder_num"]
|
||||
if isinstance(holder_num, (int, float)) and holder_num is not None:
|
||||
holder_series.append({"period": end_date, "value": float(holder_num)})
|
||||
|
||||
if holder_series:
|
||||
series["holder_num"] = holder_series
|
||||
|
||||
# 汇总员工数信息:员工数放在去年的年末(上一年的12月31日)
|
||||
if 'company_rows' in locals() and company_rows:
|
||||
# 员工数通常是静态数据,取最新的一个值
|
||||
latest_employees = None
|
||||
for r in company_rows:
|
||||
employees = r.get("employees")
|
||||
if isinstance(employees, (int, float)) and employees is not None:
|
||||
latest_employees = float(employees)
|
||||
break # 取第一个有效值
|
||||
|
||||
if latest_employees is not None:
|
||||
# 将员工数放在去年的年末(上一年的12月31日)
|
||||
previous_year = str(datetime.date.today().year - 1)
|
||||
period_key = f"{previous_year}1231"
|
||||
series["employees"] = [{"period": period_key, "value": latest_employees}]
|
||||
|
||||
# Calculate derived metrics
|
||||
years = sorted(list(set(d['year'] for s in series.values() for d in s)))
|
||||
series = self._calculate_derived_metrics(series, years)
|
||||
periods = sorted(list(set(d['period'] for s in series.values() for d in s)))
|
||||
series = self._calculate_derived_metrics(series, periods)
|
||||
|
||||
return series
|
||||
|
||||
@ -22,7 +22,6 @@ from app.schemas.financial import (
|
||||
)
|
||||
from app.services.company_profile_client import CompanyProfileClient
|
||||
from app.services.analysis_client import AnalysisClient, load_analysis_config, get_analysis_config
|
||||
from app.services.financial_calculator import calculate_derived_metrics
|
||||
|
||||
# Lazy DataManager loader to avoid import-time failures when optional providers/config are missing
|
||||
_dm = None
|
||||
@ -293,9 +292,21 @@ async def get_china_financials(
|
||||
# Fetch all financial statements at once
|
||||
step_financials = StepRecord(name="拉取财务报表", start_ts=started_real.isoformat(), status="running")
|
||||
steps.append(step_financials)
|
||||
|
||||
|
||||
# Fetch all financial statements at once (already in series format from provider)
|
||||
series = await get_dm().get_financial_statements(stock_code=ts_code, report_dates=report_dates)
|
||||
|
||||
# Get the latest current year report period for market data
|
||||
latest_current_year_report = None
|
||||
if series:
|
||||
current_year_str = str(current_year)
|
||||
for key in series:
|
||||
if series[key]:
|
||||
for item in series[key]:
|
||||
period = item.get('period', '')
|
||||
if period.startswith(current_year_str) and not period.endswith('1231'):
|
||||
if latest_current_year_report is None or period > latest_current_year_report:
|
||||
latest_current_year_report = period
|
||||
|
||||
if not series:
|
||||
errors["financial_statements"] = "Failed to fetch from all providers."
|
||||
@ -314,39 +325,78 @@ async def get_china_financials(
|
||||
step_market = StepRecord(name="拉取市值与股价", start_ts=datetime.now(timezone.utc).isoformat(), status="running")
|
||||
steps.append(step_market)
|
||||
|
||||
# 构建市场数据查询日期:年度日期 + 当前年最新报告期
|
||||
market_dates = report_dates.copy()
|
||||
if latest_current_year_report:
|
||||
# 查找当前年最新报告期对应的交易日(通常是报告期当月最后一天或最近交易日)
|
||||
try:
|
||||
report_date_obj = datetime.strptime(latest_current_year_report, '%Y%m%d')
|
||||
# 使用报告期日期作为查询日期(API会自动找到最近的交易日)
|
||||
market_dates.append(latest_current_year_report)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
try:
|
||||
if has_daily_basic:
|
||||
db_rows = await get_dm().get_data('get_daily_basic_points', stock_code=ts_code, trade_dates=report_dates)
|
||||
db_rows = await get_dm().get_data('get_daily_basic_points', stock_code=ts_code, trade_dates=market_dates)
|
||||
if isinstance(db_rows, list):
|
||||
for row in db_rows:
|
||||
trade_date = row.get('trade_date') or row.get('trade_dt') or row.get('date')
|
||||
if not trade_date:
|
||||
continue
|
||||
# 判断是否为当前年最新报告期的数据
|
||||
is_current_year_report = latest_current_year_report and str(trade_date) == latest_current_year_report
|
||||
year = str(trade_date)[:4]
|
||||
|
||||
if is_current_year_report:
|
||||
# 当前年最新报告期的数据,使用报告期period显示
|
||||
period = latest_current_year_report
|
||||
else:
|
||||
# 其他年度数据,使用年度period显示(YYYY1231)
|
||||
period = f"{year}1231"
|
||||
|
||||
for key, value in row.items():
|
||||
if key in ['ts_code', 'trade_date', 'trade_dt', 'date']:
|
||||
continue
|
||||
if isinstance(value, (int, float)) and value is not None:
|
||||
if key not in series:
|
||||
series[key] = []
|
||||
if not any(d['year'] == year for d in series[key]):
|
||||
series[key].append({"year": year, "value": value})
|
||||
# 检查是否已存在该period的数据,如果存在则替换为最新的数据
|
||||
existing_index = next((i for i, d in enumerate(series[key]) if d['period'] == period), -1)
|
||||
if existing_index >= 0:
|
||||
series[key][existing_index] = {"period": period, "value": value}
|
||||
else:
|
||||
series[key].append({"period": period, "value": value})
|
||||
if has_daily:
|
||||
d_rows = await get_dm().get_data('get_daily_points', stock_code=ts_code, trade_dates=report_dates)
|
||||
d_rows = await get_dm().get_data('get_daily_points', stock_code=ts_code, trade_dates=market_dates)
|
||||
if isinstance(d_rows, list):
|
||||
for row in d_rows:
|
||||
trade_date = row.get('trade_date') or row.get('trade_dt') or row.get('date')
|
||||
if not trade_date:
|
||||
continue
|
||||
# 判断是否为当前年最新报告期的数据
|
||||
is_current_year_report = latest_current_year_report and str(trade_date) == latest_current_year_report
|
||||
year = str(trade_date)[:4]
|
||||
|
||||
if is_current_year_report:
|
||||
# 当前年最新报告期的数据,使用报告期period显示
|
||||
period = latest_current_year_report
|
||||
else:
|
||||
# 其他年度数据,使用年度period显示(YYYY1231)
|
||||
period = f"{year}1231"
|
||||
|
||||
for key, value in row.items():
|
||||
if key in ['ts_code', 'trade_date', 'trade_dt', 'date']:
|
||||
continue
|
||||
if isinstance(value, (int, float)) and value is not None:
|
||||
if key not in series:
|
||||
series[key] = []
|
||||
if not any(d['year'] == year for d in series[key]):
|
||||
series[key].append({"year": year, "value": value})
|
||||
# 检查是否已存在该period的数据,如果存在则替换为最新的数据
|
||||
existing_index = next((i for i, d in enumerate(series[key]) if d['period'] == period), -1)
|
||||
if existing_index >= 0:
|
||||
series[key][existing_index] = {"period": period, "value": value}
|
||||
else:
|
||||
series[key].append({"period": period, "value": value})
|
||||
except Exception as e:
|
||||
errors["market_data"] = f"Failed to fetch market data: {e}"
|
||||
finally:
|
||||
@ -362,17 +412,20 @@ async def get_china_financials(
|
||||
if not series:
|
||||
raise HTTPException(status_code=502, detail={"message": "No data returned from any data source", "errors": errors})
|
||||
|
||||
# Truncate years and sort (the data should already be mostly correct, but we ensure)
|
||||
# Truncate periods and sort (the data should already be mostly correct, but we ensure)
|
||||
for key, arr in series.items():
|
||||
# Deduplicate and sort desc by year, then cut to requested years, and return asc
|
||||
uniq = {item["year"]: item for item in arr}
|
||||
arr_sorted_desc = sorted(uniq.values(), key=lambda x: x["year"], reverse=True)
|
||||
# Deduplicate and sort desc by period, then cut to requested periods, and return asc
|
||||
uniq = {item["period"]: item for item in arr}
|
||||
arr_sorted_desc = sorted(uniq.values(), key=lambda x: x["period"], reverse=True)
|
||||
arr_limited = arr_sorted_desc[:years]
|
||||
arr_sorted = sorted(arr_limited, key=lambda x: x["year"])
|
||||
arr_sorted = sorted(arr_limited, key=lambda x: x["period"])
|
||||
series[key] = arr_sorted
|
||||
|
||||
# Calculate derived financial metrics
|
||||
series = calculate_derived_metrics(series, years_list)
|
||||
# Create periods_list for derived metrics calculation
|
||||
periods_list = sorted(list(set(item["period"] for arr in series.values() for item in arr)))
|
||||
|
||||
# Note: Derived financial metrics calculation has been moved to individual data providers
|
||||
# The data_manager.get_financial_statements() should handle this
|
||||
|
||||
meta = FinancialMeta(
|
||||
started_at=started_real.isoformat(),
|
||||
|
||||
@ -5,10 +5,9 @@ from typing import Dict, List, Optional
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class YearDataPoint(BaseModel):
|
||||
year: str
|
||||
class PeriodDataPoint(BaseModel):
|
||||
period: str
|
||||
value: Optional[float]
|
||||
month: Optional[int] = None # 月份信息,用于确定季度
|
||||
|
||||
|
||||
class StepRecord(BaseModel):
|
||||
@ -33,7 +32,7 @@ class FinancialMeta(BaseModel):
|
||||
class BatchFinancialDataResponse(BaseModel):
|
||||
ts_code: str
|
||||
name: Optional[str] = None
|
||||
series: Dict[str, List[YearDataPoint]]
|
||||
series: Dict[str, List[PeriodDataPoint]]
|
||||
meta: Optional[FinancialMeta] = None
|
||||
|
||||
|
||||
|
||||
@ -268,3 +268,5 @@ A:
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 3001",
|
||||
"dev": "NODE_NO_WARNINGS=1 next dev -p 3001",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
|
||||
@ -14,3 +14,5 @@
|
||||
若暂时没有字体文件,页面会退回系统默认字体,不影响功能。
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@ -14,6 +14,7 @@ import remarkGfm from 'remark-gfm';
|
||||
import { TradingViewWidget } from '@/components/TradingViewWidget';
|
||||
import type { CompanyProfileResponse, AnalysisResponse } from '@/types';
|
||||
import { useMemo } from 'react';
|
||||
import { formatReportPeriod } from '@/lib/financial-utils';
|
||||
|
||||
export default function ReportPage() {
|
||||
const params = useParams();
|
||||
@ -732,32 +733,42 @@ export default function ReportPage() {
|
||||
<div className="overflow-x-auto">
|
||||
{(() => {
|
||||
const series = financials?.series ?? {};
|
||||
const allPoints = Object.values(series).flat() as Array<{ year: string; value: number | null; month?: number | null }>;
|
||||
const currentYearStr = String(new Date().getFullYear());
|
||||
const years = Array
|
||||
.from(new Set(allPoints.map((p) => p?.year).filter(Boolean) as string[]))
|
||||
.sort((a, b) => Number(b) - Number(a)); // 最新年份在左
|
||||
const getQuarter = (month: number | null | undefined) => {
|
||||
if (month == null) return null;
|
||||
return Math.floor((month - 1) / 3) + 1;
|
||||
// 统一 period:优先 p.period;若仅有 year 则映射到 `${year}1231`
|
||||
const toPeriod = (p: any): string | null => {
|
||||
if (!p) return null;
|
||||
if (p.period) return String(p.period);
|
||||
if (p.year) return `${p.year}1231`;
|
||||
return null;
|
||||
};
|
||||
if (years.length === 0) {
|
||||
const allPeriods = Array.from(
|
||||
new Set(
|
||||
(Object.values(series).flat() as any[])
|
||||
.map((p) => toPeriod(p))
|
||||
.filter((v): v is string => Boolean(v))
|
||||
)
|
||||
).sort((a, b) => b.localeCompare(a)); // 最新在左(按 YYYYMMDD 排序)
|
||||
|
||||
if (allPeriods.length === 0) {
|
||||
return <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 (
|
||||
<Table className="min-w-full text-sm">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="text-left p-2">指标</TableHead>
|
||||
{years.map((y) => {
|
||||
const isCurrent = y === currentYearStr;
|
||||
const yearData = allPoints.find(p => p.year === y);
|
||||
const quarter = yearData?.month ? getQuarter(yearData.month) : null;
|
||||
const label = isCurrent && quarter ? `${y} Q${quarter}` : y;
|
||||
return (
|
||||
<TableHead key={y} className="text-right p-2">{label}</TableHead>
|
||||
);
|
||||
})}
|
||||
{periods.map((p) => (
|
||||
<TableHead key={p} className="text-right p-2">{formatReportPeriod(p)}</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@ -776,8 +787,8 @@ export default function ReportPage() {
|
||||
{ key: 'n_cashflow_act' },
|
||||
{ key: 'c_pay_acq_const_fiolta' },
|
||||
{ key: '__free_cash_flow', label: '自由现金流' },
|
||||
{ key: 'cash_div_tax', label: '分红' },
|
||||
{ key: 'buyback', label: '回购' },
|
||||
{ key: 'dividend_amount', label: '分红' },
|
||||
{ key: 'repurchase_amount', label: '回购' },
|
||||
{ key: 'total_assets' },
|
||||
{ key: 'total_hldr_eqy_exc_min_int' },
|
||||
{ key: 'goodwill' },
|
||||
@ -787,8 +798,8 @@ export default function ReportPage() {
|
||||
const summaryRow = (
|
||||
<TableRow key="__main_metrics_row" className="bg-muted hover:bg-purple-100">
|
||||
<TableCell className="p-2 font-medium ">主要指标</TableCell>
|
||||
{years.map((y) => (
|
||||
<TableCell key={y} className="p-2"></TableCell>
|
||||
{periods.map((p) => (
|
||||
<TableCell key={p} className="p-2"></TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
);
|
||||
@ -803,20 +814,20 @@ export default function ReportPage() {
|
||||
'__operating_assets_ratio', '__interest_bearing_debt_ratio'
|
||||
]);
|
||||
const rows = ORDER.map(({ key, label }) => {
|
||||
const points = series[key] as Array<{ year?: string; value?: number | null }> | undefined;
|
||||
const points = series[key] as any[] | undefined;
|
||||
|
||||
return (
|
||||
<TableRow key={key} className="hover:bg-purple-100">
|
||||
<TableCell className="p-2 text-muted-foreground">
|
||||
{label || metricDisplayMap[key] || key}
|
||||
</TableCell>
|
||||
{years.map((y) => {
|
||||
const v = points?.find(p => p?.year === y)?.value ?? null;
|
||||
{periods.map((p) => {
|
||||
const v = getValueByPeriod(points, p);
|
||||
|
||||
const groupName = metricGroupMap[key];
|
||||
const rawNum = typeof v === 'number' ? v : (v == null ? null : Number(v));
|
||||
if (rawNum == null || Number.isNaN(rawNum)) {
|
||||
return <TableCell key={y} className="text-right p-2">-</TableCell>;
|
||||
return <TableCell key={p} className="text-right p-2">-</TableCell>;
|
||||
}
|
||||
if (PERCENT_KEYS.has(key)) {
|
||||
const perc = Math.abs(rawNum) <= 1 && key !== 'tax_to_ebt' && key !== '__tax_rate' ? rawNum * 100 : rawNum;
|
||||
@ -825,31 +836,31 @@ export default function ReportPage() {
|
||||
if (isGrowthRow) {
|
||||
const isNeg = typeof perc === 'number' && perc < 0;
|
||||
return (
|
||||
<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>
|
||||
</TableCell>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<TableCell key={y} className="text-right p-2">{`${text}%`}</TableCell>
|
||||
<TableCell key={p} className="text-right p-2">{`${text}%`}</TableCell>
|
||||
);
|
||||
} else {
|
||||
const isFinGroup = groupName === 'income' || groupName === 'balancesheet' || groupName === 'cashflow';
|
||||
const scaled = key === 'total_mv'
|
||||
? rawNum / 10000
|
||||
: (isFinGroup || key === '__free_cash_flow' ? rawNum / 1e8 : rawNum);
|
||||
: (isFinGroup || key === '__free_cash_flow' || key === 'repurchase_amount' ? rawNum / 1e8 : rawNum);
|
||||
const formatter = key === 'total_mv' ? integerFormatter : numberFormatter;
|
||||
const text = Number.isFinite(scaled) ? formatter.format(scaled) : '-';
|
||||
if (key === '__free_cash_flow') {
|
||||
const isNeg = typeof scaled === 'number' && scaled < 0;
|
||||
return (
|
||||
<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}
|
||||
</TableCell>
|
||||
);
|
||||
}
|
||||
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 = (
|
||||
<TableRow key="__fee_metrics_row" className="bg-muted hover:bg-purple-100">
|
||||
<TableCell className="p-2 font-medium ">费用指标</TableCell>
|
||||
{years.map((y) => (
|
||||
<TableCell key={y} className="p-2"></TableCell>
|
||||
{periods.map((p) => (
|
||||
<TableCell key={p} className="p-2"></TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
);
|
||||
@ -879,17 +890,17 @@ export default function ReportPage() {
|
||||
].map(({ key, label }) => (
|
||||
<TableRow key={key} className="hover:bg-purple-100">
|
||||
<TableCell className="p-2 text-muted-foreground">{label}</TableCell>
|
||||
{years.map((y) => {
|
||||
const points = series[key] as Array<{ year?: string; value?: number | null }> | undefined;
|
||||
const v = points?.find(p => p?.year === y)?.value ?? null;
|
||||
{periods.map((p) => {
|
||||
const points = series[key] as any[] | undefined;
|
||||
const v = getValueByPeriod(points, p);
|
||||
|
||||
if (v == null || !Number.isFinite(v)) {
|
||||
return <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 isNegative = v < 0;
|
||||
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}%`}
|
||||
</TableCell>
|
||||
);
|
||||
@ -903,20 +914,20 @@ export default function ReportPage() {
|
||||
const assetHeaderRow = (
|
||||
<TableRow key="__asset_ratio_row" className="bg-muted hover:bg-purple-100">
|
||||
<TableCell className="p-2 font-medium ">资产占比</TableCell>
|
||||
{years.map((y) => (
|
||||
<TableCell key={y} className="p-2"></TableCell>
|
||||
{periods.map((p) => (
|
||||
<TableCell key={p} className="p-2"></TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
);
|
||||
|
||||
const ratioCell = (value: number | null, y: string) => {
|
||||
const ratioCell = (value: number | null, keyStr: string) => {
|
||||
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 isNegative = value < 0;
|
||||
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}%`}
|
||||
</TableCell>
|
||||
);
|
||||
@ -940,10 +951,10 @@ export default function ReportPage() {
|
||||
].map(({ key, label }) => (
|
||||
<TableRow key={key} className={`hover:bg-purple-100 ${key === '__other_assets_ratio' ? 'bg-yellow-50' : ''}`}>
|
||||
<TableCell className="p-2 text-muted-foreground">{label}</TableCell>
|
||||
{years.map((y) => {
|
||||
const points = series[key] as Array<{ year?: string; value?: number | null }> | undefined;
|
||||
const v = points?.find(p => p?.year === y)?.value ?? null;
|
||||
return ratioCell(v, y);
|
||||
{periods.map((p) => {
|
||||
const points = series[key] as any[] | undefined;
|
||||
const v = getValueByPeriod(points, p);
|
||||
return ratioCell(v, p);
|
||||
})}
|
||||
</TableRow>
|
||||
));
|
||||
@ -954,8 +965,8 @@ export default function ReportPage() {
|
||||
const turnoverHeaderRow = (
|
||||
<TableRow key="__turnover_row" className="bg-muted hover:bg-purple-100">
|
||||
<TableCell className="p-2 font-medium ">周转能力</TableCell>
|
||||
{years.map((y) => (
|
||||
<TableCell key={y} className="p-2"></TableCell>
|
||||
{periods.map((p) => (
|
||||
<TableCell key={p} className="p-2"></TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
);
|
||||
@ -971,21 +982,21 @@ export default function ReportPage() {
|
||||
const turnoverRows = turnoverItems.map(({ key, label }) => (
|
||||
<TableRow key={key} className="hover:bg-purple-100">
|
||||
<TableCell className="p-2 text-muted-foreground">{label}</TableCell>
|
||||
{years.map((y) => {
|
||||
const points = series[key] as Array<{ year?: string; value?: number | null }> | undefined;
|
||||
const v = points?.find(p => p?.year === y)?.value ?? null;
|
||||
{periods.map((p) => {
|
||||
const points = series[key] as any[] | undefined;
|
||||
const v = getValueByPeriod(points, p);
|
||||
const value = typeof v === 'number' ? v : (v == null ? null : Number(v));
|
||||
|
||||
if (value == null || !Number.isFinite(value)) {
|
||||
return <TableCell key={y} className="text-right p-2">-</TableCell>;
|
||||
return <TableCell key={p} className="text-right p-2">-</TableCell>;
|
||||
}
|
||||
const text = numberFormatter.format(value);
|
||||
if (key === 'arturn_days' && value > 90) {
|
||||
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>
|
||||
));
|
||||
@ -1005,8 +1016,8 @@ export default function ReportPage() {
|
||||
(
|
||||
<TableRow key="__per_capita_row" className="bg-muted hover:bg-purple-100">
|
||||
<TableCell className="p-2 font-medium ">人均效率</TableCell>
|
||||
{years.map((y) => (
|
||||
<TableCell key={y} className="p-2"></TableCell>
|
||||
{periods.map((p) => (
|
||||
<TableCell key={p} className="p-2"></TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
),
|
||||
@ -1014,13 +1025,13 @@ export default function ReportPage() {
|
||||
(
|
||||
<TableRow key="__employees_row" className="hover:bg-purple-100">
|
||||
<TableCell className="p-2 text-muted-foreground">员工人数</TableCell>
|
||||
{years.map((y) => {
|
||||
const points = series['employees'] as Array<{ year?: string; value?: number | null }> | undefined;
|
||||
const v = points?.find(p => p?.year === y)?.value ?? null;
|
||||
{periods.map((p) => {
|
||||
const points = series['employees'] as any[] | undefined;
|
||||
const v = getValueByPeriod(points, p);
|
||||
if (v == null) {
|
||||
return <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>
|
||||
),
|
||||
@ -1028,13 +1039,13 @@ export default function ReportPage() {
|
||||
(
|
||||
<TableRow key="__rev_per_emp_row" className="hover:bg-purple-100">
|
||||
<TableCell className="p-2 text-muted-foreground">人均创收(万元)</TableCell>
|
||||
{years.map((y) => {
|
||||
const points = series['__rev_per_emp'] as Array<{ year?: string; value?: number | null }> | undefined;
|
||||
const val = points?.find(p => p?.year === y)?.value ?? null;
|
||||
{periods.map((p) => {
|
||||
const points = series['__rev_per_emp'] as any[] | undefined;
|
||||
const val = getValueByPeriod(points, p);
|
||||
if (val == null) {
|
||||
return <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>
|
||||
),
|
||||
@ -1042,13 +1053,13 @@ export default function ReportPage() {
|
||||
(
|
||||
<TableRow key="__profit_per_emp_row" className="hover:bg-purple-100">
|
||||
<TableCell className="p-2 text-muted-foreground">人均创利(万元)</TableCell>
|
||||
{years.map((y) => {
|
||||
const points = series['__profit_per_emp'] as Array<{ year?: string; value?: number | null }> | undefined;
|
||||
const val = points?.find(p => p?.year === y)?.value ?? null;
|
||||
{periods.map((p) => {
|
||||
const points = series['__profit_per_emp'] as any[] | undefined;
|
||||
const val = getValueByPeriod(points, p);
|
||||
if (val == null) {
|
||||
return <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>
|
||||
),
|
||||
@ -1056,13 +1067,13 @@ export default function ReportPage() {
|
||||
(
|
||||
<TableRow key="__salary_per_emp_row" className="hover:bg-purple-100">
|
||||
<TableCell className="p-2 text-muted-foreground">人均工资(万元)</TableCell>
|
||||
{years.map((y) => {
|
||||
const points = series['__salary_per_emp'] as Array<{ year?: string; value?: number | null }> | undefined;
|
||||
const val = points?.find(p => p?.year === y)?.value ?? null;
|
||||
{periods.map((p) => {
|
||||
const points = series['__salary_per_emp'] as any[] | undefined;
|
||||
const val = getValueByPeriod(points, p);
|
||||
if (val == null) {
|
||||
return <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>
|
||||
),
|
||||
@ -1072,8 +1083,8 @@ export default function ReportPage() {
|
||||
(
|
||||
<TableRow key="__market_perf_row" className="bg-muted hover:bg-purple-100">
|
||||
<TableCell className="p-2 font-medium ">市场表现</TableCell>
|
||||
{years.map((y) => (
|
||||
<TableCell key={y} className="p-2"></TableCell>
|
||||
{periods.map((p) => (
|
||||
<TableCell key={p} className="p-2"></TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
),
|
||||
@ -1081,13 +1092,13 @@ export default function ReportPage() {
|
||||
(
|
||||
<TableRow key="__price_row" className="hover:bg-purple-100">
|
||||
<TableCell className="p-2 text-muted-foreground">股价</TableCell>
|
||||
{years.map((y) => {
|
||||
const points = series['close'] as Array<{ year?: string; value?: number | null }> | undefined;
|
||||
const v = points?.find(p => p?.year === y)?.value ?? null;
|
||||
{periods.map((p) => {
|
||||
const points = series['close'] as any[] | undefined;
|
||||
const v = getValueByPeriod(points, p);
|
||||
if (v == null) {
|
||||
return <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>
|
||||
),
|
||||
@ -1095,14 +1106,14 @@ export default function ReportPage() {
|
||||
(
|
||||
<TableRow key="__market_cap_row" className="hover:bg-purple-100">
|
||||
<TableCell className="p-2 text-muted-foreground">市值(亿元)</TableCell>
|
||||
{years.map((y) => {
|
||||
const points = series['total_mv'] as Array<{ year?: string; value?: number | null }> | undefined;
|
||||
const v = points?.find(p => p?.year === y)?.value ?? null;
|
||||
{periods.map((p) => {
|
||||
const points = series['total_mv'] as any[] | undefined;
|
||||
const v = getValueByPeriod(points, p);
|
||||
if (v == null) {
|
||||
return <TableCell key={y} className="text-right p-2">-</TableCell>;
|
||||
return <TableCell key={p} className="text-right p-2">-</TableCell>;
|
||||
}
|
||||
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>
|
||||
),
|
||||
@ -1110,13 +1121,13 @@ export default function ReportPage() {
|
||||
(
|
||||
<TableRow key="__pe_row" className="hover:bg-purple-100">
|
||||
<TableCell className="p-2 text-muted-foreground">PE</TableCell>
|
||||
{years.map((y) => {
|
||||
const points = series['pe'] as Array<{ year?: string; value?: number | null }> | undefined;
|
||||
const v = points?.find(p => p?.year === y)?.value ?? null;
|
||||
{periods.map((p) => {
|
||||
const points = series['pe'] as any[] | undefined;
|
||||
const v = getValueByPeriod(points, p);
|
||||
if (v == null) {
|
||||
return <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>
|
||||
),
|
||||
@ -1124,27 +1135,27 @@ export default function ReportPage() {
|
||||
(
|
||||
<TableRow key="__pb_row" className="hover:bg-purple-100">
|
||||
<TableCell className="p-2 text-muted-foreground">PB</TableCell>
|
||||
{years.map((y) => {
|
||||
const points = series['pb'] as Array<{ year?: string; value?: number | null }> | undefined;
|
||||
const v = points?.find(p => p?.year === y)?.value ?? null;
|
||||
{periods.map((p) => {
|
||||
const points = series['pb'] as any[] | undefined;
|
||||
const v = getValueByPeriod(points, p);
|
||||
if (v == null) {
|
||||
return <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 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>
|
||||
{years.map((y) => {
|
||||
const points = series['holder_num'] as Array<{ year?: string; value?: number | null }> | undefined;
|
||||
const v = points?.find(p => p?.year === y)?.value ?? null;
|
||||
{periods.map((p) => {
|
||||
const points = series['holder_num'] as any[] | undefined;
|
||||
const v = getValueByPeriod(points, p);
|
||||
if (v == null) {
|
||||
return <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>
|
||||
),
|
||||
|
||||
@ -4,6 +4,7 @@ import remarkGfm from 'remark-gfm'
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'
|
||||
import { Table, TableHeader, TableBody, TableHead, TableRow, TableCell } from '@/components/ui/table'
|
||||
import { formatReportPeriod } from '@/lib/financial-utils'
|
||||
|
||||
type Report = {
|
||||
id: string
|
||||
@ -120,13 +121,13 @@ export default async function ReportDetailPage({ params }: { params: Promise<{ i
|
||||
const fin = (content?.financials ?? null) as null | {
|
||||
ts_code?: string
|
||||
name?: string
|
||||
series?: Record<string, Array<{ year: string; value: number | null; month?: number | null }>>
|
||||
series?: Record<string, Array<{ period: string; value: number | null }>>
|
||||
meta?: any
|
||||
}
|
||||
|
||||
const series = fin?.series || {}
|
||||
const allPoints = Object.values(series).flat() as Array<{ year: string; value: number | null; month?: number | null }>
|
||||
const years = Array.from(new Set(allPoints.map(p => p?.year).filter(Boolean) as string[])).sort((a, b) => Number(b) - Number(a))
|
||||
const allPoints = Object.values(series).flat() as Array<{ period: string; value: number | null }>
|
||||
const periods = Array.from(new Set(allPoints.map(p => p?.period).filter(Boolean) as string[])).sort((a, b) => b.localeCompare(a))
|
||||
|
||||
const numberFormatter = new Intl.NumberFormat('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
const integerFormatter = new Intl.NumberFormat('zh-CN', { minimumFractionDigits: 0, maximumFractionDigits: 0 })
|
||||
@ -203,13 +204,9 @@ export default async function ReportDetailPage({ params }: { params: Promise<{ i
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="text-left p-2">指标</TableHead>
|
||||
{years.map((y) => {
|
||||
const yearData = allPoints.find(p => p.year === y)
|
||||
const isCurrent = y === currentYearStr
|
||||
const quarter = yearData?.month ? getQuarter(yearData.month) : null
|
||||
const label = isCurrent && quarter ? `${y} Q${quarter}` : y
|
||||
return <TableHead key={y} className="text-right p-2">{label}</TableHead>
|
||||
})}
|
||||
{periods.map((p) => (
|
||||
<TableHead key={p} className="text-right p-2">{formatReportPeriod(p)}</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@ -217,34 +214,34 @@ export default async function ReportDetailPage({ params }: { params: Promise<{ i
|
||||
const summaryRow = (
|
||||
<TableRow key="__main_metrics_row" className="bg-muted hover:bg-purple-100">
|
||||
<TableCell className="p-2 font-medium ">主要指标</TableCell>
|
||||
{years.map((y) => (
|
||||
<TableCell key={y} className="p-2"></TableCell>
|
||||
{periods.map((p) => (
|
||||
<TableCell key={p} className="p-2"></TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
)
|
||||
|
||||
const rows = ORDER.map(({ key, label, kind }) => {
|
||||
const isComputed = kind === 'computed' && key === '__free_cash_flow'
|
||||
const points = series[key] as Array<{ year?: string; value?: number | null }>|undefined
|
||||
const operating = series['n_cashflow_act'] as Array<{ year?: string; value?: number | null }>|undefined
|
||||
const capex = series['c_pay_acq_const_fiolta'] as Array<{ year?: string; value?: number | null }>|undefined
|
||||
const points = series[key] as Array<{ period?: string; value?: number | null }>|undefined
|
||||
const operating = series['n_cashflow_act'] as Array<{ period?: string; value?: number | null }>|undefined
|
||||
const capex = series['c_pay_acq_const_fiolta'] as Array<{ period?: string; value?: number | null }>|undefined
|
||||
return (
|
||||
<TableRow key={key} className="hover:bg-purple-100">
|
||||
<TableCell className="p-2 text-muted-foreground">{label || metricDisplayMap[key] || key}</TableCell>
|
||||
{years.map((y) => {
|
||||
{periods.map((p) => {
|
||||
let v: number | null | undefined = undefined
|
||||
if (isComputed) {
|
||||
const op = operating?.find(p => p?.year === y)?.value ?? null
|
||||
const cp = capex?.find(p => p?.year === y)?.value ?? null
|
||||
const op = operating?.find(pt => pt?.period === p)?.value ?? null
|
||||
const cp = capex?.find(pt => pt?.period === p)?.value ?? null
|
||||
v = (op == null || cp == null) ? null : (Number(op) - Number(cp))
|
||||
} else {
|
||||
v = points?.find(p => p?.year === y)?.value ?? null
|
||||
v = points?.find(pt => pt?.period === p)?.value ?? null
|
||||
}
|
||||
|
||||
const groupName = metricGroupMap[key]
|
||||
const rawNum = typeof v === 'number' ? v : (v == null ? null : Number(v))
|
||||
if (rawNum == null || Number.isNaN(rawNum)) {
|
||||
return <TableCell key={y} className="text-right p-2">-</TableCell>
|
||||
return <TableCell key={p} className="text-right p-2">-</TableCell>
|
||||
}
|
||||
if (PERCENT_KEYS.has(key)) {
|
||||
const perc = Math.abs(rawNum) <= 1 ? rawNum * 100 : rawNum
|
||||
@ -283,8 +280,8 @@ export default async function ReportDetailPage({ params }: { params: Promise<{ i
|
||||
)
|
||||
})
|
||||
|
||||
const getVal = (arr: Array<{ year?: string; value?: number | null }> | undefined, y: string) => {
|
||||
const v = arr?.find(p => p?.year === y)?.value
|
||||
const getVal = (arr: Array<{ period?: string; value?: number | null }> | undefined, p: string) => {
|
||||
const v = arr?.find(pt => pt?.period === p)?.value
|
||||
return typeof v === 'number' ? v : (v == null ? null : Number(v))
|
||||
}
|
||||
|
||||
@ -292,8 +289,8 @@ export default async function ReportDetailPage({ params }: { params: Promise<{ i
|
||||
const feeHeaderRow = (
|
||||
<TableRow key="__fee_metrics_row" className="bg-muted hover:bg-purple-100">
|
||||
<TableCell className="p-2 font-medium ">费用指标</TableCell>
|
||||
{years.map((y) => (
|
||||
<TableCell key={y} className="p-2"></TableCell>
|
||||
{periods.map((p) => (
|
||||
<TableCell key={p} className="p-2"></TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
)
|
||||
@ -307,10 +304,10 @@ export default async function ReportDetailPage({ params }: { params: Promise<{ i
|
||||
].map(({ key, label, num, den }) => (
|
||||
<TableRow key={key} className="hover:bg-purple-100">
|
||||
<TableCell className="p-2 text-muted-foreground">{label}</TableCell>
|
||||
{years.map((y) => {
|
||||
{periods.map((p) => {
|
||||
let rate: number | null = null
|
||||
if (key === '__tax_rate') {
|
||||
const numerator = getVal(num, y)
|
||||
const numerator = getVal(num, p)
|
||||
if (numerator == null || Number.isNaN(numerator)) {
|
||||
rate = null
|
||||
} else if (Math.abs(numerator) <= 1) {
|
||||
@ -319,12 +316,12 @@ export default async function ReportDetailPage({ params }: { params: Promise<{ i
|
||||
rate = numerator
|
||||
}
|
||||
} else if (key === '__other_fee_rate') {
|
||||
const gpRaw = getVal(series['grossprofit_margin'] as any, y)
|
||||
const npRaw = getVal(series['netprofit_margin'] as any, y)
|
||||
const rev = getVal(series['revenue'] as any, y)
|
||||
const sell = getVal(series['sell_exp'] as any, y)
|
||||
const admin = getVal(series['admin_exp'] as any, y)
|
||||
const rd = getVal(series['rd_exp'] as any, y)
|
||||
const gpRaw = getVal(series['grossprofit_margin'] as any, p)
|
||||
const npRaw = getVal(series['netprofit_margin'] as any, p)
|
||||
const rev = getVal(series['revenue'] as any, p)
|
||||
const sell = getVal(series['sell_exp'] as any, p)
|
||||
const admin = getVal(series['admin_exp'] as any, p)
|
||||
const rd = getVal(series['rd_exp'] as any, p)
|
||||
if (gpRaw == null || npRaw == null || rev == null || rev === 0 || sell == null || admin == null || rd == null) {
|
||||
rate = null
|
||||
} else {
|
||||
@ -362,19 +359,19 @@ export default async function ReportDetailPage({ params }: { params: Promise<{ i
|
||||
const assetHeaderRow = (
|
||||
<TableRow key="__asset_ratio_row" className="bg-muted hover:bg-purple-100">
|
||||
<TableCell className="p-2 font-medium ">资产占比</TableCell>
|
||||
{years.map((y) => (
|
||||
<TableCell key={y} className="p-2"></TableCell>
|
||||
{periods.map((p) => (
|
||||
<TableCell key={p} className="p-2"></TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
)
|
||||
const ratioCell = (value: number | null, y: string) => {
|
||||
const ratioCell = (value: number | null, p: string) => {
|
||||
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 isNegative = value < 0
|
||||
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}%`}
|
||||
</TableCell>
|
||||
)
|
||||
@ -482,8 +479,8 @@ export default async function ReportDetailPage({ params }: { params: Promise<{ i
|
||||
const turnoverHeaderRow = (
|
||||
<TableRow key="__turnover_row" className="bg-muted hover:bg-purple-100">
|
||||
<TableCell className="p-2 font-medium ">周转能力</TableCell>
|
||||
{years.map((y) => (
|
||||
<TableCell key={y} className="p-2"></TableCell>
|
||||
{periods.map((p) => (
|
||||
<TableCell key={p} className="p-2"></TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
)
|
||||
@ -491,14 +488,15 @@ export default async function ReportDetailPage({ params }: { params: Promise<{ i
|
||||
const n = Number(ys)
|
||||
return Number.isFinite(n) ? n : null
|
||||
}
|
||||
const getPoint = (arr: Array<{ year?: string; value?: number | null }> | undefined, year: string) => {
|
||||
return arr?.find(p => p?.year === year)?.value ?? null
|
||||
const getPoint = (arr: Array<{ period?: string; value?: number | null }> | undefined, period: string) => {
|
||||
return arr?.find(p => p?.period === period)?.value ?? null
|
||||
}
|
||||
const getAvg = (arr: Array<{ year?: string; value?: number | null }> | undefined, year: string) => {
|
||||
const curr = getPoint(arr, year)
|
||||
const yNum = getYearNumber(year)
|
||||
const getAvg = (arr: Array<{ period?: string; value?: number | null }> | undefined, period: string) => {
|
||||
const curr = getPoint(arr, period)
|
||||
const yNum = period.length >= 4 ? Number(period.substring(0, 4)) : null
|
||||
const prevYear = yNum != null ? String(yNum - 1) : null
|
||||
const prev = prevYear ? getPoint(arr, prevYear) : null
|
||||
const prevPeriod = prevYear ? prevYear + period.substring(4) : null
|
||||
const prev = prevPeriod ? getPoint(arr, prevPeriod) : null
|
||||
const c = typeof curr === 'number' ? curr : (curr == null ? null : Number(curr))
|
||||
const p = typeof prev === 'number' ? prev : (prev == null ? null : Number(prev))
|
||||
if (c == null) return null
|
||||
@ -534,28 +532,28 @@ export default async function ReportDetailPage({ params }: { params: Promise<{ i
|
||||
const turnoverRows = turnoverItems.map(({ key, label }) => (
|
||||
<TableRow key={key} className="hover:bg-purple-100">
|
||||
<TableCell className="p-2 text-muted-foreground">{label}</TableCell>
|
||||
{years.map((y) => {
|
||||
{periods.map((p) => {
|
||||
let value: number | null = null
|
||||
if (key === 'payturn_days') {
|
||||
const avgAP = getAvg(series['accounts_pay'] as any, y)
|
||||
const cogs = getCOGS(y)
|
||||
const avgAP = getAvg(series['accounts_pay'] as any, p)
|
||||
const cogs = getCOGS(p)
|
||||
value = avgAP == null || cogs == null || cogs === 0 ? null : (365 * avgAP) / cogs
|
||||
} else {
|
||||
const arr = series[key] as Array<{ year?: string; value?: number | null }> | undefined
|
||||
const v = arr?.find(p => p?.year === y)?.value ?? null
|
||||
const arr = series[key] as Array<{ period?: string; value?: number | null }> | undefined
|
||||
const v = arr?.find(pt => pt?.period === p)?.value ?? null
|
||||
const num = typeof v === 'number' ? v : (v == null ? null : Number(v))
|
||||
value = num == null || Number.isNaN(num) ? null : num
|
||||
}
|
||||
if (value == null || !Number.isFinite(value)) {
|
||||
return <TableCell key={y} className="text-right p-2">-</TableCell>
|
||||
return <TableCell key={p} className="text-right p-2">-</TableCell>
|
||||
}
|
||||
const text = numberFormatter.format(value)
|
||||
if (key === 'arturn_days' && value > 90) {
|
||||
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>
|
||||
))
|
||||
@ -564,8 +562,8 @@ export default async function ReportDetailPage({ params }: { params: Promise<{ i
|
||||
const perCapitaHeaderRow = (
|
||||
<TableRow key="__per_capita_row" className="bg-muted hover:bg-purple-100">
|
||||
<TableCell className="p-2 font-medium ">人均效率</TableCell>
|
||||
{years.map((y) => (
|
||||
<TableCell key={y} className="p-2"></TableCell>
|
||||
{periods.map((p) => (
|
||||
<TableCell key={p} className="p-2"></TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
)
|
||||
@ -628,69 +626,69 @@ export default async function ReportDetailPage({ params }: { params: Promise<{ i
|
||||
const marketHeaderRow = (
|
||||
<TableRow key="__market_perf_row" className="bg-muted hover:bg-purple-100">
|
||||
<TableCell className="p-2 font-medium ">市场表现</TableCell>
|
||||
{years.map((y) => (
|
||||
<TableCell key={y} className="p-2"></TableCell>
|
||||
{periods.map((p) => (
|
||||
<TableCell key={p} className="p-2"></TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
)
|
||||
const priceRow = (
|
||||
<TableRow key="__price_row" className="hover:bg-purple-100">
|
||||
<TableCell className="p-2 text-muted-foreground">股价</TableCell>
|
||||
{years.map((y) => {
|
||||
const arr = series['close'] as Array<{ year?: string; value?: number | null }> | undefined
|
||||
const v = arr?.find(p => p?.year === y)?.value ?? null
|
||||
{periods.map((p) => {
|
||||
const arr = series['close'] as Array<{ period?: string; value?: number | null }> | undefined
|
||||
const v = arr?.find(pt => pt?.period === p)?.value ?? null
|
||||
const num = typeof v === 'number' ? v : (v == null ? null : Number(v))
|
||||
if (num == null || !Number.isFinite(num)) return <TableCell key={y} className="text-right p-2">-</TableCell>
|
||||
return <TableCell key={y} className="text-right p-2">{numberFormatter.format(num)}</TableCell>
|
||||
if (num == null || !Number.isFinite(num)) return <TableCell key={p} className="text-right p-2">-</TableCell>
|
||||
return <TableCell key={p} className="text-right p-2">{numberFormatter.format(num)}</TableCell>
|
||||
})}
|
||||
</TableRow>
|
||||
)
|
||||
const marketCapRow = (
|
||||
<TableRow key="__market_cap_row" className="hover:bg-purple-100">
|
||||
<TableCell className="p-2 text-muted-foreground">市值(亿元)</TableCell>
|
||||
{years.map((y) => {
|
||||
const arr = series['total_mv'] as Array<{ year?: string; value?: number | null }> | undefined
|
||||
const v = arr?.find(p => p?.year === y)?.value ?? null
|
||||
{periods.map((p) => {
|
||||
const arr = series['total_mv'] as Array<{ period?: string; value?: number | null }> | undefined
|
||||
const v = arr?.find(pt => pt?.period === p)?.value ?? null
|
||||
const num = typeof v === 'number' ? v : (v == null ? null : Number(v))
|
||||
if (num == null || !Number.isFinite(num)) return <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
|
||||
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>
|
||||
)
|
||||
const peRow = (
|
||||
<TableRow key="__pe_row" className="hover:bg-purple-100">
|
||||
<TableCell className="p-2 text-muted-foreground">PE</TableCell>
|
||||
{years.map((y) => {
|
||||
const arr = series['pe'] as Array<{ year?: string; value?: number | null }> | undefined
|
||||
const v = arr?.find(p => p?.year === y)?.value ?? null
|
||||
{periods.map((p) => {
|
||||
const arr = series['pe'] as Array<{ period?: string; value?: number | null }> | undefined
|
||||
const v = arr?.find(pt => pt?.period === p)?.value ?? null
|
||||
const num = typeof v === 'number' ? v : (v == null ? null : Number(v))
|
||||
if (num == null || !Number.isFinite(num)) return <TableCell key={y} className="text-right p-2">-</TableCell>
|
||||
return <TableCell key={y} className="text-right p-2">{numberFormatter.format(num)}</TableCell>
|
||||
if (num == null || !Number.isFinite(num)) return <TableCell key={p} className="text-right p-2">-</TableCell>
|
||||
return <TableCell key={p} className="text-right p-2">{numberFormatter.format(num)}</TableCell>
|
||||
})}
|
||||
</TableRow>
|
||||
)
|
||||
const pbRow = (
|
||||
<TableRow key="__pb_row" className="hover:bg-purple-100">
|
||||
<TableCell className="p-2 text-muted-foreground">PB</TableCell>
|
||||
{years.map((y) => {
|
||||
const arr = series['pb'] as Array<{ year?: string; value?: number | null }> | undefined
|
||||
const v = arr?.find(p => p?.year === y)?.value ?? null
|
||||
{periods.map((p) => {
|
||||
const arr = series['pb'] as Array<{ period?: string; value?: number | null }> | undefined
|
||||
const v = arr?.find(pt => pt?.period === p)?.value ?? null
|
||||
const num = typeof v === 'number' ? v : (v == null ? null : Number(v))
|
||||
if (num == null || !Number.isFinite(num)) return <TableCell key={y} className="text-right p-2">-</TableCell>
|
||||
return <TableCell key={y} className="text-right p-2">{numberFormatter.format(num)}</TableCell>
|
||||
if (num == null || !Number.isFinite(num)) return <TableCell key={p} className="text-right p-2">-</TableCell>
|
||||
return <TableCell key={p} className="text-right p-2">{numberFormatter.format(num)}</TableCell>
|
||||
})}
|
||||
</TableRow>
|
||||
)
|
||||
const holderNumRow = (
|
||||
<TableRow key="__holder_num_row" className="hover:bg-purple-100">
|
||||
<TableCell className="p-2 text-muted-foreground">股东户数</TableCell>
|
||||
{years.map((y) => {
|
||||
const arr = series['holder_num'] as Array<{ year?: string; value?: number | null }> | undefined
|
||||
const v = arr?.find(p => p?.year === y)?.value ?? null
|
||||
{periods.map((p) => {
|
||||
const arr = series['holder_num'] as Array<{ period?: string; value?: number | null }> | undefined
|
||||
const v = arr?.find(pt => pt?.period === p)?.value ?? null
|
||||
const num = typeof v === 'number' ? v : (v == null ? null : Number(v))
|
||||
if (num == null || !Number.isFinite(num)) return <TableCell key={y} className="text-right p-2">-</TableCell>
|
||||
return <TableCell key={y} className="text-right p-2">{integerFormatter.format(Math.round(num))}</TableCell>
|
||||
if (num == null || !Number.isFinite(num)) return <TableCell key={p} className="text-right p-2">-</TableCell>
|
||||
return <TableCell key={p} className="text-right p-2">{integerFormatter.format(Math.round(num))}</TableCell>
|
||||
})}
|
||||
</TableRow>
|
||||
)
|
||||
|
||||
@ -323,4 +323,25 @@ export function safeSetToStorage(key: string, value: unknown): boolean {
|
||||
} catch {
|
||||
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;
|
||||
}
|
||||
};
|
||||
@ -36,3 +36,5 @@ export const prisma =
|
||||
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@ -42,15 +42,13 @@ export interface CompanySuggestion {
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 年度数据点接口
|
||||
* 报告期数据点接口
|
||||
*/
|
||||
export interface YearDataPoint {
|
||||
/** 年份 */
|
||||
year: string;
|
||||
export interface PeriodDataPoint {
|
||||
/** 报告期 (YYYYMMDD格式,如 20241231, 20250930) */
|
||||
period: string;
|
||||
/** 数值 (可为null表示无数据) */
|
||||
value: number | null;
|
||||
/** 月份信息,用于确定季度 */
|
||||
month?: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -81,7 +79,7 @@ export interface FinancialMetricConfig {
|
||||
* 财务数据系列接口
|
||||
*/
|
||||
export interface FinancialDataSeries {
|
||||
[metricKey: string]: YearDataPoint[];
|
||||
[metricKey: string]: PeriodDataPoint[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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()
|
||||
@ -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("✅ 测试完成")
|
||||
|
||||
@ -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("✅ 测试完成")
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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()
|
||||
Loading…
Reference in New Issue
Block a user