From 131effcfae8b15c82cf31af63b416f93e8028ace Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8D=BF=E4=BA=91=E7=A7=8B=E6=9C=88?= <15273589815@163.com> Date: Sun, 13 Sep 2026 19:27:58 +0800 Subject: [PATCH] =?UTF-8?q?feat(tools):=20=E6=96=B0=E5=A2=9E=20fetch=5Fliv?= =?UTF-8?q?e=5Fquotes=20=E6=8B=89=E7=9C=9F=E5=AE=9E=E8=A1=8C=E6=83=85?= =?UTF-8?q?=E5=B9=B6=E6=A0=B8=E5=AF=B9=E5=BA=93=E9=87=8C=E5=BF=AB=E7=85=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 项目本来就对接了东方财富真实行情(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 节记录本次核对结果与两只不一致项的判断依据 --- docs/42-场内基金知识条目草稿.md | 101 ++++++++++++++------ tools/fetch_live_quotes.py | 157 ++++++++++++++++++++++++++++++++ 2 files changed, 230 insertions(+), 28 deletions(-) create mode 100644 tools/fetch_live_quotes.py diff --git a/docs/42-场内基金知识条目草稿.md b/docs/42-场内基金知识条目草稿.md index 1745110..81a3912 100644 --- a/docs/42-场内基金知识条目草稿.md +++ b/docs/42-场内基金知识条目草稿.md @@ -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` 的职责。 + --- ## 五、入库方式(审核通过后我执行) diff --git a/tools/fetch_live_quotes.py b/tools/fetch_live_quotes.py new file mode 100644 index 0000000..28a80e0 --- /dev/null +++ b/tools/fetch_live_quotes.py @@ -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()))