chore: sync backend/frontend/docs changes before push

This commit is contained in:
xucheng 2025-11-06 19:58:36 +08:00
parent 0b09abf2e5
commit 4c88b38a7e
10 changed files with 519 additions and 136 deletions

View File

@ -2,6 +2,7 @@ import yaml
import os import os
import json import json
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from numbers import Number
from app.data_providers.base import BaseDataProvider from app.data_providers.base import BaseDataProvider
from app.data_providers.tushare import TushareProvider from app.data_providers.tushare import TushareProvider
# from app.data_providers.ifind import TonghsProvider # from app.data_providers.ifind import TonghsProvider
@ -44,20 +45,9 @@ class DataManager:
self.providers = {} self.providers = {}
# Build provider base config from environment variables and config/config.json, then initialize providers # Build provider base config ONLY from config/config.json (do not read env vars)
base_cfg: Dict[str, Any] = {"data_sources": {}} base_cfg: Dict[str, Any] = {"data_sources": {}}
# 1) Prefer env vars when present
for name, source_config in (self.config.get('data_sources') or {}).items():
env_var = source_config.get('api_key_env')
if env_var:
api_key = os.getenv(env_var)
if api_key:
base_cfg["data_sources"][name] = {"api_key": api_key}
else:
logger.warning(f"Env var '{env_var}' for provider '{name}' not set; will try config.json.")
# 2) Fallback to config/config.json if tokens are provided there
try: try:
# Use the same REPO_ROOT calculation as data_sources.yaml # Use the same REPO_ROOT calculation as data_sources.yaml
current_dir = os.path.dirname(__file__) current_dir = os.path.dirname(__file__)
@ -76,7 +66,7 @@ class DataManager:
cfg_json = json.load(jf) cfg_json = json.load(jf)
ds_from_json = (cfg_json.get("data_sources") or {}) ds_from_json = (cfg_json.get("data_sources") or {})
for name, node in ds_from_json.items(): for name, node in ds_from_json.items():
if name not in base_cfg["data_sources"] and node.get("api_key"): if node.get("api_key"):
base_cfg["data_sources"][name] = {"api_key": node.get("api_key")} base_cfg["data_sources"][name] = {"api_key": node.get("api_key")}
logger.info(f"Loaded API key for provider '{name}' from config.json") logger.info(f"Loaded API key for provider '{name}' from config.json")
else: else:
@ -106,10 +96,8 @@ class DataManager:
} }
for name, provider_class in provider_map.items(): for name, provider_class in provider_map.items():
token = None
source_config = self.config['data_sources'].get(name, {})
if source_config and source_config.get('api_key_env'):
token = base_cfg.get("data_sources", {}).get(name, {}).get("api_key") token = base_cfg.get("data_sources", {}).get(name, {}).get("api_key")
source_config = self.config['data_sources'].get(name, {})
# Initialize the provider if a token is found or not required # Initialize the provider if a token is found or not required
if token or not source_config.get('api_key_env'): if token or not source_config.get('api_key_env'):
@ -118,7 +106,7 @@ class DataManager:
except Exception as e: except Exception as e:
logger.error(f"Failed to initialize provider '{name}': {e}") logger.error(f"Failed to initialize provider '{name}': {e}")
else: else:
logger.warning(f"Provider '{name}' requires token env '{source_config.get('api_key_env')}', but none provided. Skipping.") logger.warning(f"Provider '{name}' requires API key but none provided in config.json. Skipping.")
def _detect_market(self, stock_code: str) -> str: def _detect_market(self, stock_code: str) -> str:
if stock_code.endswith(('.SH', '.SZ')): if stock_code.endswith(('.SH', '.SZ')):
@ -143,7 +131,17 @@ class DataManager:
try: try:
method = getattr(provider, method_name) method = getattr(provider, method_name)
data = await method(stock_code=stock_code, **kwargs) data = await method(stock_code=stock_code, **kwargs)
if data is not None and (not isinstance(data, list) or data): is_success = False
if data is None:
is_success = False
elif isinstance(data, list):
is_success = len(data) > 0
elif isinstance(data, dict):
is_success = len(data) > 0
else:
is_success = True
if is_success:
logger.info(f"Data successfully fetched from '{provider_name}' for '{stock_code}'.") logger.info(f"Data successfully fetched from '{provider_name}' for '{stock_code}'.")
return data return data
except Exception as e: except Exception as e:
@ -171,11 +169,18 @@ class DataManager:
for key, value in report.items(): for key, value in report.items():
if key in ['ts_code', 'stock_code', 'year', 'end_date', 'period', 'ann_date', 'f_ann_date', 'report_type']: if key in ['ts_code', 'stock_code', 'year', 'end_date', 'period', 'ann_date', 'f_ann_date', 'report_type']:
continue continue
if isinstance(value, (int, float)) and value is not None: # Accept numpy/pandas numeric types as well as builtin numbers
if value is not None and isinstance(value, Number):
if key not in series: if key not in series:
series[key] = [] series[key] = []
if not any(d['year'] == year for d in series[key]): if not any(d['year'] == year for d in series[key]):
series[key].append({"year": year, "value": value}) # Store as builtin float to avoid JSON serialization issues
try:
numeric_value = float(value)
except Exception:
# Fallback: skip if cannot coerce to float
continue
series[key].append({"year": year, "value": numeric_value})
return series return series
else: else:
return {} return {}

View File

@ -5,6 +5,7 @@ import pandas as pd
from datetime import datetime, timedelta from datetime import datetime, timedelta
import asyncio import asyncio
import logging import logging
import httpx
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -14,11 +15,37 @@ class FinnhubProvider(BaseDataProvider):
if not self.token: if not self.token:
raise ValueError("Finnhub API key not provided.") raise ValueError("Finnhub API key not provided.")
self.client = finnhub.Client(api_key=self.token) self.client = finnhub.Client(api_key=self.token)
try:
masked = f"***{self.token[-4:]}" if isinstance(self.token, str) and len(self.token) >= 4 else "***"
logger.info(f"[Finnhub] client initialized (token={masked})")
except Exception:
# 避免日志失败影响初始化
pass
async def get_stock_basic(self, stock_code: str) -> Optional[Dict[str, Any]]: async def get_stock_basic(self, stock_code: str) -> Optional[Dict[str, Any]]:
async def _fetch(): def _fetch():
try:
profile = None
try: try:
profile = self.client.company_profile2(symbol=stock_code) profile = self.client.company_profile2(symbol=stock_code)
logger.debug(f"[Finnhub] SDK company_profile2 ok symbol={stock_code} name={profile.get('name') if isinstance(profile, dict) else None}")
except Exception as e:
logger.warning(f"[Finnhub] SDK company_profile2 failed for {stock_code}: {e}")
# Fallback to direct HTTP if SDK call fails
try:
resp = httpx.get(
'https://finnhub.io/api/v1/stock/profile2',
params={'symbol': stock_code},
headers={'X-Finnhub-Token': self.token},
timeout=20.0,
)
logger.debug(f"[Finnhub] HTTP profile2 status={resp.status_code} len={len(resp.text)}")
if resp.status_code == 200:
profile = resp.json()
else:
logger.error(f"[Finnhub] HTTP profile2 failed status={resp.status_code} body={resp.text[:200]}")
except Exception:
profile = None
if not profile: if not profile:
return None return None
@ -39,13 +66,18 @@ class FinnhubProvider(BaseDataProvider):
return await loop.run_in_executor(None, _fetch) return await loop.run_in_executor(None, _fetch)
async def get_daily_price(self, stock_code: str, start_date: str, end_date: str) -> List[Dict[str, Any]]: async def get_daily_price(self, stock_code: str, start_date: str, end_date: str) -> List[Dict[str, Any]]:
async def _fetch(): def _fetch():
try: try:
start_ts = int(datetime.strptime(start_date, '%Y%m%d').timestamp()) start_ts = int(datetime.strptime(start_date, '%Y%m%d').timestamp())
end_ts = int(datetime.strptime(end_date, '%Y%m%d').timestamp()) end_ts = int(datetime.strptime(end_date, '%Y%m%d').timestamp())
logger.debug(f"[Finnhub] stock_candles symbol={stock_code} D {start_date}->{end_date}")
res = self.client.stock_candles(stock_code, 'D', start_ts, end_ts) res = self.client.stock_candles(stock_code, 'D', start_ts, end_ts)
if res.get('s') != 'ok': if res.get('s') != 'ok':
try:
logger.warning(f"[Finnhub] stock_candles not ok symbol={stock_code} status={res.get('s')}")
except Exception:
pass
return [] return []
df = pd.DataFrame(res) df = pd.DataFrame(res)
@ -68,72 +100,207 @@ class FinnhubProvider(BaseDataProvider):
return await loop.run_in_executor(None, _fetch) return await loop.run_in_executor(None, _fetch)
async def get_financial_statements(self, stock_code: str, report_dates: List[str]) -> List[Dict[str, Any]]: async def get_financial_statements(self, stock_code: str, report_dates: List[str]) -> List[Dict[str, Any]]:
async def _fetch(): def _fetch():
try:
# 1) 拉取年度报表financials_reported, annual
res = None
try: try:
# Finnhub provides financials as a whole, not by specific date ranges in one call
# We fetch all available and then filter.
# Note: 'freq' can be 'annual' or 'quarterly'. We'll use annual.
res = self.client.financials_reported(symbol=stock_code, freq='annual') res = self.client.financials_reported(symbol=stock_code, freq='annual')
except Exception as e:
logger.warning(f"[Finnhub] SDK financials_reported failed for {stock_code}: {e}")
# Fallback: direct HTTP
try:
r = httpx.get(
'https://finnhub.io/api/v1/stock/financials-reported',
params={'symbol': stock_code, 'freq': 'annual'},
headers={'X-Finnhub-Token': self.token},
timeout=30.0,
)
logger.debug(f"[Finnhub] HTTP financials-reported status={r.status_code} len={len(r.text)}")
if r.status_code == 200:
res = r.json()
else:
logger.error(f"[Finnhub] HTTP financials-reported failed status={r.status_code} body={r.text[:300]}")
except Exception:
res = None
if not res or not res.get('data'): if not res or not res.get('data'):
logger.warning(f"[Finnhub] financials-reported empty for {stock_code}")
return [] return []
df = pd.DataFrame(res['data']) df = pd.DataFrame(res['data'])
if df.empty:
logger.warning(f"[Finnhub] financials-reported dataframe empty for {stock_code}")
return []
# Filter by requested dates # 2) 仅保留请求的年份
years_to_fetch = {date[:4] for date in report_dates} years_to_fetch = {str(date)[:4] for date in report_dates}
logger.debug(f"[Finnhub] filter years {sorted(list(years_to_fetch))} before={len(df)}")
if 'year' in df.columns:
df = df[df['year'].astype(str).isin(years_to_fetch)] df = df[df['year'].astype(str).isin(years_to_fetch)]
# 兜底:如果缺失 year 列,则用 endDate 推断
if 'year' not in df.columns and 'endDate' in df.columns:
df = df[df['endDate'].astype(str).str[:4].isin(years_to_fetch)]
# The data is deeply nested in 'report'. We need to extract and pivot it. if df.empty:
all_reports = [] logger.warning(f"[Finnhub] financials-reported no rows after filter for {stock_code}")
for index, row in df.iterrows(): return []
report_data = {'ts_code': stock_code, 'end_date': row['endDate']}
# Extract concepts from balance sheet, income statement, and cash flow def _normalize_key(s: Optional[str]) -> str:
for item in row['report'].get('bs', []): if not isinstance(s, str):
report_data[item['concept']] = item['value'] return ""
for item in row['report'].get('ic', []): return ''.join(ch.lower() for ch in s if ch.isalnum())
report_data[item['concept']] = item['value']
for item in row['report'].get('cf', []):
report_data[item['concept']] = item['value']
all_reports.append(report_data) def pick(report_block: List[Dict[str, Any]], concept_candidates: List[str], label_candidates: List[str] = []) -> Optional[float]:
if not report_block:
return None
try:
by_concept = { _normalize_key(item.get('concept')): item.get('value') for item in report_block if isinstance(item, dict) }
by_label = { _normalize_key(item.get('label')): item.get('value') for item in report_block if isinstance(item, dict) }
except Exception:
return None
for key in concept_candidates:
v = by_concept.get(_normalize_key(key))
if v is not None:
try:
return float(v)
except Exception:
continue
for key in label_candidates:
v = by_label.get(_normalize_key(key))
if v is not None:
try:
return float(v)
except Exception:
continue
return None
# Further normalization of keys would be needed here to match a common format # 3) 遍历年度记录,展开并标准化字段名
# e.g. 'AssetsTotal' -> 'total_assets' flat_reports: List[Dict[str, Any]] = []
# This is a complex task and depends on the desired final schema. for _, row in df.iterrows():
bs = (row.get('report') or {}).get('bs', [])
ic = (row.get('report') or {}).get('ic', [])
cf = (row.get('report') or {}).get('cf', [])
# We will now normalize and calculate derived metrics end_date = str(row.get('endDate') or '')
normalized_reports = []
for report in all_reports: revenue = pick(
normalized_report = { ic,
"ts_code": report.get("ts_code"), concept_candidates=['Revenues', 'RevenueFromContractWithCustomerExcludingAssessedTax', 'SalesRevenueNet', 'Revenue', 'RevenuesNet', 'SalesRevenueGoodsNet'],
"end_date": report.get("end_date"), label_candidates=['Total revenue', 'Revenue', 'Sales revenue']
# Balance Sheet )
"total_assets": report.get("AssetsTotal"), net_income = pick(
"total_liabilities": report.get("LiabilitiesTotal"), ic,
"equity": report.get("StockholdersEquityTotal"), concept_candidates=['NetIncomeLoss', 'ProfitLoss', 'NetIncomeLossAvailableToCommonStockholdersBasic', 'NetIncomeLossAvailableToCommonStockholdersDiluted'],
# Income Statement label_candidates=['Net income', 'Net income (loss)']
"revenue": report.get("RevenuesTotal"), )
"net_income": report.get("NetIncomeLoss"), gross_profit = pick(
"gross_profit": report.get("GrossProfit"), ic,
# Cash Flow concept_candidates=['GrossProfit'],
"net_cash_flow_operating": report.get("NetCashFlowOperating"), label_candidates=['Gross profit']
)
total_assets = pick(
bs,
concept_candidates=['Assets', 'AssetsTotal', 'AssetsCurrentAndNoncurrent', 'AssetsIncludingAssetsMeasuredAtFairValue'],
label_candidates=['Total assets']
)
total_equity = pick(
bs,
concept_candidates=['StockholdersEquityIncludingPortionAttributableToNoncontrollingInterest', 'StockholdersEquity', 'StockholdersEquityTotal', 'Equity'],
label_candidates=['Total equity', "Stockholders' equity"]
)
goodwill = pick(
bs,
concept_candidates=['Goodwill', 'GoodwillAndIntangibleAssets'],
label_candidates=['Goodwill', 'Goodwill and intangible assets']
)
n_cashflow_act = pick(
cf,
concept_candidates=['NetCashProvidedByUsedInOperatingActivities', 'NetCashProvidedByUsedInOperatingActivitiesContinuingOperations', 'NetCashFlowOperating'],
label_candidates=['Net cash provided by operating activities']
)
capex = pick(
cf,
concept_candidates=['CapitalExpenditures', 'PaymentsToAcquirePropertyPlantAndEquipment', 'PaymentsToAcquireProductiveAssets'],
label_candidates=['Capital expenditures']
)
# 计算衍生指标
free_cash_flow = None
if isinstance(n_cashflow_act, (int, float)) and isinstance(capex, (int, float)):
free_cash_flow = n_cashflow_act - capex
normalized = {
# 基本元字段
'ts_code': stock_code,
'end_date': end_date, # DataManager 会从这里抽取 year
# 标准命名(见 financial_data_dictionary
'revenue': revenue,
'n_income': net_income,
'gross_profit': gross_profit,
'total_assets': total_assets,
'total_hldr_eqy_exc_min_int': total_equity,
'goodwill': goodwill,
'n_cashflow_act': n_cashflow_act,
'c_pay_acq_const_fiolta': capex,
'__free_cash_flow': free_cash_flow,
} }
# Calculate derived metrics # 一些常用比率(若有足够数据则计算),命名对齐文档
if normalized_report["revenue"] and normalized_report["revenue"] > 0: if isinstance(revenue, (int, float)) and revenue > 0 and isinstance(gross_profit, (int, float)):
normalized_report["gross_margin"] = (normalized_report["gross_profit"] / normalized_report["revenue"]) if normalized_report["gross_profit"] else None normalized['grossprofit_margin'] = gross_profit / revenue
normalized_report["net_margin"] = (normalized_report["net_income"] / normalized_report["revenue"]) if normalized_report["net_income"] else None if isinstance(revenue, (int, float)) and revenue > 0 and isinstance(net_income, (int, float)):
normalized['netprofit_margin'] = net_income / revenue
if isinstance(total_assets, (int, float)) and total_assets > 0 and isinstance(net_income, (int, float)):
normalized['roa'] = net_income / total_assets
if isinstance(total_equity, (int, float)) and total_equity > 0 and isinstance(net_income, (int, float)):
normalized['roe'] = net_income / total_equity
if normalized_report["total_assets"] and normalized_report["total_assets"] > 0: flat_reports.append(normalized)
normalized_report["roa"] = (normalized_report["net_income"] / normalized_report["total_assets"]) if normalized_report["net_income"] else None try:
logger.debug(
f"[Finnhub] row endDate={end_date} revenue={revenue} net_income={net_income} gross_profit={gross_profit} "
f"assets={total_assets} equity={total_equity} goodwill={goodwill} n_cfo={n_cashflow_act} capex={capex}"
)
except Exception:
pass
if normalized_report["equity"] and normalized_report["equity"] > 0: # Convert flat reports to series dict directly to match DataManager expected format
normalized_report["roe"] = (normalized_report["net_income"] / normalized_report["equity"]) if normalized_report["net_income"] else None series: Dict[str, List[Dict[str, Any]]] = {}
for report in flat_reports:
end_date = str(report.get('end_date') or '')
year = end_date[:4] if len(end_date) >= 4 else None
if not year:
continue
period = f"{year}1231"
normalized_reports.append(normalized_report) for key, value in report.items():
if key in ['ts_code', 'end_date']:
continue
# Only collect numeric values
try:
if value is None:
continue
num = float(value)
except Exception:
continue
if key not in series:
series[key] = []
# Avoid duplicate period entries
exists = any(dp.get('period') == period for dp in series[key])
if not exists:
series[key].append({'period': period, 'value': num})
return normalized_reports try:
total_points = sum(len(v) for v in series.values())
logger.info(f"[Finnhub] built series for {stock_code} keys={len(series)} points={total_points}")
except Exception:
pass
return series
except Exception as e: except Exception as e:
logger.error(f"Finnhub get_financial_statements failed for {stock_code}: {e}") logger.error(f"Finnhub get_financial_statements failed for {stock_code}: {e}")

View File

@ -21,7 +21,7 @@ class YfinanceProvider(BaseDataProvider):
return stock_code return stock_code
async def get_stock_basic(self, stock_code: str) -> Optional[Dict[str, Any]]: async def get_stock_basic(self, stock_code: str) -> Optional[Dict[str, Any]]:
async def _fetch(): def _fetch():
try: try:
ticker = yf.Ticker(self._map_stock_code(stock_code)) ticker = yf.Ticker(self._map_stock_code(stock_code))
info = ticker.info info = ticker.info
@ -44,7 +44,7 @@ class YfinanceProvider(BaseDataProvider):
return await loop.run_in_executor(None, _fetch) return await loop.run_in_executor(None, _fetch)
async def get_daily_price(self, stock_code: str, start_date: str, end_date: str) -> List[Dict[str, Any]]: async def get_daily_price(self, stock_code: str, start_date: str, end_date: str) -> List[Dict[str, Any]]:
async def _fetch(): def _fetch():
try: try:
# yfinance date format is YYYY-MM-DD # yfinance date format is YYYY-MM-DD
start_fmt = datetime.strptime(start_date, '%Y%m%d').strftime('%Y-%m-%d') start_fmt = datetime.strptime(start_date, '%Y%m%d').strftime('%Y-%m-%d')

View File

@ -5,6 +5,7 @@ import json
import os import os
import time import time
from datetime import datetime, timezone, timedelta from datetime import datetime, timezone, timedelta
from enum import Enum
from typing import Dict, List from typing import Dict, List
from fastapi import APIRouter, HTTPException, Query from fastapi import APIRouter, HTTPException, Query
@ -46,6 +47,13 @@ def get_dm():
router = APIRouter() router = APIRouter()
class MarketEnum(str, Enum):
cn = "cn"
us = "us"
hk = "hk"
jp = "jp"
# Load metric config from file (project root is repo root, not backend/) # Load metric config from file (project root is repo root, not backend/)
# routers/ -> app/ -> backend/ -> repo root # routers/ -> app/ -> backend/ -> repo root
REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..")) REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
@ -257,9 +265,10 @@ async def get_financial_config():
return FinancialConfigResponse(api_groups=api_groups) return FinancialConfigResponse(api_groups=api_groups)
@router.get("/china/{ts_code}", response_model=BatchFinancialDataResponse) @router.get("/{market}/{stock_code}", response_model=BatchFinancialDataResponse)
async def get_china_financials( async def get_financials(
ts_code: str, market: MarketEnum,
stock_code: str,
years: int = Query(10, ge=1, le=10), years: int = Query(10, ge=1, le=10),
): ):
# Load metric config # Load metric config
@ -274,11 +283,11 @@ async def get_china_financials(
steps: List[StepRecord] = [] steps: List[StepRecord] = []
# Get company name # Get company name
company_name = ts_code company_name = stock_code
try: try:
basic_data = await get_dm().get_stock_basic(stock_code=ts_code) basic_data = await get_dm().get_stock_basic(stock_code=stock_code)
if basic_data: if basic_data:
company_name = basic_data.get("name", ts_code) company_name = basic_data.get("name", stock_code)
except Exception: except Exception:
pass # Continue without it pass # Continue without it
@ -295,7 +304,7 @@ async def get_china_financials(
steps.append(step_financials) steps.append(step_financials)
# Fetch all financial statements at once (already in series format from provider) # Fetch all financial statements at once (already in series format from provider)
series = await get_dm().get_financial_statements(stock_code=ts_code, report_dates=report_dates) series = await get_dm().get_financial_statements(stock_code=stock_code, report_dates=report_dates)
# Get the latest current year report period for market data # Get the latest current year report period for market data
latest_current_year_report = None latest_current_year_report = None
@ -322,7 +331,8 @@ async def get_china_financials(
has_daily_basic = bool(api_groups.get("daily_basic")) has_daily_basic = bool(api_groups.get("daily_basic"))
has_daily = bool(api_groups.get("daily")) has_daily = bool(api_groups.get("daily"))
if has_daily_basic or has_daily: # 目前仅对中国市场启用 daily_basic/daily 数据拉取,其他市场由对应 provider 后续实现
if market == MarketEnum.cn and (has_daily_basic or has_daily):
step_market = StepRecord(name="拉取市值与股价", start_ts=datetime.now(timezone.utc).isoformat(), status="running") step_market = StepRecord(name="拉取市值与股价", start_ts=datetime.now(timezone.utc).isoformat(), status="running")
steps.append(step_market) steps.append(step_market)
@ -339,7 +349,7 @@ async def get_china_financials(
try: try:
if has_daily_basic: if has_daily_basic:
db_rows = await get_dm().get_data('get_daily_basic_points', stock_code=ts_code, trade_dates=market_dates) db_rows = await get_dm().get_data('get_daily_basic_points', stock_code=stock_code, trade_dates=market_dates)
if isinstance(db_rows, list): if isinstance(db_rows, list):
for row in db_rows: for row in db_rows:
trade_date = row.get('trade_date') or row.get('trade_dt') or row.get('date') trade_date = row.get('trade_date') or row.get('trade_dt') or row.get('date')
@ -369,7 +379,7 @@ async def get_china_financials(
else: else:
series[key].append({"period": period, "value": value}) series[key].append({"period": period, "value": value})
if has_daily: if has_daily:
d_rows = await get_dm().get_data('get_daily_points', stock_code=ts_code, trade_dates=market_dates) d_rows = await get_dm().get_data('get_daily_points', stock_code=stock_code, trade_dates=market_dates)
if isinstance(d_rows, list): if isinstance(d_rows, list):
for row in d_rows: for row in d_rows:
trade_date = row.get('trade_date') or row.get('trade_dt') or row.get('date') trade_date = row.get('trade_date') or row.get('trade_dt') or row.get('date')
@ -413,10 +423,23 @@ async def get_china_financials(
if not series: if not series:
raise HTTPException(status_code=502, detail={"message": "No data returned from any data source", "errors": errors}) raise HTTPException(status_code=502, detail={"message": "No data returned from any data source", "errors": errors})
# Truncate periods and sort (the data should already be mostly correct, but we ensure) # 统一 period 字段;若仅有 year 则映射为 YYYY1231然后去重与排序
for key, arr in series.items(): for key, arr in list(series.items()):
# Deduplicate and sort desc by period, then cut to requested periods, and return asc normalized: List[Dict] = []
uniq = {item["period"]: item for item in arr} for item in arr:
period = item.get("period")
if not period:
year = item.get("year")
if year:
period = f"{str(year)}1231"
if not period:
# 跳过无法确定 period 的项
continue
value = item.get("value")
normalized.append({"period": str(period), "value": value})
# Deduplicate by period
uniq = {it["period"]: it for it in normalized}
arr_sorted_desc = sorted(uniq.values(), key=lambda x: x["period"], reverse=True) arr_sorted_desc = sorted(uniq.values(), key=lambda x: x["period"], reverse=True)
arr_limited = arr_sorted_desc[:years] arr_limited = arr_sorted_desc[:years]
arr_sorted = sorted(arr_limited, key=lambda x: x["period"]) arr_sorted = sorted(arr_limited, key=lambda x: x["period"])
@ -438,7 +461,7 @@ async def get_china_financials(
steps=steps, steps=steps,
) )
return BatchFinancialDataResponse(ts_code=ts_code, name=company_name, series=series, meta=meta) return BatchFinancialDataResponse(ts_code=stock_code, name=company_name, series=series, meta=meta)
@router.get("/china/{ts_code}/company-profile", response_model=CompanyProfileResponse) @router.get("/china/{ts_code}/company-profile", response_model=CompanyProfileResponse)
@ -800,6 +823,66 @@ async def get_today_snapshot(ts_code: str):
raise HTTPException(status_code=500, detail=f"Failed to fetch snapshot: {e}") raise HTTPException(status_code=500, detail=f"Failed to fetch snapshot: {e}")
@router.get("/{market}/{stock_code}/snapshot", response_model=TodaySnapshotResponse)
async def get_market_snapshot(market: MarketEnum, stock_code: str):
"""
市场无关的昨日快照接口
- CN: 复用中国市场的快照逻辑daily_basic/daily
- 其他市场: 兜底使用日行情获取最近交易日收盘价其余字段暂返回空值
"""
if market == MarketEnum.cn:
return await get_today_snapshot(stock_code)
try:
# 公司名称(可选)
company_name = None
try:
basic = await get_dm().get_stock_basic(stock_code=stock_code)
if basic:
company_name = basic.get("name")
except Exception:
company_name = None
base_dt = (datetime.now() - timedelta(days=1)).date()
base_str = base_dt.strftime("%Y%m%d")
# 为了稳妥拿到最近交易日,回看近 10 天
start_dt = base_dt - timedelta(days=10)
start_str = start_dt.strftime("%Y%m%d")
end_dt = base_dt + timedelta(days=1)
end_str = end_dt.strftime("%Y%m%d")
rows = await get_dm().get_daily_price(stock_code=stock_code, start_date=start_str, end_date=end_str)
trade_date = None
close = None
if isinstance(rows, list) and rows:
# 选择 <= base_str 的最后一条记录
try:
candidates = [r for r in rows if str(r.get("trade_date") or r.get("date") or "") <= base_str]
if candidates:
last = sorted(candidates, key=lambda r: str(r.get("trade_date") or r.get("date") or ""))[-1]
trade_date = str(last.get("trade_date") or last.get("date") or base_str)
close = last.get("close")
except Exception:
pass
if trade_date is None:
trade_date = base_str
return TodaySnapshotResponse(
ts_code=stock_code,
trade_date=trade_date,
name=company_name,
close=close,
pe=None,
pb=None,
dv_ratio=None,
total_mv=None,
)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to fetch snapshot: {e}")
@router.get("/china/{ts_code}/analysis/{analysis_type}/stream") @router.get("/china/{ts_code}/analysis/{analysis_type}/stream")
async def stream_analysis( async def stream_analysis(
ts_code: str, ts_code: str,
@ -925,3 +1008,16 @@ async def stream_analysis(
"X-Accel-Buffering": "no", "X-Accel-Buffering": "no",
} }
return StreamingResponse(streamer(), media_type="text/plain; charset=utf-8", headers=headers) return StreamingResponse(streamer(), media_type="text/plain; charset=utf-8", headers=headers)
@router.get("/{market}/{stock_code}/analysis/{analysis_type}/stream")
async def stream_analysis_market(
market: MarketEnum,
stock_code: str,
analysis_type: str,
company_name: str = Query(None, description="Company name for better context"),
):
"""
市场无关的分析流接口逻辑与中国市场一致仅路径不同
"""
return await stream_analysis(stock_code, analysis_type, company_name)

View File

@ -19,9 +19,9 @@
- [x] **T2.1 [Backend/DB]**: 根据设计文档使用SQLAlchemy ORM定义`Report`, `AnalysisModule`, `ProgressTracking`, `SystemConfig`四个核心数据模型。 **[完成 - 2025-10-21]** - [x] **T2.1 [Backend/DB]**: 根据设计文档使用SQLAlchemy ORM定义`Report`, `AnalysisModule`, `ProgressTracking`, `SystemConfig`四个核心数据模型。 **[完成 - 2025-10-21]**
- [x] **T2.2 [Backend/DB]**: 创建第一个Alembic迁移脚本在数据库中生成上述四张表。 **[完成 - 2025-10-21]** - [x] **T2.2 [Backend/DB]**: 创建第一个Alembic迁移脚本在数据库中生成上述四张表。 **[完成 - 2025-10-21]**
- [x] **T2.3 [Backend]**: 实现`ConfigManager`服务,完成从`config.json`加载配置并与数据库配置合并的逻辑。 **[完成 - 2025-10-21]** - [x] **T2.3 [Backend]**: 实现`ConfigManager`服务,完成从`config.json`加载配置并与数据库配置合并的逻辑。 **[完成 - 2025-10-21]**
- **T2.4 [Backend/API]**: 创建Pydantic Schema用于配置接口的请求和响应 (`ConfigResponse`, `ConfigUpdateRequest`, `ConfigTestRequest`, `ConfigTestResponse`)。 - [x] **T2.4 [Backend/API]**: 创建Pydantic Schema用于配置接口的请求和响应 (`ConfigResponse`, `ConfigUpdateRequest`, `ConfigTestRequest`, `ConfigTestResponse`)。
- **T2.5 [Backend/API]**: 实现`/api/config`的`GET`和`PUT`端点,用于读取和更新系统配置。 - [x] **T2.5 [Backend/API]**: 实现`/api/config`的`GET`和`PUT`端点,用于读取和更新系统配置。
- **T2.6 [Backend/API]**: 实现`/api/config/test`的`POST`端点,用于验证数据库连接等配置的有效性。 - [x] **T2.6 [Backend/API]**: 实现`/api/config/test`的`POST`端点,用于验证数据库连接等配置的有效性。
## Phase 3: 前端基础与配置页面 (P1) ## Phase 3: 前端基础与配置页面 (P1)
@ -39,8 +39,8 @@
此阶段是项目的核心,重点开发后端的报告生成流程和前端的实时进度展示。 此阶段是项目的核心,重点开发后端的报告生成流程和前端的实时进度展示。
- **T4.1 [Backend/Service]**: 实现`DataSourceManager`封装对Tushare和Yahoo Finance的数据获取逻辑。 - [x] **T4.1 [Backend/Service]**: 实现`DataSourceManager`封装对Tushare和Yahoo Finance的数据获取逻辑。
- **T4.2 [Backend/Service]**: 实现`AIService`封装对Google Gemini API的调用逻辑包括Token使用统计。 - [x] **T4.2 [Backend/Service]**: 实现`AIService`封装对Google Gemini API的调用逻辑包括Token使用统计。
- **T4.3 [Backend/Service]**: 实现`ProgressTracker`服务,提供`initialize`, `start_step`, `complete_step`, `get_progress`等方法,并与数据库交互。 - **T4.3 [Backend/Service]**: 实现`ProgressTracker`服务,提供`initialize`, `start_step`, `complete_step`, `get_progress`等方法,并与数据库交互。
- **T4.4 [Backend/Service]**: 定义`AnalysisModule`的基类/接口,并初步实现一到两个模块(如`FinancialDataModule`)作为示例。 - **T4.4 [Backend/Service]**: 定义`AnalysisModule`的基类/接口,并初步实现一到两个模块(如`FinancialDataModule`)作为示例。
- **T4.5 [Backend/Service]**: 实现核心的`ReportGenerator`服务,编排数据获取、各分析模块调用、进度更新的完整流程。 - **T4.5 [Backend/Service]**: 实现核心的`ReportGenerator`服务,编排数据获取、各分析模块调用、进度更新的完整流程。

View File

@ -0,0 +1,67 @@
# 美国市场数据集成任务清单
本文档用于跟踪和管理为项目集成美国市场数据(使用 Finnhub 作为数据源)所需的各项开发任务。
## 任务列表
- [x] **后端:实现 FinnhubProvider 数据映射**
- **目标**:根据 `docs/financial_data_dictionary.md` 中的定义,在 `backend/app/data_providers/finnhub.py` 文件中,完成从 Finnhub API 原始数据到系统标准字段的完整映射。
- **关键点**
- [x] 处理直接映射的字段。
- [x] 实现所有需要通过计算得出的衍生指标。
- [x] 确保处理 `null` 或空值,避免计算错误。
- [x] 验证返回的数据结构符合 `DataManager` 的预期。
- [x] **后端:按市场分段的 API 路由**
- **目标**:在 `backend/app/routers/financial.py` 中,将现有的 `/api/v1/financials/china/{ts_code}` 改为按市场分段:`/api/v1/financials/{market}/{stock_code}`(示例:`/api/v1/financials/us/AAPL``/api/v1/financials/cn/600519.SH`)。
- **关键点**
- [x] 去除硬编码的 `china`,新增路径参数 `market`,并对取值做校验(`cn/us/hk/jp`)。
- [x] 使用单一处理函数,根据 `market` 分派到相应的数据提供方与代码格式规范。
- [x] **前端:更新 API 调用**
- **目标**:修改前端调用,基于用户选择的市场与股票代码,请求新的按市场分段路由。
- **关键点**
- [x] 替换 `useChinaFinancials`,新增通用 `useFinancials(market, stockCode, years)`
- [x] 将请求路径改为 `/api/financials/{market}/{stock_code}?years=...`(代理到后端对应的 `/api/v1/financials/{market}/{stock_code}`)。
- [ ] 确保展示与错误处理兼容美国、香港、日本等市场。
- [ ] **测试与验证**
- **目标**:对整个流程进行端到端测试,确保两个市场的功能都稳定可靠。
- **关键点**
- [ ] **中国市场回归测试**:使用多个中国 A 股代码测试,确保原有功能不受影响。
- [ ] **美国市场功能测试**:使用多个美国股票代码(如 `AAPL`, `MSFT`)测试,验证报告能否成功生成。
- [ ] **数据一致性验证**:抽样对比 Finnhub 返回的数据和前端展示的数据,确保映射和计算的准确性。
- [ ] **错误处理测试**:测试无效的股票代码,检查系统是否能给出清晰的错误提示。
- **前置条件**
- [ ] 在 `config/config.json` 或环境变量中配置 `FINNHUB_API_KEY`
- [ ] 后端已启动(默认 `http://127.0.0.1:8000/api`),前端已启动(默认 `http://127.0.0.1:3000`)。
- **接口用例(后端)**
- [ ] GET `/api/v1/financials/cn/600519.SH?years=10`
- 期望:`200`;返回 `ts_code`、`name`、`series`(含 `revenue`、`n_income` 等关键指标period/年序列齐全)。
- [ ] GET `/api/v1/financials/cn/000001.SZ?years=5`
- 期望:`200`;返回与上同,近 5 年序列。
- [ ] GET `/api/v1/financials/us/AAPL?years=10`
- 期望:`200``series` 至少包含:`revenue`、`n_income`、`total_assets`、`total_hldr_eqy_exc_min_int`、`__free_cash_flow`、`grossprofit_margin`、`netprofit_margin`、`roe`、`roa`。
- [ ] GET `/api/v1/financials/us/MSFT?years=10`
- 期望:`200`;字段与口径同 AAPL。
- [ ] GET `/api/v1/financials/us/INVALID?years=10`
- 期望:`4xx/5xx``detail.message` 含可读错误。
- **页面用例(前端)**
- [ ] 打开 `/report/600519.SH?market=cn`
- 期望:基本信息与“昨日快照”显示;“财务数据(来自 Tushare”表格展示 10 期内主要指标。
- [ ] 打开 `/report/000001.SZ?market=cn`
- 期望:与上同;代码规范化逻辑(无后缀时自动补 `.SZ/.SH`)正常。
- [ ] 打开 `/report/AAPL?market=us`
- 期望“股价图表”正常“财务数据”表格展示主要指标含自由现金流、毛利率、净利率、ROA、ROE
- [ ] 打开 `/report/MSFT?market=us`
- 期望:与上同。
- [ ] 打开 `/report/INVALID?market=us`
- 期望:顶部状态为“读取失败”并有错误提示文案。
- **验收标准**
- [ ] 中国市场功能无回归;美国市场关键指标齐全、值域合理(百分比类 ∈ [-1000%, 1000%],金额类为有限数)。
- [ ] 报错信息清晰可读;网络/密钥缺失时提示明确。
- [ ] 页内主要表格不出现 `NaN/Infinity`;空值以 `-` 展示。

View File

@ -18,3 +18,5 @@

View File

@ -1,7 +1,7 @@
'use client'; 'use client';
import { useParams, useSearchParams } from 'next/navigation'; import { useParams, useSearchParams } from 'next/navigation';
import { useChinaFinancials, useFinancialConfig, useAnalysisConfig, generateFullAnalysis, useChinaSnapshot } from '@/hooks/useApi'; import { useChinaFinancials, useFinancials, useFinancialConfig, useAnalysisConfig, generateFullAnalysis, useSnapshot } from '@/hooks/useApi';
import { Spinner } from '@/components/ui/spinner'; import { Spinner } from '@/components/ui/spinner';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { CheckCircle, XCircle, RotateCw } from 'lucide-react'; import { CheckCircle, XCircle, RotateCw } from 'lucide-react';
@ -20,11 +20,18 @@ export default function ReportPage() {
const params = useParams(); const params = useParams();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const symbol = params.symbol as string; const symbol = params.symbol as string;
const market = (searchParams.get('market') || '').toLowerCase(); const marketParam = (searchParams.get('market') || '').toLowerCase();
const normalizedMarket = (() => {
if (marketParam === 'usa') return 'us';
if (marketParam === 'china') return 'cn';
if (marketParam === 'hkex') return 'hk';
if (marketParam === 'jpn') return 'jp';
return marketParam;
})();
const displayMarket = market === 'china' ? '中国' : market; const displayMarket = marketParam === 'china' ? '中国' : marketParam;
const isChina = market === 'china' || market === 'cn'; const isChina = normalizedMarket === 'cn';
// 规范化中国市场 ts_code若为6位数字或无后缀自动推断交易所 // 规范化中国市场 ts_code若为6位数字或无后缀自动推断交易所
const normalizedTsCode = (() => { const normalizedTsCode = (() => {
@ -40,8 +47,13 @@ export default function ReportPage() {
return symbol.toUpperCase(); return symbol.toUpperCase();
})(); })();
const { data: financials, error, isLoading } = useChinaFinancials(isChina ? normalizedTsCode : undefined, 10); const chinaFin = useChinaFinancials(isChina ? normalizedTsCode : undefined, 10);
const { data: snapshot, error: snapshotError, isLoading: snapshotLoading } = useChinaSnapshot(isChina ? normalizedTsCode : undefined); const otherFin = useFinancials(!isChina ? normalizedMarket : undefined, !isChina ? symbol : undefined, 10);
const financials = (chinaFin.data ?? otherFin.data) as any;
const error = chinaFin.error ?? otherFin.error;
const isLoading = chinaFin.isLoading || otherFin.isLoading;
const unifiedSymbol = isChina ? normalizedTsCode : symbol;
const { data: snapshot, error: snapshotError, isLoading: snapshotLoading } = useSnapshot(normalizedMarket, unifiedSymbol);
const { data: financialConfig } = useFinancialConfig(); const { data: financialConfig } = useFinancialConfig();
const { data: analysisConfig } = useAnalysisConfig(); const { data: analysisConfig } = useAnalysisConfig();
@ -139,7 +151,7 @@ export default function ReportPage() {
} }
const runFullAnalysis = async () => { const runFullAnalysis = async () => {
if (!isChina || !financials || !analysisConfig?.analysis_modules || isAnalysisRunningRef.current) { if (!financials || !analysisConfig?.analysis_modules || isAnalysisRunningRef.current) {
return; return;
} }
@ -321,7 +333,7 @@ export default function ReportPage() {
}, [startTime]); }, [startTime]);
const retryAnalysis = async (analysisType: string) => { const retryAnalysis = async (analysisType: string) => {
if (!isChina || !financials || !analysisConfig?.analysis_modules) { if (!financials || !analysisConfig?.analysis_modules) {
return; return;
} }
analysisFetchedRefs.current[analysisType] = false; analysisFetchedRefs.current[analysisType] = false;
@ -344,7 +356,7 @@ export default function ReportPage() {
try { try {
const startedMsLocal = Date.now(); const startedMsLocal = Date.now();
const response = await fetch( const response = await fetch(
`/api/financials/china/${normalizedTsCode}/analysis/${analysisType}/stream?company_name=${encodeURIComponent(financials?.name || normalizedTsCode)}` `/api/financials/${normalizedMarket}/${unifiedSymbol}/analysis/${analysisType}/stream?company_name=${encodeURIComponent(financials?.name || unifiedSymbol)}`
); );
if (!response.ok) { if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`); throw new Error(`HTTP error! status: ${response.status}`);
@ -420,7 +432,7 @@ export default function ReportPage() {
}; };
useEffect(() => { useEffect(() => {
if (!isChina || isLoading || error || !financials || !analysisConfig?.analysis_modules || analysisTypes.length === 0) { if (isLoading || error || !financials || !analysisConfig?.analysis_modules || analysisTypes.length === 0) {
return; return;
} }
if (isAnalysisRunningRef.current) { if (isAnalysisRunningRef.current) {
@ -477,7 +489,7 @@ export default function ReportPage() {
abortControllerRef.current = new AbortController(); abortControllerRef.current = new AbortController();
const startedMsLocal = Date.now(); const startedMsLocal = Date.now();
const response = await fetch( const response = await fetch(
`/api/financials/china/${normalizedTsCode}/analysis/${analysisType}/stream?company_name=${encodeURIComponent(financials?.name || normalizedTsCode)}`, `/api/financials/${normalizedMarket}/${unifiedSymbol}/analysis/${analysisType}/stream?company_name=${encodeURIComponent(financials?.name || unifiedSymbol)}`,
{ signal: abortControllerRef.current.signal } { signal: abortControllerRef.current.signal }
); );
if (!response.ok) { if (!response.ok) {
@ -572,7 +584,7 @@ export default function ReportPage() {
} }
}; };
runAnalysesSequentially(); runAnalysesSequentially();
}, [isChina, isLoading, error, financials, analysisConfig, analysisTypes, normalizedTsCode, manualRunKey]); }, [isLoading, error, financials, analysisConfig, analysisTypes, normalizedTsCode, manualRunKey]);
const stopAll = () => { const stopAll = () => {
stopRequestedRef.current = true; stopRequestedRef.current = true;
@ -603,7 +615,7 @@ export default function ReportPage() {
<CardContent className="space-y-2 text-sm"> <CardContent className="space-y-2 text-sm">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-muted-foreground min-w-20">:</span> <span className="text-muted-foreground min-w-20">:</span>
<span className="font-medium">{normalizedTsCode}</span> <span className="font-medium">{unifiedSymbol}</span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-muted-foreground min-w-20">:</span> <span className="text-muted-foreground min-w-20">:</span>
@ -627,7 +639,6 @@ export default function ReportPage() {
</CardContent> </CardContent>
</Card> </Card>
{isChina && (
<Card className="w-80 flex-shrink-0"> <Card className="w-80 flex-shrink-0">
<CardHeader> <CardHeader>
<CardTitle className="text-lg"></CardTitle> <CardTitle className="text-lg"></CardTitle>
@ -714,8 +725,6 @@ export default function ReportPage() {
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
)}
{isChina && (
<Card className="w-40 flex-shrink-0"> <Card className="w-40 flex-shrink-0">
<CardContent className="flex flex-col gap-2"> <CardContent className="flex flex-col gap-2">
<Button onClick={runFullAnalysis} disabled={isAnalysisRunningRef.current}> <Button onClick={runFullAnalysis} disabled={isAnalysisRunningRef.current}>
@ -729,8 +738,6 @@ export default function ReportPage() {
</Button> </Button>
</CardContent> </CardContent>
</Card> </Card>
)}
{isChina && (
<Card className="w-80"> <Card className="w-80">
<CardHeader className="flex flex-col space-y-2 pb-2"> <CardHeader className="flex flex-col space-y-2 pb-2">
<div className="flex flex-row items-center justify-between w-full"> <div className="flex flex-row items-center justify-between w-full">
@ -774,10 +781,8 @@ export default function ReportPage() {
)} )}
</CardContent> </CardContent>
</Card> </Card>
)}
</div> </div>
{isChina && (
<Tabs defaultValue="chart" className="mt-4"> <Tabs defaultValue="chart" className="mt-4">
<TabsList className="flex-wrap"> <TabsList className="flex-wrap">
<TabsTrigger value="chart"></TabsTrigger> <TabsTrigger value="chart"></TabsTrigger>
@ -795,19 +800,19 @@ export default function ReportPage() {
<div className="flex items-center gap-3 text-sm mb-4"> <div className="flex items-center gap-3 text-sm mb-4">
<CheckCircle className="size-4 text-green-600" /> <CheckCircle className="size-4 text-green-600" />
<div className="text-muted-foreground"> <div className="text-muted-foreground">
- {normalizedTsCode} - {unifiedSymbol}
</div> </div>
</div> </div>
<TradingViewWidget <TradingViewWidget
symbol={normalizedTsCode} symbol={unifiedSymbol}
market={market} market={marketParam}
height={500} height={500}
/> />
</TabsContent> </TabsContent>
<TabsContent value="financial" className="space-y-4"> <TabsContent value="financial" className="space-y-4">
<h2 className="text-lg font-medium"> Tushare</h2> <h2 className="text-lg font-medium"></h2>
<div className="flex items-center gap-3 text-sm"> <div className="flex items-center gap-3 text-sm">
{isLoading ? ( {isLoading ? (
<Spinner className="size-4" /> <Spinner className="size-4" />
@ -817,11 +822,7 @@ export default function ReportPage() {
<CheckCircle className="size-4 text-green-600" /> <CheckCircle className="size-4 text-green-600" />
)} )}
<div className="text-muted-foreground"> <div className="text-muted-foreground">
{isLoading {isLoading ? '正在读取数据…' : error ? '读取失败' : '读取完成'}
? '正在读取 Tushare 数据…'
: error
? '读取失败'
: '读取完成'}
</div> </div>
</div> </div>
{error && <p className="text-red-500"></p>} {error && <p className="text-red-500"></p>}
@ -1527,7 +1528,6 @@ export default function ReportPage() {
)} )}
</TabsContent> </TabsContent>
</Tabs> </Tabs>
)}
</div> </div>
); );
} }

View File

@ -63,7 +63,7 @@ export function useFinancialConfig() {
export function useChinaFinancials(ts_code?: string, years: number = 10) { export function useChinaFinancials(ts_code?: string, years: number = 10) {
return useSWR<BatchFinancialDataResponse>( return useSWR<BatchFinancialDataResponse>(
ts_code ? `/api/financials/china/${encodeURIComponent(ts_code)}?years=${encodeURIComponent(String(years))}` : null, ts_code ? `/api/financials/cn/${encodeURIComponent(ts_code)}?years=${encodeURIComponent(String(years))}` : null,
fetcher, fetcher,
{ {
revalidateOnFocus: false, // 不在窗口聚焦时重新验证 revalidateOnFocus: false, // 不在窗口聚焦时重新验证
@ -74,6 +74,28 @@ export function useChinaFinancials(ts_code?: string, years: number = 10) {
); );
} }
export function useFinancials(market?: string, stockCode?: string, years: number = 10) {
const normalizeMarket = (m?: string) => {
const t = (m || '').toLowerCase();
if (t === 'usa') return 'us';
if (t === 'china') return 'cn';
if (t === 'hkex') return 'hk';
if (t === 'jpn') return 'jp';
return t;
};
const mkt = normalizeMarket(market);
return useSWR<BatchFinancialDataResponse>(
mkt && stockCode ? `/api/financials/${encodeURIComponent(mkt)}/${encodeURIComponent(stockCode)}?years=${encodeURIComponent(String(years))}` : null,
fetcher,
{
revalidateOnFocus: false,
revalidateOnReconnect: false,
dedupingInterval: 300000,
errorRetryCount: 1,
}
);
}
export function useAnalysisConfig() { export function useAnalysisConfig() {
return useSWR<AnalysisConfigResponse>('/api/financials/analysis-config', fetcher); return useSWR<AnalysisConfigResponse>('/api/financials/analysis-config', fetcher);
} }
@ -124,3 +146,25 @@ export function useChinaSnapshot(ts_code?: string) {
} }
); );
} }
export function useSnapshot(market?: string, stockCode?: string) {
const normalizeMarket = (m?: string) => {
const t = (m || '').toLowerCase();
if (t === 'usa') return 'us';
if (t === 'china') return 'cn';
if (t === 'hkex') return 'hk';
if (t === 'jpn') return 'jp';
return t;
};
const mkt = normalizeMarket(market);
return useSWR<TodaySnapshotResponse>(
mkt && stockCode ? `/api/financials/${encodeURIComponent(mkt)}/${encodeURIComponent(stockCode)}/snapshot` : null,
fetcher,
{
revalidateOnFocus: false,
revalidateOnReconnect: false,
dedupingInterval: 120000,
errorRetryCount: 1,
}
);
}

View File

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