feat(sync): 给 fin_market_price 补真实行情同步链路(下单前置)
## 解决的问题 全流程验收时发现:**20 只产品里只有 2 只有行情,其余下单直接 503**;而且行情一旦过期 就**没有任何机制刷新**。根因是这张表此前**只有演示种子脚本写**,而它是下单的硬前置 (TradeService 要求 close_price>0、total_fund_shares>0、source_updated_at 在 MAX_QUOTE_AGE 内)。 ## 数据源(踩了两个坑才选对) 1) **东财 push2 / push2his 两个行情域名在本环境一律连不上** (RemoteProtocolError: Server disconnected),而它的 api.fund(净值)与 fundf10(概况/费率)**正常** —— 即东财只有"行情类"接口不可达。 一开始按 K 线方案写完,20 只全失败;排查时还被我自己的 except 吞掉过异常。 2) 一度怀疑被限流,等 90 秒仍失败;做源可用性对照才确认是**域名级不可达**。 最终选**腾讯行情**(qt.gtimg.cn):一次请求可带多只,给今开/最高/最低/现价/ 成交量(手)/成交额(万元)/**总市值(亿元)**,正好够写一行日行情。 K 线能力仍保留在适配器里(别的网络环境可能可达),注释写明本环境不可用。 ## 实现 - EastmoneyFundAdapter.fetch_tencent_quotes:解析腾讯的位置约定格式,只取语义明确的 字段并对价格做合理性校验;成交额万元→元、成交量按"手"(与东财 K 线口径一致,实测同为 9666652)。 - MarketPriceSyncService(新):编排 + 落库。总份额按 「已有值 → 腾讯总市值推算 → 季度规模兜底」的优先级确定,source 标明是否含推算成分; 三者都拿不到就**跳过该产品**,不编份额。 - ools/sync_market_prices.py(新):CLI,演示/验收前跑一次。 ## 另一个坑:非交易日的假行情行 种子脚本用 rade_date = date.today() 写行情,而 2026-09-13 是**周六**、真实行情时间是 09-11 16:14。TradeService 取 order_by(trade_date.desc()).limit(1),于是那两行假的"今天" 永远排在真实行情前面——表现是"同步成功了、下单还说行情过期"。已删除那两行。 ## 验证 - 同步:请求 20 只、落库 19 行(160129 腾讯源没有该代码) - 下单:510300 @4.579、510500 @7.611、159948 @3.693、511810 @100.012 全部 201 已成交 —— 用的是**真实行情价**,不再是种子编的 4.5/6.2 - 风控处置闭环:新建 ALDEMO0003 后确认接收→进入调查→到达终态, ack_status=已确认、handler_id=9002 ## 顺带发现的疑点(未改,留给风控线确认) ALDEMO0003 到达终态时 status='已关闭' 但 **closed_at 与 handle_result 都是 NULL**; 对照 ALDEMO0001(已排除)则 closed_at 有值。结案时间是否应该写入,需业务侧确认。 门禁:ruff 通过 / mypy 250 文件 0 错 / 单元+契约 1380 passed 2 skipped。
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
"""把真实场内日行情同步进 `fin_market_price`(下单的硬前置)。
|
||||
|
||||
## 什么时候要跑
|
||||
|
||||
`fin_market_price` 是下单的硬前置:`TradeService` 要求产品在这张表里有
|
||||
`close_price > 0`、`total_fund_shares > 0`,且 `source_updated_at` 落在 `MAX_QUOTE_AGE` 内。
|
||||
**行情过期后没有任何自动刷新机制**,表现是下单全线 `503 FUND_QUOTE_UNAVAILABLE`,
|
||||
而错误信息只说"行情已过期",看不出根因。
|
||||
|
||||
所以:**每次演示/验收之前跑一次本脚本**。它同时解决两件事:
|
||||
· 把只有 2 只产品的行情补齐到全部场内产品(`seed_sim_account_demo` 只写 2 只);
|
||||
· 把已有行情的时间戳刷新到当前,让新鲜度校验通过。
|
||||
|
||||
## 数据源
|
||||
|
||||
腾讯行情(`qt.gtimg.cn`)—— 本环境唯一可用的行情源:东财的 push2 / push2his
|
||||
两个行情域名实测一律连接被拒(`Server disconnected`),而它的净值/概况域名正常。
|
||||
总份额按"已有值 → 腾讯总市值推算 → 季度规模兜底"的优先级确定,详见
|
||||
`app/service/market_price_sync_service.py`。
|
||||
|
||||
## 用法
|
||||
|
||||
python tools/sync_market_prices.py # 全部场内产品
|
||||
python tools/sync_market_prices.py --codes 510300,510500
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from app.service.market_price_sync_service import ( # noqa: E402
|
||||
MarketPriceSyncService,
|
||||
summarize,
|
||||
)
|
||||
|
||||
if hasattr(sys.stdout, "reconfigure"):
|
||||
sys.stdout.reconfigure(errors="replace") # type: ignore[union-attr]
|
||||
|
||||
|
||||
async def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="同步场内日行情到 fin_market_price")
|
||||
parser.add_argument("--codes", default="", help="只同步这些产品代码(逗号分隔)")
|
||||
args = parser.parse_args()
|
||||
|
||||
codes = tuple(code.strip() for code in args.codes.split(",") if code.strip())
|
||||
service = MarketPriceSyncService()
|
||||
result = await service.sync(product_codes=codes or None)
|
||||
|
||||
print(summarize(result))
|
||||
skipped = result.get("skipped") or []
|
||||
if skipped:
|
||||
print("\n未同步的产品(以及原因):")
|
||||
for item in skipped:
|
||||
print(f" · {item['product_code']} {item['reason']}")
|
||||
if not result.get("written"):
|
||||
print("\n[警告] 一行都没写 —— 下单仍然会失败。")
|
||||
return 1
|
||||
print("\n完成。下单应已可用(过期校验看的是 source_updated_at)。")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(asyncio.run(main()))
|
||||
Reference in New Issue
Block a user