diff --git a/DEVLOG.md b/DEVLOG.md index 201df83..4180b40 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -1955,3 +1955,10 @@ PMS 页面需要重新登录;基座页面版本与研究字段已实机核对 16d8fd7、564f553 已在 155 git pull 生效。两个候选包均在匹配镜像禁网通过三项守卫。写前备份留在 ~/pms_logs/,最终静态页 SHA256 为 8cd75f4cafd3e23f90c036ac2accd5131568ad2c26bc7bc844f5d45cd8808b31。四容器镜像、启动时间与写前一致,未重启;188 不动。 浏览器实测 3 提议、13 候选、2 当前方案。瑞芯微数量差异及最低一手说明保留;候选等待条件、计划入口与折叠正常。右栏 clientWidth=scrollWidth=317,候选行均为 295,错误数为零。未测试交易提交。详情见四仓同步交接 10:20 节;自然事件技术验收仍待后续正常事件。 + + +## 2026-10-16 · 提议重核稳定依据修复 + +自然用户两次确认科士达提议均被要求重新确认。代码误将实时报价计算的在途金额纳入仓位依据指纹。新增稳定承诺依据,覆盖来源、标识、股票、数量及执行进度;指纹按其比较。额度仍使用原实时金额,原规则闸与执行终检保持。旧输入缺稳定依据时保守比较,旧提议可重核一次。 + +先新增三个回归,原代码出现两失败一错误。修复只涉及 plan_revision.py 与 proposal_service.py;既有批次 38 增加回归并更新入口说明。预期仅报价变化不会再重跑研判或重复要求确认,实际数量、承诺、持仓和资金变化仍重核。关联桥台账 069。部署前须本地和候选镜像禁网通过,再仅在 155 重建四容器。具体部署与真机判收待追加。 diff --git a/app/core/plan_revision.py b/app/core/plan_revision.py index d866032..563b0b1 100644 --- a/app/core/plan_revision.py +++ b/app/core/plan_revision.py @@ -18,12 +18,18 @@ def basis(row): def portfolio_context(view, commitments, stock=None): """持仓/资金/在途承诺变化使旧数量依据失效;价格漂移仍由执行终检管理。""" 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')) for r in view.get('held') or []), 'cash_source': totals.get('cash_source'), 'cash_avail': totals.get('cash_avail'), 'sector_ready': view.get('sector_ready'), 'stock': stock or {}, - 'inflight': {'names': sorted(commitments.get('names') or []), - 'amount': commitments.get('amount')}}) + 'inflight': inflight}) def compare(old, current, code): diff --git a/app/services/proposal_service.py b/app/services/proposal_service.py index fddfd34..d8be527 100644 --- a/app/services/proposal_service.py +++ b/app/services/proposal_service.py @@ -597,19 +597,22 @@ def _no_candidate_why(sel) -> str: def _inflight_open_commitments(view, exclude_proposal=None) -> dict: - """在途的新建仓承诺: {names: {代码}, amount: 预估占用金额}。 + """在途的新建仓承诺: names 为代码集合,amount 为预估占用,basis 为稳定核验依据。 口径: 在途 (LIVE) 的 OPEN 指令 + 等拍板 (WAIT_USER) 的 OPEN 提议, 只算**尚未持有** 的票 (已成交的部分 names_count 里已经有了)。金额按 数量×现价 估 (指令下发前没有限价), 现价取不到时按提议里存的价兜底。读库失败按空 —— 与其它在途读取同一口径, 宁可这轮 多放也不能因读不到把新建仓全停 (规则闸和真实资金封顶仍在后面兜)。""" names, amount = set(), 0.0 + basis = [] held = {x["ts_code"] for x in view.get("held") or []} rows = [] try: 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: 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: logger.warning("读在途 OPEN 指令失败 (按无在途): %s", e) try: @@ -620,10 +623,12 @@ def _inflight_open_commitments(view, exclude_proposal=None) -> dict: hn = pr.get("hard_numbers") or {} rows.append((pr["ts_code"], int(pr.get("qty") or 0), 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: logger.warning("读在途 OPEN 提议失败 (按无在途): %s", e) if not rows: - return {"names": names, "amount": 0.0} + return {"names": names, "amount": 0.0, "basis": []} try: prices = market.get_prices(sorted({c for c, _, _ in rows})) except Exception: @@ -634,7 +639,8 @@ def _inflight_open_commitments(view, exclude_proposal=None) -> dict: names.add(code) px = float(prices.get(code) or 0) or float(px_hint or 0) 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: diff --git a/scripts/run_tests.py b/scripts/run_tests.py index d44f540..1a089bb 100644 --- a/scripts/run_tests.py +++ b/scripts/run_tests.py @@ -7,7 +7,8 @@ 包含 (例数按 2026-10-12 隔离回归实跑校正): test_batch40_units.py 逐笔历史修复、进度暂停、并发与事务回滚与均价失效 (22 例) test_batch39_units.py 成交双来源归属、迟到、部分成交与失败守成 (12 例) - test_batch38_units.py 每日计划顺序、版本核验、重新确认、并发采纳、历史保留 (15 例) + test_batch38_units.py 每日计划顺序、版本核验、重新确认、并发采纳、历史保留 (18 例) + 含在途报价变化不重复确认、真实承诺变化仍重核。 test_core_units.py 仓位规划器 / 安全垫与成本账 (14 例) test_batch2_units.py 命令状态机 / 方案生成器 / 回放对账纯逻辑 (35 例) test_batch3_units.py 规则闸 / 择时执行器实现B 纯逻辑 / 用户止损价只披露不拦截 (26 例) diff --git a/scripts/test_batch38_units.py b/scripts/test_batch38_units.py index ab93dec..1427e88 100644 --- a/scripts/test_batch38_units.py +++ b/scripts/test_batch38_units.py @@ -37,6 +37,77 @@ class DailyPlanTest(unittest.TestCase): commitments.update(names=['600002.SH'], amount=1000) 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): raw = {'date': '2026-10-14', 'rank_axis': 'quality', 'decision': {'id': 'v2'}, 'main': [{'code': 'SH600001', 'rank': 1, 'score': 230, 'tier': '强传导', 'evidence': {'theme': '电子'}},