From f6bfcc26b3fd3fcb758eb2cc3e5ed2a66981def2 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 22:28:42 +0800 Subject: [PATCH] =?UTF-8?q?fix(demo):=20=E6=8A=8A=E6=9C=AA=E4=B8=8A?= =?UTF-8?q?=E5=B8=82=E7=9A=84=20160129=20=E6=8D=A2=E6=88=90=E7=9C=9F?= =?UTF-8?q?=E5=AE=9E=E6=8C=82=E7=89=8C=E7=9A=84=20515450=EF=BC=8C=E5=B9=B6?= =?UTF-8?q?=E4=BF=AE=E6=8E=89=E7=A7=8D=E5=AD=90=E5=81=87=E8=A1=8C=E6=83=85?= =?UTF-8?q?=E7=9B=96=E4=BD=8F=E7=9C=9F=E8=A1=8C=E6=83=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 1. 160129 本就不该在场内清单里 `160129`(南方金利定开债券C)是 `160128`(金利定开债券 **A** 类)的 C 类份额, 而 C 类份额只在场外销售、**不在交易所挂牌** —— 行情源对它永远返回空,下单只能走 净值降级(`source=eastmoney_nav_fallback`),既掩盖了"该产品并不交易"这一事实, 又让成交价带上折溢价偏差。 选型依据:把南方基金全部 **882 个代码**逐个问过腾讯行情源(该源只对交易所上市证券 返回数据),确认真实上市交易的只有 **109 个**;其中 R1/R2 的场内产品 (159700、160128、511070、511810)**原先已在清单内**,所以替换品只能来自 R3 及以上。 选定 **`515450` 红利低波50ETF南方**:仍是南方基金旗下、成交额约 1.3 亿元流动性充足、 红利低波定位偏稳健,与它替换掉的债券 LOF 定位最接近;客户风险等级已覆盖 R3 (原有 `510300` 即 R3)。 改动:`hq.py` 的 `FUND_TYPE_GROUPS`、`tools/import_hq_test_products.py` 的场内映射, 两处都留了注释防止被加回来;`docs/42` / `docs/43` 的产品表与费率表同步 (净值 1.4027、管理费 0.50%、托管费 0.10%);`docs/42` 另加了一条决策记录。 库内用**复用 `fin_product.id = 9100005`** 的方式替换,使 `fin_holding` / `fin_sim_order` / `fin_transaction` / `fin_market_price` 的既有引用自动跟随, 不产生孤儿数据。那笔历史成交保留 `quote_source='eastmoney_nav_fallback'` —— 它确实是当时的事实,不该被粉饰。同时清掉了 `fin_market_price` 里那条净值降级行, 全库净值降级行情行数归零。 ## 2. 种子假行情会盖住真实行情(更严重,且会反复出现) `seed_sim_account_demo.py` 原来按"今天"upsert 一条假行情(510300=4.50、 510500=6.20,`source='eastmoney_demo_seed'`),而真实行情来自行情源、日期是 **最近交易日**。下单按 `trade_date DESC` 取行情,这条种子行**永远排在真实行情前面**; 它的 `source_updated_at` 只是 seed 运行时刻,过了 `MAX_QUOTE_AGE`(15 分钟)就让整个 产品变成 `503 行情已过期` —— 而库里明明躺着一条刚同步好的真实行情。周末尤其明显: `trade_date` 落在**非交易日**,价格还是编的。 实测表现:`tools/seed_demo_data.py` 跑完第 4 步刚同步过真实行情,冒烟脚本的下单仍报 `503`,且本该 4.579 的成交价被查到 4.50。 改为:**已有任何行情行就绝不插手**(真实行情优先,种子不参与竞争); 一条都没有时才补,且 `trade_date` 退到最近交易日。 验证:`tools/e2e_smoke_test.py` → **40/40**(下单成交价 4.579 = 真实行情); ruff 通过;mypy 250 文件 0 错;unit+contract 1381 passed / 0 failed。 --- app/service/market_price_sync_service.py | 7 +++- docs/42-场内基金知识条目草稿.md | 28 +++++++++++++- docs/43-场内基金产品手册(知识库入库版).md | 4 +- hq.py | 7 +++- tools/import_hq_test_products.py | 4 +- tools/seed_sim_account_demo.py | 43 ++++++++++----------- 6 files changed, 62 insertions(+), 31 deletions(-) diff --git a/app/service/market_price_sync_service.py b/app/service/market_price_sync_service.py index b6d49c4..fed91f8 100644 --- a/app/service/market_price_sync_service.py +++ b/app/service/market_price_sync_service.py @@ -202,8 +202,11 @@ class MarketPriceSyncService: ⚠️ **净值不等于市价**:若该产品确实在交易所交易,用它当收盘价会有折溢价偏差。 所以这条路径只应落在"行情源没有覆盖"的产品上,且值得业务侧复核 —— - 实测触发它的 `160129` 是 C 类份额(其 A 类 `160128` 在腾讯源有行情), - 它很可能未上市交易,那它本就不该出现在场内产品列表里。 + 曾经触发它的 `160129` 是 C 类份额(其 A 类 `160128` 在腾讯源有行情): + C 类份额只在场外销售、**不在交易所挂牌**,本就不该出现在场内产品列表里, + 2026-09-13 已替换为 `515450`(红利低波50ETF南方)。 + 所以这条降级**再次被触发时,先怀疑清单里混进了非上市份额**(C 类、场外份额), + 而不是行情源出了问题。 """ history = await self.adapter.fetch_history(code, date.today()) nav = history.get("nav") diff --git a/docs/42-场内基金知识条目草稿.md b/docs/42-场内基金知识条目草稿.md index 5cd45cf..39251a5 100644 --- a/docs/42-场内基金知识条目草稿.md +++ b/docs/42-场内基金知识条目草稿.md @@ -88,11 +88,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 | +| 515450 | 红利低波50ETF南方 | SSE | ETF | R3 | 1.4027 | -0.43% | 2026-09-11 | | 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 | @@ -106,6 +106,30 @@ > (库里存 6 位小数,如 `0.916700`,数据源给 4 位 `0.9167`,是同一笔数), > **2 只不一致** —— `510300` 与 `511810`,见下方 3.1 与第四节。 +> **2026-09-13 变更:`160129` → `515450`** +> +> 原 `160129`(南方金利定开债券C)已从清单移除,替换为 **`515450` 红利低波50ETF南方** +> (SSE / ETF / R3,管理费 0.50%、托管费 0.10%,净值 1.4027)。 +> +> **为什么必须换**:`160129` 是 `160128`(金利定开债券 **A** 类)的 **C 类份额**, +> 而 C 类份额只在场外销售、**不在交易所挂牌** —— 行情源对它永远返回空,下单只能走 +> 净值降级(`source=eastmoney_nav_fallback`)。这既掩盖了"该产品并不交易"这一事实, +> 又让成交价带上折溢价偏差。**它本就不该出现在场内清单里。** +> +> **选型依据**:把南方基金全部 **882 个代码**逐个问过腾讯行情源(该源只对交易所上市 +> 证券返回数据),确认真实上市交易的只有 **109 个**;其中 R1/R2 的场内产品 +> (159700、160128、511070、511810)**原先已在清单内**,所以替换品只能来自 R3 及以上。 +> 选 `515450` 是因为它**仍属南方基金**、成交额约 1.3 亿元流动性充足、红利低波定位偏 +> 稳健,与它替换掉的债券 LOF 定位最接近;客户风险等级已覆盖 R3(原有 `510300` 即 R3)。 +> +> **处理方式**:复用 `fin_product.id = 9100005` 而非删除重建,使 `fin_holding` / +> `fin_sim_order` / `fin_transaction` / `fin_market_price` 的既有引用自动跟随, +> 不产生孤儿数据。那笔历史成交保留 `quote_source='eastmoney_nav_fallback'` —— +> 它确实是当时的事实,不该被粉饰。 +> +> 同时移出的还有 `hq.py` 的 `FUND_TYPE_GROUPS`(`160129` 原在"债券型"组) +> 与 `tools/import_hq_test_products.py` 的场内映射,两处都留了注释防止被加回来。 + 问:平台上有哪些场内基金?产品代码是什么? 答:本平台模拟交易范围内共有 20 只场内基金,包含 13 只 ETF 和 7 只 LOF, @@ -241,7 +265,6 @@ python tools/backfill_product_snapshot.py --codes 511810 --apply --overwrite | 160105 | 南方积极配置混合(LOF) | 1.20 | 0.20 | — | | 160127 | 南方新兴消费增长股票(LOF)A | 1.20 | 0.20 | — | | 160128 | 南方金利定开债券A | 0.50 | 0.15 | — | -| 160129 | 南方金利定开债券C | 0.50 | 0.15 | — | | 160142 | 南方优势产业(LOF) | 1.20 | 0.20 | — | | 160143 | 南方创业板2年定期开放混合 | 1.20 | 0.20 | — | | 501018 | 南方原油A | 1.00 | 0.20 | — | @@ -250,6 +273,7 @@ python tools/backfill_product_snapshot.py --codes 511810 --apply --overwrite | 510500 | 中证500ETF南方 | 0.15 | 0.05 | — | | 511070 | 公司债ETF南方 | 0.15 | 0.05 | — | | 511810 | 货币ETF南方 | 0.30 | 0.05 | **0.25** | +| 515450 | 红利低波50ETF南方 | 0.50 | 0.10 | — | | 588890 | 科创芯片ETF南方 | 0.50 | 0.10 | — | **规律**:宽基/债券 ETF 多为 0.15% 管理费,行业主题 ETF 多为 0.50%, diff --git a/docs/43-场内基金产品手册(知识库入库版).md b/docs/43-场内基金产品手册(知识库入库版).md index 7d06c09..29c50fc 100644 --- a/docs/43-场内基金产品手册(知识库入库版).md +++ b/docs/43-场内基金产品手册(知识库入库版).md @@ -86,11 +86,11 @@ LOF 与主动管理型较高(如南方积极配置混合 1.20%/年、托管费 | 510500 | 中证500ETF南方 | 上交所 | ETF | R4 | 7.6027 | 2026-09-11 | | 511070 | 公司债ETF南方 | 上交所 | ETF | R2 | 103.0145 | 2026-09-11 | | 511810 | 货币ETF南方 | 上交所 | ETF | R1 | 0.2661 | 2026-09-11 | +| 515450 | 红利低波50ETF南方 | 上交所 | ETF | R3 | 1.4027 | 2026-09-11 | | 588890 | 科创芯片ETF南方 | 上交所 | ETF | R4 | 1.2327 | 2026-09-11 | | 160105 | 南方积极配置混合(LOF) | 深交所 | LOF | R3 | 1.2514 | 2026-09-11 | | 160127 | 南方新兴消费增长股票(LOF)A | 深交所 | LOF | R4 | 0.8364 | 2026-09-11 | | 160128 | 南方金利定开债券A | 深交所 | LOF | R2 | 1.0260 | 2026-09-11 | -| 160129 | 南方金利定开债券C | 深交所 | LOF | R2 | 1.0240 | 2026-09-11 | | 160142 | 南方优势产业(LOF) | 深交所 | LOF | R3 | 1.0623 | 2026-09-11 | | 160143 | 南方创业板2年定期开放混合 | 深交所 | LOF | R3 | 1.6105 | 2026-09-11 | | 501018 | 南方原油A | 上交所 | LOF | R5 | 2.0750 | 2026-09-10 | @@ -139,11 +139,11 @@ LOF 与主动管理型较高(如南方积极配置混合 1.20%/年、托管费 | 510500 | 中证500ETF南方 | 0.15 | 0.05 | — | | 511070 | 公司债ETF南方 | 0.15 | 0.05 | — | | 511810 | 货币ETF南方 | 0.30 | 0.05 | 0.25 | +| 515450 | 红利低波50ETF南方 | 0.50 | 0.10 | — | | 588890 | 科创芯片ETF南方 | 0.50 | 0.10 | — | | 160105 | 南方积极配置混合(LOF) | 1.20 | 0.20 | — | | 160127 | 南方新兴消费增长股票(LOF)A | 1.20 | 0.20 | — | | 160128 | 南方金利定开债券A | 0.50 | 0.15 | — | -| 160129 | 南方金利定开债券C | 0.50 | 0.15 | — | | 160142 | 南方优势产业(LOF) | 1.20 | 0.20 | — | | 160143 | 南方创业板2年定期开放混合 | 1.20 | 0.20 | — | | 501018 | 南方原油A | 1.00 | 0.20 | — | diff --git a/hq.py b/hq.py index ee371c9..a5b97e4 100644 --- a/hq.py +++ b/hq.py @@ -33,12 +33,15 @@ EXCHANGE_HISTORY_MIN_INTERVAL_SECONDS = 1.0 MAX_FUNDS_PER_CALL = 1000 FUND_TYPE_GROUPS = { "货币型": ("202308", "020480", "511810"), - "债券型": ("007161", "003776", "020281", "511070", "159700", "160128", "160129"), + # ⚠️ 此处曾含 `160129`(南方金利定开债券C),已移除:C 类份额只在场外销售、 + # **不在交易所挂牌**,行情源对它永远返回空(下单因此走净值降级)。 + # 它的 A 类 `160128` 是上市交易的,已在本组内。 + "债券型": ("007161", "003776", "020281", "511070", "159700", "160128"), "混合型": ("018019", "014189", "018020", "160105", "160142", "160143", "501062"), "股票型": ( "020553", "016449", "008854", "008264", "008736", "010592", "160127", "588890", "020839", "589700", "159382", "159511", "002900", "021958", "159948", "009059", - "001421", "510500", + "001421", "510500", "515450", ), "QDII": ("501018", "159329", "159615", "159687"), } diff --git a/tools/import_hq_test_products.py b/tools/import_hq_test_products.py index 9dedf9b..990da10 100644 --- a/tools/import_hq_test_products.py +++ b/tools/import_hq_test_products.py @@ -34,7 +34,9 @@ EXCHANGE_PRODUCT_REFERENCE: dict[str, tuple[str, str, str]] = { "511070": ("SSE", "ETF", "R2"), "159700": ("SZSE", "ETF", "R2"), "160128": ("SZSE", "LOF", "R2"), - "160129": ("SZSE", "LOF", "R2"), + # 原为 `160129`(南方金利定开债券C):C 类份额只在场外销售、不在交易所挂牌, + # 行情源对它永远返回空。换成南方旗下**真实上市且行情可取**的红利低波 50ETF。 + "515450": ("SSE", "ETF", "R3"), "160105": ("SZSE", "LOF", "R3"), "160142": ("SZSE", "LOF", "R3"), "160143": ("SZSE", "LOF", "R3"), diff --git a/tools/seed_sim_account_demo.py b/tools/seed_sim_account_demo.py index 7a51003..bc365e9 100644 --- a/tools/seed_sim_account_demo.py +++ b/tools/seed_sim_account_demo.py @@ -118,38 +118,37 @@ async def _upsert_product(session: Session, spec: dict) -> int: async def _upsert_market_price(session: Session, product_id: int, spec: dict) -> None: - today = datetime.now(UTC).date() - now = datetime.now(UTC).replace(tzinfo=None) + """只在**该产品一行行情都没有**时补一条种子行情,供"不跑行情同步也能下单"兜底。 + + ⚠️ 这里**刻意不再按"今天"去 upsert**(原实现如此),因为那是一个真实踩过的坑: + + seed 写的 `trade_date` 是**当天**,而真实行情来自行情源、日期是**最近交易日**。 + 下单取行情时按 `trade_date DESC` 排序,于是这条种子行**永远排在真实行情前面**; + 它的 `source_updated_at` 只是 seed 运行时刻,过了 `MAX_QUOTE_AGE`(15 分钟)就让 + 整个产品变成 `503 行情已过期` —— 而库里明明躺着一条刚同步好的真实行情。 + 周末尤其明显:`trade_date` 落在**非交易日**,价格还是编的(4.50 对不上真实的 4.579)。 + + 所以:**已有任何行情行就绝不插手**(真实行情优先,种子不参与竞争); + 一条都没有时,`trade_date` 也要退到最近交易日,避免再次盖住后续同步的真实行情。 + """ existing = ( await session.execute( - select(FundMarketPrice.id).where( - FundMarketPrice.product_id == product_id, - FundMarketPrice.trade_date == today, - ) + select(FundMarketPrice.id) + .where(FundMarketPrice.product_id == product_id) + .limit(1) ) ).scalar_one_or_none() if existing is not None: - await session.execute( - update(FundMarketPrice) - .where(FundMarketPrice.id == existing) - .values( - open_price=spec["close_price"], - high_price=spec["close_price"] + Decimal("0.05"), - low_price=spec["close_price"] - Decimal("0.05"), - close_price=spec["close_price"], - volume=Decimal("1000000"), - turnover_amount=spec["close_price"] * Decimal("1000000"), - total_fund_shares=spec["total_fund_shares"], - source="eastmoney_demo_seed", - source_updated_at=now, - ) - ) return + today = datetime.now(UTC).date() + # 周末退到最近的周五,别把 trade_date 写进非交易日 + trade_date = today if today.weekday() < 5 else today - timedelta(days=today.weekday() - 4) + now = datetime.now(UTC).replace(tzinfo=None) next_id = await _next_id(session, FundMarketPrice) stmt = insert(FundMarketPrice).values( id=next_id, product_id=product_id, - trade_date=today, + trade_date=trade_date, open_price=spec["close_price"], high_price=spec["close_price"] + Decimal("0.05"), low_price=spec["close_price"] - Decimal("0.05"),