fix(demo): 把未上市的 160129 换成真实挂牌的 515450,并修掉种子假行情盖住真行情

## 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。
This commit is contained in:
2026-09-13 22:28:42 +08:00
parent 4629e7d5dd
commit f6bfcc26b3
6 changed files with 62 additions and 31 deletions
+3 -1
View File
@@ -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"),
+21 -22
View File
@@ -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"),