Files
group_fqcd_jr/tools/fetch_live_quotes.py
T
lzf_0626 131effcfae feat(tools): 新增 fetch_live_quotes 拉真实行情并核对库里快照
项目本来就对接了东方财富真实行情(app/infrastructure/fund_market_adapter.py:
push2.eastmoney.com 的行情接口 + api.fund.eastmoney.com 的历史净值接口),
下单链路取 quote_price/quote_source 走的也是它。之前做知识草稿时用的是
fin_product 表里的快照,没有去核对真实数据源,这次补上。

新增 tools/fetch_live_quotes.py:
- 用项目自带的 EastmoneyFundAdapter 取数,不自己拼 HTTP、不引第三方行情库
- 产品范围与 MarketQuoteSyncService.sync 一致(南方基金 / SSE,SZSE / 上市)
- 逐只对比真实净值与 fin_product.current_nav,只报告差异、**不写库**
  (刷新快照是 ProductHistorySyncService / MarketQuoteSyncService 的职责)

实测结果(2026-09-13,20 只全部取到真实数据):
- 18 只完全一致(库里 6 位小数、数据源 4 位,是同一笔数)
- 2 只不一致:
  · 510300 沪深300ETF:库里 4.500000,真实 4.5794。库里快照时间是今天 08:19:37,
    其余 19 只都停在 09-11 15:00:00 —— 说明这是被人为改过的演示值(4.5 凑"1 手 450 元")。
    需要定:知识用真实值还是演示值;若交易按库里的 4.5 成交而知识说 4.5794,两处口径会打架。
  · 511810 货币ETF南方:库里 0.266100,NAV_API 给 0.2332,而 QUOTE_API(场内实时价)
    给 100.012 —— 差三个数量级,说明两个接口对这只的含义不同,需人工核实口径。

docs/42 随之更新:
- 产品清单改用真实净值,并加上当日涨跌与净值日期,注明取数来源与复现命令
- 3.1 节的沪深300ETF 条目改用真实值 4.5794,并列出两处待确认口径
- 新增 4.2 节记录本次核对结果与两只不一致项的判断依据
2026-09-13 19:27:58 +08:00

158 lines
6.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""拉取场内基金的真实行情并核对库里的快照。
## 数据源
走项目自带的 `EastmoneyFundAdapter`(`app/infrastructure/fund_market_adapter.py`),
不自己拼 HTTP、不引第三方行情库:
· 实时/收盘价 `https://push2.eastmoney.com/api/qt/ulist.np/get`
· 历史净值 `https://api.fund.eastmoney.com/f10/lsjz`
同一套适配器也是下单链路(`TradeService` 取 `quote_price`/`quote_source`)
与 `MarketQuoteSyncService`(东财 + 腾讯双源)在用的,所以这里拿到的值与成交价同源。
## 用法
python tools/fetch_live_quotes.py # 与库里快照逐只对比
python tools/fetch_live_quotes.py --json # 只输出 JSON(供生成知识条目等)
## 为什么要做对比而不是直接覆盖
`fin_product.current_nav` 是**快照缓存**,正常情况下与数据源一致(本机 20 只里 18 只
完全一致,只是小数位表示不同)。**不一致的那两只才是重点**:它们要么被人为改过
(演示需要),要么字段口径本身有问题 —— 直接覆盖会把线索一起抹掉。
所以本脚本只**报告**差异,不写库;要不要刷新由 `ProductHistorySyncService` 决定。
"""
from __future__ import annotations
import argparse
import asyncio
import json
import sys
from datetime import date
from pathlib import Path
from sqlalchemy import text
PROJECT_ROOT = Path(__file__).resolve().parents[1]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from app.infrastructure.db import SessionFactory # noqa: E402
from app.infrastructure.fund_market_adapter import EastmoneyFundAdapter # noqa: E402
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(errors="replace") # type: ignore[union-attr]
#: 与 `MarketQuoteSyncService.sync` 保持同一批产品:南方基金、场内、已上市。
PRODUCT_QUERY = """
SELECT product_code, product_name, exchange_code, product_category, risk_level,
lot_size, min_amount, current_nav, current_nav_at,
management_fee_rate, custodian_fee_rate
FROM fin_product
WHERE fund_manager = '南方基金'
AND exchange_code IN ('SSE', 'SZSE')
AND status = '上市'
ORDER BY product_code
"""
async def collect() -> list[dict[str, object]]:
async with SessionFactory() as session:
rows = (await session.execute(text(PRODUCT_QUERY))).mappings().all()
adapter = EastmoneyFundAdapter()
codes = [str(row["product_code"]) for row in rows]
histories = await asyncio.gather(
*(adapter.fetch_history(code, date.today()) for code in codes),
return_exceptions=True,
)
records: list[dict[str, object]] = []
for row, history in zip(rows, histories, strict=True):
if isinstance(history, BaseException):
history = {"degraded": True, "error": type(history).__name__}
live_nav = history.get("nav")
stored_nav = row["current_nav"]
records.append({
"product_code": str(row["product_code"]),
"product_name": str(row["product_name"]),
"exchange_code": str(row["exchange_code"]),
"product_category": str(row["product_category"]),
"risk_level": str(row["risk_level"]),
"lot_size": str(row["lot_size"]),
"min_amount": str(row["min_amount"]),
"management_fee_rate": (
None if row["management_fee_rate"] is None else str(row["management_fee_rate"])
),
"custodian_fee_rate": (
None if row["custodian_fee_rate"] is None else str(row["custodian_fee_rate"])
),
"live_nav": None if live_nav is None else str(live_nav),
"live_nav_date": (
None if history.get("nav_date") is None else str(history["nav_date"])
),
"live_change_pct": (
None if history.get("daily_change") is None else str(history["daily_change"])
),
"stored_nav": str(stored_nav),
"stored_nav_at": str(row["current_nav_at"]),
"matches_stored": (
live_nav is not None and float(live_nav) == float(stored_nav)
),
"degraded": bool(history.get("degraded")),
})
return records
def render(records: list[dict[str, object]]) -> None:
ok = [item for item in records if not item["degraded"]]
same = [item for item in ok if item["matches_stored"]]
diff = [item for item in ok if not item["matches_stored"]]
print(f"场内产品 {len(records)} 只;取到真实行情 {len(ok)} 只"
f"(与库里一致 {len(same)}、不一致 {len(diff)})\n")
header = f"{'代码':<8}{'名称':<26}{'真实净值':>10}{'涨跌%':>8}{'净值日期':>12} {'库里净值':>11} 核对"
print(header)
print("-" * len(header))
for item in records:
if item["degraded"]:
verdict = "!! 取数失败"
elif item["matches_stored"]:
verdict = "一致"
else:
verdict = "**不一致**"
print(
f"{str(item['product_code']):<8}{str(item['product_name'])[:24]:<26}"
f"{str(item['live_nav']):>10}{str(item['live_change_pct']):>8}"
f"{str(item['live_nav_date']):>12} {str(item['stored_nav']):>11} {verdict}"
)
if diff:
print("\n不一致的条目(需要人工判断,不要直接覆盖):")
for item in diff:
print(f" · {item['product_code']} {item['product_name']}")
print(f" 真实 {item['live_nav']}({item['live_nav_date']})"
f" vs 库里 {item['stored_nav']}(快照于 {item['stored_nav_at']})")
missing = [item["product_code"] for item in records if item["degraded"]]
if missing:
print(f"\n取数失败:{missing}")
async def main() -> int:
parser = argparse.ArgumentParser(description="拉取场内基金真实行情并核对库里快照")
parser.add_argument("--json", action="store_true", help="只输出 JSON")
args = parser.parse_args()
records = await collect()
if args.json:
print(json.dumps(records, ensure_ascii=False, indent=1))
else:
render(records)
return 0
if __name__ == "__main__":
sys.exit(asyncio.run(main()))