From 9a5259d7875d11492bf3449342cad6d14db0a2dc 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: Mon, 14 Sep 2026 00:30:06 +0800 Subject: [PATCH] =?UTF-8?q?feat(market):=20=E5=AD=98=E4=B8=8B=E8=A1=8C?= =?UTF-8?q?=E6=83=85=E6=BA=90=E7=BB=99=E7=9A=84=E6=B6=A8=E8=B7=8C=E5=B9=85?= =?UTF-8?q?=EF=BC=8C=E4=BA=A7=E5=93=81=E5=88=97=E8=A1=A8=E4=B8=8E=E6=8E=92?= =?UTF-8?q?=E8=A1=8C=E9=A1=B5=E4=B8=8D=E5=86=8D=E6=98=BE=E7=A4=BA"?= =?UTF-8?q?=E6=9A=82=E6=97=A0"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 问题 产品列表与排行页的「最近涨跌」全是"暂无"。原因是它只能由**两个交易日的收盘价** 现算,而 `fin_market_price` 每只产品每天只有一行 —— 库里头一天只有一天数据时 根本算不出来。 ## 但行情源本来就给了这个数 腾讯行情接口的第 32 位就是**当日涨跌幅**(适配器此前只解析了开高低收量额,没取它)。 所以不必等第二个交易日去算,把源给的数存下来即可。 ## 改动 - **迁移** `20260913_market_price_change_pct`:给 `fin_market_price` **新增一个可空列** `change_pct DECIMAL(10,4)`。只加列、不改任何已有字段(AGENTS.md 规则 2 允许), 可空且不回填,既有读写方全部不受影响。 - **适配器**:`_parse_tencent` 增解析位置 4(昨收)与位置 32(涨跌幅)。 - **同步服务**:写入 `change_pct`;降级路径(净值兜底)没有这个数就写 **NULL**。 - **接口**:`_resolve_change_pct` **优先用存下来的值**;迁移前的历史行没有该列的值, 才回退到"今日收盘 vs 昨收"现算。仍为 `null` 时前端显示"暂无" —— **不得当成 0**(`formatPercent(null)` 会渲染成 `+0.00%`,那等于说"今天平盘")。 ## 契约测试同步 两处契约断言因结构演进需要跟进 —— 它们的作用正是拦住这类变更: - `test_fund_readonly_contract.py`:`fin_market_price` 列数 **13 → 14** - `test_advisor_migration_contract.py`:迁移 head 更新为新版本 (核心断言仍是"链收敛到唯一 head",此处只是钉住末端) 验证:实测 20 只产品**全部有真实涨跌幅**(如 515450 −0.43%、159511 +1.23%); unit+contract **1397 passed**;integration **110 passed**;ruff 通过; mypy 251 文件 0 错;`audit_schema` 与 `migration_state_check` 均通过;e2e 冒烟 40/40。 --- .../20260913_market_price_change_pct.py | 36 +++++++++++++++++++ app/infrastructure/fund_market_adapter.py | 5 +++ app/model/fund.py | 3 ++ app/service/market_price_sync_service.py | 3 ++ app/service/public_product_service.py | 22 ++++++++---- docs/40-前端验收清单.md | 5 ++- docs/44-演示流程.md | 6 ++-- .../repository/test_fund_readonly_contract.py | 5 ++- tests/unit/test_advisor_migration_contract.py | 5 ++- 9 files changed, 79 insertions(+), 11 deletions(-) create mode 100644 alembic/versions/20260913_market_price_change_pct.py diff --git a/alembic/versions/20260913_market_price_change_pct.py b/alembic/versions/20260913_market_price_change_pct.py new file mode 100644 index 0000000..e699567 --- /dev/null +++ b/alembic/versions/20260913_market_price_change_pct.py @@ -0,0 +1,36 @@ +"""add change_pct to fin_market_price + +Compatibility proof: this revision only **adds one nullable column** +``change_pct`` to the existing baseline table ``fin_market_price``. +No baseline table or baseline field is renamed, deleted, reused, or retyped; +the new column is nullable and has no default backfill, so existing rows keep +their values and every existing reader/writer keeps working unchanged. +""" + +from alembic import op + +revision = "20260913_market_price_change_pct" +down_revision = "20260911_merge_adv_risk_heads" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # 行情源(腾讯)**直接返回当日涨跌幅**,此前没有存下来,于是"最近涨跌"只能靠 + # "今天的收盘 vs 上一交易日的收盘"现算 —— 而 `fin_market_price` 每只产品每天只有 + # 一行,库里头一天只有一天数据时根本算不出来,前端只能显示"暂无"。 + # 存下源给的涨跌幅后,产品列表与排行页立刻就有真实数据可显示。 + op.execute( + """ + ALTER TABLE fin_market_price + ADD COLUMN change_pct DECIMAL(10,4) NULL + COMMENT '当日涨跌幅(%),来自行情源;源未提供时为 NULL' + AFTER close_price + """ + ) + + +def downgrade() -> None: + raise RuntimeError( + "fin_market_price.change_pct must not be dropped automatically" + ) diff --git a/app/infrastructure/fund_market_adapter.py b/app/infrastructure/fund_market_adapter.py index 88867d2..d1a41db 100644 --- a/app/infrastructure/fund_market_adapter.py +++ b/app/infrastructure/fund_market_adapter.py @@ -419,6 +419,11 @@ class EastmoneyFundAdapter: "name": parts[1] or None, "open": EastmoneyFundAdapter._decimal(parts[5]), "close": close, + # 昨收(位置 4)与**当日涨跌幅 %**(位置 32)。腾讯直接给出涨跌幅, + # 所以"最近涨跌"不需要等第二个交易日才显示 —— 见 + # `fin_market_price.change_pct`(20260913 迁移新增)。 + "previous_close": EastmoneyFundAdapter._decimal(parts[4]), + "change_pct": EastmoneyFundAdapter._decimal(parts[32]), "high": EastmoneyFundAdapter._decimal(parts[33]), "low": EastmoneyFundAdapter._decimal(parts[34]), # 成交量单位是**手**(与东财 K 线口径一致,实测同为 9666652)。 diff --git a/app/model/fund.py b/app/model/fund.py index 4c1f35e..5f528b4 100644 --- a/app/model/fund.py +++ b/app/model/fund.py @@ -91,6 +91,9 @@ class FundMarketPrice(Base): high_price: Mapped[Decimal] = mapped_column(Numeric(18, 6), nullable=False) low_price: Mapped[Decimal] = mapped_column(Numeric(18, 6), nullable=False) close_price: Mapped[Decimal] = mapped_column(Numeric(18, 6), nullable=False) + #: 当日涨跌幅(%),来自行情源。**可空**:源未提供、或该行由不提供涨跌幅的降级路径 + #: (如 `eastmoney_nav_fallback`)写入时为 NULL —— 调用方必须显示"暂无"而不是当成 0。 + change_pct: Mapped[Decimal | None] = mapped_column(Numeric(10, 4), nullable=True) volume: Mapped[Decimal | None] = mapped_column(Numeric(24, 4), nullable=True) turnover_amount: Mapped[Decimal | None] = mapped_column(Numeric(24, 2), nullable=True) total_fund_shares: Mapped[Decimal] = mapped_column(Numeric(24, 4), nullable=False) diff --git a/app/service/market_price_sync_service.py b/app/service/market_price_sync_service.py index fed91f8..d7f7209 100644 --- a/app/service/market_price_sync_service.py +++ b/app/service/market_price_sync_service.py @@ -269,6 +269,9 @@ class MarketPriceSyncService: "high_price": MarketPriceSyncService._q(quote.get("high") or close, PRICE_QUANT), "low_price": MarketPriceSyncService._q(quote.get("low") or close, PRICE_QUANT), "close_price": MarketPriceSyncService._q(close, PRICE_QUANT), + # 当日涨跌幅(%)。行情源直接给;降级路径(净值兜底)没有这个数, + # 那就写 NULL —— 前端对 NULL 显示"暂无",**不允许**当成 0(那是"平盘")。 + "change_pct": MarketPriceSyncService._q_optional(quote.get("change_pct")), "volume": MarketPriceSyncService._q_optional(quote.get("volume")), "turnover_amount": MarketPriceSyncService._q_optional(quote.get("turnover")), "total_fund_shares": shares, diff --git a/app/service/public_product_service.py b/app/service/public_product_service.py index a29b384..ac5b697 100644 --- a/app/service/public_product_service.py +++ b/app/service/public_product_service.py @@ -174,17 +174,27 @@ class PublicProductService: "latest_close": public(latest.close_price, "latest_close") if latest else None, "latest_trade_date": _iso(latest.trade_date) if latest else None, "quote_source": latest.source if latest else None, - # 只有一天的行情时**返回 null 而不是 0** —— 0 会被读成"平盘", - # 那是编出来的结论。前端对 null 显示"—"。 - "change_pct": _change_pct(latest, previous), + # 当日涨跌幅:**优先用行情源写入的 `change_pct`**(腾讯直接提供), + # 历史行没有该字段时才回退到"最新收盘 vs 上一交易日收盘"现算。 + # 见 `_resolve_change_pct`。 + "change_pct": _resolve_change_pct(latest, previous), } -def _change_pct( +def _resolve_change_pct( latest: FundMarketPrice | None, previous: FundMarketPrice | None ) -> float | None: - """当日涨跌幅(百分比)。缺任一日的收盘价就返回 None。""" - if latest is None or previous is None: + """当日涨跌幅(%);都拿不到就返回 None。 + + ⚠️ 返回 None 时调用方必须显示"暂无",**不得当成 0** —— `0` 会被读成"今天平盘", + 那是编出来的结论。`formatPercent(null)` 恰好会渲染成 `+0.00%`,所以前端要显式判空。 + """ + if latest is None: + return None + if latest.change_pct is not None: + return float(latest.change_pct) + # 回退:需要两个交易日的收盘价(行情源没给涨跌幅、或该行由降级路径写入) + if previous is None: return None try: base = Decimal(previous.close_price) diff --git a/docs/40-前端验收清单.md b/docs/40-前端验收清单.md index fc38c70..d31aec2 100644 --- a/docs/40-前端验收清单.md +++ b/docs/40-前端验收清单.md @@ -89,9 +89,12 @@ python -m app.worker > (如把海富通的 `511360` 标成"南方短融ETF"),净值也是编的。该文件已删除。 > > **两条不能想当然的口径**(改前端前先读): -> 1. `change_pct` **可能是 `null`**(行情只同步过一个交易日时算不出涨跌)。 +> 1. `change_pct` **可能是 `null`**(该行由净值降级路径写入、源没给涨跌幅,或行情未同步)。 > 页面必须显示"暂无"—— `formatPercent` 收到 `null` 会渲染成 `+0.00%`, > 那等于告诉客户"今天平盘"。 +> **2026-09-13 起已正常有值**:行情源(腾讯)直接返回涨跌幅,由 +> `20260913_market_price_change_pct` 迁移新增的 `fin_market_price.change_pct` 存下, +> 同步脚本写入;接口优先用它,历史行才回退到"今日收盘 vs 昨收"现算。 > 2. **历史净值走势图**:`fin_nav_history` 由 `tools/sync_nav_history.py` 同步 > (东财净值接口,近 120 个交易日,实测 20 只 / 2513 行)。 > **表为空时不画曲线**、显式显示"尚未接入",绝不回退到编造点位 —— diff --git a/docs/44-演示流程.md b/docs/44-演示流程.md index e9c23d3..39356f7 100644 --- a/docs/44-演示流程.md +++ b/docs/44-演示流程.md @@ -86,8 +86,10 @@ python tools/e2e_smoke_test.py --read-only > 行情由 `tools/sync_market_prices.py` 从行情源同步,页面底部有来源声明。 > > ⚠️ 两个**可能被问到、但其实不是故障**的地方: -> - 产品列表的「最近涨跌」列当前显示 **"暂无"** —— 当日涨跌要**两个交易日**的收盘价才算得出, -> 而行情库目前只有一个交易日。跑过第二次行情同步后自然会显示。我们**不编这个数**。 +> - 「最近涨跌」列取的是**行情源直接给出的当日涨跌幅**(腾讯返回,存在 +> `fin_market_price.change_pct`),已接入。若某只显示"暂无",说明它走的是净值降级 +> 路径(源没给涨跌幅),或行情没同步过 —— 跑一次 `sync_market_prices.py` 即可。 +> 我们**不编**这个数:缺数据就显示"暂无",而不是 0(0 会被读成"今天平盘")。 > - 产品详情的**净值走势图现在是真实数据**:`fin_nav_history` 由 > `tools/sync_nav_history.py`(演示数据第 5 步)从东财净值接口同步,近 **120 个交易日**。 > 若图上显示"尚未接入",说明第 5 步没跑 —— 补跑即可,不用重启服务。 diff --git a/tests/unit/repository/test_fund_readonly_contract.py b/tests/unit/repository/test_fund_readonly_contract.py index 4a64189..2b16e03 100644 --- a/tests/unit/repository/test_fund_readonly_contract.py +++ b/tests/unit/repository/test_fund_readonly_contract.py @@ -55,7 +55,10 @@ FORBIDDEN_METHOD_WORDS = ( # 表名 -> (列数, 主键列) FUND_TABLES: dict[str, tuple[int, str]] = { "fin_product": (27, "id"), - "fin_market_price": (13, "id"), + # `fin_market_price` 2026-09-13 由 `20260913_market_price_change_pct` 新增一列 + # `change_pct`(行情源给的当日涨跌幅),13 → 14。加列属允许的结构演进, + # 但**必须同步这个计数**,否则本契约测试会失败 —— 那正是它的作用。 + "fin_market_price": (14, "id"), "fin_nav_history": (5, "id"), "fin_sim_account": (12, "id"), "fin_cash_ledger": (12, "id"), diff --git a/tests/unit/test_advisor_migration_contract.py b/tests/unit/test_advisor_migration_contract.py index 33268e5..1e3de72 100644 --- a/tests/unit/test_advisor_migration_contract.py +++ b/tests/unit/test_advisor_migration_contract.py @@ -36,7 +36,10 @@ def created_tables(path: Path) -> set[str]: def test_advisor_migrations_form_one_chain_from_qyqy_head() -> None: script = ScriptDirectory.from_config(Config(str(ROOT / "alembic.ini"))) assert len(script.get_heads()) == 1 - assert script.get_heads()[0] == "20260911_merge_adv_risk_heads" + # 核心断言是上面那句"链收敛到一个 head";下面钉住当前末端版本,便于发现迁移被误删或分叉。 + # ⚠️ **新增迁移后要同步更新这个值**。2026-09-13 追加了 + # `20260913_market_price_change_pct`(给 `fin_market_price` 补 `change_pct`)。 + assert script.get_heads()[0] == "20260913_market_price_change_pct" first = (VERSIONS / ADVISOR_FILES[0]).read_text(encoding="utf-8") assert 'down_revision = "20260910_drop_review_separation"' in first