feat(sync): 行情源覆盖不到的产品改用净值源降级(160129 现在也可下单)

160129(南方金利定开债券C)在腾讯行情源上**查不到** —— 返回体只有 1 个字符,
换 sh/无前缀、换代码写法都不行;而它的 A 类 160128 正常返回 88 个字段。
判断它很可能未上市交易。但它在东财历史净值(1.0240 @09-11)与 pingzhongdata
(规模 3.91 亿)里都有数据,所以按"换一个源"处理:

- 新增净值降级路径 _nav_fallback:收盘价取 DWJZ、开高低用同值(该接口只给一个价格)、
  olume/	urnover **留空**(净值源不提供量额,不编数字)、总份额由季度规模推算。
- source 写成 eastmoney_nav_fallback,与行情源明确区分,便于日后核对。
- 降级请求放在**事务外**:最初写在落库循环里,会让网络请求拉长事务。

⚠️ **净值不等于市价**:若该产品确实在交易所交易,用它当收盘价会有折溢价偏差。
这条路径只落在"行情源没有覆盖"的产品上;且 160129 很可能本就不该出现在场内产品
列表里(同基金的 A/C 类只有 A 类上市),值得业务侧复核 —— 但按项目方要求先让它可用。

结果:20 只全部同步上(19 行 tencent_quote + 1 行 eastmoney_nav_fallback),
160129 下单 201 已成交 @1.024。

门禁:ruff 通过 / mypy 250 文件 0 错 / 单元+契约 1381 passed 2 skipped。
This commit is contained in:
2026-09-13 21:32:15 +08:00
parent b6281ba6bc
commit 06f0dee39f
+58 -7
View File
@@ -48,9 +48,11 @@ from app.infrastructure.db import SessionFactory
from app.infrastructure.fund_market_adapter import EastmoneyFundAdapter
from app.model.fund import FundMarketPrice, FundProduct
#: 来源口径。带 `+shares_estimated` 表示 `total_fund_shares` 是推算的。
#: 来源口径。带 `+shares_estimated` 表示 `total_fund_shares` 是推算的;
#: `eastmoney_nav_fallback` 表示腾讯行情没有这只是,改用东财净值构造(见 `_nav_fallback`)。
SOURCE_QUOTE = "tencent_quote"
SOURCE_QUOTE_ESTIMATED = "tencent_quote+shares_estimated"
SOURCE_NAV_FALLBACK = "eastmoney_nav_fallback"
PRICE_QUANT = Decimal("0.000001")
AMOUNT_QUANT = Decimal("0.01")
@@ -100,6 +102,14 @@ class MarketPriceSyncService:
codes = [str(row["product_code"]) for row in products]
code_to_id = {str(row["product_code"]): int(row["id"]) for row in products}
quotes = await self.adapter.fetch_tencent_quotes(codes)
# 行情源没有的产品,**在事务外**用净值源补上(在事务里做网络请求会拉长事务)。
fallbacks: dict[str, dict[str, Any]] = {}
for code in codes:
if quotes.get(code):
continue
fallback = await self._nav_fallback(code)
if fallback is not None:
fallbacks[code] = fallback
# 只为**既没有已登记份额、行情里也没有总市值**的产品去抓季度规模兜底。
need_scale = [
code
@@ -116,9 +126,10 @@ class MarketPriceSyncService:
for row in products:
code = str(row["product_code"])
product_id = int(row["id"])
quote = quotes.get(code)
quote = quotes.get(code) or fallbacks.get(code)
used_fallback = code in fallbacks
if not quote:
skipped.append({"product_code": code, "reason": "行情源未返回该产品"})
skipped.append({"product_code": code, "reason": "行情源与净值源都没有它"})
continue
trade_date = self._trade_date(quote, now)
if trade_date is None:
@@ -134,7 +145,8 @@ class MarketPriceSyncService:
})
continue
if await self._upsert(
session, product_id, trade_date, quote, shares, estimated, now
session, product_id, trade_date, quote, shares,
self._source_of(used_fallback, estimated), now,
):
written += 1
@@ -162,15 +174,54 @@ class MarketPriceSyncService:
@staticmethod
def _trade_date(quote: Mapping[str, Any], fallback: datetime) -> date | None:
"""从腾讯的行情时间戳(`YYYYMMDDHHMMSS`)取交易日;解析不出就用当天。"""
"""取交易日:优先腾讯行情时间戳(`YYYYMMDDHHMMSS`),其次净值日期,最后当天。"""
raw = str(quote.get("quoted_at") or "")
if len(raw) >= 8 and raw[:8].isdigit():
try:
return date(int(raw[:4]), int(raw[4:6]), int(raw[6:8]))
except ValueError:
return fallback.date()
nav_date = quote.get("nav_date")
if isinstance(nav_date, date):
return nav_date
return fallback.date()
@staticmethod
def _source_of(used_fallback: bool, estimated: bool) -> str:
"""这一行的来源口径:净值降级 > 含估算份额 > 纯行情。"""
if used_fallback:
return SOURCE_NAV_FALLBACK
return SOURCE_QUOTE_ESTIMATED if estimated else SOURCE_QUOTE
async def _nav_fallback(self, code: str) -> dict[str, Any] | None:
"""行情源没有这只是时,用东财历史净值构造一条**降级**行情。
为什么可以接受:`source` 会写成 `eastmoney_nav_fallback` 明确标注来源,
`volume`/`turnover` 留空(净值接口不提供量额),不编造任何数字;
开高低用净值同值(该接口只给一个价格)。
⚠️ **净值不等于市价**:若该产品确实在交易所交易,用它当收盘价会有折溢价偏差。
所以这条路径只应落在"行情源没有覆盖"的产品上,且值得业务侧复核 ——
实测触发它的 `160129` 是 C 类份额(其 A 类 `160128` 在腾讯源有行情),
它很可能未上市交易,那它本就不该出现在场内产品列表里。
"""
history = await self.adapter.fetch_history(code, date.today())
nav = history.get("nav")
if nav is None or history.get("degraded"):
return None
return {
"code": code,
"open": nav,
"close": nav,
"high": nav,
"low": nav,
"volume": None,
"turnover": None,
"total_market_value_yi": None,
"quoted_at": None,
"nav_date": history.get("nav_date"),
}
@staticmethod
def _resolve_shares(
existing: Decimal | None,
@@ -203,7 +254,7 @@ class MarketPriceSyncService:
trade_date: date,
quote: Mapping[str, Any],
shares: Decimal,
estimated: bool,
source: str,
now: datetime,
) -> bool:
"""按 `(product_id, trade_date)` 覆盖写一行;返回是否真的写了。"""
@@ -218,7 +269,7 @@ class MarketPriceSyncService:
"volume": MarketPriceSyncService._q_optional(quote.get("volume")),
"turnover_amount": MarketPriceSyncService._q_optional(quote.get("turnover")),
"total_fund_shares": shares,
"source": SOURCE_QUOTE_ESTIMATED if estimated else SOURCE_QUOTE,
"source": source,
"source_updated_at": now,
}
existing = await session.scalar(