提议重核:报价波动不重复触发确认,保留真实承诺变化核验
一、修复重复确认 正常用户在 10:02 和 10:04 两次确认科士达均被要求重核。实时估算在途金额进入仓位指纹,报价变化会误判为承诺变化。稳定依据改用来源、标识、股票、数量与成交进度。 二、验证与边界 批次 38 的 18 个用例和完整 44 批隔离禁网回归通过。涵盖纯报价不重跑研判、真实承诺变更、旧输入保守兼容、并发确认。新增字段不改变原占用金额计算、候选顺序与执行终检。 三、部署范围 只部署已授权的 155 模拟 PMS,188 不动。旧提议格式升级仍需一次重核,不迁移旧批准。桥决定台账 069 与四仓规格、交接另行同步。用户已有台账草稿与探针不纳入。
This commit is contained in:
parent
1d7c74f46c
commit
15072f72e4
|
|
@ -1955,3 +1955,10 @@ PMS 页面需要重新登录;基座页面版本与研究字段已实机核对
|
||||||
16d8fd7、564f553 已在 155 git pull 生效。两个候选包均在匹配镜像禁网通过三项守卫。写前备份留在 ~/pms_logs/,最终静态页 SHA256 为 8cd75f4cafd3e23f90c036ac2accd5131568ad2c26bc7bc844f5d45cd8808b31。四容器镜像、启动时间与写前一致,未重启;188 不动。
|
16d8fd7、564f553 已在 155 git pull 生效。两个候选包均在匹配镜像禁网通过三项守卫。写前备份留在 ~/pms_logs/,最终静态页 SHA256 为 8cd75f4cafd3e23f90c036ac2accd5131568ad2c26bc7bc844f5d45cd8808b31。四容器镜像、启动时间与写前一致,未重启;188 不动。
|
||||||
|
|
||||||
浏览器实测 3 提议、13 候选、2 当前方案。瑞芯微数量差异及最低一手说明保留;候选等待条件、计划入口与折叠正常。右栏 clientWidth=scrollWidth=317,候选行均为 295,错误数为零。未测试交易提交。详情见四仓同步交接 10:20 节;自然事件技术验收仍待后续正常事件。
|
浏览器实测 3 提议、13 候选、2 当前方案。瑞芯微数量差异及最低一手说明保留;候选等待条件、计划入口与折叠正常。右栏 clientWidth=scrollWidth=317,候选行均为 295,错误数为零。未测试交易提交。详情见四仓同步交接 10:20 节;自然事件技术验收仍待后续正常事件。
|
||||||
|
|
||||||
|
|
||||||
|
## 2026-10-16 · 提议重核稳定依据修复
|
||||||
|
|
||||||
|
自然用户两次确认科士达提议均被要求重新确认。代码误将实时报价计算的在途金额纳入仓位依据指纹。新增稳定承诺依据,覆盖来源、标识、股票、数量及执行进度;指纹按其比较。额度仍使用原实时金额,原规则闸与执行终检保持。旧输入缺稳定依据时保守比较,旧提议可重核一次。
|
||||||
|
|
||||||
|
先新增三个回归,原代码出现两失败一错误。修复只涉及 plan_revision.py 与 proposal_service.py;既有批次 38 增加回归并更新入口说明。预期仅报价变化不会再重跑研判或重复要求确认,实际数量、承诺、持仓和资金变化仍重核。关联桥台账 069。部署前须本地和候选镜像禁网通过,再仅在 155 重建四容器。具体部署与真机判收待追加。
|
||||||
|
|
|
||||||
|
|
@ -18,12 +18,18 @@ def basis(row):
|
||||||
def portfolio_context(view, commitments, stock=None):
|
def portfolio_context(view, commitments, stock=None):
|
||||||
"""持仓/资金/在途承诺变化使旧数量依据失效;价格漂移仍由执行终检管理。"""
|
"""持仓/资金/在途承诺变化使旧数量依据失效;价格漂移仍由执行终检管理。"""
|
||||||
totals = view.get('totals') or {}
|
totals = view.get('totals') or {}
|
||||||
|
inflight = {'names': sorted(commitments.get('names') or [])}
|
||||||
|
# 实时报价只改变预估占用,不能使同一份确认每次失效。
|
||||||
|
# 旧调用方缺少稳定依据时保留原比较;旧提议指纹变化仍须重核一次。
|
||||||
|
if isinstance(commitments.get('basis'), list):
|
||||||
|
inflight['basis'] = sorted(commitments['basis'])
|
||||||
|
else:
|
||||||
|
inflight['amount'] = commitments.get('amount')
|
||||||
return fingerprint({'held': sorted((r['ts_code'], r.get('total_qty'), r.get('sector'))
|
return fingerprint({'held': sorted((r['ts_code'], r.get('total_qty'), r.get('sector'))
|
||||||
for r in view.get('held') or []),
|
for r in view.get('held') or []),
|
||||||
'cash_source': totals.get('cash_source'), 'cash_avail': totals.get('cash_avail'),
|
'cash_source': totals.get('cash_source'), 'cash_avail': totals.get('cash_avail'),
|
||||||
'sector_ready': view.get('sector_ready'), 'stock': stock or {},
|
'sector_ready': view.get('sector_ready'), 'stock': stock or {},
|
||||||
'inflight': {'names': sorted(commitments.get('names') or []),
|
'inflight': inflight})
|
||||||
'amount': commitments.get('amount')}})
|
|
||||||
|
|
||||||
|
|
||||||
def compare(old, current, code):
|
def compare(old, current, code):
|
||||||
|
|
|
||||||
|
|
@ -597,19 +597,22 @@ def _no_candidate_why(sel) -> str:
|
||||||
|
|
||||||
|
|
||||||
def _inflight_open_commitments(view, exclude_proposal=None) -> dict:
|
def _inflight_open_commitments(view, exclude_proposal=None) -> dict:
|
||||||
"""在途的新建仓承诺: {names: {代码}, amount: 预估占用金额}。
|
"""在途的新建仓承诺: names 为代码集合,amount 为预估占用,basis 为稳定核验依据。
|
||||||
|
|
||||||
口径: 在途 (LIVE) 的 OPEN 指令 + 等拍板 (WAIT_USER) 的 OPEN 提议, 只算**尚未持有**
|
口径: 在途 (LIVE) 的 OPEN 指令 + 等拍板 (WAIT_USER) 的 OPEN 提议, 只算**尚未持有**
|
||||||
的票 (已成交的部分 names_count 里已经有了)。金额按 数量×现价 估 (指令下发前没有限价),
|
的票 (已成交的部分 names_count 里已经有了)。金额按 数量×现价 估 (指令下发前没有限价),
|
||||||
现价取不到时按提议里存的价兜底。读库失败按空 —— 与其它在途读取同一口径, 宁可这轮
|
现价取不到时按提议里存的价兜底。读库失败按空 —— 与其它在途读取同一口径, 宁可这轮
|
||||||
多放也不能因读不到把新建仓全停 (规则闸和真实资金封顶仍在后面兜)。"""
|
多放也不能因读不到把新建仓全停 (规则闸和真实资金封顶仍在后面兜)。"""
|
||||||
names, amount = set(), 0.0
|
names, amount = set(), 0.0
|
||||||
|
basis = []
|
||||||
held = {x["ts_code"] for x in view.get("held") or []}
|
held = {x["ts_code"] for x in view.get("held") or []}
|
||||||
rows = []
|
rows = []
|
||||||
try:
|
try:
|
||||||
for i in pms_repo.list_instructions(statuses=list(executor.LIVE), limit=300):
|
for i in pms_repo.list_instructions(statuses=list(executor.LIVE), limit=300):
|
||||||
if i.get("action") == ae.A_OPEN and i["ts_code"] not in held:
|
if i.get("action") == ae.A_OPEN and i["ts_code"] not in held:
|
||||||
rows.append((i["ts_code"], int(i.get("qty") or 0), None))
|
rows.append((i["ts_code"], int(i.get("qty") or 0), None))
|
||||||
|
basis.append(('instruction', str(i.get('instruction_id') or ''),
|
||||||
|
i['ts_code'], int(i.get('qty') or 0), int(i.get('exec_qty') or 0)))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("读在途 OPEN 指令失败 (按无在途): %s", e)
|
logger.warning("读在途 OPEN 指令失败 (按无在途): %s", e)
|
||||||
try:
|
try:
|
||||||
|
|
@ -620,10 +623,12 @@ def _inflight_open_commitments(view, exclude_proposal=None) -> dict:
|
||||||
hn = pr.get("hard_numbers") or {}
|
hn = pr.get("hard_numbers") or {}
|
||||||
rows.append((pr["ts_code"], int(pr.get("qty") or 0),
|
rows.append((pr["ts_code"], int(pr.get("qty") or 0),
|
||||||
float(hn.get("price") or 0) or None))
|
float(hn.get("price") or 0) or None))
|
||||||
|
basis.append(('proposal', str(pr.get('proposal_id') or ''),
|
||||||
|
pr['ts_code'], int(pr.get('qty') or 0), 0))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("读在途 OPEN 提议失败 (按无在途): %s", e)
|
logger.warning("读在途 OPEN 提议失败 (按无在途): %s", e)
|
||||||
if not rows:
|
if not rows:
|
||||||
return {"names": names, "amount": 0.0}
|
return {"names": names, "amount": 0.0, "basis": []}
|
||||||
try:
|
try:
|
||||||
prices = market.get_prices(sorted({c for c, _, _ in rows}))
|
prices = market.get_prices(sorted({c for c, _, _ in rows}))
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|
@ -634,7 +639,8 @@ def _inflight_open_commitments(view, exclude_proposal=None) -> dict:
|
||||||
names.add(code)
|
names.add(code)
|
||||||
px = float(prices.get(code) or 0) or float(px_hint or 0)
|
px = float(prices.get(code) or 0) or float(px_hint or 0)
|
||||||
amount += qty * px
|
amount += qty * px
|
||||||
return {"names": names, "amount": round(amount, 2)}
|
# 金额继续按原顺序逐票去重。核验依据保留全部承诺,避免等额替换或数量变化漏检。
|
||||||
|
return {"names": names, "amount": round(amount, 2), "basis": sorted(basis)}
|
||||||
|
|
||||||
|
|
||||||
def _plain_check(c) -> str:
|
def _plain_check(c) -> str:
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,8 @@
|
||||||
包含 (例数按 2026-10-12 隔离回归实跑校正):
|
包含 (例数按 2026-10-12 隔离回归实跑校正):
|
||||||
test_batch40_units.py 逐笔历史修复、进度暂停、并发与事务回滚与均价失效 (22 例)
|
test_batch40_units.py 逐笔历史修复、进度暂停、并发与事务回滚与均价失效 (22 例)
|
||||||
test_batch39_units.py 成交双来源归属、迟到、部分成交与失败守成 (12 例)
|
test_batch39_units.py 成交双来源归属、迟到、部分成交与失败守成 (12 例)
|
||||||
test_batch38_units.py 每日计划顺序、版本核验、重新确认、并发采纳、历史保留 (15 例)
|
test_batch38_units.py 每日计划顺序、版本核验、重新确认、并发采纳、历史保留 (18 例)
|
||||||
|
含在途报价变化不重复确认、真实承诺变化仍重核。
|
||||||
test_core_units.py 仓位规划器 / 安全垫与成本账 (14 例)
|
test_core_units.py 仓位规划器 / 安全垫与成本账 (14 例)
|
||||||
test_batch2_units.py 命令状态机 / 方案生成器 / 回放对账纯逻辑 (35 例)
|
test_batch2_units.py 命令状态机 / 方案生成器 / 回放对账纯逻辑 (35 例)
|
||||||
test_batch3_units.py 规则闸 / 择时执行器实现B 纯逻辑 / 用户止损价只披露不拦截 (26 例)
|
test_batch3_units.py 规则闸 / 择时执行器实现B 纯逻辑 / 用户止损价只披露不拦截 (26 例)
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,77 @@ class DailyPlanTest(unittest.TestCase):
|
||||||
commitments.update(names=['600002.SH'], amount=1000)
|
commitments.update(names=['600002.SH'], amount=1000)
|
||||||
self.assertNotEqual(original, pr.portfolio_context(view, commitments))
|
self.assertNotEqual(original, pr.portfolio_context(view, commitments))
|
||||||
|
|
||||||
|
def test_commitment_quotes_do_not_change_quantity_basis(self):
|
||||||
|
view = {'held': [], 'totals': {'cash_source': 'ws', 'cash_avail': 10000}}
|
||||||
|
old = {'names': {'600002.SH'}, 'amount': 1000,
|
||||||
|
'basis': [('instruction', 'i1', '600002.SH', 100, 0)]}
|
||||||
|
changed = dict(old, amount=1100)
|
||||||
|
self.assertEqual(pr.portfolio_context(view, old), pr.portfolio_context(view, changed))
|
||||||
|
for basis in [[('instruction', 'i2', '600002.SH', 100, 0)],
|
||||||
|
[('instruction', 'i1', '600002.SH', 200, 0)],
|
||||||
|
[('instruction', 'i1', '600002.SH', 100, 50)], []]:
|
||||||
|
self.assertNotEqual(pr.portfolio_context(view, old),
|
||||||
|
pr.portfolio_context(view, dict(old, basis=basis)))
|
||||||
|
double = dict(old, basis=old['basis'] + [('proposal', 'p1', '600003.SH', 100, 0)])
|
||||||
|
self.assertEqual(pr.portfolio_context(view, double),
|
||||||
|
pr.portfolio_context(view, dict(double, basis=list(reversed(double['basis'])))))
|
||||||
|
self.assertNotEqual(pr.portfolio_context(view, old), pr.portfolio_context(view, old, {'black': True}))
|
||||||
|
changed_view = copy.deepcopy(view)
|
||||||
|
changed_view['totals']['cash_avail'] = 9000
|
||||||
|
self.assertNotEqual(pr.portfolio_context(view, old), pr.portfolio_context(changed_view, old))
|
||||||
|
legacy = {k: v for k, v in old.items() if k != 'basis'}
|
||||||
|
self.assertNotEqual(pr.portfolio_context(view, legacy),
|
||||||
|
pr.portfolio_context(view, dict(legacy, amount=1100)))
|
||||||
|
|
||||||
|
def test_inflight_basis_is_stable_and_preserves_amount_rules(self):
|
||||||
|
from app.services import proposal_service as ps
|
||||||
|
from app.repo import pms_repo
|
||||||
|
ins = [{'instruction_id': 'i1', 'action': 'OPEN', 'ts_code': '600002.SH', 'qty': 100, 'exec_qty': 0},
|
||||||
|
{'instruction_id': 'held', 'action': 'OPEN', 'ts_code': '600003.SH', 'qty': 100}]
|
||||||
|
proposals = [{'proposal_id': 'own', 'action': 'OPEN', 'ts_code': '600001.SH', 'qty': 100},
|
||||||
|
{'proposal_id': 'p2', 'action': 'OPEN', 'ts_code': '600002.SH', 'qty': 200,
|
||||||
|
'hard_numbers': {'price': 9}}]
|
||||||
|
view = {'held': [{'ts_code': '600003.SH'}]}
|
||||||
|
with patch.object(pms_repo, 'list_instructions', return_value=ins), \
|
||||||
|
patch.object(pms_repo, 'list_proposals', return_value=proposals), \
|
||||||
|
patch.object(ps.market, 'get_prices', side_effect=[{'600002.SH': 10}, {'600002.SH': 11}, {}]):
|
||||||
|
old = ps._inflight_open_commitments(view, exclude_proposal='own')
|
||||||
|
new = ps._inflight_open_commitments(view, exclude_proposal='own')
|
||||||
|
missing_price = ps._inflight_open_commitments(view, exclude_proposal='own')
|
||||||
|
self.assertEqual(old['names'], {'600002.SH'})
|
||||||
|
self.assertEqual(old['amount'], 1000)
|
||||||
|
self.assertEqual(new['amount'], 1100)
|
||||||
|
self.assertEqual(missing_price['amount'], 0) # 原首条指令优先去重口径保持。
|
||||||
|
self.assertEqual(old['basis'], new['basis'])
|
||||||
|
self.assertEqual(old['basis'], [('instruction', 'i1', '600002.SH', 100, 0),
|
||||||
|
('proposal', 'p2', '600002.SH', 200, 0)])
|
||||||
|
with patch.object(pms_repo, 'list_instructions', return_value=[]), \
|
||||||
|
patch.object(pms_repo, 'list_proposals', return_value=[]), \
|
||||||
|
patch.object(ps.market, 'get_prices', side_effect=AssertionError('无在途不读行情')):
|
||||||
|
self.assertEqual(ps._inflight_open_commitments(view)['basis'], [])
|
||||||
|
|
||||||
|
def test_quote_only_recheck_keeps_confirmation_without_model(self):
|
||||||
|
from app.services import plan_recheck as rc, proposal_service as ps, portfolio, command_service
|
||||||
|
row = dict(selected()[0], consumption_id='test-consumption')
|
||||||
|
view = {'held': [], 'totals': {'cash_source': 'ws', 'cash_avail': 10000}}
|
||||||
|
old = {'names': {'600002.SH'}, 'amount': 1000,
|
||||||
|
'basis': [('instruction', 'i1', '600002.SH', 100, 0)]}
|
||||||
|
hard = {'plan_basis': pr.basis(row), 'scan_params_hash': pr.fingerprint({}),
|
||||||
|
'portfolio_context_hash': pr.portfolio_context(view, old), 'proposal_revision': 'same'}
|
||||||
|
p = {'proposal_id': 'p1', 'action': 'OPEN', 'ts_code': row['ts_code'], 'hard_numbers': hard}
|
||||||
|
with patch.object(portfolio, 'positions_view', return_value=view), \
|
||||||
|
patch.object(command_service, 'effective_stock_params', return_value={}), \
|
||||||
|
patch.object(ps, '_scan_params', return_value={}), \
|
||||||
|
patch.object(pf, 'candidates', return_value={'items': [row], 'decision': row['decision']}), \
|
||||||
|
patch.object(ps, '_inflight_open_commitments', return_value=dict(old, amount=1100)), \
|
||||||
|
patch.object(ps, '_scan_open', side_effect=AssertionError('报价变化不得重算')) as scan, \
|
||||||
|
patch.object(ps, '_route_one', side_effect=AssertionError('报价变化不得调用研判')) as route:
|
||||||
|
out = rc.before_accept(p, 'same')
|
||||||
|
self.assertTrue(out['ok'], out)
|
||||||
|
self.assertEqual(out['proposal']['hard_numbers']['version_check']['status'], 'passed')
|
||||||
|
scan.assert_not_called()
|
||||||
|
route.assert_not_called()
|
||||||
|
|
||||||
def test_recheck_ignores_rank_cutoffs_but_keeps_qualification(self):
|
def test_recheck_ignores_rank_cutoffs_but_keeps_qualification(self):
|
||||||
raw = {'date': '2026-10-14', 'rank_axis': 'quality', 'decision': {'id': 'v2'},
|
raw = {'date': '2026-10-14', 'rank_axis': 'quality', 'decision': {'id': 'v2'},
|
||||||
'main': [{'code': 'SH600001', 'rank': 1, 'score': 230, 'tier': '强传导', 'evidence': {'theme': '电子'}},
|
'main': [{'code': 'SH600001', 'rank': 1, 'score': 230, 'tier': '强传导', 'evidence': {'theme': '电子'}},
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue