mirror of
https://github.com/ZhuLinsen/daily_stock_analysis
synced 2026-09-20 10:53:33 +08:00
* fix: route 4-5 digit bare HK codes to .HK in YfinanceFetcher (fixes #2091) Issue #2091: 5-digit HK listings without an explicit 'HK' prefix (e.g. 02513 for Zhipu) fell through to the 'cannot determine market, default to .SZ' tail branch in YfinanceFetcher._convert_stock_code(), producing '02513.SZ' which Yahoo Finance rejects with 404, breaking the daily OHLC chain and leaving LLM-driven stop-loss / MA levels unreliable. Fix: insert a new branch ahead of the .SZ fallback that routes 4-5 digit pure-numeric codes to '.HK' using the same zero-padding logic as the existing HK-prefix branch (lstrip leading zeros, zfill to 4). Safety: - A-share codes are always 6 digits, so no A-share rule is shadowed. - BSE codes are 6 digits (4xxxxx / 8xxxxx / 920xxx) and routed earlier via is_bse_code(); the new branch only sees 4-5 digit inputs. - ETF branches (15xx/16xx/18xx/51xx/52xx/56xx/58xx) all expect 6-digit codes and run earlier; no overlap. - JP/KR/TW/US suffix codes are routed earlier; unaffected. - Codes already carrying .SS/.SZ/.HK/.BJ pass through verbatim earlier. - 1-3 digit numerics continue to fall through to the .SZ default to preserve prior behaviour; the fix is intentionally scoped to 4-5 digits per maintainer note in issue #2091, avoiding speculative expansion of the HK rule to inputs users never fetch in practice. Tests: tests/test_yfinance_hk_bare_code.py covers: - HK-prefix still routes to .HK (4 cases, regression guard) - Bare 4-5 digit numeric -> .HK with correct zero padding (5 cases) - A-share 600/601/603/688 (.SS) and 000/002/300 (.SZ) unchanged (7 cases) - BSE 4xxxxx / 8xxxxx / 920xxx routes to .BJ unchanged (3 cases) - ETF (510300.SS / 159915.SZ), JP/KR/US suffix, and pre-suffixed codes pass through unchanged (7 cases) All 23 new tests pass; 71 yfinance / convert_stock_code tests total across the wider related test set pass with no regression. * fix: 修复 _is_hk_market 4位裸港股码路由 + DataFetcherManager 回归测试 修复 maintainer 在 PR #2097 review 中指出的 OR-COR-bfddfd66 blocker: _DataFetcherManager.get_daily_data() 仍按 A 股链路路由 4 位裸港股码。 根因:data_provider/base.py::_is_hk_market() 只把 5 位裸数字识别 为港股,4 位裸数字 (0001 长和 / 0941 中国移动) 被路由到 cn 分支, 导致 AkshareFetcher 走 stock_zh_a_hist、BaostockFetcher 兜成 sz.0001、 TushareFetcher 转成 0001.SZ——issue #2091 在主调用路径上未真正关闭。 修复:_is_hk_market 的裸数字分支从 len(normalized) == 5 改为 4 <= len(normalized) <= 5,与 YfinanceFetcher._convert_stock_code 的 4-5 位分支保持一致。A 股 (6 位) / BSE (6 位) / ETF (51/15 开头) 不受影响,因为它们走不同前缀或位数判定。 回归测试:新增 tests/test_data_fetcher_manager_hk_bare_code.py - _is_hk_market('0001'/'0941'/'0078') -> True - 4位裸港股码经 DataFetcherManager 只路由到 HK-capable fetcher (YfinanceFetcher/AkshareFetcher/TushareFetcher),Efinance/Tencent/ TickFlow/Pytdx/Baostock 完全不被调用 - 5位裸港股码行为不变 (00700 -> YfinanceFetcher) - 6位 A 股仍路由到 CN-only fetcher,YfinanceFetcher 不被调用 跨 7 个相关测试文件 186 passed,全量 135 passed,无回归。 * fix(#2091): 同步 akshare_fetcher._is_hk_code 到 4-5 位裸港股码 Review blocker OR-COR-ea09dfe8 (#2097): manager 层 _is_hk_market() 已在前序 commit 放开到 4-5 位裸数字,但 AkshareFetcher._is_hk_code() 仍只接受 5 位。DataFetcherManager 默认优先级下 AkshareFetcher 优先 于 YfinanceFetcher 执行,0001 在 manager 被判为 HK 后于 AkshareFetcher 内部因 _is_hk_code('0001')==False 落到 _fetch_stock_data A股链路,两套市场契约冲突。 同步放宽 _is_hk_code: - 无前缀裸数字从 len==5 改为 4<=len<=5 - docstring 补充 OR-COR-ea09dfe8 说明 新增两类 provider-level regression tests: 1. TestAkshareFetcherIsHkCodeContract: 直接断言 _is_hk_code 对 4/5 位裸码、6位裸码(排除)、前缀后缀的解码结果 2. TestAkshareFetcherRoutingCallsHkBranch: patch _fetch_hk_data / _fetch_stock_data,驱动 _fetch_raw_data('0001') 验证真实分流到 HK 分支而非 A股分支,防止 _is_hk_code 被重收紧后静默回归 * fix(#2091): 同步 longbridge_fetcher._is_hk_code 到 4-5 位裸港股码 OR-COR-ea09dfe8 关闭:DataFetcherManager 路由层 (data_provider/base.py) 已放宽到 4-5 位裸港股码,把 LongbridgeFetcher 视为 HK-capable provider 保留进港股链路;但 longbridge_fetcher._is_hk_code 仍只接受 5 位裸数字, 导致 4 位裸港股 (0001 长和 / 0941 中国移动) 在配置了 Longbridge 的真实 日线/实时链路上 _to_longbridge_symbol 返回 None,日线 fallback 抛 ValueError、实时兜底被静默跳过。 修复与 base._is_hk_market 的市场契约对齐: - _is_hk_code: 4-5 位裸数字判定为港股 (与 base._is_hk_market 一致); .HK 后缀严格校验后缀 base 部分为 1-5 位数字 (之前无条件 True,会把 类似 "XXX.HK" 也误判,本次顺带收紧);HK 前缀分支保持不变。 - _to_longbridge_symbol: 在 _is_hk_code 通过后的 .HK 后缀输入仍走 原路径直接 return upper,行为不变;4 位裸码现在能正确 zfill(4) 到 "0001.HK" / "0941.HK"。 回归测试: - tests/test_longbridge_fetcher.py 新增 TestSymbolConversion .test_hk_stock_4digit_bare_code_issue_2091: 显式 assert 0001/0941 _is_hk_code=True + _to_longbridge_symbol="0001.HK"/"0941.HK"。 - 现有 TestSymbolConversion 7 测试 + 全文件 30 测试 全通过。 - tests/test_yfinance_hk_bare_code.py + test_data_fetcher_manager_hk_bare_code.py 共 39 测试 全通过。 - 总计新相关回归 69/69 pass。 本地确定性复现 (reviewer 报告的 head 行为): - 修复前: _is_hk_code("0001") == False, _to_longbridge_symbol("0001") == None - 修复后: _is_hk_code("0001") == True, _to_longbridge_symbol("0001") == "0001.HK" - _is_hk_code("0941") == True, _to_longbridge_symbol("0941") == "0941.HK" - _is_hk_code("00700") == True, _to_longbridge_symbol("00700") == "0700.HK" - _is_hk_code("00700.HK") == True, _to_longbridge_symbol("00700.HK") == "00700.HK" - _is_hk_code("HK00700") == True, _to_longbridge_symbol("HK00700") == "0700.HK" - _is_hk_code("600690") == False (6 位 A 股不误判), _to_longbridge_symbol("600690") == None - _is_hk_code("AAPL") == False, _to_longbridge_symbol("AAPL") == "AAPL.US" 注:本轮只关闭 OR-COR-ea09dfe8 阻断;非阻断建议 (CHANGELOG #2063 条目 移出、PR 描述 sync、docstring 中 review blocker 编号清理) 将在下一条 commit 单独处理。 * docs(#2097): 清理评审叙事 + 收敛 CHANGELOG 范围 回应 reviewer 非阻断建议 (上一条 commita251e4f8提到的"下一 commit 处理"): 1. docs/CHANGELOG.md - 移除本 PR 主题 (#2091 yfinance/HK bare-code 路由) 之外混入的 "parse_analysis_target() / #2063 Phase 1" 条目;该 feat 属于 commit 0313dd3c (issue #2063 Phase 1) 单独 PR 的范围,不应在本 PR 中夹带。 - 把 #2091 条目改写为反映本 PR 实际范围:三处 _is_hk_code 同步 (YfinanceFetcher 在 4e915a4f、AkshareFetcher 在 e2bf3a88、 LongbridgeFetcher 在a251e4f8) + DataFetcherManager 港股路由 回归测试 (9c473787),统一描述为"DataFetcherManager 港股路由" 而非只提 yfinance 单侧,与当前累计 diff 一致。 2. data_provider/base.py:_is_hk_market / akshare_fetcher.py:_is_hk_code docstring 清理:移除"Review blocker OR-COR-... (PR #2097 / issue #2091)"评审叙事、PR 编号、blocker 编号、过程性"之前只接受..."。 改为稳定的市场识别规则说明:支持哪种形式 (.HK 后缀 / HK 前缀 / 4-5 位裸数字)、与 provider 内 _is_hk_code 的位数契约对齐即可。 长期保留的产品代码不应写入评审过程叙事。 行为无变化:仅 docstring + CHANGELOG 文本修改,无代码路径修改。 回归:tests/test_longbridge_fetcher.py + tests/test_yfinance_hk_bare_code.py + tests/test_data_fetcher_manager_hk_bare_code.py 共 69/69 pass。
959 lines
38 KiB
Python
959 lines
38 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""
|
||
===================================
|
||
YfinanceFetcher - 兜底数据源 (Priority 4)
|
||
===================================
|
||
|
||
数据来源:Yahoo Finance(通过 yfinance 库)
|
||
特点:国际数据源、可能有延迟或缺失
|
||
定位:当所有国内数据源都失败时的最后保障
|
||
|
||
关键策略:
|
||
1. 自动将 A 股代码转换为 yfinance 格式(.SS / .SZ)
|
||
2. 处理 Yahoo Finance 的数据格式差异
|
||
3. 失败后指数退避重试
|
||
"""
|
||
|
||
import csv
|
||
import logging
|
||
from datetime import datetime
|
||
from io import StringIO
|
||
from typing import Optional, List, Dict, Any
|
||
from urllib.error import HTTPError, URLError
|
||
from urllib.request import Request, urlopen
|
||
|
||
import pandas as pd
|
||
from tenacity import (
|
||
retry,
|
||
stop_after_attempt,
|
||
wait_exponential,
|
||
retry_if_exception_type,
|
||
before_sleep_log,
|
||
)
|
||
|
||
from .base import BaseFetcher, DataFetchError, STANDARD_COLUMNS, is_bse_code
|
||
from .realtime_types import UnifiedRealtimeQuote, RealtimeSource
|
||
from .us_index_mapping import get_us_index_yf_symbol, is_us_stock_code
|
||
from .yfinance_fundamental_adapter import _safe_float
|
||
from src.services.market_symbol_utils import get_suffix_market, is_suffix_market_symbol
|
||
|
||
# 可选导入本地股票映射补丁,若缺失则使用空字典兜底
|
||
try:
|
||
from src.data.stock_mapping import STOCK_NAME_MAP, is_meaningful_stock_name
|
||
except (ImportError, ModuleNotFoundError):
|
||
STOCK_NAME_MAP = {}
|
||
|
||
def is_meaningful_stock_name(name: str | None, stock_code: str) -> bool:
|
||
"""简单的名称有效性校验兜底"""
|
||
if not name:
|
||
return False
|
||
n = str(name).strip()
|
||
return bool(n and n.upper() != str(stock_code).strip().upper())
|
||
|
||
import os
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
class YfinanceFetcher(BaseFetcher):
|
||
"""
|
||
Yahoo Finance 数据源实现
|
||
|
||
优先级:4(最低,作为兜底)
|
||
数据来源:Yahoo Finance
|
||
|
||
关键策略:
|
||
- 自动转换股票代码格式
|
||
- 处理时区和数据格式差异
|
||
- 失败后指数退避重试
|
||
|
||
注意事项:
|
||
- A 股数据可能有延迟
|
||
- 某些股票可能无数据
|
||
- 数据精度可能与国内源略有差异
|
||
"""
|
||
|
||
name = "YfinanceFetcher"
|
||
priority = int(os.getenv("YFINANCE_PRIORITY", "4"))
|
||
|
||
def __init__(self):
|
||
"""初始化 YfinanceFetcher"""
|
||
pass
|
||
|
||
@staticmethod
|
||
def _is_jp_kr_suffix_stock(stock_code: str) -> bool:
|
||
"""Return True for supported JP/KR suffix-only Yahoo symbols."""
|
||
return is_suffix_market_symbol(stock_code, "jp") or is_suffix_market_symbol(stock_code, "kr")
|
||
|
||
@staticmethod
|
||
def _is_tw_suffix_stock(stock_code: str) -> bool:
|
||
"""Return True for supported Taiwan suffix-only Yahoo symbols (TWSE `.TW` / TPEx `.TWO`).
|
||
|
||
Taiwan base codes are 4-6 digits (common stocks 4, ETFs/others up to 6,
|
||
e.g. 00878 / 006208), wider than the JP `.T` range.
|
||
"""
|
||
return is_suffix_market_symbol(stock_code, "tw")
|
||
|
||
def _convert_stock_code(self, stock_code: str) -> str:
|
||
"""
|
||
转换股票代码为 Yahoo Finance 格式
|
||
|
||
Yahoo Finance 代码格式:
|
||
- A股沪市:600519.SS (Shanghai Stock Exchange)
|
||
- A股深市:000001.SZ (Shenzhen Stock Exchange)
|
||
- 港股:0700.HK (Hong Kong Stock Exchange)
|
||
- 美股:AAPL, TSLA, GOOGL (无需后缀)
|
||
|
||
Args:
|
||
stock_code: 原始代码,如 '600519', 'hk00700', 'AAPL'
|
||
|
||
Returns:
|
||
Yahoo Finance 格式代码
|
||
|
||
Examples:
|
||
>>> fetcher._convert_stock_code('600519')
|
||
'600519.SS'
|
||
>>> fetcher._convert_stock_code('hk00700')
|
||
'0700.HK'
|
||
>>> fetcher._convert_stock_code('AAPL')
|
||
'AAPL'
|
||
"""
|
||
code = stock_code.strip().upper()
|
||
|
||
# 美股指数:映射到 Yahoo Finance 符号(如 SPX -> ^GSPC)
|
||
yf_symbol, _ = get_us_index_yf_symbol(code)
|
||
if yf_symbol:
|
||
logger.debug(f"识别为美股指数: {code} -> {yf_symbol}")
|
||
return yf_symbol
|
||
|
||
# 美股:1-5 个大写字母(可选 .X 后缀),原样返回
|
||
if is_us_stock_code(code):
|
||
logger.debug(f"识别为美股代码: {code}")
|
||
return code
|
||
|
||
# 日股/韩股/台股 MVP:显式 Yahoo Finance suffix-only 代码,原样传给 Yahoo。
|
||
if self._is_jp_kr_suffix_stock(code) or self._is_tw_suffix_stock(code):
|
||
logger.debug(f"识别为日韩台 Yahoo suffix 代码: {code}")
|
||
return code
|
||
|
||
# 港股:hk前缀 -> .HK后缀
|
||
if code.startswith('HK'):
|
||
hk_code = code[2:].lstrip('0') or '0' # 去除前导0,但保留至少一个0
|
||
hk_code = hk_code.zfill(4) # 补齐到4位
|
||
logger.debug(f"转换港股代码: {stock_code} -> {hk_code}.HK")
|
||
return f"{hk_code}.HK"
|
||
|
||
# 港股裸码:4-5 位纯数字(如 00700、02513、0001)按港股处理。
|
||
# A 股代码全部是 6 位,4-5 位裸数字不可能是 A 股或 BSE(BSE 是
|
||
# 4/8/920xxx 6 位),因此可以先于 .SZ 兜底分流到 .HK,避免 yfinance
|
||
# 把 02513 这类新港股误判为深市后缀导致 Yahoo 404。详见 issue #2091。
|
||
if code.isdigit() and 4 <= len(code) <= 5:
|
||
hk_code = code.lstrip('0') or '0'
|
||
hk_code = hk_code.zfill(4)
|
||
logger.debug(f"识别裸港股代码: {stock_code} -> {hk_code}.HK")
|
||
return f"{hk_code}.HK"
|
||
|
||
# 已经包含后缀的情况
|
||
if '.SS' in code or '.SZ' in code or '.HK' in code or '.BJ' in code:
|
||
return code
|
||
|
||
# 去除可能的 .SH 后缀
|
||
code = code.replace('.SH', '')
|
||
|
||
# ETF: Shanghai ETF (51xx, 52xx, 56xx, 58xx) -> .SS; Shenzhen ETF (15xx, 16xx, 18xx) -> .SZ
|
||
if len(code) == 6:
|
||
if code.startswith(('51', '52', '56', '58')):
|
||
return f"{code}.SS"
|
||
if code.startswith(('15', '16', '18')):
|
||
return f"{code}.SZ"
|
||
|
||
# BSE (Beijing Stock Exchange): 8xxxxx, 4xxxxx, 920xxx
|
||
if is_bse_code(code):
|
||
base = code.split('.')[0] if '.' in code else code
|
||
return f"{base}.BJ"
|
||
|
||
# A股:根据代码前缀判断市场
|
||
if code.startswith(('600', '601', '603', '688')):
|
||
return f"{code}.SS"
|
||
elif code.startswith(('000', '002', '300')):
|
||
return f"{code}.SZ"
|
||
else:
|
||
logger.warning(f"无法确定股票 {code} 的市场,默认使用深市")
|
||
return f"{code}.SZ"
|
||
|
||
@retry(
|
||
stop=stop_after_attempt(3),
|
||
wait=wait_exponential(multiplier=1, min=2, max=30),
|
||
retry=retry_if_exception_type((ConnectionError, TimeoutError)),
|
||
before_sleep=before_sleep_log(logger, logging.WARNING),
|
||
)
|
||
def _fetch_raw_data(self, stock_code: str, start_date: str, end_date: str) -> pd.DataFrame:
|
||
"""
|
||
从 Yahoo Finance 获取原始数据
|
||
|
||
使用 yfinance.download() 获取历史数据
|
||
|
||
流程:
|
||
1. 转换股票代码格式
|
||
2. 调用 yfinance API
|
||
3. 处理返回数据
|
||
"""
|
||
import yfinance as yf
|
||
|
||
# 转换代码格式
|
||
yf_code = self._convert_stock_code(stock_code)
|
||
|
||
logger.debug(f"调用 yfinance.download({yf_code}, {start_date}, {end_date})")
|
||
|
||
try:
|
||
# 使用 yfinance 下载数据
|
||
df = yf.download(
|
||
tickers=yf_code,
|
||
start=start_date,
|
||
end=end_date,
|
||
progress=False, # 禁止进度条
|
||
auto_adjust=True, # 自动调整价格(复权)
|
||
multi_level_index=True
|
||
)
|
||
|
||
# 筛选出 yf_code 的列, 避免多只股票数据混淆
|
||
if isinstance(df.columns, pd.MultiIndex) and len(df.columns) > 1:
|
||
ticker_level = df.columns.get_level_values(1)
|
||
mask = ticker_level == yf_code
|
||
if mask.any():
|
||
df = df.loc[:, mask].copy()
|
||
|
||
if df.empty:
|
||
raise DataFetchError(f"Yahoo Finance 未查询到 {stock_code} 的数据")
|
||
|
||
return df
|
||
|
||
except Exception as e:
|
||
if isinstance(e, DataFetchError):
|
||
raise
|
||
raise DataFetchError(f"Yahoo Finance 获取数据失败: {e}") from e
|
||
|
||
def _normalize_data(self, df: pd.DataFrame, stock_code: str) -> pd.DataFrame:
|
||
"""
|
||
标准化 Yahoo Finance 数据
|
||
|
||
yfinance 返回的列名:
|
||
Open, High, Low, Close, Volume(索引是日期)
|
||
|
||
注意:新版 yfinance 返回 MultiIndex 列名,如 ('Close', 'AMD')
|
||
需要先扁平化列名再进行处理
|
||
|
||
需要映射到标准列名:
|
||
date, open, high, low, close, volume, amount, pct_chg
|
||
"""
|
||
df = df.copy()
|
||
|
||
# 处理 MultiIndex 列名(新版 yfinance 返回格式)
|
||
# 例如: ('Close', 'AMD') -> 'Close'
|
||
if isinstance(df.columns, pd.MultiIndex):
|
||
logger.debug("检测到 MultiIndex 列名,进行扁平化处理")
|
||
# 取第一级列名(Price level: Close, High, Low, etc.)
|
||
df.columns = df.columns.get_level_values(0)
|
||
|
||
# 重置索引,将日期从索引变为列
|
||
df = df.reset_index()
|
||
|
||
# 列名映射(yfinance 使用首字母大写)
|
||
column_mapping = {
|
||
'Date': 'date',
|
||
'Datetime': 'date',
|
||
'datetime': 'date',
|
||
'Open': 'open',
|
||
'High': 'high',
|
||
'Low': 'low',
|
||
'Close': 'close',
|
||
'Volume': 'volume',
|
||
}
|
||
|
||
df = df.rename(columns=column_mapping)
|
||
if 'date' not in df.columns:
|
||
index_col = df.columns[0] if len(df.columns) else None
|
||
if index_col is not None:
|
||
candidate = df[index_col]
|
||
if pd.api.types.is_datetime64_any_dtype(candidate):
|
||
df = df.rename(columns={index_col: 'date'})
|
||
elif not pd.api.types.is_numeric_dtype(candidate):
|
||
parsed_dates = pd.to_datetime(candidate, errors='coerce')
|
||
if parsed_dates.notna().any():
|
||
df = df.rename(columns={index_col: 'date'})
|
||
df['date'] = parsed_dates
|
||
|
||
# 计算涨跌幅(因为 yfinance 不直接提供)
|
||
if 'close' in df.columns:
|
||
df['pct_chg'] = df['close'].pct_change() * 100
|
||
df['pct_chg'] = df['pct_chg'].fillna(0).round(2)
|
||
|
||
# 计算成交额(yfinance 不提供,使用估算值)
|
||
# 成交额 ≈ 成交量 * 平均价格
|
||
if 'volume' in df.columns and 'close' in df.columns:
|
||
df['amount'] = df['volume'] * df['close']
|
||
else:
|
||
df['amount'] = 0
|
||
|
||
# 添加股票代码列
|
||
df['code'] = stock_code
|
||
|
||
# 只保留需要的列
|
||
keep_cols = ['code'] + STANDARD_COLUMNS
|
||
existing_cols = [col for col in keep_cols if col in df.columns]
|
||
df = df[existing_cols]
|
||
|
||
return df
|
||
|
||
def _fetch_yf_ticker_data(self, yf, yf_code: str, name: str, return_code: str) -> Optional[Dict[str, Any]]:
|
||
"""
|
||
通过 yfinance 拉取单个指数/股票的行情数据。
|
||
|
||
Args:
|
||
yf: yfinance 模块引用
|
||
yf_code: yfinance 使用的代码(如 '000001.SS'、'^GSPC')
|
||
name: 指数显示名称
|
||
return_code: 写入结果 dict 的 code 字段(如 'sh000001'、'SPX')
|
||
|
||
Returns:
|
||
行情字典,失败时返回 None
|
||
"""
|
||
ticker = yf.Ticker(yf_code)
|
||
# 取近两日数据以计算涨跌幅
|
||
hist = ticker.history(period='2d')
|
||
if hist.empty:
|
||
return None
|
||
today_row = hist.iloc[-1]
|
||
prev_row = hist.iloc[-2] if len(hist) > 1 else today_row
|
||
price = float(today_row['Close'])
|
||
prev_close = float(prev_row['Close'])
|
||
change = price - prev_close
|
||
change_pct = (change / prev_close) * 100 if prev_close else 0
|
||
high = float(today_row['High'])
|
||
low = float(today_row['Low'])
|
||
# 振幅 = (最高 - 最低) / 昨收 * 100
|
||
amplitude = ((high - low) / prev_close * 100) if prev_close else 0
|
||
return {
|
||
'code': return_code,
|
||
'name': name,
|
||
'current': price,
|
||
'change': change,
|
||
'change_pct': change_pct,
|
||
'open': float(today_row['Open']),
|
||
'high': high,
|
||
'low': low,
|
||
'prev_close': prev_close,
|
||
'volume': float(today_row['Volume']),
|
||
'amount': 0.0, # Yahoo Finance 不提供准确成交额
|
||
'amplitude': amplitude,
|
||
}
|
||
|
||
def get_main_indices(self, region: str = "cn") -> Optional[List[Dict[str, Any]]]:
|
||
"""
|
||
获取主要指数行情 (Yahoo Finance),支持 A 股、美股、港股、日股、韩股与台股。
|
||
region=us 时委托给 _get_us_main_indices。
|
||
region=hk 时委托给 _get_hk_main_indices。
|
||
region=jp/kr/tw 时分别委托给对应市场指数方法。
|
||
"""
|
||
import yfinance as yf
|
||
|
||
if region == "us":
|
||
return self._get_us_main_indices(yf)
|
||
if region == "hk":
|
||
return self._get_hk_main_indices(yf)
|
||
if region == "jp":
|
||
return self._get_jp_main_indices(yf)
|
||
if region == "kr":
|
||
return self._get_kr_main_indices(yf)
|
||
if region == "tw":
|
||
return self._get_tw_main_indices(yf)
|
||
|
||
# A 股指数:akshare 代码 -> (yfinance 代码, 显示名称)
|
||
yf_mapping = {
|
||
'sh000001': ('000001.SS', '上证指数'),
|
||
'sz399001': ('399001.SZ', '深证成指'),
|
||
'sz399006': ('399006.SZ', '创业板指'),
|
||
'sh000688': ('000688.SS', '科创50'),
|
||
'sh000016': ('000016.SS', '上证50'),
|
||
'sh000300': ('000300.SS', '沪深300'),
|
||
}
|
||
|
||
results = []
|
||
try:
|
||
for ak_code, (yf_code, name) in yf_mapping.items():
|
||
try:
|
||
item = self._fetch_yf_ticker_data(yf, yf_code, name, ak_code)
|
||
if item:
|
||
results.append(item)
|
||
logger.debug(f"[Yfinance] 获取指数 {name} 成功")
|
||
except Exception as e:
|
||
logger.warning(f"[Yfinance] 获取指数 {name} 失败: {e}")
|
||
|
||
if results:
|
||
logger.info(f"[Yfinance] 成功获取 {len(results)} 个 A 股指数行情")
|
||
return results
|
||
|
||
except Exception as e:
|
||
logger.error(f"[Yfinance] 获取 A 股指数行情失败: {e}")
|
||
|
||
return None
|
||
|
||
def _get_us_main_indices(self, yf) -> Optional[List[Dict[str, Any]]]:
|
||
"""获取美股主要指数行情(SPX、IXIC、DJI、VIX),复用 _fetch_yf_ticker_data"""
|
||
# 大盘复盘所需核心美股指数
|
||
us_indices = ['SPX', 'IXIC', 'DJI', 'VIX']
|
||
results = []
|
||
try:
|
||
for code in us_indices:
|
||
yf_symbol, name = get_us_index_yf_symbol(code)
|
||
if not yf_symbol:
|
||
continue
|
||
try:
|
||
item = self._fetch_yf_ticker_data(yf, yf_symbol, name, code)
|
||
if item:
|
||
results.append(item)
|
||
logger.debug(f"[Yfinance] 获取美股指数 {name} 成功")
|
||
except Exception as e:
|
||
logger.warning(f"[Yfinance] 获取美股指数 {name} 失败: {e}")
|
||
|
||
if results:
|
||
logger.info(f"[Yfinance] 成功获取 {len(results)} 个美股指数行情")
|
||
return results
|
||
|
||
except Exception as e:
|
||
logger.error(f"[Yfinance] 获取美股指数行情失败: {e}")
|
||
|
||
return None
|
||
|
||
def _get_hk_main_indices(self, yf) -> Optional[List[Dict[str, Any]]]:
|
||
"""获取港股主要指数行情(HSI、HSTECH、HSCEI),复用 _fetch_yf_ticker_data"""
|
||
# Yahoo Finance 港股指数符号映射:
|
||
# - HSI -> ^HSI
|
||
# - HSTECH -> HSTECH.HK(不是 ^HSTECH)
|
||
# - HSCEI -> ^HSCE(不是 ^HSCEI)
|
||
# 该映射由离线单测 tests/test_yfinance_hk_indices.py 固化,避免在线依赖导致非确定性失败。
|
||
hk_indices = {
|
||
'HSI': ('^HSI', '恒生指数'),
|
||
'HSTECH': ('HSTECH.HK', '恒生科技指数'),
|
||
'HSCEI': ('^HSCE', '国企指数'),
|
||
}
|
||
results = []
|
||
try:
|
||
for code, (yf_symbol, name) in hk_indices.items():
|
||
try:
|
||
item = self._fetch_yf_ticker_data(yf, yf_symbol, name, code)
|
||
if item:
|
||
results.append(item)
|
||
logger.debug(f"[Yfinance] 获取港股指数 {name} 成功")
|
||
except Exception as e:
|
||
logger.warning(f"[Yfinance] 获取港股指数 {name} 失败: {e}")
|
||
|
||
if results:
|
||
logger.info(f"[Yfinance] 成功获取 {len(results)} 个港股指数行情")
|
||
return results
|
||
|
||
except Exception as e:
|
||
logger.error(f"[Yfinance] 获取港股指数行情失败: {e}")
|
||
|
||
return None
|
||
|
||
def _get_jp_main_indices(self, yf) -> Optional[List[Dict[str, Any]]]:
|
||
"""获取日本主要指数行情(日经225、TOPIX),复用 _fetch_yf_ticker_data。"""
|
||
jp_indices = {
|
||
'N225': ('^N225', '日经225'),
|
||
'TOPX': ('^TOPX', '东证指数'),
|
||
}
|
||
results = []
|
||
try:
|
||
for code, (yf_symbol, name) in jp_indices.items():
|
||
try:
|
||
item = self._fetch_yf_ticker_data(yf, yf_symbol, name, code)
|
||
if item:
|
||
results.append(item)
|
||
logger.debug(f"[Yfinance] 获取日本指数 {name} 成功")
|
||
except Exception as e:
|
||
logger.warning(f"[Yfinance] 获取日本指数 {name} 失败: {e}")
|
||
if results:
|
||
logger.info(f"[Yfinance] 成功获取 {len(results)} 个日本指数行情")
|
||
return results
|
||
except Exception as e:
|
||
logger.error(f"[Yfinance] 获取日本指数行情失败: {e}")
|
||
return None
|
||
|
||
def _get_kr_main_indices(self, yf) -> Optional[List[Dict[str, Any]]]:
|
||
"""获取韩国主要指数行情(KOSPI、KOSDAQ),复用 _fetch_yf_ticker_data。"""
|
||
kr_indices = {
|
||
'KS11': ('^KS11', 'KOSPI'),
|
||
'KQ11': ('^KQ11', 'KOSDAQ'),
|
||
}
|
||
results = []
|
||
try:
|
||
for code, (yf_symbol, name) in kr_indices.items():
|
||
try:
|
||
item = self._fetch_yf_ticker_data(yf, yf_symbol, name, code)
|
||
if item:
|
||
results.append(item)
|
||
logger.debug(f"[Yfinance] 获取韩国指数 {name} 成功")
|
||
except Exception as e:
|
||
logger.warning(f"[Yfinance] 获取韩国指数 {name} 失败: {e}")
|
||
if results:
|
||
logger.info(f"[Yfinance] 成功获取 {len(results)} 个韩国指数行情")
|
||
return results
|
||
except Exception as e:
|
||
logger.error(f"[Yfinance] 获取韩国指数行情失败: {e}")
|
||
return None
|
||
|
||
def _get_tw_main_indices(self, yf) -> Optional[List[Dict[str, Any]]]:
|
||
"""获取台湾主要指数行情(加权指数 ^TWII、柜买指数 ^TWOII),复用 _fetch_yf_ticker_data。"""
|
||
tw_indices = {
|
||
'TWII': ('^TWII', '台湾加权指数'),
|
||
'TWOII': ('^TWOII', '台湾柜买指数'),
|
||
}
|
||
results = []
|
||
try:
|
||
for code, (yf_symbol, name) in tw_indices.items():
|
||
try:
|
||
item = self._fetch_yf_ticker_data(yf, yf_symbol, name, code)
|
||
if item:
|
||
results.append(item)
|
||
logger.debug(f"[Yfinance] 获取台湾指数 {name} 成功")
|
||
except Exception as e:
|
||
logger.warning(f"[Yfinance] 获取台湾指数 {name} 失败: {e}")
|
||
if results:
|
||
logger.info(f"[Yfinance] 成功获取 {len(results)} 个台湾指数行情")
|
||
return results
|
||
except Exception as e:
|
||
logger.error(f"[Yfinance] 获取台湾指数行情失败: {e}")
|
||
return None
|
||
|
||
def _is_us_stock(self, stock_code: str) -> bool:
|
||
"""
|
||
判断代码是否为美股股票(排除美股指数)。
|
||
|
||
委托给 us_index_mapping 模块的 is_us_stock_code()。
|
||
"""
|
||
return is_us_stock_code(stock_code)
|
||
|
||
def _get_us_stock_quote_from_stooq(self, stock_code: str) -> Optional[UnifiedRealtimeQuote]:
|
||
"""
|
||
使用 Stooq 为美股实时行情提供免密钥兜底。
|
||
|
||
Stooq 提供的是最新交易日行情,精度不如分时实时接口,但在 Yahoo / yfinance
|
||
被限流时,至少能为 Web UI 提供可用价格;若可获取到昨收价,则同时提供涨跌幅等衍生指标。
|
||
"""
|
||
symbol = stock_code.strip().upper()
|
||
stooq_symbol = f"{symbol.lower()}.us"
|
||
url = f"https://stooq.com/q/l/?s={stooq_symbol}"
|
||
request = Request(
|
||
url,
|
||
headers={
|
||
"User-Agent": "Mozilla/5.0 (compatible; DSA/1.0; +https://github.com/ZhuLinsen/daily_stock_analysis)",
|
||
"Accept": "text/plain,text/csv,*/*",
|
||
},
|
||
)
|
||
|
||
try:
|
||
with urlopen(request, timeout=15) as response:
|
||
payload = response.read().decode("utf-8", "ignore").strip()
|
||
except (HTTPError, URLError, TimeoutError) as exc:
|
||
logger.warning(f"[Stooq] 获取美股 {symbol} 实时行情失败: {exc}")
|
||
return None
|
||
|
||
if not payload or payload.upper().startswith("NO DATA"):
|
||
logger.warning(f"[Stooq] 无法获取 {symbol} 的行情数据")
|
||
return None
|
||
|
||
def _fetch_prev_close() -> Optional[float]:
|
||
history_url = f"https://stooq.com/q/d/l/?s={stooq_symbol}&i=d"
|
||
history_request = Request(
|
||
history_url,
|
||
headers={
|
||
"User-Agent": "Mozilla/5.0 (compatible; DSA/1.0; +https://github.com/ZhuLinsen/daily_stock_analysis)",
|
||
"Accept": "text/plain,text/csv,*/*",
|
||
},
|
||
)
|
||
try:
|
||
with urlopen(history_request, timeout=15) as response:
|
||
history_payload = response.read().decode("utf-8", "ignore").strip()
|
||
except (HTTPError, URLError, TimeoutError) as exc:
|
||
logger.debug(f"[Stooq] 获取美股 {symbol} 日线历史失败: {exc}")
|
||
return None
|
||
|
||
if not history_payload or history_payload.upper().startswith("NO DATA"):
|
||
return None
|
||
|
||
try:
|
||
reader = csv.reader(StringIO(history_payload))
|
||
header = next(reader, None)
|
||
if not header:
|
||
return None
|
||
|
||
header_tokens = [cell.strip().lower() for cell in header]
|
||
has_header = "close" in header_tokens and "date" in header_tokens
|
||
if not has_header:
|
||
return None
|
||
|
||
date_index = header_tokens.index("date")
|
||
close_index = header_tokens.index("close")
|
||
|
||
daily_rows: list[tuple[datetime, float]] = []
|
||
for row in reader:
|
||
if not row:
|
||
continue
|
||
date_text = row[date_index].strip() if len(row) > date_index else ""
|
||
close_text = row[close_index].strip() if len(row) > close_index else ""
|
||
if not date_text or not close_text:
|
||
continue
|
||
try:
|
||
dt = datetime.strptime(date_text, "%Y-%m-%d")
|
||
close_val = float(close_text)
|
||
except Exception:
|
||
continue
|
||
daily_rows.append((dt, close_val))
|
||
|
||
if len(daily_rows) < 2:
|
||
return None
|
||
|
||
daily_rows.sort(key=lambda item: item[0])
|
||
return daily_rows[-2][1]
|
||
except Exception:
|
||
return None
|
||
|
||
try:
|
||
reader = csv.reader(StringIO(payload))
|
||
first_row = next(reader, None)
|
||
if first_row is None:
|
||
raise ValueError(f"unexpected Stooq payload: {payload}")
|
||
|
||
normalized_first_row = [cell.strip() for cell in first_row]
|
||
header_tokens = {cell.lower() for cell in normalized_first_row if cell}
|
||
has_header = 'open' in header_tokens and 'close' in header_tokens
|
||
row = next(reader, None) if has_header else first_row
|
||
if row is None:
|
||
raise ValueError(f"unexpected Stooq payload: {payload}")
|
||
|
||
normalized_row = [cell.strip() for cell in row]
|
||
while normalized_row and normalized_row[-1] == '':
|
||
normalized_row.pop()
|
||
|
||
if len(normalized_row) >= 8:
|
||
open_index, high_index, low_index, price_index, volume_index = 3, 4, 5, 6, 7
|
||
elif len(normalized_row) >= 7:
|
||
open_index, high_index, low_index, price_index, volume_index = 2, 3, 4, 5, 6
|
||
else:
|
||
raise ValueError(f"unexpected Stooq payload: {payload}")
|
||
|
||
open_price = float(normalized_row[open_index])
|
||
high = float(normalized_row[high_index])
|
||
low = float(normalized_row[low_index])
|
||
price = float(normalized_row[price_index])
|
||
volume = int(float(normalized_row[volume_index]))
|
||
|
||
prev_close = _fetch_prev_close()
|
||
change_amount = None
|
||
change_pct = None
|
||
amplitude = None
|
||
if prev_close is not None and prev_close > 0:
|
||
change_amount = price - prev_close
|
||
change_pct = (change_amount / prev_close) * 100
|
||
amplitude = ((high - low) / prev_close) * 100
|
||
|
||
quote = UnifiedRealtimeQuote(
|
||
code=symbol,
|
||
name=STOCK_NAME_MAP.get(symbol, ''),
|
||
source=RealtimeSource.STOOQ,
|
||
price=price,
|
||
change_pct=round(change_pct, 2) if change_pct is not None else None,
|
||
change_amount=round(change_amount, 4) if change_amount is not None else None,
|
||
volume=volume,
|
||
amount=None,
|
||
volume_ratio=None,
|
||
turnover_rate=None,
|
||
amplitude=round(amplitude, 2) if amplitude is not None else None,
|
||
open_price=open_price,
|
||
high=high,
|
||
low=low,
|
||
pre_close=prev_close,
|
||
pe_ratio=None,
|
||
pb_ratio=None,
|
||
total_mv=None,
|
||
circ_mv=None,
|
||
)
|
||
logger.info(f"[Stooq] 获取美股 {symbol} 兜底行情成功: 价格={price}")
|
||
return quote
|
||
except Exception as exc:
|
||
logger.warning(f"[Stooq] 解析美股 {symbol} 行情失败: {exc}")
|
||
return None
|
||
|
||
def _get_us_index_realtime_quote(
|
||
self,
|
||
user_code: str,
|
||
yf_symbol: str,
|
||
index_name: str,
|
||
) -> Optional[UnifiedRealtimeQuote]:
|
||
"""
|
||
Get realtime quote for US index (e.g. SPX -> ^GSPC).
|
||
|
||
Args:
|
||
user_code: User input code (e.g. SPX)
|
||
yf_symbol: Yahoo Finance symbol (e.g. ^GSPC)
|
||
index_name: Chinese name for the index
|
||
|
||
Returns:
|
||
UnifiedRealtimeQuote or None
|
||
"""
|
||
import yfinance as yf
|
||
|
||
try:
|
||
logger.debug(f"[Yfinance] 获取美股指数 {user_code} ({yf_symbol}) 实时行情")
|
||
ticker = yf.Ticker(yf_symbol)
|
||
|
||
try:
|
||
info = ticker.fast_info
|
||
if info is None:
|
||
raise ValueError("fast_info is None")
|
||
price = getattr(info, 'lastPrice', None) or getattr(info, 'last_price', None)
|
||
prev_close = getattr(info, 'previousClose', None) or getattr(info, 'previous_close', None)
|
||
open_price = getattr(info, 'open', None)
|
||
high = getattr(info, 'dayHigh', None) or getattr(info, 'day_high', None)
|
||
low = getattr(info, 'dayLow', None) or getattr(info, 'day_low', None)
|
||
volume = getattr(info, 'lastVolume', None) or getattr(info, 'last_volume', None)
|
||
except Exception:
|
||
logger.debug("[Yfinance] fast_info 失败,尝试 history 方法")
|
||
hist = ticker.history(period='2d')
|
||
if hist.empty:
|
||
logger.warning(f"[Yfinance] 无法获取 {yf_symbol} 的数据")
|
||
return None
|
||
today = hist.iloc[-1]
|
||
prev = hist.iloc[-2] if len(hist) > 1 else today
|
||
price = float(today['Close'])
|
||
prev_close = float(prev['Close'])
|
||
open_price = float(today['Open'])
|
||
high = float(today['High'])
|
||
low = float(today['Low'])
|
||
volume = int(today['Volume'])
|
||
|
||
change_amount = None
|
||
change_pct = None
|
||
if price is not None and prev_close is not None and prev_close > 0:
|
||
change_amount = price - prev_close
|
||
change_pct = (change_amount / prev_close) * 100
|
||
|
||
amplitude = None
|
||
if high is not None and low is not None and prev_close is not None and prev_close > 0:
|
||
amplitude = ((high - low) / prev_close) * 100
|
||
|
||
try:
|
||
ticker_info = ticker.info or {}
|
||
except Exception:
|
||
ticker_info = {}
|
||
missing_fields = [
|
||
field
|
||
for field, value in {
|
||
"price": price,
|
||
"prev_close": prev_close,
|
||
"volume": volume,
|
||
"amount": None,
|
||
"pe_ratio": None,
|
||
"pb_ratio": None,
|
||
}.items()
|
||
if value is None
|
||
]
|
||
|
||
quote = UnifiedRealtimeQuote(
|
||
code=user_code,
|
||
name=index_name or user_code,
|
||
source=RealtimeSource.FALLBACK,
|
||
market="us",
|
||
currency=str(ticker_info.get("currency") or "").upper() or None,
|
||
data_quality="partial" if missing_fields else "ok",
|
||
missing_fields=missing_fields or None,
|
||
price=price,
|
||
change_pct=round(change_pct, 2) if change_pct is not None else None,
|
||
change_amount=round(change_amount, 4) if change_amount is not None else None,
|
||
volume=volume,
|
||
amount=None,
|
||
volume_ratio=None,
|
||
turnover_rate=None,
|
||
amplitude=round(amplitude, 2) if amplitude is not None else None,
|
||
open_price=open_price,
|
||
high=high,
|
||
low=low,
|
||
pre_close=prev_close,
|
||
pe_ratio=None,
|
||
pb_ratio=None,
|
||
total_mv=None,
|
||
circ_mv=None,
|
||
)
|
||
logger.info(f"[Yfinance] 获取美股指数 {user_code} 实时行情成功: 价格={price}")
|
||
return quote
|
||
except Exception as e:
|
||
logger.warning(f"[Yfinance] 获取美股指数 {user_code} 实时行情失败: {e}")
|
||
return None
|
||
|
||
def get_realtime_quote(self, stock_code: str) -> Optional[UnifiedRealtimeQuote]:
|
||
"""
|
||
获取美股/美股指数实时行情数据
|
||
|
||
支持美股股票(AAPL、TSLA)和美股指数(SPX、DJI 等)。
|
||
数据来源:yfinance Ticker.info
|
||
|
||
Args:
|
||
stock_code: 美股代码或指数代码,如 'AMD', 'AAPL', 'SPX', 'DJI'
|
||
|
||
Returns:
|
||
UnifiedRealtimeQuote 对象,获取失败返回 None
|
||
"""
|
||
import yfinance as yf
|
||
|
||
# 美股指数:使用映射(SPX -> ^GSPC)
|
||
yf_symbol, index_name = get_us_index_yf_symbol(stock_code)
|
||
if yf_symbol:
|
||
return self._get_us_index_realtime_quote(
|
||
user_code=stock_code.strip().upper(),
|
||
yf_symbol=yf_symbol,
|
||
index_name=index_name,
|
||
)
|
||
|
||
# 仅处理美股股票或 JP/KR/TW suffix-only 股票
|
||
if not (
|
||
self._is_us_stock(stock_code)
|
||
or self._is_jp_kr_suffix_stock(stock_code)
|
||
or self._is_tw_suffix_stock(stock_code)
|
||
):
|
||
logger.debug(f"[Yfinance] {stock_code} 不是美股或日韩 suffix 代码,跳过")
|
||
return None
|
||
|
||
try:
|
||
symbol = self._convert_stock_code(stock_code)
|
||
is_us_symbol = self._is_us_stock(symbol)
|
||
suffix_market = get_suffix_market(symbol)
|
||
logger.debug(f"[Yfinance] 获取 {symbol} 实时行情")
|
||
|
||
ticker = yf.Ticker(symbol)
|
||
|
||
# 尝试获取 fast_info(更快,但字段较少)
|
||
try:
|
||
info = ticker.fast_info
|
||
if info is None:
|
||
raise ValueError("fast_info is None")
|
||
|
||
price = getattr(info, 'lastPrice', None) or getattr(info, 'last_price', None)
|
||
prev_close = getattr(info, 'previousClose', None) or getattr(info, 'previous_close', None)
|
||
open_price = getattr(info, 'open', None)
|
||
high = getattr(info, 'dayHigh', None) or getattr(info, 'day_high', None)
|
||
low = getattr(info, 'dayLow', None) or getattr(info, 'day_low', None)
|
||
volume = getattr(info, 'lastVolume', None) or getattr(info, 'last_volume', None)
|
||
market_cap = getattr(info, 'marketCap', None) or getattr(info, 'market_cap', None)
|
||
|
||
except Exception:
|
||
# 回退到 history 方法获取最新数据
|
||
logger.debug("[Yfinance] fast_info 失败,尝试 history 方法")
|
||
hist = ticker.history(period='2d')
|
||
if hist.empty:
|
||
if is_us_symbol:
|
||
logger.warning(f"[Yfinance] 无法获取 {symbol} 的数据,尝试 Stooq 兜底")
|
||
return self._get_us_stock_quote_from_stooq(symbol)
|
||
logger.warning(f"[Yfinance] 无法获取 {symbol} 的数据")
|
||
return None
|
||
|
||
today = hist.iloc[-1]
|
||
prev = hist.iloc[-2] if len(hist) > 1 else today
|
||
|
||
price = float(today['Close'])
|
||
prev_close = float(prev['Close'])
|
||
open_price = float(today['Open'])
|
||
high = float(today['High'])
|
||
low = float(today['Low'])
|
||
volume = int(today['Volume'])
|
||
market_cap = None
|
||
|
||
# 计算涨跌幅
|
||
change_amount = None
|
||
change_pct = None
|
||
if price is not None and prev_close is not None and prev_close > 0:
|
||
change_amount = price - prev_close
|
||
change_pct = (change_amount / prev_close) * 100
|
||
|
||
# 计算振幅
|
||
amplitude = None
|
||
if high is not None and low is not None and prev_close is not None and prev_close > 0:
|
||
amplitude = ((high - low) / prev_close) * 100
|
||
|
||
# 获取股票名称与 provider 元数据
|
||
try:
|
||
ticker_info = ticker.info or {}
|
||
except Exception:
|
||
ticker_info = {}
|
||
try:
|
||
info_name = ticker_info.get('shortName', '') or ticker_info.get('longName', '') or ''
|
||
name = info_name if is_meaningful_stock_name(info_name, symbol) else STOCK_NAME_MAP.get(symbol, '')
|
||
except Exception:
|
||
name = STOCK_NAME_MAP.get(symbol, '')
|
||
|
||
# 复用上方已获取的 ticker_info,无额外请求
|
||
pe_ratio = _safe_float(ticker_info.get('trailingPE'))
|
||
pb_ratio = _safe_float(ticker_info.get('priceToBook'))
|
||
|
||
missing_fields = [
|
||
field
|
||
for field, value in {
|
||
"price": price,
|
||
"prev_close": prev_close,
|
||
"volume": volume,
|
||
"amount": None,
|
||
"pe_ratio": pe_ratio,
|
||
"pb_ratio": pb_ratio,
|
||
}.items()
|
||
if value is None
|
||
]
|
||
quote = UnifiedRealtimeQuote(
|
||
code=symbol,
|
||
name=name,
|
||
source=RealtimeSource.FALLBACK,
|
||
market=suffix_market or ("us" if is_us_symbol else None),
|
||
currency=str(ticker_info.get("currency") or "").upper() or None,
|
||
data_quality="partial" if missing_fields else "ok",
|
||
missing_fields=missing_fields or None,
|
||
price=price,
|
||
change_pct=round(change_pct, 2) if change_pct is not None else None,
|
||
change_amount=round(change_amount, 4) if change_amount is not None else None,
|
||
volume=volume,
|
||
amount=None, # yfinance 不直接提供成交额
|
||
volume_ratio=None,
|
||
turnover_rate=None,
|
||
amplitude=round(amplitude, 2) if amplitude is not None else None,
|
||
open_price=open_price,
|
||
high=high,
|
||
low=low,
|
||
pre_close=prev_close,
|
||
pe_ratio=pe_ratio,
|
||
pb_ratio=pb_ratio,
|
||
total_mv=market_cap,
|
||
circ_mv=None,
|
||
)
|
||
|
||
logger.info(f"[Yfinance] 获取 {symbol} 实时行情成功: 价格={price}")
|
||
return quote
|
||
|
||
except Exception as e:
|
||
if self._is_us_stock(stock_code):
|
||
logger.warning(f"[Yfinance] 获取美股 {stock_code} 实时行情失败: {e},尝试 Stooq 兜底")
|
||
return self._get_us_stock_quote_from_stooq(stock_code)
|
||
logger.warning(f"[Yfinance] 获取 {stock_code} 实时行情失败: {e}")
|
||
return None
|
||
|
||
|
||
if __name__ == "__main__":
|
||
# 测试代码
|
||
logging.basicConfig(level=logging.DEBUG)
|
||
|
||
fetcher = YfinanceFetcher()
|
||
|
||
try:
|
||
df = fetcher.get_daily_data('600519') # 茅台
|
||
print(f"获取成功,共 {len(df)} 条数据")
|
||
print(df.tail())
|
||
except Exception as e:
|
||
print(f"获取失败: {e}")
|