feat(data): 510300 回填真实值并在产品页披露;511810 口径查清
按项目方决定处理三件事。 ## 510300(沪深300ETF) 决定:保留 fund_manager = "南方基金" 作演示口径,**不**改成"华泰柏瑞" (改了它会退出 MarketQuoteSyncService 的同步范围,投顾与知识条目也不该再算自家产品), 但净值与费率改成真实值,并在产品页显式披露它不是本公司产品。 - 回填(tools/backfill_product_snapshot.py --codes 510300 --apply --overwrite): current_nav 4.500000 → 4.5794、current_nav_at 同步为 2026-09-11 15:00:00、 management_fee_rate 0.5000 → 0.15、custodian_fee_rate 0.1000 → 0.05 - 产品页披露:mock-data.js 给这只加 product_note,详情页新增提示块渲染它 ("本产品为同指数参考产品,非本公司发行的基金,仅用于功能演示")。 同时把 mock 里这只的净值/费率也对齐真实值,避免出现"页面显示 mock 值、 库里是真实值"两套数字。 - 产品详情页的 css/js 版本参数提到 20260913-5,避免旧缓存。 ## 511810(货币ETF南方) 之前说它"三个数量级不一致"是**我比错了接口**:QUOTE_API 返的是**场内交易价格** (货币 ETF 约 100 元/份),而 current_nav 的口径是 DWJZ 单位净值。 查证结果:库里 0.2661 正是 09-11 的真实 DWJZ,快照时间也对得上 —— 不是脏数据,是旧值。 口径由其余 19 只的一致性确定(current_nav == DWJZ)。 要不要刷新到最新(0.2332)属运维节奏问题,**本次未执行**。 ## 最小交易金额 按项目方口径:一律**以产品字段为准**(100.00 元),不用「1 手 × 净值」推算金额 —— 后者会与字段值不一致,客户会看到两套数字。docs/42 的 1.1 节据此重写。 ## 工具改名 tools/backfill_product_fees.py → tools/backfill_product_snapshot.py:扩展为 净值 + 费率统一入口,新增 --codes 限定范围(避免顺手把全部快照刷成最新, 全量刷新是 ProductHistorySyncService / MarketQuoteSyncService 的职责)。 安全设计不变:默认 dry-run、默认只补 NULL(净值永不为空,所以刷净值需显式 --overwrite)、写入走单事务。 门禁:ruff 通过 / mypy 249 文件 0 错 / 单元+契约 1377 passed 2 skipped。
This commit is contained in:
@@ -15,6 +15,8 @@ const PRODUCT_BASE = {
|
||||
single_investor_max_holding_ratio: '10.0000',
|
||||
management_fee_rate: '0.5000',
|
||||
custodian_fee_rate: '0.1000',
|
||||
//: 产品级说明,默认无。只有需要额外披露的条目才填,例如「同指数参考产品」。
|
||||
product_note: null,
|
||||
risk_disclosure_required: 1,
|
||||
second_confirmation_required: 0,
|
||||
recording_required: 0,
|
||||
@@ -24,7 +26,15 @@ const PRODUCT_BASE = {
|
||||
};
|
||||
|
||||
export const MOCK_PRODUCTS = Object.freeze([
|
||||
{ ...PRODUCT_BASE, id: 1, product_code: '510300', product_name: '南方沪深300ETF', current_nav: '4.218000', current_nav_at: '2026-09-11T15:00:00', product_category: '宽基ETF', risk_level: 'R3' },
|
||||
{
|
||||
...PRODUCT_BASE, id: 1, product_code: '510300', product_name: '南方沪深300ETF',
|
||||
current_nav: '4.579400', current_nav_at: '2026-09-11T15:00:00',
|
||||
product_category: '宽基ETF', risk_level: 'R3',
|
||||
management_fee_rate: '0.1500', custodian_fee_rate: '0.0500',
|
||||
// 这只的代码 510300 在真实市场上对应的是**另一家管理人**的沪深300ETF,
|
||||
// 所以必须显式披露:否则客户会以为它是本公司发行的产品。
|
||||
product_note: '本产品为同指数参考产品,非本公司发行的基金,仅用于功能演示;净值与费率取自公开数据源。',
|
||||
},
|
||||
{ ...PRODUCT_BASE, id: 2, product_code: '510500', product_name: '南方中证500ETF', current_nav: '6.742000', current_nav_at: '2026-09-11T15:00:00', product_category: '宽基ETF', risk_level: 'R4' },
|
||||
{ ...PRODUCT_BASE, id: 3, product_code: '159915', product_name: '南方创业板ETF', current_nav: '2.184000', current_nav_at: '2026-09-11T15:00:00', product_category: '成长ETF', risk_level: 'R4', exchange_code: 'SZSE' },
|
||||
{ ...PRODUCT_BASE, id: 4, product_code: '512100', product_name: '南方中证1000ETF', current_nav: '2.367000', current_nav_at: '2026-09-11T15:00:00', product_category: '宽基ETF', risk_level: 'R4' },
|
||||
|
||||
@@ -5,18 +5,19 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>基金详情 · 南方财富</title>
|
||||
<link rel="stylesheet" href="/static/portal/common/base.css">
|
||||
<link rel="stylesheet" href="/static/portal/guest/product-detail/product-detail.css?v=20260913-4">
|
||||
<link rel="stylesheet" href="/static/portal/guest/product-detail/product-detail.css?v=20260913-5">
|
||||
</head>
|
||||
<body>
|
||||
<main id="main-content" class="page-shell" data-detail-root>
|
||||
<div class="page-heading"><div><a class="detail-back" href="/portal/guest/products/">返回基金列表</a><h1 class="page-heading__title" data-product-name>基金详情</h1><p class="page-heading__description" data-product-meta></p></div><div class="page-heading__actions"><a class="button" href="/portal/customer/login/">登录后交易</a></div></div>
|
||||
<div class="source-notice" role="note" data-source-notice></div>
|
||||
<div class="product-note" role="note" data-product-note hidden></div>
|
||||
<section class="detail-grid">
|
||||
<article class="panel detail-chart"><div class="panel__header"><h2 class="panel__title">历史净值</h2><span class="section-heading__meta">近十二期</span></div><div class="panel__body"><div class="detail-chart__quote"><strong data-current-nav>--</strong><span data-change>--</span></div><svg class="detail-chart__svg" viewBox="0 0 760 280" role="img" aria-label="基金历史净值走势" data-chart></svg><div class="detail-chart__axis"><span data-start-date></span><span data-end-date></span></div></div></article>
|
||||
<aside class="panel"><div class="panel__header"><h2 class="panel__title">产品要素</h2></div><div class="panel__body detail-facts" data-product-facts></div></aside>
|
||||
</section>
|
||||
<section class="panel risk-disclosure"><div class="panel__header"><h2 class="panel__title">风险与交易说明</h2></div><div class="panel__body"><div class="risk-disclosure__grid"><div><strong>风险等级</strong><span data-risk-level></span></div><div><strong>最小金额</strong><span data-min-amount></span></div><div><strong>交易方式</strong><span>场内市价模拟成交</span></div></div><p>基金净值会随市场变化。历史数据不代表未来表现,交易前应结合自身风险承受能力判断。</p></div></section>
|
||||
</main>
|
||||
<script type="module" src="/static/portal/guest/product-detail/product-detail.js?v=20260913-4"></script>
|
||||
<script type="module" src="/static/portal/guest/product-detail/product-detail.js?v=20260913-5"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
.detail-back { display: inline-block; margin-bottom: var(--space-3); color: var(--brand-dark); font-size: var(--fs-small); }
|
||||
.source-notice { margin-bottom: var(--space-4); padding: var(--space-3) var(--space-4); color: var(--ink-soft); background: var(--accent-soft); border-left: 3px solid var(--accent); font-size: var(--fs-small); line-height: 1.6; }
|
||||
.product-note { margin-bottom: var(--space-4); padding: var(--space-3) var(--space-4); color: var(--brand-dark); background: var(--surface); border: 1px solid var(--line); border-left: 3px solid var(--brand); border-radius: var(--radius-sm); font-size: var(--fs-small); line-height: 1.6; }
|
||||
.detail-grid { display: grid; grid-template-columns: minmax(0, 2fr) minmax(280px, 1fr); gap: var(--space-4); }
|
||||
.detail-chart__quote { display: flex; align-items: baseline; gap: var(--space-3); }
|
||||
.detail-chart__quote strong { font-size: var(--fs-metric); }
|
||||
|
||||
@@ -13,6 +13,12 @@ document.querySelector('[data-product-name]').textContent = product.product_name
|
||||
document.querySelector('[data-product-meta]').textContent = `${product.product_code} · ${product.exchange_code} · ${product.product_category}`;
|
||||
// 详情页含历史净值曲线,全部来自 common/mock-data.js(公开产品 HTTP 接口尚未实现)。
|
||||
document.querySelector('[data-source-notice]').textContent = MOCK_SOURCE_NOTICE;
|
||||
// 产品级披露:只有带 product_note 的条目才显示(如"同指数参考产品")。
|
||||
const noteNode = document.querySelector('[data-product-note]');
|
||||
if (product.product_note) {
|
||||
noteNode.textContent = product.product_note;
|
||||
noteNode.hidden = false;
|
||||
}
|
||||
document.querySelector('[data-current-nav]').textContent = product.current_nav;
|
||||
const changeNode = document.querySelector('[data-change]');
|
||||
changeNode.textContent = formatPercent(change);
|
||||
|
||||
+45
-29
@@ -25,9 +25,9 @@
|
||||
问:场内基金怎么买?最小买多少?
|
||||
|
||||
答:本平台的场内基金按"**手**"撮合,**1 手 = 100 份**,委托数量必须是 100 份的整数倍。
|
||||
最小买入金额 = 成交价 × 100 份,因此**随市价变动**,没有固定门槛。
|
||||
按当前净值估算,价格 1 元的基金买 1 手约需 100 元,价格 4.5 元的基金买 1 手约需 450 元。
|
||||
实际所需金额与可买数量请以交易页面的实时报价为准。
|
||||
最小交易金额以**产品详情页披露的字段为准**(本平台产品库中登记为 **100.00 元**);
|
||||
实际所需金额按成交价 × 份数计算,**随市价变动**。
|
||||
实际可买数量与所需金额请以交易页面的实时报价为准。
|
||||
|
||||
### 1.2 场内基金的费用有哪些
|
||||
|
||||
@@ -68,8 +68,8 @@
|
||||
场外基金按当日净值申赎,通常 T+1 确认。本平台的模拟交易针对**场内**基金,
|
||||
下单即按市价全额成交;场外基金的业务流程与本模块相互独立。
|
||||
|
||||
> ⚠️ **待你确认**:上面 1.1 里"按净值估算约需金额"的算法(净值 × 100 份)是否需要写进知识?
|
||||
> 也可以只保留"1 手 = 100 份、金额随市价变动"这种不含算例的表述。
|
||||
> **口径已定**(项目方 2026-09-13):「最小交易金额」一律**以产品字段为准**,
|
||||
> 不用「1 手 × 净值」推算金额 —— 后者会与字段值不一致,客户会看到两套数字。
|
||||
|
||||
---
|
||||
|
||||
@@ -129,30 +129,29 @@
|
||||
该产品的管理费率为 **0.15%/年**、托管费率为 **0.05%/年**(从基金资产中计提,不单独向您收取)。
|
||||
实际成交金额、可买数量与费用请以交易页面的实时报价为准。
|
||||
|
||||
> 🔴 **这只不是南方基金的产品**。数据源返回的基金全称是
|
||||
> 「**华泰柏瑞**沪深300交易型开放式指数证券投资基金」,而库里 `fund_manager` 写的是
|
||||
> **「南方基金」**。其余 19 只的全称都带"南方"前缀,只有它例外。
|
||||
> ✅ **已按项目方决定处理**(2026-09-13):保留 `fund_manager = "南方基金"` 作为演示口径
|
||||
> —— **不**改成"华泰柏瑞",因为改了它就会退出 `MarketQuoteSyncService` 的同步范围,
|
||||
> 投顾与知识条目也不该再把它算作自家产品;改为**在产品页显式披露**:
|
||||
> `common/mock-data.js` 给这只加了 `product_note`,详情页会显示
|
||||
> 「本产品为同指数参考产品,非本公司发行的基金,仅用于功能演示」。
|
||||
>
|
||||
> 这解释了为什么**唯独它**的数据处处特殊:
|
||||
> 同时**净值与费率已回填为真实值**:
|
||||
>
|
||||
> | 字段 | 库里 | 真实(东方财富) |
|
||||
> ```
|
||||
> python tools/backfill_product_snapshot.py --codes 510300 --apply --overwrite
|
||||
> ```
|
||||
>
|
||||
> | 字段 | 原值 | 现值(真实) |
|
||||
> |---|---|---|
|
||||
> | `fund_manager` | 南方基金 | **华泰柏瑞** |
|
||||
> | `current_nav` | 4.500000(快照时间**当天**) | 4.5794 |
|
||||
> | `current_nav` | 4.500000 | **4.5794** |
|
||||
> | `current_nav_at` | 2026-09-13 08:19:37 | **2026-09-11 15:00:00** |
|
||||
> | `management_fee_rate` | 0.5000 | **0.15** |
|
||||
> | `custodian_fee_rate` | 0.1000 | **0.05** |
|
||||
>
|
||||
> 而且 `0.50/0.10` 恰好是另外几只 ETF(`159329`/`159382`/`159511`/`588890`)的真实费率,
|
||||
> 看起来是**照抄过来的值**。
|
||||
>
|
||||
> 影响面比"知识库答不准"更大:`MarketQuoteSyncService` 按 `fund_manager = '南方基金'`
|
||||
> 筛选同步对象,所以这只**会被当成南方产品一起同步、一起显示**。
|
||||
> **请你决定**:是把它的 `fund_manager` 改回"华泰柏瑞"(那它就会退出同步范围、
|
||||
> 投顾与知识条目也不该再把它算作自家产品),还是保留现状作为演示数据 ——
|
||||
> 若保留,建议在演示口径里说清楚"这只是同指数产品、不是本公司产品"。
|
||||
>
|
||||
> ⚠️ 另外两点仍需你定:**(a)**净值该用真实值 4.5794 还是库里的演示值 4.500000;
|
||||
> **(b)**"1 手约 458 元"与"最小交易金额 100.00 元"仍不相等,请确认以哪个为准。
|
||||
> 上面词条里的数字已按真实值书写。**保留一条披露备查**:这只的真实全称是
|
||||
> 「华泰柏瑞沪深300交易型开放式指数证券投资基金」,其余 19 只全称都以"南方"开头;
|
||||
> 且 `0.50/0.10` 恰好是另外四只 ETF(`159329`/`159382`/`159511`/`588890`)的真实费率,
|
||||
> 原先那组值像是照抄来的。
|
||||
|
||||
### 3.2 其他产品(简版模板)
|
||||
|
||||
@@ -196,13 +195,30 @@
|
||||
- 要决定的:知识条目用真实值还是演示值。**若交易链路按库里的 4.5 成交,
|
||||
而知识说 4.5794,两处口径会打架** —— 这比"知识库有没有这条"更要紧
|
||||
|
||||
**`511810 货币ETF南方`:库里 `0.266100` vs 真实 `0.2332`**
|
||||
**`511810 货币ETF南方`:不是脏数据,是数据已更新 —— 而且我一开始比错了接口**
|
||||
|
||||
- 这只的口径**本身可疑**:货币 ETF 通常以 **100 元/份**为面值(如 511990 华宝添益),
|
||||
而 `0.2661` 和 `0.2332` 都不是这个量级
|
||||
- 我第一次用 `QUOTE_API`(场内实时价)取它拿到的是 **100.012**,与 `NAV_API` 的 `0.2332`
|
||||
**差三个数量级** —— 两个接口对这只的含义不同,**需要人工核实该用哪个口径**
|
||||
- 建议:核实之前,知识条目**不要写这只的具体净值**
|
||||
查证结果(2026-09-13):
|
||||
|
||||
| 日期 | `DWJZ`(单位净值) | `LJJZ`(累计净值) |
|
||||
|---|---|---|
|
||||
| 2026-09-13 | 0.2332 | 0.8740 |
|
||||
| 2026-09-11 | **0.2661** | 0.8740 |
|
||||
|
||||
- 库里存的 `0.266100` **正是 09-11 的真实 `DWJZ`**,快照时间 `2026-09-11 15:00:00` 也对得上
|
||||
⇒ **它不是脏数据,只是旧值**
|
||||
- 我先前说的"差三个数量级"是**我自己用错了接口**:`QUOTE_API` 返的是**场内交易价格**
|
||||
(货币 ETF 约 100 元/份,所以 100.012),而 `current_nav` 的口径是 **`DWJZ` 单位净值**。
|
||||
两者不是一回事,不能混比
|
||||
- **口径由既有数据确定**:其余 19 只的 `current_nav` 与 `fetch_history` 的 `DWJZ` 逐只一致
|
||||
|
||||
⇒ 这只若要刷新,按 `DWJZ` 口径刷成 **0.2332** 即可:
|
||||
|
||||
```
|
||||
python tools/backfill_product_snapshot.py --codes 511810 --apply --overwrite
|
||||
```
|
||||
|
||||
**本次未执行**,等你确认 —— 因为 0.2661 本身也是真实值(只是旧一天),
|
||||
刷新属于"要不要把快照推进到最新",是运维节奏问题,不是修错。
|
||||
|
||||
> 复现:`python tools/fetch_live_quotes.py`(默认表格对比,`--json` 输出结构化结果,
|
||||
> `--no-fees` 可跳过费率抓取)。该脚本只报告差异、**不写库** —— `fin_product.current_nav`
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
"""把真实费率回填到 `fin_product`(默认 dry-run,只有 `--apply` 才写库)。
|
||||
|
||||
## 为什么需要它
|
||||
|
||||
`fin_product.management_fee_rate` / `custodian_fee_rate` 在本机 **20 只里 19 只为空**,
|
||||
客户问"管理费多少"只能转人工。费率可以从东方财富 f10 基金概况页取到
|
||||
(`EastmoneyFundAdapter.fetch_fees`),本脚本负责把它落到库里。
|
||||
|
||||
## 安全设计
|
||||
|
||||
1. **默认 dry-run**:只打印将执行的 UPDATE,不写库。要真正写入必须显式给 `--apply`。
|
||||
2. **默认只补空值**:`NULL` 才写,**已有值一律不动** —— 因为已有值可能是人为设定
|
||||
(本机 `510300` 就是:库里 0.5000/0.1000,真实 0.15/0.05)。要覆盖必须再给 `--overwrite`。
|
||||
3. **`--overwrite` 也只改这一只这一类**:它不会顺手把别的东西一起改掉。
|
||||
4. 写入走单个事务,任一失败整体回滚。
|
||||
|
||||
## 用法
|
||||
|
||||
python tools/backfill_product_fees.py # 看将要改什么
|
||||
python tools/backfill_product_fees.py --apply # 只补空值
|
||||
python tools/backfill_product_fees.py --apply --overwrite # 连已有值一起改成真实值
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import sys
|
||||
from datetime import UTC, datetime
|
||||
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 id, product_code, product_name, management_fee_rate, custodian_fee_rate
|
||||
FROM fin_product
|
||||
WHERE fund_manager = '南方基金'
|
||||
AND exchange_code IN ('SSE', 'SZSE')
|
||||
AND status = '上市'
|
||||
ORDER BY product_code
|
||||
"""
|
||||
|
||||
#: 库字段 ↔ 数据源字段。
|
||||
FEE_FIELDS = (
|
||||
("management_fee_rate", "management_fee_rate"),
|
||||
("custodian_fee_rate", "custodian_fee_rate"),
|
||||
)
|
||||
|
||||
|
||||
def _same(left: object, right: object) -> bool:
|
||||
if left is None or right is None:
|
||||
return False
|
||||
try:
|
||||
return abs(float(str(left)) - float(str(right))) < 1e-9
|
||||
except (TypeError, ValueError):
|
||||
return str(left) == str(right)
|
||||
|
||||
|
||||
async def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="把真实费率回填到 fin_product")
|
||||
parser.add_argument("--apply", action="store_true", help="真正写库(默认只看不改)")
|
||||
parser.add_argument(
|
||||
"--overwrite", action="store_true",
|
||||
help="连已有值也改(默认只补 NULL,不动人为设定过的值)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
async with SessionFactory() as session:
|
||||
rows = (await session.execute(text(PRODUCT_QUERY))).mappings().all()
|
||||
|
||||
adapter = EastmoneyFundAdapter()
|
||||
fees = await adapter.fetch_fees([str(row["product_code"]) for row in rows])
|
||||
print(f"产品 {len(rows)} 只;取到真实费率 {len(fees)} 只\n")
|
||||
|
||||
updates: list[tuple[int, str, str, object]] = []
|
||||
conflicts: list[str] = []
|
||||
for row in rows:
|
||||
code = str(row["product_code"])
|
||||
live = fees.get(code)
|
||||
if not live:
|
||||
continue
|
||||
for column, key in FEE_FIELDS:
|
||||
live_value = live.get(key)
|
||||
if live_value is None:
|
||||
continue
|
||||
stored = row[column]
|
||||
if stored is not None and not _same(stored, live_value):
|
||||
conflicts.append(
|
||||
f"{code} {row['product_name']} 的 {column}:库里 {stored} → 真实 {live_value}"
|
||||
)
|
||||
if not args.overwrite:
|
||||
continue
|
||||
if stored is not None and not args.overwrite:
|
||||
continue
|
||||
if _same(stored, live_value):
|
||||
continue
|
||||
updates.append((int(row["id"]), code, column, live_value))
|
||||
|
||||
if conflicts:
|
||||
print("与库里已有值冲突(默认不动,需要 --overwrite 才覆盖):")
|
||||
for line in conflicts:
|
||||
print(f" * {line}")
|
||||
print()
|
||||
|
||||
if not updates:
|
||||
print("没有需要回填的字段。")
|
||||
return 0
|
||||
|
||||
print(f"将执行 {len(updates)} 条 UPDATE:")
|
||||
for _, code, column, value in updates:
|
||||
print(f" · {code} {column} = {value}")
|
||||
|
||||
if not args.apply:
|
||||
print("\n[dry-run] 未写库。加 --apply 执行。")
|
||||
return 0
|
||||
|
||||
now = datetime.now(UTC).replace(tzinfo=None)
|
||||
async with SessionFactory() as session, session.begin():
|
||||
for product_id, _, column, value in updates:
|
||||
# 列名来自本模块常量,不是外部输入;值走参数绑定。
|
||||
await session.execute(
|
||||
text(
|
||||
f"UPDATE fin_product SET {column} = :value, updated_at = :now "
|
||||
"WHERE id = :product_id"
|
||||
),
|
||||
{"value": value, "now": now, "product_id": product_id},
|
||||
)
|
||||
print(f"\n已写入 {len(updates)} 个字段。")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(asyncio.run(main()))
|
||||
@@ -0,0 +1,192 @@
|
||||
"""把真实行情快照(净值 + 费率)回填到 `fin_product`(默认 dry-run,只有 `--apply` 才写库)。
|
||||
|
||||
## 为什么需要它
|
||||
|
||||
`fin_product` 里两个费率字段在本机 **20 只里 19 只为空**,客户问"管理费多少"只能转人工;
|
||||
`current_nav` 则有几只被人为改成演示值。这些都能从东方财富取到
|
||||
(`EastmoneyFundAdapter.fetch_history` / `fetch_fees`),本脚本负责落到库里。
|
||||
|
||||
## 口径
|
||||
|
||||
- **净值取 `DWJZ`(单位净值)**,不是场内交易价。这条口径由既有数据确定:
|
||||
库里 19 只的 `current_nav` 与 `fetch_history` 的 `DWJZ` 逐只一致。
|
||||
货币 ETF 是唯一容易搞混的 —— 它 `QUOTE_API` 的 `f2` 约 100 元(场内价),
|
||||
而 `DWJZ` 是 0.2332 这种量级,**两者不是一回事,不要混用**。
|
||||
- 费率取 f10 基金概况页(管理费/托管费),单位是「%/年」。
|
||||
|
||||
## 安全设计
|
||||
|
||||
1. **默认 dry-run**:只打印将执行的 UPDATE,不写库。真写要显式 `--apply`。
|
||||
2. **默认只补空值**:`NULL` 才写,**已有值一律不动** —— 已有值可能是人为设定。
|
||||
净值字段**从来不为空**,所以刷新净值必须显式给 `--overwrite`。
|
||||
3. **`--codes` 限定范围**:只回填指定产品,避免"顺手"把全部快照刷成最新
|
||||
(全量刷新是 `ProductHistorySyncService` / `MarketQuoteSyncService` 的职责)。
|
||||
4. 写入走单个事务,任一失败整体回滚。
|
||||
|
||||
## 用法
|
||||
|
||||
python tools/backfill_product_snapshot.py # 看将改什么
|
||||
python tools/backfill_product_snapshot.py --apply # 只补空值(费率)
|
||||
python tools/backfill_product_snapshot.py --apply --overwrite --codes 510300
|
||||
# 连净值和已有费率一起改
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import sys
|
||||
from datetime import UTC, date, datetime, time
|
||||
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 id, product_code, product_name, 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
|
||||
"""
|
||||
|
||||
#: 库里已有值 → 数据源字段。**净值排在最前**,便于阅读。
|
||||
FEE_FIELDS = (
|
||||
("management_fee_rate", "management_fee_rate"),
|
||||
("custodian_fee_rate", "custodian_fee_rate"),
|
||||
)
|
||||
|
||||
#: 净值快照写回时的时间部分:与库里既有记录的 `15:00:00` 保持一致(收盘时刻)。
|
||||
NAV_AT_TIME = time(15, 0, 0)
|
||||
|
||||
|
||||
def _same(left: object, right: object) -> bool:
|
||||
"""比较两个数值文本,容忍 `0.5` 与 `0.5000`、`4.5` 与 `4.500000` 这种表示差异。"""
|
||||
if left is None or right is None:
|
||||
return False
|
||||
try:
|
||||
return abs(float(str(left)) - float(str(right))) < 1e-9
|
||||
except (TypeError, ValueError):
|
||||
return str(left) == str(right)
|
||||
|
||||
|
||||
async def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="把真实净值与费率回填到 fin_product")
|
||||
parser.add_argument("--apply", action="store_true", help="真正写库(默认只看不改)")
|
||||
parser.add_argument(
|
||||
"--overwrite", action="store_true",
|
||||
help="连已有值也改(默认只补 NULL,不动人为设定过的值)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--codes", default="",
|
||||
help="只处理这些产品代码(逗号分隔);留空表示全部",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
wanted = {code.strip() for code in args.codes.split(",") if code.strip()}
|
||||
|
||||
async with SessionFactory() as session:
|
||||
rows = (await session.execute(text(PRODUCT_QUERY))).mappings().all()
|
||||
if wanted:
|
||||
rows = [row for row in rows if str(row["product_code"]) in wanted]
|
||||
if not rows:
|
||||
print("没有匹配的产品。")
|
||||
return 1
|
||||
|
||||
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,
|
||||
)
|
||||
fees = await adapter.fetch_fees(codes)
|
||||
|
||||
print(f"产品 {len(rows)} 只;取到净值 {sum(1 for h in histories if isinstance(h, dict) and not h.get('degraded'))} 只"
|
||||
f"、费率 {len(fees)} 只\n")
|
||||
|
||||
updates: list[tuple[int, str, str, object]] = []
|
||||
conflicts: list[str] = []
|
||||
for row, history in zip(rows, histories, strict=True):
|
||||
code = str(row["product_code"])
|
||||
live_nav: object = None
|
||||
nav_at: datetime | None = None
|
||||
if isinstance(history, dict) and not history.get("degraded"):
|
||||
live_nav = history.get("nav")
|
||||
nav_date = history.get("nav_date")
|
||||
if isinstance(nav_date, date):
|
||||
nav_at = datetime.combine(nav_date, NAV_AT_TIME)
|
||||
if live_nav is not None:
|
||||
stored_nav = row["current_nav"]
|
||||
if _same(stored_nav, live_nav):
|
||||
pass
|
||||
elif stored_nav is not None and not args.overwrite:
|
||||
conflicts.append(
|
||||
f"{code} {row['product_name']} 的 current_nav:"
|
||||
f"库里 {stored_nav} → 真实 {live_nav}"
|
||||
)
|
||||
else:
|
||||
updates.append((int(row["id"]), code, "current_nav", live_nav))
|
||||
if nav_at is not None:
|
||||
updates.append((int(row["id"]), code, "current_nav_at", nav_at))
|
||||
|
||||
live_fee = fees.get(code) or {}
|
||||
for column, key in FEE_FIELDS:
|
||||
live_value = live_fee.get(key)
|
||||
if live_value is None:
|
||||
continue
|
||||
stored = row[column]
|
||||
if _same(stored, live_value):
|
||||
continue
|
||||
if stored is not None and not args.overwrite:
|
||||
conflicts.append(
|
||||
f"{code} {row['product_name']} 的 {column}:库里 {stored} → 真实 {live_value}"
|
||||
)
|
||||
continue
|
||||
updates.append((int(row["id"]), code, column, live_value))
|
||||
|
||||
if conflicts:
|
||||
print("与库里已有值冲突(默认不动,需要 --overwrite 才覆盖):")
|
||||
for line in conflicts:
|
||||
print(f" * {line}")
|
||||
print()
|
||||
|
||||
if not updates:
|
||||
print("没有需要回填的字段。")
|
||||
return 0
|
||||
|
||||
print(f"将执行 {len(updates)} 条 UPDATE:")
|
||||
for _, code, column, value in updates:
|
||||
print(f" · {code} {column} = {value}")
|
||||
|
||||
if not args.apply:
|
||||
print("\n[dry-run] 未写库。加 --apply 执行。")
|
||||
return 0
|
||||
|
||||
now = datetime.now(UTC).replace(tzinfo=None)
|
||||
async with SessionFactory() as session, session.begin():
|
||||
for product_id, _, column, value in updates:
|
||||
# 列名来自本模块常量,不是外部输入;值一律走参数绑定。
|
||||
await session.execute(
|
||||
text(
|
||||
f"UPDATE fin_product SET {column} = :value, updated_at = :now "
|
||||
"WHERE id = :product_id"
|
||||
),
|
||||
{"value": value, "now": now, "product_id": product_id},
|
||||
)
|
||||
print(f"\n已写入 {len(updates)} 个字段。")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(asyncio.run(main()))
|
||||
Reference in New Issue
Block a user