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 节记录本次核对结果与两只不一致项的判断依据
This commit is contained in:
2026-09-13 19:27:58 +08:00
parent f72a545c39
commit 131effcfae
2 changed files with 230 additions and 28 deletions
+73 -28
View File
@@ -3,7 +3,10 @@
> **状态**:草稿。**尚未写入知识库** —— 审核通过后再走
> `POST /api/v1/knowledge/documents` 入库。
>
> **数据来源**:`fin_product` 表导出(2026-09-13),共 20 只(ETF 13 + LOF 7)。
> **数据来源**:**净值取自东方财富真实行情** —— 走项目自带的
> `app/infrastructure/fund_market_adapter.py`(与下单链路、`MarketQuoteSyncService` 同源),
> 取数时间 2026-09-13;产品属性(代码/名称/交易所/风险等级/交易单位)取自 `fin_product` 表。
> 共 20 只(ETF 13 + LOF 7)。复现命令:`python tools/fetch_live_quotes.py`。
> **原则**:只写表里**确实有值**的字段;缺失的字段一律写"以交易页面为准",
> **不补编数字**(编费率是最容易出事的一类错误)。
>
@@ -64,28 +67,36 @@
## 二、产品清单(20 只,数据来自 `fin_product`)
| 代码 | 名称 | 交易所 | 类型 | 风险等级 | 最新净值 | 净值日期 |
|---|---|---|---|---|---|---|
| 159329 | 沙特ETF南方 | SZSE | ETF | R5 | 0.9167 | 2026-09-10 |
| 159382 | 创业板人工智能ETF南方 | SZSE | ETF | R4 | 2.5841 | 2026-09-11 |
| 159511 | 通信ETF南方 | SZSE | ETF | R4 | 2.3755 | 2026-09-11 |
| 159615 | 恒生生物科技ETF南方 | SZSE | ETF | R5 | 1.1027 | 2026-09-11 |
| 159687 | 亚太精选ETF南方 | SZSE | ETF | R5 | 1.8891 | 2026-09-11 |
| 159700 | 科创债ETF南方 | SZSE | ETF | R2 | 101.8009 | 2026-09-11 |
| 159948 | 创业板ETF南方 | SZSE | ETF | R4 | 3.6895 | 2026-09-11 |
| **510300** | **沪深300ETF** | **SSE** | **ETF** | **R3** | **4.5000** | 2026-09-11 |
| 510500 | 中证500ETF南方 | SSE | ETF | R4 | 7.6027 | 2026-09-11 |
| 511070 | 公司债ETF南方 | SSE | ETF | R2 | 103.0145 | 2026-09-11 |
| 511810 | 货币ETF南方 | SSE | ETF | R1 | 0.2661 | 2026-09-11 |
| 588890 | 科创芯片ETF南方 | SSE | ETF | R4 | 1.2327 | 2026-09-11 |
| 160105 | 南方积极配置混合(LOF) | SZSE | LOF | R3 | 1.2514 | 2026-09-11 |
| 160127 | 南方新兴消费增长股票(LOF)A | SZSE | LOF | R4 | 0.8364 | 2026-09-11 |
| 160128 | 南方金利定开债券A | SZSE | LOF | R2 | 1.0260 | 2026-09-11 |
| 160129 | 南方金利定开债券C | SZSE | LOF | R2 | 1.0240 | 2026-09-11 |
| 160142 | 南方优势产业(LOF) | SZSE | LOF | R3 | 1.0623 | 2026-09-11 |
| 160143 | 南方创业板2年定期开放混合 | SZSE | LOF | R3 | 1.6105 | 2026-09-11 |
| 501018 | 南方原油A | SSE | LOF | R5 | 2.0750 | 2026-09-11 |
| 501062 | 南方瑞合定开混合(LOF) | SSE | LOF | R3 | 2.1048 | 2026-09-11 |
| 代码 | 名称 | 交易所 | 类型 | 风险等级 | **真实净值** | 当日涨跌 | 净值日期 |
|---|---|---|---|---|---|---|---|
| 159329 | 沙特ETF南方 | SZSE | ETF | R5 | 0.9167 | -0.01% | 2026-09-10 |
| 159382 | 创业板人工智能ETF南方 | SZSE | ETF | R4 | 2.5841 | -0.49% | 2026-09-11 |
| 159511 | 通信ETF南方 | SZSE | ETF | R4 | 2.3755 | +0.61% | 2026-09-11 |
| 159615 | 恒生生物科技ETF南方 | SZSE | ETF | R5 | 1.1027 | -1.39% | 2026-09-11 |
| 159687 | 亚太精选ETF南方 | SZSE | ETF | R5 | 1.8891 | -0.38% | 2026-09-10 |
| 159700 | 科创债ETF南方 | SZSE | ETF | R2 | 101.8009 | +0.01% | 2026-09-11 |
| 159948 | 创业板ETF南方 | SZSE | ETF | R4 | 3.6895 | -0.49% | 2026-09-11 |
| **510300** | **沪深300ETF** | **SSE** | **ETF** | **R3** | **4.5794** | **-0.83%** | 2026-09-11 |
| 510500 | 中证500ETF南方 | SSE | ETF | R4 | 7.6027 | -1.79% | 2026-09-11 |
| 511070 | 公司债ETF南方 | SSE | ETF | R2 | 103.0145 | +0.01% | 2026-09-11 |
| 511810 | 货币ETF南方 | SSE | ETF | R1 | 0.2332 ⚠️ | 0.00% | 2026-09-13 |
| 588890 | 科创芯片ETF南方 | SSE | ETF | R4 | 1.2327 | -1.36% | 2026-09-11 |
| 160105 | 南方积极配置混合(LOF) | SZSE | LOF | R3 | 1.2514 | -1.34% | 2026-09-11 |
| 160127 | 南方新兴消费增长股票(LOF)A | SZSE | LOF | R4 | 0.8364 | -0.64% | 2026-09-11 |
| 160128 | 南方金利定开债券A | SZSE | LOF | R2 | 1.0260 | 0.00% | 2026-09-11 |
| 160129 | 南方金利定开债券C | SZSE | LOF | R2 | 1.0240 | 0.00% | 2026-09-11 |
| 160142 | 南方优势产业(LOF) | SZSE | LOF | R3 | 1.0623 | -2.09% | 2026-09-11 |
| 160143 | 南方创业板2年定期开放混合 | SZSE | LOF | R3 | 1.6105 | -1.14% | 2026-09-11 |
| 501018 | 南方原油A | SSE | LOF | R5 | 2.0750 | +3.86% | 2026-09-10 |
| 501062 | 南方瑞合定开混合(LOF) | SSE | LOF | R3 | 2.1048 | -0.49% | 2026-09-11 |
> **本表净值是真实数据**,取数时间 2026-09-13,来源为项目自带的东方财富适配器
> (`app/infrastructure/fund_market_adapter.py`:`push2.eastmoney.com` 行情 +
> `api.fund.eastmoney.com` 历史净值)。复现命令:`python tools/fetch_live_quotes.py`。
>
> **核对结果**:20 只全部取到真实数据;其中 **18 只与库里 `fin_product.current_nav` 完全一致**
> (库里存 6 位小数,如 `0.916700`,数据源给 4 位 `0.9167`,是同一笔数),
> **2 只不一致** —— `510300` 与 `511810`,见下方 3.1 与第四节。
问:平台上有哪些场内基金?产品代码是什么?
@@ -105,15 +116,21 @@
答:沪深300ETF 的产品代码为 **510300**,在**上交所(SSE)**挂牌,属于 ETF,
风险等级 **R3**。它按"手"交易,**1 手 = 100 份**,
按最新净值 4.5000 元估算,买入 1 手约需 **450 元**;
按最新净值 **4.5794 元**(2026-09-11)估算,买入 1 手约需 **458 元**;
产品库中登记的最小交易金额为 **100.00 元**。
该产品的管理费率为 **0.50%/年**、托管费率为 **0.10%/年**(从基金资产中计提,不单独向您收取)。
实际成交金额、可买数量与费用请以交易页面的实时报价为准。
> ⚠️ **待你确认(重要)**:这条里同时出现了"1 手约 450 元"和"最小交易金额 100.00 元",
> 两者不一致 —— 前者是按`lot_size=100 × 净值 4.5`推的,后者是 `fin_product.min_amount` 字段的原值。
> 请确认该以哪个为准,或者说明 `min_amount` 对场内产品只是历史字段、不参与场内校验。
> **在你确认之前,我建议回答里只保留"1 手 = 100 份、金额随市价变动",不写具体数字。**
> ⚠️ **两处待你确认**:
>
> 1. **净值口径**:这条用的是数据源真实值 **4.5794**,而库里 `fin_product.current_nav` 是
> **4.500000**,且快照时间是 **2026-09-13 08:19:37**(就是今天)—— 其余 19 只的快照
> 都停在 `2026-09-11 15:00:00`。**说明这条是被人为改过的演示值**(整数好算"450 元")。
> 知识条目该用真实值还是演示值,请你定;如果交易链路读的是库里那个值,
> 那么"知识说 4.5794、下单按 4.5 成交"会不一致。
> 2. **"1 手约 458 元"与"最小交易金额 100.00 元"仍不相等** —— 前者是 `lot_size=100 × 真实净值`,
> 后者是 `fin_product.min_amount` 原值。请确认该以哪个为准,或说明 `min_amount`
> 对场内产品不参与校验。**未确认前建议回答里只保留"1 手 = 100 份、金额随市价变动"。**
### 3.2 其他产品(简版模板)
@@ -141,6 +158,34 @@
| `risk_disclosure_required` 等三项 | 全部为 `0` | 与"严格风险等级匹配"的业务口径是否需要区分产品,待确认 |
| 产品"起投金额" | **表里没有这个字段** | 场内按手交易,本就没有固定起投金额;建议统一按 1.1 的口径回答 |
### 4.2 真实数据核对结果(2026-09-13)
用 `python tools/fetch_live_quotes.py` 拉东方财富真实行情,与库里 `fin_product.current_nav` 逐只比对:
| 结果 | 只数 | 说明 |
|---|---|---|
| 完全一致 | **18** | 库里存 6 位小数(`0.916700`)、数据源给 4 位(`0.9167`),是同一笔数 |
| 不一致 | **2** | 见下 |
**`510300 沪深300ETF`:库里 `4.500000` vs 真实 `4.5794`**
- 库里快照时间是 `2026-09-13 08:19:37`(就是今天),其余 19 只都停在 `2026-09-11 15:00:00`
- ⇒ 这一条是**被人为改过的演示值**(4.5 是整数,正好凑"1 手 = 450 元")
- 要决定的:知识条目用真实值还是演示值。**若交易链路按库里的 4.5 成交,
而知识说 4.5794,两处口径会打架** —— 这比"知识库有没有这条"更要紧
**`511810 货币ETF南方`:库里 `0.266100` vs 真实 `0.2332`**
- 这只的口径**本身可疑**:货币 ETF 通常以 **100 元/份**为面值(如 511990 华宝添益),
而 `0.2661` 和 `0.2332` 都不是这个量级
- 我第一次用 `QUOTE_API`(场内实时价)取它拿到的是 **100.012**,与 `NAV_API` 的 `0.2332`
**差三个数量级** —— 两个接口对这只的含义不同,**需要人工核实该用哪个口径**
- 建议:核实之前,知识条目**不要写这只的具体净值**
> 复现:`python tools/fetch_live_quotes.py`(默认表格对比,`--json` 输出结构化结果)。
> 该脚本只报告差异、**不写库** —— `fin_product.current_nav` 的刷新属于
> `ProductHistorySyncService` / `MarketQuoteSyncService` 的职责。
---
## 五、入库方式(审核通过后我执行)
+157
View File
@@ -0,0 +1,157 @@
"""拉取场内基金的真实行情并核对库里的快照。
## 数据源
走项目自带的 `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()))