Backend
- router(financial): 新增通用路径 /{market}/{stock_code}、/snapshot、/analysis/stream
- 用 MarketEnum 统一市场(cn/us/hk/jp)
- 将 /china/{ts_code} 改为通用 get_financials,并规范 period,按年限裁剪
- 新增通用昨日快照接口(CN 复用原逻辑,其他市场兜底近交易日收盘)
- data_manager: 仅从 config/config.json 读取各 provider API key,不再读取环境变量
- series 构建更健壮:None/空结构判定;接受 numpy/pandas 数值类型并安全转 float
- provider(finnhub):
- SDK 失败时使用 httpx 直连兜底(profile2、financials-reported)
- 规范化年度报表,映射 revenue/net income/gross profit/assets/equity/goodwill/OCF/CapEx
- 计算 gross/net margin、ROA、ROE;直接产出 series 结构
- 增加关键步骤日志与异常保护
- provider(yfinance): 修正同步阻塞的获取逻辑,使用 run_in_executor 包装
Frontend
- hooks(useApi):
- 将中国财务接口路径改为 /api/financials/cn
- 新增 useFinancials 与 useSnapshot,统一多市场数据访问
- report/[symbol]/page.tsx:
- 支持多市场(映射 usa→us、china→cn 等),统一 symbol 与分析流路径
- 去除仅限中国市场的 UI 限制,财务/分析/图表对多市场可用
- 使用新的分析与快照 API 路径
- lib/prisma.ts: 去除无关内容(微小空行调整)
Docs
- 重组文档目录:
- docs/已完成任务/tasks.md(重命名自 docs/tasks.md)
- docs/未完成任务/us_market_integration_tasks.md 新增
BREAKING CHANGE
- API 路径变更:
- 财务数据:/api/financials/china/{ts_code} → /api/financials/{market}/{stock_code}
- 快照:/api/financials/china/{ts_code}/snapshot → /api/financials/{market}/{stock_code}/snapshot
- 分析流:/api/financials/china/{ts_code}/analysis/{type}/stream → /api/financials/{market}/{stock_code}/analysis/{type}/stream
- 前端需使用 useFinancials/useSnapshot 或更新为 /cn 路径以兼容中国市场
115 lines
5.2 KiB
Python
115 lines
5.2 KiB
Python
from .base import BaseDataProvider
|
|
from typing import Any, Dict, List, Optional
|
|
import yfinance as yf
|
|
import pandas as pd
|
|
from datetime import datetime
|
|
import asyncio
|
|
import logging
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
class YfinanceProvider(BaseDataProvider):
|
|
|
|
def _map_stock_code(self, stock_code: str) -> str:
|
|
# yfinance uses different tickers for CN market
|
|
if stock_code.endswith('.SH'):
|
|
return stock_code.replace('.SH', '.SS')
|
|
elif stock_code.endswith('.SZ'):
|
|
# For Shenzhen stocks, try without suffix first, then with .SZ
|
|
base_code = stock_code.replace('.SZ', '')
|
|
return base_code # Try without suffix first
|
|
return stock_code
|
|
|
|
async def get_stock_basic(self, stock_code: str) -> Optional[Dict[str, Any]]:
|
|
def _fetch():
|
|
try:
|
|
ticker = yf.Ticker(self._map_stock_code(stock_code))
|
|
info = ticker.info
|
|
|
|
# Normalize data to match expected format
|
|
return {
|
|
"ts_code": stock_code,
|
|
"name": info.get("longName"),
|
|
"area": info.get("country"),
|
|
"industry": info.get("industry"),
|
|
"market": info.get("market"),
|
|
"exchange": info.get("exchange"),
|
|
"list_date": datetime.fromtimestamp(info.get("firstTradeDateEpoch", 0)).strftime('%Y%m%d') if info.get("firstTradeDateEpoch") else None,
|
|
}
|
|
except Exception as e:
|
|
logger.error(f"yfinance get_stock_basic failed for {stock_code}: {e}")
|
|
return None
|
|
|
|
loop = asyncio.get_event_loop()
|
|
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]]:
|
|
def _fetch():
|
|
try:
|
|
# yfinance date format is YYYY-MM-DD
|
|
start_fmt = datetime.strptime(start_date, '%Y%m%d').strftime('%Y-%m-%d')
|
|
end_fmt = datetime.strptime(end_date, '%Y%m%d').strftime('%Y-%m-%d')
|
|
|
|
ticker = yf.Ticker(self._map_stock_code(stock_code))
|
|
df = ticker.history(start=start_fmt, end=end_fmt)
|
|
|
|
df.reset_index(inplace=True)
|
|
# Normalize column names
|
|
df.rename(columns={
|
|
"Date": "trade_date",
|
|
"Open": "open", "High": "high", "Low": "low", "Close": "close",
|
|
"Volume": "vol"
|
|
}, inplace=True)
|
|
df['trade_date'] = df['trade_date'].dt.strftime('%Y%m%d')
|
|
return df.to_dict('records')
|
|
except Exception as e:
|
|
logger.error(f"yfinance get_daily_price failed for {stock_code}: {e}")
|
|
return []
|
|
|
|
loop = asyncio.get_event_loop()
|
|
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]]:
|
|
def _fetch():
|
|
try:
|
|
ticker = yf.Ticker(self._map_stock_code(stock_code))
|
|
|
|
# yfinance provides financials quarterly or annually. We'll fetch annually and try to match the dates.
|
|
# Note: This is an approximation as yfinance does not allow fetching by specific end-of-year dates.
|
|
df_financials = ticker.financials.transpose()
|
|
df_balance = ticker.balance_sheet.transpose()
|
|
df_cashflow = ticker.cash_flow.transpose()
|
|
|
|
if df_financials.empty and df_balance.empty and df_cashflow.empty:
|
|
return []
|
|
|
|
# Combine the data
|
|
df_combined = pd.concat([df_financials, df_balance, df_cashflow], axis=1)
|
|
df_combined.index.name = 'end_date'
|
|
df_combined.reset_index(inplace=True)
|
|
df_combined['end_date_str'] = df_combined['end_date'].dt.strftime('%Y%m%d')
|
|
|
|
# Filter by requested dates (allowing for some flexibility if exact match not found)
|
|
# This simplistic filtering might need to be more robust.
|
|
# For now, we assume the yearly data maps to the year in report_dates.
|
|
years_to_fetch = {date[:4] for date in report_dates}
|
|
df_combined = df_combined[df_combined['end_date'].dt.year.astype(str).isin(years_to_fetch)]
|
|
|
|
# Data Normalization (yfinance columns are different from Tushare)
|
|
# This is a sample, a more comprehensive mapping would be required.
|
|
df_combined.rename(columns={
|
|
"Total Revenue": "revenue",
|
|
"Net Income": "net_income",
|
|
"Total Assets": "total_assets",
|
|
"Total Liab": "total_liabilities",
|
|
}, inplace=True, errors='ignore')
|
|
|
|
return df_combined.to_dict('records')
|
|
|
|
except Exception as e:
|
|
logger.error(f"yfinance get_financial_statements failed for {stock_code}: {e}")
|
|
return []
|
|
|
|
loop = asyncio.get_event_loop()
|
|
return await loop.run_in_executor(None, _fetch)
|