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:
+73
-28
@@ -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` 的职责。
|
||||
|
||||
---
|
||||
|
||||
## 五、入库方式(审核通过后我执行)
|
||||
|
||||
@@ -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()))
|
||||
Reference in New Issue
Block a user