每日选股计划:统一收到版本的读写指纹,修正页面追溯说明

一、真实页面误报未落快照
收到记录已使用决策版本与查询范围指纹,变化和只读预览仍使用旧名册指纹。三个出口统一使用收到身份,旧数据保持原口径。记录结构及历史内容不改。

二、研究展示一致
删去上游实时装配、无版本戳及始终按分数排序的过期说明。运维提议页复用已有研究详情,与交易员视图一致。没有调整排序、过滤、数量或采纳规则。

三、验证
新增同版与查询范围的往返回归。全部 44 批隔离禁网测试通过;补齐页面展示后,三项页面守卫再通过。候选镜像与线上验收另行记录。
This commit is contained in:
baobao 2026-10-16 09:17:34 +08:00
parent 3989b21c75
commit 3057250330
4 changed files with 56 additions and 12 deletions

View File

@ -1900,3 +1900,12 @@ PMS 功能仍 a56dac5,本轮未重建 PMS 容器。07:27 三票历史修复数
五分钟任务已于 07:35 正常调度且回调成功。未采纳提议或制造成交。真实消费、提议重核案例及自然新成交仍待判收。 五分钟任务已于 07:35 正常调度且回调成功。未采纳提议或制造成交。真实消费、提议重核案例及自然新成交仍待判收。
PMS 页面需要重新登录;基座页面版本与研究字段已实机核对。automation-2 下一次为今日 08:55,核对正常消费及区制修订。 PMS 页面需要重新登录;基座页面版本与研究字段已实机核对。automation-2 下一次为今日 08:55,核对正常消费及区制修订。
详见四仓同步《交接_每日选股计划完善_2026-10-15.md》最后一节。 详见四仓同步《交接_每日选股计划完善_2026-10-15.md》最后一节。
## 2026-10-16 · 计划版本页面比对兼容修复
浏览器验收发现,新版收到记录已使用 decision 与查询范围指纹,变化与预览仍以旧名册指纹比较。
因此落库成功也被误报为未落快照。写入和两个比对出口统一使用收到计划的身份。
旧数据继续按旧名册口径,不改变消费记录、排序或提议行为。页面删去实时重装配及永远按分数排序的过期说明。
新增离线往返回归:相同版本与范围通过,新版本或范围变更不误认;落新记录后恢复一致。
部署与实机判收另记到四仓同步交接。用户原有未提交台账内容保留。

View File

@ -929,6 +929,15 @@ def _sync_themes_quiet(plan: dict) -> dict:
# ================================================================ 名册快照与榜单变化 # ================================================================ 名册快照与榜单变化
def _received_digest(plan: dict, roster=None) -> str:
"""写入与页面比对共用收到计划的身份,旧数据继续使用名册指纹。"""
if plan.get('decision'):
from app.core.plan_revision import fingerprint
return fingerprint({'received': plan['decision'], 'requested': plan.get('requested')})[:40]
from app.core import plan_diff as pdf
return pdf.digest_of(pdf.roster_of(plan) if roster is None else roster)
def snapshot(plan: dict, *, keep: int = 200, rank_mode=None) -> dict: def snapshot(plan: dict, *, keep: int = 200, rank_mode=None) -> dict:
"""把这一份计划的名册落进 pms_plan_snapshot。 """把这一份计划的名册落进 pms_plan_snapshot。
@ -938,10 +947,7 @@ def snapshot(plan: dict, *, keep: int = 200, rank_mode=None) -> dict:
from app.core import plan_diff as pdf from app.core import plan_diff as pdf
from app.repo import pms_repo from app.repo import pms_repo
roster = pdf.roster_of(plan) roster = pdf.roster_of(plan)
digest = pdf.digest_of(roster) digest = _received_digest(plan, roster)
if plan.get('decision'):
from app.core.plan_revision import fingerprint
digest = fingerprint({'received': plan['decision'], 'requested': plan.get('requested')})[:40]
meta = {k: plan.get(k) for k in ("counts", "returned", "requested", "funnel", meta = {k: plan.get(k) for k in ("counts", "returned", "requested", "funnel",
"theme_cap", "heat_date", "market_snapshot_days", "theme_cap", "heat_date", "market_snapshot_days",
"age_tdays", "url", "age_tdays", "url",
@ -1048,7 +1054,7 @@ def preview_changes(plan: dict, *, held=None, rank_jump=None, tail_guard=None) -
curr_roster = pdf.roster_of(plan) curr_roster = pdf.roster_of(plan)
out["stored"] = ({k: prev.get(k) for k in ("id", "plan_date", "digest", "fetched_at")} out["stored"] = ({k: prev.get(k) for k in ("id", "plan_date", "digest", "fetched_at")}
if prev else None) if prev else None)
out["same_as_stored"] = bool(prev and prev["digest"] == pdf.digest_of(curr_roster)) out["same_as_stored"] = bool(prev and prev["digest"] == _received_digest(plan, curr_roster))
try: try:
tr = plan.get("truncated") or {} tr = plan.get("truncated") or {}
out["diff"] = _diff_against(prev, curr_roster, curr_date=plan.get("date"), out["diff"] = _diff_against(prev, curr_roster, curr_date=plan.get("date"),
@ -1084,7 +1090,7 @@ def changes(*, held=None, rank_jump=None, tail_guard=None) -> dict:
curr_digest = None curr_digest = None
try: # 先确保手上这份已落库 (缓存命中时是个空操作) try: # 先确保手上这份已落库 (缓存命中时是个空操作)
plan = get_plan() plan = get_plan()
curr_digest = pdf.digest_of(pdf.roster_of(plan)) curr_digest = _received_digest(plan)
except PlanFeedError as e: except PlanFeedError as e:
out["hint"] = f"上游计划取不到, 只能拿库里的历史快照比: {e}" out["hint"] = f"上游计划取不到, 只能拿库里的历史快照比: {e}"
except Exception as e: except Exception as e:

View File

@ -1991,7 +1991,9 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
</el-table-column> </el-table-column>
<el-table-column prop="qty" label="数量" width="90"></el-table-column> <el-table-column prop="qty" label="数量" width="90"></el-table-column>
<el-table-column label="当时的依据" min-width="320"> <el-table-column label="当时的依据" min-width="320">
<template #default="s"><span class="muted" :title="JSON.stringify(s.row.hard_numbers)">{{ hardCn(s.row.hard_numbers) }}</span></template> <template #default="s"><span class="muted" :title="JSON.stringify(s.row.hard_numbers)">{{ hardCn(s.row.hard_numbers) }}</span>
<details><summary>计划版本与研究变化</summary><div v-for="line in researchInfo(s.row.hard_numbers)" :key="line">{{ line }}</div></details>
</template>
</el-table-column> </el-table-column>
<el-table-column label="研判" width="100"> <el-table-column label="研判" width="100">
<template #default="s">{{ tx('verdict', s.row.judge_verdict) }}</template> <template #default="s">{{ tx('verdict', s.row.judge_verdict) }}</template>
@ -2418,7 +2420,7 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
</span> </span>
</div> </div>
<div class="muted" v-if="planLoading" style="margin-bottom:8px"> <div class="muted" v-if="planLoading" style="margin-bottom:8px">
正在读上游选股计划…… 上游装配这份计划要读论断与催化事件,慢的时候要几十秒。 正在读上游选股计划…… 正在读取已发布的完整版本。
这段时间抽屉是空的,属正常;真读不到会在这里显示原因。</div> 这段时间抽屉是空的,属正常;真读不到会在这里显示原因。</div>
<el-alert v-if="planStatus.ok===false" type="error" effect="dark" show-icon :closable="false" <el-alert v-if="planStatus.ok===false" type="error" effect="dark" show-icon :closable="false"
:title="planStatus.hint || '不可用'" style="margin-bottom:8px"></el-alert> :title="planStatus.hint || '不可用'" style="margin-bottom:8px"></el-alert>
@ -2458,8 +2460,8 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
</el-alert> </el-alert>
<el-alert v-else-if="chg && chg.kind === 'same_date_revision'" type="warning" effect="dark" <el-alert v-else-if="chg && chg.kind === 'same_date_revision'" type="warning" effect="dark"
show-icon :closable="false" style="margin-bottom:8px" show-icon :closable="false" style="margin-bottom:8px"
:title="'同一计划日 ' + chg.curr_date + ' 的新版本 —— 上游重算过, ' :title="'同一计划日 ' + chg.curr_date + ' 的完整修订版本:'
+ '此前拿到的候选池与现在不是同一份 (上游 /plan 是实时算的, 没有版本戳)'"> + '此前收到的计划与当前版本不同,请查看版本及研究变化'">
</el-alert> </el-alert>
<el-alert v-if="chg && chg.prev_broken" type="error" effect="dark" show-icon <el-alert v-if="chg && chg.prev_broken" type="error" effect="dark" show-icon
:closable="false" style="margin-bottom:8px" :closable="false" style="margin-bottom:8px"
@ -2596,7 +2598,7 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
</el-table> </el-table>
<div class="muted" style="margin-top:6px"> <div class="muted" style="margin-top:6px">
「预期空间」= 券商目标价相对现价的空间 (上游 upside 字段, 2.12 → +212%)。噪音大, 「预期空间」= 券商目标价相对现价的空间 (上游 upside 字段, 2.12 → +212%)。噪音大,
所以<b>永不参与排序</b> —— 排序始终按 score; 只在 PMS_PLAN_MIN_UPSIDE 上做下限过滤。 所以<b>永不参与排序</b> —— 排序按当前有效模式与稳定次级键; 只在 PMS_PLAN_MIN_UPSIDE 上做下限过滤。
筛选参数在「参数设置」里: PMS_PLAN_TOP_N / PMS_PLAN_TIERS / PMS_PLAN_INCLUDE_OBSERVE / 筛选参数在「参数设置」里: PMS_PLAN_TOP_N / PMS_PLAN_TIERS / PMS_PLAN_INCLUDE_OBSERVE /
PMS_PLAN_MIN_SCORE / PMS_PLAN_MIN_SOURCES / PMS_PLAN_MIN_UPSIDE / PMS_PLAN_STALE_TDAYS。 PMS_PLAN_MIN_SCORE / PMS_PLAN_MIN_SOURCES / PMS_PLAN_MIN_UPSIDE / PMS_PLAN_STALE_TDAYS。
<br>发给上游的三个: PMS_PLAN_TOP / PMS_PLAN_OBS_TOP / PMS_PLAN_THEME_CAP (0=不传, 用上游默认 <br>发给上游的三个: PMS_PLAN_TOP / PMS_PLAN_OBS_TOP / PMS_PLAN_THEME_CAP (0=不传, 用上游默认
@ -4453,7 +4455,7 @@ createApp({
const src = p.plan_source === 'snapshot' ? '快照' : (p.plan_source === 'live' ? '现算' : (p.plan_source || '')); const src = p.plan_source === 'snapshot' ? '快照' : (p.plan_source === 'live' ? '现算' : (p.plan_source || ''));
const gate = p.gate_on === true ? '赛道闸开' : (p.gate_on === false ? '赛道闸关' : ''); const gate = p.gate_on === true ? '赛道闸开' : (p.gate_on === false ? '赛道闸关' : '');
const head = ['选股计划数据至 ' + (p.date || '-') + '(数据日)', planFetchedText.value, src, gate].filter(Boolean).join(' · '); const head = ['选股计划数据至 ' + (p.date || '-') + '(数据日)', planFetchedText.value, src, gate].filter(Boolean).join(' · ');
return head + '。日期是数据日:上游每个交易日早上 07:10 用上一交易日收盘后的证据出计划,持仓系统 08:40 拉取,所以正常显示的是上一交易日。计划来源:快照=盘前落盘那份,毫秒返回、可复现;现算=盘中实时装配,较慢,多半是快照过旧回落。赛道闸=上游是否只在十条赛道内选票。'; return head + '。日期是数据日:上游每个交易日早上 07:10 用上一交易日收盘后的证据出计划,持仓系统 08:40 拉取,所以正常显示的是上一交易日。计划来源:快照=已发布的完整版本,包含晨版及盘中修订;旧接口的现算来源不具备同等追溯能力。赛道闸=上游是否只在十条赛道内选票。';
}); });
const hdMenuLabel = computed(() => { const hdMenuLabel = computed(() => {
const who = authed.value ? (me.value.username || me.value.phone || '') : ''; const who = authed.value ? (me.value.username || me.value.phone || '') : '';

View File

@ -632,6 +632,33 @@ def t_j4():
restore() restore()
@case("[J4b] 新版收到身份写读一致,查询范围变化不能误认同版")
def t_j4b():
from app.services import plan_feed as pf
from unittest.mock import patch
store, restore = _with_fake_repo({"PMS_PLAN_SNAPSHOT": True})
try:
p = plan(main=[_r("600418.SH", 1, 242.24)], date="2026-07-30")
p.update(decision={'id': 'decision-a', 'input_hash': 'a'}, requested={'top': 300})
pf.snapshot(p)
before = len(store['rows'])
assert pf.preview_changes(p)['same_as_stored'] is True
with patch.object(pf, 'get_plan', return_value=p):
assert pf.changes()['in_sync'] is True
assert len(store['rows']) == before
q = dict(p, requested={'top': 100})
assert pf.preview_changes(q)['same_as_stored'] is False
q = dict(p, decision={'id': 'decision-b', 'input_hash': 'b'})
with patch.object(pf, 'get_plan', return_value=q):
assert pf.changes()['in_sync'] is False
assert pf.snapshot(q)['stored'] is True
with patch.object(pf, 'get_plan', return_value=q):
assert pf.changes()['in_sync'] is True
assert pf.preview_changes(q)['same_as_stored'] is True
finally:
restore()
@case("[J5] 只读预览能比出手上这份与库里那份的差异") @case("[J5] 只读预览能比出手上这份与库里那份的差异")
def t_j5(): def t_j5():
from app.services import plan_feed as pf from app.services import plan_feed as pf