提议采纳:后台完成准备,人工确认只做快速检查(桥台账077)

一、待确认建议先完成研究、数量、规则和消费留痕。研究变化交后台原扫描重评,组合变化复用研判,准备结果与审计原子发布。

二、采纳不调用模型或新增消费快照,固定用户看到的数量,保留资料新鲜度、资金、规则、到期和买卖冲突检查。更新与确认通过原JSON条件互斥。

三、54项专项与46批隔离禁网回归通过,浏览器替身交互零错误。交易时段、策略阈值和既有执行终检保持;188及历史账务不改。生产部署另行判收。
This commit is contained in:
baobao 2026-10-20 11:31:02 +08:00
parent 82f4f00848
commit 0acf7b8fdb
13 changed files with 704 additions and 131 deletions

View File

@ -2055,3 +2055,19 @@ QMT在线,无补同步要求,健康正常,正常回放0差异。93行指
本地Chrome替身页面零错误,线上服务读回进展正确;生产登录浏览器未直接验收。
人工T+1、失败撤单与并发边界仅离线验证,不制造交易,188未操作。
四仓规格与交接逐字同步,完整备份和回退见14:30交接。
## 2026-10-20 提议先准备后采纳:本地实施
用户批准将完整重评移到后台。待确认建议先完成数量、规则和消费记录。
采纳只读当前完整计划并检查已展示数量,不调用研判、不新增消费快照。
原自主扫描维护旧建议,研究变化重评,组合与参数变化复用原研判。
明确否决撤下,来源或留痕失败保持准备中。并发更新采用条件写入。
准备结果与动作审计同一事务,人工确认不能越过旧内容标识。
改动涉及plan_revision、plan_feed、plan_recheck、proposal_service、仓储和网页。
新增batch42共24例,更新batch38共30例。46批本地隔离禁网回归通过。
三页面守卫通过。本地Chrome替身测试页面错误零,确认提交一次。
窄屏展开右栏后遮挡确认框的旧问题同步修复,样式保持。
当前155尚未部署本改动。11:30备份为prepared-before-20261020.json。
现场没有WAIT_USER提议,不将替身验证当作自然用户采纳判收。
正式规格见docs/开发规格_提议先准备后采纳_2026-10-20.md。

View File

@ -45,7 +45,14 @@ def compare(old, current, code):
def needs_new_judgement(hard, previous):
"""决策系统缓存按股票/动作,缺同依据的既有研判就必须要求重新回答。"""
keys = ('plan_basis', 'scan_params_hash', 'portfolio_context_hash')
return (not previous or any(previous.get(k) is None for k in keys)
or any(fingerprint(hard.get(k)) != fingerprint(previous.get(k)) for k in keys))
"""仅研究依据变化重问模型;组合及参数变化复算规则和数量。"""
return (not previous or previous.get('plan_basis') is None
or fingerprint(hard.get('plan_basis')) != fingerprint(previous.get('plan_basis')))
def confirmation_revision(qty, hard, verdict):
"""确认针对可见的建议内容;后台检查时间、名次及无关版本不让确认失效。"""
return fingerprint({'qty': qty, 'basis': hard.get('plan_basis'),
'advice': hard.get('advice'), 'reason': hard.get('reason'),
'verdict': verdict.get('verdict'), 'judge_reason': verdict.get('reason'),
'confidence': verdict.get('confidence')})

View File

@ -332,3 +332,36 @@ def withdraw_rejected_proposal(old, hard):
'failed':encode(check.get('reasons') or []),
'reason':('提议已撤下:'+check['message'])[:500], 'ref':old['proposal_id']})
return 1
def publish_prepared_proposal(old, candidate, verdict):
"""准备结果与变更审计同成同败。旧确认与后台发布只有一个能赢。"""
import json
from datetime import datetime
encode = lambda v: json.dumps(v, ensure_ascii=False, default=str)
hard = candidate['hard_numbers']
if (hard.get('preparation') or {}).get('status') != 'ready' or not hard.get('consumption_id'):
raise ValueError('准备结果缺少完整依据或消费凭据')
old_hard = old.get('hard_numbers') or {}
audit = dict(hard, previous_proposal={'qty': old['qty'], 'decision': old_hard.get('decision'),
'consumption_id': old_hard.get('consumption_id'), 'proposal_revision': old_hard.get('proposal_revision'),
'judge_verdict': old.get('judge_verdict'), 'judge_reason': old.get('judge_reason')})
_check_cooldown('proxy')
with get_engine('proxy').begin() as c:
def query(sql, params):
sql = map_tables(sql)
assert_single_table(sql)
return c.execute(text(sql), params)
n = query("UPDATE pms_proposal SET qty=:qty,hard_numbers_json=:hn,judge_verdict=:jv,judge_reason=:jr "
"WHERE proposal_id=:pid AND status='WAIT_USER' AND hard_numbers_json=:old",
{'qty':candidate['qty'],'hn':encode(hard),'jv':verdict.get('verdict'),
'jr':(verdict.get('reason') or '')[:500],'pid':old['proposal_id'],'old':old['hard_numbers_json']}).rowcount
if not n:
return 0
query('INSERT INTO pms_action_ledger (ts_code,decided_at,action,arbiter,verdict,price_at,'
'hard_numbers_json,failed_checks_json,reason,ref_id,outcome_scored) '
'VALUES (:code,:at,:action,:arbiter,:verdict,:price,:hard,:failed,:reason,:ref,0)',
{'code':old['ts_code'],'at':datetime.now(),'action':old['action'],'arbiter':'rule',
'verdict':'NOTE','price':float(hard.get('price') or 0),'hard':encode(audit),'failed':'[]',
'reason':'后台完成建议更新,等待人工确认','ref':old['proposal_id']})
return 1

View File

@ -1091,3 +1091,8 @@ def list_op_log(*, limit: int = 200) -> list:
for r in rows:
r["params"] = _loads(r.get("params_json"), {})
return rows
def publish_prepared_proposal(old, candidate, verdict):
from app.db.session import publish_prepared_proposal as publish
return publish(old, candidate, verdict)

View File

@ -901,6 +901,15 @@ def get_plan(*, force: bool = False, date=None) -> dict:
return plan
def read_current_plan():
"""采纳只读最新完整发布版,限时取数,不写接收快照或行业映射。"""
p = _params()
plan = fetch(base=p['base'], path=p['path'], timeout=min(3, max(1, p['timeout'])),
extra_params=build_query(p))
assert_fresh(plan, max_stale_tdays=p['stale_tdays'])
return plan
def invalidate():
with _lock:
_cache.update({"at": 0.0, "key": None, "plan": None, "error": None})
@ -1147,7 +1156,7 @@ def snapshot_log(*, plan_date=None, limit: int = 50) -> dict:
# ================================================================ 对外: 候选与状态
def candidates(*, held=(), black=(), route_by_verdict: bool = False, force=False,
record=True, recheck_code=None) -> dict:
record=True, recheck_code=None, plan_override=None) -> dict:
"""参数驱动的候选清单 (不含价格 —— 价格由调用方用 market.get_price 现取)。
route_by_verdict 默认 False: 命令驱动建仓 (command_service._candidates) 不传, 于是用户
@ -1157,7 +1166,7 @@ def candidates(*, held=(), black=(), route_by_verdict: bool = False, force=False
显式传 True。要让命令驱动也按判决过滤, 改这里的调用方, 不改默认值。
"""
p = _params()
plan = get_plan(force=force) if force else get_plan()
plan = plan_override if plan_override is not None else (get_plan(force=force) if force else get_plan())
route = bool(route_by_verdict)
out = select_candidates(plan, held=held, black=black, top_n=0 if recheck_code else p["top_n"], tiers=p["tiers"],
include_observe=p["include_observe"],
@ -1200,6 +1209,7 @@ def candidates(*, held=(), black=(), route_by_verdict: bool = False, force=False
capped_main=False, capped_obs=False)
except Exception as e:
# 记录失败可展示,不能把无凭据候选变成自主指令。
logger.exception('候选消费记录保存失败 code=%s decision=%s', recheck_code, (plan.get('decision') or {}).get('id'))
out['consumption']['error'] = type(e).__name__
for row in out['items']:
row['consumption_error'] = type(e).__name__

View File

@ -1,39 +1,253 @@
"""采纳前核验;复用原扫描、合议、研判与规则闸,不解释自然语言。"""
from datetime import datetime
"""后台准备建议;人工采纳只做限时、无模型的执行检查。"""
from datetime import datetime, timedelta
import logging
import re
from app.core.plan_revision import compare, fingerprint, portfolio_context
from app.core.plan_revision import compare, fingerprint, portfolio_context, confirmation_revision
logger = logging.getLogger('pms.proposal.prepare')
def current_basis(hard, code, proposal_id=None):
def current_basis(hard, code, proposal_id=None, *, record=True):
from app.services import plan_feed, portfolio, command_service, proposal_service as ps
view = portfolio.positions_view()
stock = command_service.effective_stock_params()
params = ps._scan_params(view)
options = {'force': True} if record else {'record': False, 'plan_override': plan_feed.read_current_plan()}
selected = plan_feed.candidates(held=[x['ts_code'] for x in view['held']],
black=[c for c, v in stock.items() if v.get('black')], force=True,
route_by_verdict=bool(params.get('open_route_by_verdict')), recheck_code=code)
black=[c for c, v in stock.items() if v.get('black')],
route_by_verdict=bool(params.get('open_route_by_verdict')), recheck_code=code, **options)
current = next((r for r in selected['items'] if r['ts_code'] == code), None)
if not (selected.get('decision') or {}).get('id'):
return {'ok': False, 'error': '当前计划没有可核验版本'}
return {'ok': False, 'error': '当前研究资料暂不可用,恢复后自动更新建议', 'stage': 'source'}
state = selected.get('refresh_status') or {}
if code in (state.get('pending_codes') or []) or (selected.get('consumption') or {}).get('error'):
return {'ok': False, 'error': '当前计划修订或消费留痕尚未完成'}
if code in (state.get('pending_codes') or []):
return {'ok': False, 'error': '这只股票的研究正在更新,完成后重新开放采纳', 'stage': 'research_pending'}
if (selected.get('consumption') or {}).get('error'):
return {'ok': False, 'error': '建议保存未完成,系统将自动重试', 'stage': 'record'}
if current is None:
return {'ok': False, 'error': '该票已不满足当前候选资格', 'material': True, 'withdraw': True}
result = compare(hard.get('plan_basis'), current, code)
if result == 'unavailable':
return {'ok': False, 'error': '当前计划修订或消费留痕尚未完成'}
return {'ok': False, 'error': '当前研究资料无法核对,恢复后自动更新建议', 'stage': 'source'}
ph = fingerprint({k: v for k, v in params.items() if not k.startswith('_')})
commitments = ps._inflight_open_commitments(view, exclude_proposal=proposal_id)
context = portfolio_context(view, commitments, stock.get(code))
material = (result == 'material' or hard.get('scan_params_hash') != ph
or hard.get('portfolio_context_hash') != context)
return {'ok': not material, 'material': material,
'error': '研究、资格或仓位参数已变化,须重新评估' if material else None,
'view': view, 'stock': stock, 'params': params,
changed = {'research_changed': result == 'material',
'params_changed': hard.get('scan_params_hash') != ph,
'portfolio_changed': hard.get('portfolio_context_hash') != context}
material = any(changed.values())
return {'ok': not material, 'material': material, **changed,
'error': '建议依据已变化,后台正在更新' if material else None,
'view': view, 'stock': stock, 'params': params, 'commitments': commitments,
'provenance': {'decision': current['decision'], 'consumption_id': current['consumption_id'],
'version_check': {'status': 'passed', 'at': datetime.now().astimezone().isoformat(),
'reason': '依据相同,名次变化不撤销'}}}
'reason': '依据已核对,名次变化不撤销'}}}
def _judgement(p):
h = p.get('hard_numbers') or {}
return h.get('prepared_judgement') or {
'verdict': p.get('judge_verdict') or h.get('judge_verdict') or 'UNAVAILABLE',
'reason': p.get('judge_reason') or '', 'confidence': h.get('judge_conf'),
'degraded': (p.get('judge_verdict') or h.get('judge_verdict')) != 'PASS',
'pv_heads': h.get('judge_pv_heads')}
def ready_hard(hard, verdict, now=None):
h = dict(hard)
h.pop('last_accept_check', None)
h['prepared_judgement'] = {k: verdict.get(k) for k in
('verdict', 'reason', 'confidence', 'degraded', 'pv_heads')}
h['preparation'] = {'status': 'ready', 'at': (now or datetime.now()).astimezone().isoformat()}
return h
def prepare_new(hard, code, verdict):
"""模型返回期间依据可能变化;只在准备完成后开放确认。"""
try:
check = current_basis(hard, code)
if check.get('ok') and hard.get('consumption_id') and not hard.get('consumption_error'):
return ready_hard(dict(hard, **check['provenance']), verdict)
reason = check.get('error') or '建议依据待复核,后台正在更新'
except Exception:
logger.exception('新提议准备失败 code=%s', code)
reason = '建议资料暂不可用,恢复后自动更新'
return dict(hard, prepared_judgement={k: verdict.get(k) for k in
('verdict', 'reason', 'confidence', 'degraded', 'pv_heads')},
preparation={'status': 'pending', 'reason': reason})
def _mark_pending(p, reason, stage='pending'):
"""先关闭旧内容的确认入口;原JSON条件更新与人工确认互斥。"""
from app.repo import pms_repo
h = dict(p.get('hard_numbers') or {})
state = {'status': 'pending', 'reason': reason, 'stage': stage}
if h.get('preparation') == state:
return p
h['preparation'] = state
if not pms_repo.record_proposal_check(p, h):
return None
return dict(p, hard_numbers=h, hard_numbers_json=pms_repo._dumps(h))
def _pending_response(p, reason, stage='pending'):
saved = _mark_pending(p, reason, stage)
return {'ok': False, 'pending': True, 'error': reason,
'proposal': saved or p, 'proposal_id': p.get('proposal_id')}
def fast_checks(p, check):
"""核对已展示数量是否仍可执行,不重算建议,不调用模型。"""
from app.services import proposal_service as ps, param_store, portfolio, market
from app.core import tradedays as td
from app.services.execution_conflicts import buy_blocker
h = p.get('hard_numbers') or {}
view, params, stock = check['view'], dict(check['params']), check['stock']
code = p['ts_code']
conflict = buy_blocker(code)
if conflict:
return conflict
if param_store.get('PMS_AUTONOMY', 'propose_only') == 'off' or param_store.get('PMS_OPEN_AUTONOMY', 'full') == 'off':
return '自主建仓已暂停'
t, vp = view['totals'], view['params']
inflight = check['commitments']
if int(vp['max_names']) - int(t['names_count']) - len(inflight['names']) <= 0:
return '可用持仓名额不足,后台将重新安排建议'
price = float(market.get_price(code) or 0)
amount = int(p['qty']) * price
room = float(vp['portfolio_cap']) * float(vp['scale']) - float(t['portfolio_mv']) - inflight['amount']
if amount > room:
return '可用仓位额度不足,后台将更新建议数量'
if t.get('cash_source') == portfolio.CASH_WS and t.get('cash_avail') is not None:
if amount > float(t['cash_avail']):
return '可用资金不足,后台将更新建议数量'
elif param_store.get_bool('PMS_OPEN_REQUIRE_WS_CASH', True):
return '账户可用资金暂不可读,恢复后重新开放采纳'
params['_mkt'] = {code: {'day': market.day_snapshot(code) or {}, 'ma5': market.get_ma5(code)}}
c = {'ts_code': code, 'action': 'OPEN', 'side': 'buy', 'qty': p['qty'],
'price': price, 'sector': h.get('sector')}
gate = ps.check_candidate_rules(c, view, params, stock,
td.ymd() < param_store.get_int('PMS_BRAKE_UNTIL', 0))
if not gate['passed']:
return ';'.join(re.sub(r'^[A-Z][A-Z0-9_]*:\s*', '', x) for x in gate['failed'])
return None
def before_accept(p, expected_revision):
"""只确认已准备的建议。来源变化交后台,HTTP请求绝不运行完整研判。"""
hard = p.get('hard_numbers') or {}
if p.get('action') != 'OPEN' or hard.get('kind') == 'strategy':
return {'ok': True, 'proposal': p}
from app.services import proposal_service as ps
if ps.is_expired(p):
return {'ok': False, 'error': '建议已过期,不能采纳'}
if (hard.get('preparation') or {}).get('status') != 'ready' or not hard.get('consumption_id'):
return {'ok': False, 'pending': True, 'error': (hard.get('preparation') or {}).get('reason')
or '建议正在准备,完成后可采纳', 'proposal': p}
if not hard.get('proposal_revision') or expected_revision != hard['proposal_revision']:
return {'ok': False, 'error': '建议内容已更新,请查看后再次确认', 'proposal': p}
try:
check = current_basis(hard, p['ts_code'], proposal_id=p.get('proposal_id'), record=False)
if check.get('withdraw'):
return _failed_recheck(p, {'rejected': [{'by': 'qualification', 'why': check['error']}]})
if not check.get('view'):
return _pending_response(p, check.get('error') or '建议资料暂不可用', check.get('stage', 'source'))
if check.get('research_changed') or check.get('params_changed') or hard.get('last_accept_check'):
return _pending_response(p, '建议依据已变化,后台更新完成后可确认', 'changed')
reason = fast_checks(p, check)
if reason:
return _pending_response(p, reason, 'execution')
# 保留生成时的已落库消费凭据。采纳核对结果随动作账本写入,不写另一份消费快照。
provenance = dict(check['provenance'])
provenance['consumption_id'] = hard.get('consumption_id')
return {'ok': True, 'proposal': dict(p, hard_numbers=dict(hard, **provenance))}
except Exception:
logger.exception('采纳快速检查失败 proposal=%s', p.get('proposal_id'))
reason = '当前资料暂不可用,建议保留,后台将自动检查'
try:
return _pending_response(p, reason, 'source')
except Exception:
logger.exception('保存建议准备状态失败 proposal=%s', p.get('proposal_id'))
return {'ok': False, 'pending': True, 'error': reason, 'proposal': p}
def refresh_pending(*, now=None, deadline=None):
"""复用正常自主扫描的交易时段和模型预算;不新增任务。"""
from app.services import proposal_service as ps
from app.repo import pms_repo
results = []
rows = pms_repo.list_proposals(statuses=('WAIT_USER',), limit=200)
for p in rows:
if p.get('action') != 'OPEN' or (p.get('hard_numbers') or {}).get('kind') == 'strategy' or ps.is_expired(p, now):
continue
try:
result = refresh_one(p, now=now, deadline=deadline)
except Exception:
logger.exception('后台更新建议失败 proposal=%s', p['proposal_id'])
result = _pending_response(p, '建议更新暂未完成,系统将自动重试', 'source')
results.append({'proposal_id': p['proposal_id'], 'ok': result.get('ok', False),
'withdrawn': result.get('withdrawn', False), 'error': result.get('error')})
return results
def refresh_one(p, *, now=None, deadline=None):
from app.services import proposal_service as ps, param_store
from app.repo import pms_repo
from app.core import tradedays as td
now = now or datetime.now()
hard = p.get('hard_numbers') or {}
lease = (hard.get('preparation') or {}).get('lease_until')
if lease and datetime.fromisoformat(lease) > now.astimezone():
return {'ok': False, 'error': '后台正在更新建议'}
check = current_basis(hard, p['ts_code'], proposal_id=p['proposal_id'], record=False)
if check.get('withdraw'):
return _failed_recheck(p, {'rejected': [{'by': 'qualification', 'why': check['error']}]})
if not check.get('view'):
return _pending_response(p, check.get('error') or '建议资料暂不可用', check.get('stage', 'source'))
if check.get('ok') and (hard.get('preparation') or {}).get('status') == 'ready' and not hard.get('last_accept_check'):
return {'ok': True}
research_changed = check.get('research_changed', check.get('material', False))
previous_failure = hard.get('last_accept_check') or {}
needs_model = research_changed or previous_failure.get('stage') in ('judge', 'system')
claimed_hard = dict(hard, preparation={'status': 'pending', 'reason': '建议正在更新,完成后可确认',
'stage': 'updating', 'lease_until': (now.astimezone() + timedelta(seconds=240)).isoformat()})
claimed = (dict(p, hard_numbers=claimed_hard, hard_numbers_json=pms_repo._dumps(claimed_hard))
if pms_repo.record_proposal_check(p, claimed_hard) else None)
if not claimed:
return {'ok': False, 'error': '建议已被其他操作更新'}
p = claimed
if needs_model and not ps._judge_budget_left(deadline):
return _pending_response(p, '等待下一轮完成研究更新', 'budget')
out = {'skipped': [], 'rejected': [], 'queued': [], 'executed': []}
params = dict(check['params'])
mkt = {}
params['_mkt'] = mkt
fresh = ps._scan_open(check['view'], params, check['stock'], {}, mkt, out, now=now,
review_code=p['ts_code'], exclude_proposal=p['proposal_id'])
if not fresh:
return _failed_recheck(p, out)
c = fresh[0]
c['_plan_recheck'] = needs_model
verdict = None if needs_model else _judgement(p)
result = ps._route_one(c, check['view'], params, check['stock'],
td.ymd() < param_store.get_int('PMS_BRAKE_UNTIL', 0), now, True, out,
review_only=True, deadline=deadline, prepared_verdict=verdict)
if not result:
return _failed_recheck(p, out)
hn = c['hard_numbers']
latest = current_basis(hn, p['ts_code'], proposal_id=p['proposal_id'])
if not latest.get('ok'):
return _pending_response(p, latest.get('error') or '建议依据再次变化,下一轮继续更新', latest.get('stage', 'changed'))
hn = ready_hard(dict(hn, **latest['provenance']), result['verdict'], now)
hn['proposal_revision'] = confirmation_revision(c['qty'], hn, result['verdict'])
# 仅资金上下文变化而建议内容不变,维持已有确认标识。
if not needs_model and confirmation_revision(p['qty'], hard, _judgement(p)) == hn['proposal_revision']:
hn['proposal_revision'] = hard.get('proposal_revision') or hn['proposal_revision']
c['hard_numbers'] = hn
if not pms_repo.publish_prepared_proposal(p, c, result['verdict']):
return {'ok': False, 'error': '建议已被其他操作更新'}
return {'ok': True, 'proposal': dict(p, qty=c['qty'], hard_numbers=hn,
judge_verdict=result['verdict'].get('verdict'), judge_reason=result['verdict'].get('reason'))}
def _failed_recheck(p, out):
@ -64,7 +278,8 @@ def _failed_recheck(p, out):
check = {'status': 'withdrawn' if withdrawn else 'blocked',
'at': at or datetime.now().astimezone().isoformat(),
'stage': stage, 'message': message, 'reasons': reasons, 'details': out}
hard = dict(p.get('hard_numbers') or {}, last_accept_check=check)
hard = dict(p.get('hard_numbers') or {}, last_accept_check=check,
preparation={'status': 'pending', 'reason': message, 'stage': stage})
save = pms_repo.withdraw_proposal if withdrawn else pms_repo.record_proposal_check
if not save(p, hard):
return {'ok': False, 'error': '提议已被更新,请刷新后查看当前结果',
@ -87,56 +302,3 @@ def withdraw_recorded_rejections():
(check.get('details') or {}).get('rejected')):
count += bool(_failed_recheck(p, check['details']).get('withdrawn'))
return count
def before_accept(p, expected_revision):
from app.services import proposal_service as ps
from app.repo import pms_repo
hard = p.get('hard_numbers') or {}
if p.get('action') != 'OPEN' or hard.get('kind') == 'strategy':
return {'ok': True, 'proposal': p}
try:
check = current_basis(hard, p['ts_code'], proposal_id=p.get('proposal_id'))
# 已有失败结论不能因仓位后来恰好回到旧值而沿用旧批准。下一次仍完整核验。
if check.get('ok') and hard.get('last_accept_check'):
check.update(ok=False, material=True)
if check.get('ok'):
if hard.get('proposal_revision') and expected_revision != hard['proposal_revision']:
return {'ok': False, 'error': '提议内容已更新,请刷新后再次确认', 'proposal': p}
return {'ok': True, 'proposal': dict(p, hard_numbers=dict(hard, **check['provenance']))}
if check.get('withdraw'):
return _failed_recheck(p, {'rejected': [{'by': 'qualification', 'why': check['error']}]})
if not check.get('material') or not check.get('view'):
return check
now = datetime.now()
out = {'skipped': [], 'rejected': [], 'queued': [], 'executed': []}
params, view = check['params'], check['view']
mkt = {}
params['_mkt'] = mkt
fresh = ps._scan_open(view, params, check['stock'], {}, mkt, out, now=now,
review_code=p['ts_code'], exclude_proposal=p['proposal_id'])
if not fresh:
return _failed_recheck(p, out)
c = fresh[0]
c['_plan_recheck'] = True
from app.services import param_store
from app.core import tradedays as td
brake = td.ymd() < param_store.get_int('PMS_BRAKE_UNTIL', 0)
result = ps._route_one(c, view, params, check['stock'], brake, now, True, out, review_only=True)
if not result:
return _failed_recheck(p, out)
hn = c['hard_numbers']
hn['version_check'] = {'status': 'reconfirmed_required', 'at': now.astimezone().isoformat(),
'reason': '依据变化,已重跑合议、研判、数量及规则闸'}
hn['proposal_revision'] = fingerprint({'qty': c['qty'], 'hard_numbers': hn})
if not pms_repo.revise_pending_proposal(p, c, result['verdict']):
return {'ok': False, 'error': '提议被并发更新,请刷新'}
pms_repo.insert_ledger(ts_code=p['ts_code'], action=p['action'], arbiter='rule',
verdict='WARN', price_at=hn.get('price') or 0, hard_numbers=hn,
ref_id=p['proposal_id'], reason='新版依据变化,更新提议并要求重新确认')
return {'ok': False, 'error': '提议已按新版重新评估,请查看更新内容后再次确认',
'proposal': dict(p, qty=c['qty'], hard_numbers=hn,
judge_verdict=result['verdict'].get('verdict'),
judge_reason=result['verdict'].get('reason'))}
except Exception as e:
return {'ok': False, 'error': f'版本核验暂不可用,保留提议:{type(e).__name__}'}

View File

@ -234,17 +234,21 @@ def scan_and_route(*, now=None, dry_run: bool = False) -> dict:
deadline = time.monotonic() + max(
0, param_store.get_int("PMS_JUDGE_TICK_BUDGET_SEC", 150))
for c in list(scanned["candidates"]) + open_cands:
try:
_rk = _unlocked.get((c.get("ts_code"), c.get("action")))
if _rk:
c["reask"] = _rk
_route_one(c, view, params, stock_params, brake_active, now, dry_run, out,
deadline=deadline)
except Exception as e:
logger.exception("提议分流失败 %s", c.get("ts_code"))
out["errors"].append(f"{c.get('ts_code')} {c.get('action')}: "
f"{type(e).__name__}: {e}")
for group_index, group in enumerate((list(scanned['candidates']), open_cands)):
if group_index == 1 and not dry_run and param_store.get_bool('PMS_PLAN_VERSION_RECHECK', False):
from app.services.plan_recheck import refresh_pending
out['prepared_proposals'] = refresh_pending(now=now, deadline=deadline)
for c in group:
try:
_rk = _unlocked.get((c.get("ts_code"), c.get("action")))
if _rk:
c["reask"] = _rk
_route_one(c, view, params, stock_params, brake_active, now, dry_run, out,
deadline=deadline)
except Exception as e:
logger.exception("提议分流失败 %s", c.get("ts_code"))
out["errors"].append(f"{c.get('ts_code')} {c.get('action')}: "
f"{type(e).__name__}: {e}")
# 技术面转空离场去重集写回 (2026-09-11 工作包三): 本轮真处理过 (执行或入队, 未被闸拒) 的
# 转空离场记进去重集, 下次同一翻空不再重复处理。试算不写。清掉 SAR 已翻多的那步在扫描前做过了。
if not dry_run:
@ -774,17 +778,12 @@ def disposition_snapshot(now=None) -> dict:
return res
def _route_one(c, view, params, stock_params, brake_active, now, dry_run, out,
*, deadline=None, review_only=False):
code, action, side = c["ts_code"], c["action"], c["side"]
is_open = (action == ae.A_OPEN)
def check_candidate_rules(c, view, params, stock_params, brake_active):
code, action, side = c['ts_code'], c['action'], c['side']
is_open = action == ae.A_OPEN
pos = _pos_of(view, code)
# 新建仓的现价取候选自带的那份。**不能取 pos 的**: 从没持仓过的票在账本里没有行,
# `_pos_of` 回的是空壳、没有 price, 取它会是 0, 规则闸一上来就判 PRICE_MISSING。
price = float(c.get("price") or 0) if is_open else float(pos.get("price") or 0)
# ---- 一级: 规则闸 (自主动作受刹车约束, is_command=False) ----
gate = rule_gate.check(
price = float(c.get('price') or 0) if is_open else float(pos.get('price') or 0)
return rule_gate.check(
side=side, action=action, qty=c["qty"], price=price,
ctx={"ts_code": code, "position": pos,
# 当日行情走 _market_ctx 取到的真实快照。原来这里是
@ -811,6 +810,19 @@ def _route_one(c, view, params, stock_params, brake_active, now, dry_run, out,
# 不拦截, 见 rule_gate 里那段说明。
"stop_price": stock_params.get(code, {}).get("stop_price"),
"is_command": False}})
def _route_one(c, view, params, stock_params, brake_active, now, dry_run, out,
*, deadline=None, review_only=False, prepared_verdict=None):
code, action, side = c["ts_code"], c["action"], c["side"]
is_open = (action == ae.A_OPEN)
pos = _pos_of(view, code)
# 新建仓的现价取候选自带的那份。**不能取 pos 的**: 从没持仓过的票在账本里没有行,
# `_pos_of` 回的是空壳、没有 price, 取它会是 0, 规则闸一上来就判 PRICE_MISSING。
price = float(c.get("price") or 0) if is_open else float(pos.get("price") or 0)
# ---- 一级: 规则闸 (自主动作受刹车约束, is_command=False) ----
gate = check_candidate_rules(c, view, params, stock_params, brake_active)
if not gate["passed"]:
out["rejected"].append({"ts_code": code, "action": action, "by": "rule",
"failed": gate["failed"]})
@ -829,6 +841,12 @@ def _route_one(c, view, params, stock_params, brake_active, now, dry_run, out,
and _sd_mode in ("decline", "full")):
verdict = {"verdict": "SKIPPED", "reason": "逻辑存疑,不送研判(系统自决直接放弃)",
"degraded": False, "raw": None, "confidence": None}
elif prepared_verdict is not None:
verdict = dict(prepared_verdict) # 后台仅额度变化时复用同研究依据的结果。
if verdict.get('verdict') == judge.REJECT:
out['rejected'].append({'ts_code': code, 'action': action, 'by': 'judge',
'failed': [verdict.get('reason') or '决策系统驳回']})
return
elif c.get("judge_required"):
if not _judge_budget_left(deadline):
# 本轮研判时间用尽 —— 整条跳过, 下一跳重来。
@ -1438,8 +1456,11 @@ def _make_proposal(c, price, verdict) -> str:
# 采纳按钮那边直接抄提议里的股数原样落单 (只有卖出侧会按可卖量重算), 所以不在这里
# 改, 后面就没有第二次机会了。
_apply_advice_tier(c, hn, verdict)
from app.core.plan_revision import fingerprint
hn['proposal_revision'] = fingerprint({'qty': c['qty'], 'hard_numbers': hn})
if c['action'] == ae.A_OPEN and param_store.get_bool('PMS_PLAN_VERSION_RECHECK', False):
from app.services.plan_recheck import prepare_new
hn = prepare_new(hn, c['ts_code'], verdict)
from app.core.plan_revision import confirmation_revision
hn['proposal_revision'] = confirmation_revision(c['qty'], hn, verdict)
try:
pms_repo.insert_proposal(
proposal_id=pid, ts_code=c["ts_code"], action=c["action"], qty=c["qty"],

View File

@ -476,6 +476,13 @@ def api_proposals(status: str = Query("WAIT_USER"), limit: int = 100,
tech_service.attach(rows)
except Exception as e: # noqa: BLE001
logger.warning("[提议] 技术面立场挂不上: %s", e)
if param_store.get_bool('PMS_PLAN_VERSION_RECHECK', False):
for row in rows:
h = row.get('hard_numbers') or {}
if row.get('action') == 'OPEN' and h.get('kind') != 'strategy':
preparation = h.get('preparation') or {}
row['accept_ready'] = preparation.get('status') == 'ready' and bool(h.get('consumption_id'))
row['prepare_reason'] = preparation.get('reason') or '建议正在准备,完成后可采纳'
return rows
return ok(_list)
@ -485,8 +492,8 @@ def api_decide_reasons():
"""裁决预设理由的两张码表 (采纳 / 驳回), 页面据此铺按钮。台账 015。"""
return {"ok": True, "accept": decide_reasons.ACCEPT_REASONS,
"decline": decide_reasons.DECLINE_REASONS,
# 浏览器等待要覆盖研判超时与取数,不能先报失败、后端还在处理采纳。
"request_timeout_ms": max(120, param_store.get_int('PMS_JUDGE_TIMEOUT', 90) + 60) * 1000}
# 采纳只做快速检查,不等待完整研判;超时仍按结果未知处理。
"request_timeout_ms": 30000}
@app.post("/api/proposals/{proposal_id}/decide")

View File

@ -1229,7 +1229,11 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
<span class="n">· {{ proposals.length }} 条</span><span class="car">{{ sectClosed.prop?'▸':'▾' }}</span></div>
<div class="sectb">
<div class="muted" v-if="!proposals.length">暂无待确认提议。</div>
<div v-else class="rail-note">采纳后生成待执行指令;驳回后今日不再提议。</div>
<div v-for="p in preparingProposals" :key="p.proposal_id" class="st muted" style="margin-bottom:8px">
{{ nm(p.ts_code) }} · {{ p.prepare_reason }}
<button class="research-link" @click="decide(p,'DECLINED')">放弃</button>
</div>
<div v-if="proposals.length" class="rail-note">采纳后生成待执行指令;驳回后今日不再提议。</div>
<div v-for="p in proposals" :key="p.proposal_id" class="mini">
<div class="top"><b class="lk" title="查看个股详情" @click="openCodeSignals(p.ts_code)">{{ nm(p.ts_code) }}</b>
<span class="muted mono lk" style="font-size:11px" title="查看个股详情" @click="openCodeSignals(p.ts_code)">{{ p.ts_code }}</span>
@ -1410,7 +1414,7 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
<!-- 挂交易方案 对话框 (非盲填: 现价 / 持仓 / 可卖 / 浮盈都摆出来) -->
<!-- 裁决弹窗 (2026-09-14 台账 015): 采纳/驳回从预设理由里选, 选其他再补一句。 -->
<el-dialog v-model="decideDlg.visible" width="460px"
<el-dialog v-model="decideDlg.visible" width="460px" :z-index="3200"
:close-on-click-modal="!decideDlg.busy" :close-on-press-escape="!decideDlg.busy" :show-close="!decideDlg.busy"
:title="(decideDlg.withdrawn?'提议已撤下':(decideDlg.decision==='ACCEPTED'?'采纳':'驳回')) + ' · ' + (decideDlg.row ? nm(decideDlg.row.ts_code) : '') + (decideDlg.row ? ' ' + tx('action', decideDlg.row.action) : '')">
<div v-if="decideDlg.row && !decideDlg.withdrawn" class="prop-order" style="margin-bottom:10px">
@ -1420,7 +1424,7 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
show-icon style="margin-bottom:10px;white-space:pre-line"></el-alert>
<div v-if="decideDlg.updated" class="why" style="margin-bottom:10px">{{ decideDlg.row.judge_reason || propWhy(decideDlg.row) }}</div>
<div v-if="decideDlg.busy" class="muted" role="status" style="margin-bottom:10px">
正在核对采纳条件;依据有变化时会重新研判,可能需要一至两分钟。请勿重复提交。
正在确认交易条件,请勿重复提交。
</div>
<template v-if="!decideDlg.withdrawn">
<div class="muted" style="margin-bottom:8px;font-size:12px">选一个理由记进评审账本。选「其他」时补充必填。</div>
@ -1432,8 +1436,8 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
</template>
<template #footer>
<el-button :disabled="decideDlg.busy" @click="decideDlg.visible=false">关闭</el-button>
<el-button v-if="!decideDlg.withdrawn" :type="decideDlg.decision==='ACCEPTED'?'success':'danger'" :loading="decideDlg.busy" :disabled="decideDlg.uncertain" @click="submitDecide">
{{ decideDlg.busy ? '正在处理' : (decideDlg.updated ? '确认更新后的建议' : (decideDlg.decision==='ACCEPTED' ? (((decideDlg.row||{}).hard_numbers||{}).last_accept_check ? '重新核验' : '确认采纳') : '确认驳回')) }}</el-button>
<el-button v-if="!decideDlg.withdrawn" :type="decideDlg.decision==='ACCEPTED'?'success':'danger'" :loading="decideDlg.busy" :disabled="decideDlg.uncertain || decideDlg.pending" @click="submitDecide">
{{ decideDlg.busy ? '正在处理' : (decideDlg.updated ? '确认更新后的建议' : (decideDlg.decision==='ACCEPTED' ? '确认采纳' : '确认驳回')) }}</el-button>
</template>
</el-dialog>
@ -2694,7 +2698,7 @@ createApp({
const health = ref({}), ov = ref({}), params = ref([]), paramsRaw = ref({});
const catalog = ref([]), commands = ref([]), plans = ref([]), plansOf = ref('');
const positions = ref([]), lots = ref([]), lotsOf = ref('');
const instructions = ref([]), ledger = ref([]), proposals = ref([]);
const instructions = ref([]), ledger = ref([]), proposals = ref([]), preparingProposals = ref([]);
const strategies = ref([]), stratEnabled = ref(false), opLog = ref([]);
const autoScanRes = ref(null), autoScanBusy = ref(false); // 自动挂载试算 (2026-08-25)
const autoInfo = ref(null); // 自动挂载仪表: 总开关 + 今天正式一跳跑没跑 (2026-08-26)
@ -3817,7 +3821,10 @@ createApp({
const d = await call('get', '/api/ledger?limit=60'); ledger.value = d.data || [];
}
async function loadProposals() {
const d = await call('get', '/api/proposals'); proposals.value = d.data || [];
const d = await call('get', '/api/proposals');
const rows = d.data || [];
proposals.value = rows.filter(p => p.accept_ready !== false);
preparingProposals.value = rows.filter(p => p.accept_ready === false);
}
async function loadOpenScan() {
const d = await call('get', '/api/open-scan');
@ -4380,7 +4387,7 @@ createApp({
DECLINE_WAIT_PULLBACK:'等待回调再议', DECLINE_MARKET_RISK:'大盘风险偏高', DECLINE_OTHER:'其他' },
};
const decideReasons = reactive({ accept:{}, decline:{} });
let decideTimeoutMs = 180000;
let decideTimeoutMs = 30000;
async function loadDecideReasons() {
const d = await call('get', '/api/proposals/decide-reasons');
const src = (d && d.ok !== false) ? d : DECIDE_REASONS_FALLBACK;
@ -4388,19 +4395,19 @@ createApp({
decideReasons.decline = src.decline || DECIDE_REASONS_FALLBACK.decline;
if (Number(src.request_timeout_ms) > 0) decideTimeoutMs = Number(src.request_timeout_ms);
}
const decideDlg = reactive({ visible:false, busy:false, row:null, decision:'', code:'', note:'', message:'', updated:false, uncertain:false, withdrawn:false });
const decideDlg = reactive({ visible:false, busy:false, row:null, decision:'', code:'', note:'', message:'', updated:false, uncertain:false, withdrawn:false, pending:false });
// 采纳/驳回都从预设理由里选一条 (选「其他」再补一句), 不再手写整段 (三源合议自决后剩的都是真交人的)。
function decide(row, decision) {
if (decideDlg.busy) return;
if (!Object.keys(decideReasons.accept).length) loadDecideReasons();
Object.assign(decideDlg, { visible:true, busy:false, row, decision, code:'', note:'',
message:((row.hard_numbers||{}).last_accept_check||{}).message || '', updated:false, uncertain:false, withdrawn:false });
message:((row.hard_numbers||{}).last_accept_check||{}).message || '', updated:false, uncertain:false, withdrawn:false, pending:false });
}
const decideCodes = computed(() =>
decideDlg.decision === 'ACCEPTED' ? decideReasons.accept : decideReasons.decline);
async function submitDecide() {
const dd = decideDlg;
if (dd.busy || dd.uncertain || dd.withdrawn) return;
if (dd.busy || dd.uncertain || dd.withdrawn || dd.pending) return;
if (!dd.code && !dd.note.trim()) { ElementPlus.ElMessage.warning('选一个理由,或补一句说明'); return; }
if (dd.code && dd.code.endsWith('_OTHER') && !dd.note.trim()) {
ElementPlus.ElMessage.warning('选「其他」时必须填补充说明'); return; }
@ -4419,12 +4426,13 @@ createApp({
} else {
dd.uncertain = !!d.uncertain;
dd.withdrawn = !!d.withdrawn;
dd.pending = !!d.pending;
dd.message = dd.uncertain
? '尚未收到处理结果。请关闭后刷新,先核对待确认提议和当日指令,勿立即重复提交。'
: (d.error || '本次未能采纳,请稍后再试');
if (d.proposal) {
dd.row = { ...dd.row, ...d.proposal };
dd.updated = !dd.withdrawn && !((d.proposal.hard_numbers||{}).last_accept_check);
dd.updated = !dd.withdrawn && !dd.pending && !((d.proposal.hard_numbers||{}).last_accept_check);
if (dd.updated) { dd.code = ''; dd.note = ''; }
await loadProposals();
}
@ -4902,7 +4910,7 @@ createApp({
});
return { tab, loading, err, health, ov, params, catalog, commands, plans, plansOf,
positions, lots, lotsOf, instructions, ledger, ledgerToday, proposals, report, reportDrawer,
positions, lots, lotsOf, instructions, ledger, ledgerToday, proposals, preparingProposals, report, reportDrawer,
opsDrawer, opsResult, opsLoading, issuing, form, curSpec, dirtyCount, dm,
money, pct, groupLabel, fieldLabel, stTag, cuTag, logicTag, canCancel, progPct, insPct,
scaleGap,

View File

@ -0,0 +1,44 @@
# 提议先准备后采纳开发规格
2026-10-20,北京时间。用户已批准本方案及155模拟仓部署,188不操作。
## 目标与问题
待确认提议必须已经完成研究、数量、规则和消费记录。采纳请求只做快速检查,不调用模型。
今天600183的提议在10:19被请求内重评,10:20又因统一技术错误提示被拦,第三次才成功。
原实现将研究变化、组合变化、记录失败混在一起。后台只跳过待确认项,不主动维护它们。
## 处理边界
复用已有交易时段的自主扫描,不新增任务,不改交易时段和策略阈值。
新建仓提议增加准备状态,仍保存在原JSON。旧提议由后台准备后才开放采纳。
数量、研判与必要留痕准备好后进入可确认状态,当日失效时刻沿用现有计算。
读取页面不运行研判。准备中的条目单列简短状态,不给采纳按钮,不堆技术字段。
研究变化由后台重跑原合议、研判、数量与规则闸。明确资格失效或研判否决撤下。
组合或参数变化重算数量与规则,复用相同研究依据的已有研判,不无故重问模型。
资金不足或来源暂不可用保留为准备中,后台恢复后重试;失败原因分开记录。
排序或无关计划更新不改变确认内容。可见数量、依据或风险变化才更换确认标识。
后台更新先使旧内容不可确认,再计算并用原状态与原JSON条件更新,防止并发覆盖。
采纳时检查确认内容、当日有效期、当前研究是否仍适用,以及资金、仓位、暂停与买卖冲突。
最新计划只读取完整发布结果,不新装配,不新增消费快照。必要交易动作审计仍必须落账。
依据发生变化时快速返回等待后台更新,不在HTTP请求里重评,也不暗中改数量。
已准备的数量仍在当前额度内时,不因其他持仓或资金读数变化强制重新确认。
正常下发继续执行既有择时及交易前终检。
## 预判与测试
新增离线反例:新提议直接确认零模型调用;纯资金变化复用研判;研究变化后台更新;
旧确认标识拒绝;准备中没有采纳按钮;明确失效撤下;消费记录失败不发布可确认提议;
后台预算耗尽不调用模型;并发确认只生成一次;当前计划取数失败不下单;
正常到期及卖出、策略确认保持。必须运行完整隔离禁网回归与三页面守卫。
浏览器使用替身核验交互,再只读核对生产页面或接口;不得代用户采纳或制造交易。
## 部署与回退
部署前备份155参数、持仓、批次、提议、指令和出口委托,记录旧提交与镜像。
候选镜像断网测试通过后统一重建四容器,避开盘前维护窗口。
部署后核对正常调度、准备状态、模型调用边界、参数与账务。自然事件不足明确未判收。
回退须反向提交并拉取后重建,保留准备记录、真实成交与审计,不恢复已撤销指令。
同步四仓规格与交接,桥决定台账登记本轮选择。

View File

@ -5,6 +5,7 @@
运行: docker compose run --rm pms-web python scripts/run_tests.py
包含 (例数按 2026-10-12 隔离回归实跑校正):
test_batch42_units.py 提议先准备后采纳、模型复用、并发更新与发布审计 (24 例)
test_batch41_units.py 跨流程买卖互斥、清仓收尾、出口撤单与真实进展及人工清仓T+1 (24 例)
test_batch40_units.py 逐笔历史修复、进度暂停、并发与事务回滚与均价失效 (22 例)
test_batch39_units.py 成交双来源归属、迟到、部分成交与失败守成 (12 例)
@ -192,7 +193,7 @@ SUITES = ["test_core_units.py", "test_batch2_units.py", "test_batch3_units.py",
"test_batch35_units.py",
"test_batch36_units.py",
"test_batch37_units.py",
"test_batch38_units.py", "test_batch39_units.py", "test_batch40_units.py", "test_batch41_units.py",
"test_batch38_units.py", "test_batch39_units.py", "test_batch40_units.py", "test_batch41_units.py", "test_batch42_units.py",
"test_page_copy_guard.py",
"test_page_enum_guard.py",
"test_page_wiring_guard.py",

View File

@ -93,9 +93,10 @@ class DailyPlanTest(unittest.TestCase):
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'}
'portfolio_context_hash': pr.portfolio_context(view, old), 'proposal_revision': 'same', 'consumption_id': 'saved', 'preparation': {'status': 'ready'}}
p = {'proposal_id': 'p1', 'action': 'OPEN', 'ts_code': row['ts_code'], 'hard_numbers': hard}
with patch.object(portfolio, 'positions_view', return_value=view), \
with patch.object(rc, 'fast_checks', return_value=None), patch.object(pf, 'read_current_plan', return_value={}), \
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']}), \
@ -134,14 +135,15 @@ class DailyPlanTest(unittest.TestCase):
'hard_numbers_json': '{}', 'hard_numbers': {'proposal_revision': 'old'}}
c = {'qty': 100, 'ts_code': p['ts_code'], 'hard_numbers': {'price': 10, 'plan_basis': {}}}
check = {'material': True, 'view': {'held': []}, 'params': {}, 'stock': {}}
with patch.object(rc, 'current_basis', return_value=check), \
with patch.object(rc, 'current_basis', side_effect=[check, {'ok':True,'provenance':{}}]), \
patch.object(pms_repo, 'record_proposal_check', return_value=1), \
patch.object(ps, '_scan_open', return_value=[c]) as scan, \
patch.object(ps, '_route_one', return_value={'verdict': {'verdict': 'PASS'}}) as route, \
patch.object(param_store, 'get_int', return_value=0), \
patch.object(pms_repo, 'revise_pending_proposal', return_value=1) as revise, \
patch.object(pms_repo, 'publish_prepared_proposal', return_value=1) as revise, \
patch.object(pms_repo, 'insert_ledger', return_value=1):
out = rc.before_accept(p, 'old')
self.assertFalse(out['ok'])
out = rc.refresh_one(p)
self.assertTrue(out['ok'])
self.assertEqual(out['proposal']['qty'], 100)
self.assertNotEqual(out['proposal']['hard_numbers']['proposal_revision'], 'old')
self.assertEqual(scan.call_args.kwargs['exclude_proposal'], 'p1')
@ -156,13 +158,14 @@ class DailyPlanTest(unittest.TestCase):
c = {'qty': 100, 'ts_code': p['ts_code'], 'hard_numbers': {'price': 10, 'plan_basis': {'new': True}}}
def reject(*args, **kw):
args[7]['rejected'].append({'ts_code': p['ts_code'], 'by': 'judge', 'failed': ['资金净流出,短期看空']})
with patch.object(rc, 'current_basis', return_value={'material': True, 'view': {'held': []}, 'params': {}, 'stock': {}}), \
with patch.object(pms_repo, 'record_proposal_check', return_value=1), \
patch.object(rc, 'current_basis', return_value={'material': True, 'view': {'held': []}, 'params': {}, 'stock': {}}), \
patch.object(ps, '_scan_open', return_value=[c]), patch.object(ps, '_route_one', side_effect=reject), \
patch.object(param_store, 'get_int', return_value=0), \
patch.object(pms_repo, 'withdraw_proposal', return_value=1) as record, \
patch.object(pms_repo, 'revise_pending_proposal', side_effect=AssertionError('不得升级原批准依据')), \
patch.object(pms_repo, 'publish_prepared_proposal', side_effect=AssertionError('不得升级原批准依据')), \
patch.object(pms_repo, 'insert_instruction', side_effect=AssertionError('不得生成指令')):
out = rc.before_accept(p, 'old')
out = rc.refresh_one(p)
self.assertFalse(out['ok'])
self.assertIn('最新研判不建议建仓:资金净流出,短期看空', out['error'])
hard = record.call_args.args[1]
@ -210,11 +213,13 @@ class DailyPlanTest(unittest.TestCase):
from app.services import plan_recheck as rc, proposal_service as ps
p = {'proposal_id': 'p1', 'action': 'OPEN', 'ts_code': '600001.SH',
'hard_numbers': {'proposal_revision': 'old', 'last_accept_check': {'status': 'blocked'}}}
with patch.object(rc, 'current_basis', return_value={'ok': True, 'material': False,
with patch.object(rc, '_mark_pending', side_effect=lambda p,*a: p), \
patch.object(__import__('app.repo.pms_repo', fromlist=['record_proposal_check']), 'record_proposal_check', return_value=1), \
patch.object(rc, 'current_basis', return_value={'ok': True, 'material': False,
'view': {'held': []}, 'params': {}, 'stock': {}}), \
patch.object(ps, '_scan_open', return_value=[]) as scan, \
patch.object(rc, '_failed_recheck', return_value={'ok': False}) as blocked:
self.assertFalse(rc.before_accept(p, 'old')['ok'])
self.assertFalse(rc.refresh_one(p)['ok'])
scan.assert_called_once()
blocked.assert_called_once()
@ -282,11 +287,11 @@ class DailyPlanTest(unittest.TestCase):
self.assertFalse(out.get('withdrawn'))
self.assertNotIn('在途占用',out['error'])
def test_browser_wait_covers_effective_judge_timeout(self):
def test_browser_wait_is_independent_of_judge_timeout(self):
from app.web import main
with patch.object(main.param_store, 'get_int', return_value=240):
out = main.api_decide_reasons()
self.assertGreater(out['request_timeout_ms'], 240000)
self.assertEqual(out['request_timeout_ms'], 30000)
self.assertTrue(out['accept'])
def test_missing_candidate_during_pending_revision_is_not_withdrawn(self):
@ -309,7 +314,7 @@ class DailyPlanTest(unittest.TestCase):
'withdraw':True,'error':'已不满足候选资格'}), \
patch.object(pms_repo,'withdraw_proposal',return_value=1), \
patch.object(ps,'_route_one',side_effect=AssertionError('无需新研判')):
out=rc.before_accept(p,None)
out=rc.refresh_one(p)
self.assertTrue(out['withdrawn'])
def test_version_unavailable_and_qualification_loss_preserve_proposal(self):
@ -378,8 +383,9 @@ class DailyPlanTest(unittest.TestCase):
def test_stale_human_confirmation_rejected(self):
from app.services import plan_recheck as rc
p = {'action': 'OPEN', 'ts_code': '600001.SH', 'hard_numbers': {'proposal_revision': 'new'}}
with patch.object(rc, 'current_basis', return_value={'ok': True, 'provenance': {}}):
p = {'action': 'OPEN', 'ts_code': '600001.SH', 'hard_numbers': {'proposal_revision': 'new', 'consumption_id': 'saved', 'preparation': {'status':'ready'}}}
with patch.object(rc, 'fast_checks', return_value=None), \
patch.object(rc, 'current_basis', return_value={'ok': True, 'provenance': {}, 'view': {'held':[]}}):
self.assertFalse(rc.before_accept(p, 'old')['ok'])
self.assertTrue(rc.before_accept(p, 'new')['ok'])

View File

@ -0,0 +1,253 @@
"""开发机:python scripts/unit_test_guard.py scripts/test_batch42_units.py。配置隔离且禁网。"""
import unit_test_guard
import os
import sys
import copy
import unittest
from datetime import datetime, timedelta
from unittest.mock import patch
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app.services import plan_recheck as rc, plan_feed as pf, proposal_service as ps
from app.repo import pms_repo
from app.core import plan_revision as pr
CODE='600183.SH'
def proposal(**kw):
h={'preparation':{'status':'ready'},'proposal_revision':'shown','consumption_id':'saved',
'plan_basis':{'risk':[]},'price':10,'sector':'电子','prepared_judgement':{'verdict':'PASS','reason':'原研究','degraded':False}}
p={'proposal_id':'p1','ts_code':CODE,'action':'OPEN','qty':100,'hard_numbers':h,
'hard_numbers_json':pms_repo._dumps(h),'expire_at':datetime.now()+timedelta(hours=1)}
p.update(kw)
return p
def current(**kw):
r={'ok':True,'view':{'held':[]},'params':{},'stock':{},'commitments':{},
'provenance':{'decision':{'id':'new'},'consumption_id':'unwritten','version_check':{'status':'passed'}},
'research_changed':False,'params_changed':False,'portfolio_changed':False}
r.update(kw)
return r
class PreparedProposalTest(unittest.TestCase):
def test_accept_ready_is_model_free_and_does_not_write_consumption(self):
with patch.object(rc,'current_basis',return_value=current()) as basis, \
patch.object(rc,'fast_checks',return_value=None), \
patch.object(ps,'_scan_open',side_effect=AssertionError('采纳不能扫描重算')), \
patch.object(ps,'_route_one',side_effect=AssertionError('采纳不能研判')), \
patch.object(pms_repo,'insert_plan_snapshot',side_effect=AssertionError('采纳不能写消费快照')):
out=rc.before_accept(proposal(),'shown')
self.assertTrue(out['ok'])
self.assertFalse(basis.call_args.kwargs['record'])
self.assertEqual(out['proposal']['hard_numbers']['consumption_id'],'saved')
def test_cash_or_other_position_change_can_accept_existing_quantity(self):
with patch.object(rc,'current_basis',return_value=current(ok=False,material=True,portfolio_changed=True)), \
patch.object(rc,'fast_checks',return_value=None), \
patch.object(ps,'_route_one',side_effect=AssertionError('资金变化不问模型')):
out=rc.before_accept(proposal(),'shown')
self.assertTrue(out['ok'])
self.assertEqual(out['proposal']['qty'],100)
def test_research_change_marks_pending_without_model(self):
with patch.object(rc,'current_basis',return_value=current(ok=False,research_changed=True)), \
patch.object(pms_repo,'record_proposal_check',return_value=1), \
patch.object(ps,'_route_one',side_effect=AssertionError('只能后台研判')):
out=rc.before_accept(proposal(),'shown')
self.assertTrue(out['pending'])
self.assertEqual(out['proposal']['hard_numbers']['preparation']['status'],'pending')
def test_pending_or_legacy_proposal_cannot_accept(self):
for h in ({},{'preparation':{'status':'pending','reason':'研究更新中'}}):
with patch.object(rc,'current_basis',side_effect=AssertionError('尚未准备不远程取数')):
out=rc.before_accept(proposal(hard_numbers=h),'shown')
self.assertTrue(out['pending'])
def test_expired_proposal_does_not_read_or_accept(self):
with patch.object(rc,'current_basis',side_effect=AssertionError('已过期')):
out=rc.before_accept(proposal(expire_at=datetime.now()-timedelta(seconds=1)),'shown')
self.assertFalse(out['ok'])
def test_stale_content_is_returned_without_recalculation(self):
with patch.object(rc,'current_basis',side_effect=AssertionError('旧页面不远程取数')):
out=rc.before_accept(proposal(),'old')
self.assertFalse(out['ok'])
self.assertEqual(out['proposal']['qty'],100)
def test_source_failure_is_closed_without_discarding_proposal(self):
with patch.object(rc,'current_basis',side_effect=RuntimeError('offline')), \
patch.object(pms_repo,'record_proposal_check',return_value=1):
out=rc.before_accept(proposal(),'shown')
self.assertFalse(out['ok'])
self.assertTrue(out['pending'])
self.assertEqual(out['proposal']['qty'],100)
self.assertEqual(out['proposal']['hard_numbers']['preparation']['status'],'pending')
def test_pause_or_money_block_is_saved_with_specific_reason(self):
with patch.object(rc,'current_basis',return_value=current()), \
patch.object(rc,'fast_checks',return_value='可用资金不足'), \
patch.object(pms_repo,'record_proposal_check',return_value=1):
out=rc.before_accept(proposal(),'shown')
self.assertEqual(out['error'],'可用资金不足')
self.assertTrue(out['pending'])
def test_prepared_research_not_reasked_for_portfolio_or_param_change(self):
old={'plan_basis':{'risk':[]},'portfolio_context_hash':'a','scan_params_hash':'a'}
self.assertFalse(pr.needs_new_judgement(dict(old,portfolio_context_hash='b',scan_params_hash='b'),old))
self.assertTrue(pr.needs_new_judgement(dict(old,plan_basis={'risk':['变化']}),old))
def test_read_current_plan_has_short_timeout_and_no_snapshot(self):
params={'base':'http://fixture','path':'/plan','timeout':30,'stale_tdays':1}
with patch.object(pf,'_params',return_value=params), patch.object(pf,'build_query',return_value={}), \
patch.object(pf,'fetch',return_value={'date':'2026-10-19'}) as fetch, \
patch.object(pf,'assert_fresh') as fresh, \
patch.object(pf,'_snapshot_quiet',side_effect=AssertionError('只读取')):
pf.read_current_plan()
self.assertEqual(fetch.call_args.kwargs['timeout'],3)
fresh.assert_called_once()
def test_new_proposal_not_ready_when_record_failed(self):
with patch.object(rc,'current_basis',return_value={'ok':False,'error':'建议保存未完成','stage':'record'}):
h=rc.prepare_new({'consumption_id':'c'},CODE,{'verdict':'PASS'})
self.assertEqual(h['preparation']['status'],'pending')
self.assertIn('保存',h['preparation']['reason'])
def test_new_proposal_ready_only_with_recorded_consumption(self):
with patch.object(rc,'current_basis',return_value=current()):
self.assertEqual(rc.prepare_new({},CODE,{'verdict':'PASS'})['preparation']['status'],'pending')
self.assertEqual(rc.prepare_new({'consumption_id':'c'},CODE,{'verdict':'PASS'})['preparation']['status'],'ready')
def _background(self, changed, *, route=None):
p=proposal()
c={'ts_code':CODE,'action':'OPEN','qty':100,'hard_numbers':{'consumption_id':'c','price':10,'plan_basis':{'risk':[]}}}
with patch.object(rc,'current_basis',side_effect=[current(ok=False,material=True,**changed),current()]), \
patch.object(pms_repo,'record_proposal_check',return_value=1), \
patch.object(ps,'_scan_open',return_value=[c]), \
patch.object(ps,'_route_one',return_value={'verdict':{'verdict':'PASS','reason':'原研究'}}) as routed, \
patch.object(pms_repo,'publish_prepared_proposal',return_value=1), \
patch.object(rc,'fast_checks',return_value=None), patch.object(ps.param_store,'get_int',return_value=0):
out=rc.refresh_one(p)
return out,routed
def test_background_portfolio_change_reuses_judgement(self):
out,route=self._background({'portfolio_changed':True})
self.assertTrue(out['ok'])
self.assertEqual(route.call_args.kwargs['prepared_verdict']['reason'],'原研究')
self.assertFalse(route.call_args.args[0]['_plan_recheck'])
self.assertEqual(out['proposal']['hard_numbers']['proposal_revision'],'shown')
def test_background_research_change_uses_original_judge_pipeline(self):
out,route=self._background({'research_changed':True})
self.assertTrue(out['ok'])
self.assertIsNone(route.call_args.kwargs['prepared_verdict'])
self.assertTrue(route.call_args.args[0]['_plan_recheck'])
self.assertNotEqual(out['proposal']['hard_numbers']['proposal_revision'],'shown')
def test_active_update_lease_prevents_duplicate_judgement(self):
p=proposal();p['hard_numbers']['preparation']['lease_until']=(datetime.now().astimezone()+timedelta(seconds=100)).isoformat()
with patch.object(rc,'current_basis',side_effect=AssertionError('租约内不能重评')):
self.assertFalse(rc.refresh_one(p)['ok'])
def test_budget_exhausted_returns_to_pending_without_model(self):
with patch.object(rc,'current_basis',return_value=current(ok=False,research_changed=True)), \
patch.object(pms_repo,'record_proposal_check',return_value=1), \
patch.object(ps,'_judge_budget_left',return_value=False), \
patch.object(ps,'_scan_open',side_effect=AssertionError('预算不足不扫描')):
out=rc.refresh_one(proposal(),deadline=0)
self.assertTrue(out['pending'])
self.assertNotIn('lease_until',out['proposal']['hard_numbers']['preparation'])
def test_background_cas_loser_never_calls_model(self):
with patch.object(rc,'current_basis',return_value=current(ok=False,research_changed=True)), \
patch.object(pms_repo,'record_proposal_check',return_value=0), \
patch.object(ps,'_scan_open',side_effect=AssertionError('失去所有权')):
self.assertFalse(rc.refresh_one(proposal())['ok'])
def test_sell_and_strategy_proposals_keep_existing_path(self):
with patch.object(rc,'current_basis',side_effect=AssertionError('无选股重评')):
self.assertTrue(rc.before_accept(proposal(action='EXIT'),None)['ok'])
self.assertTrue(rc.before_accept(proposal(hard_numbers={'kind':'strategy'}),None)['ok'])
def test_publication_and_audit_rollback_and_concurrent_accept(self):
from sqlalchemy import create_engine,text
from app.db import session
engine=create_engine('sqlite:///:memory:');self.addCleanup(engine.dispose)
p=proposal();h=rc.ready_hard({'consumption_id':'c','price':10},{'verdict':'PASS'})
c={'qty':200,'hard_numbers':h};v={'verdict':'PASS'}
with engine.begin() as db:
db.execute(text('CREATE TABLE pms_proposal(proposal_id TEXT,status TEXT,qty INTEGER,hard_numbers_json TEXT,judge_verdict TEXT,judge_reason TEXT,decided_at TEXT)'))
db.execute(text("INSERT INTO pms_proposal VALUES ('p1','WAIT_USER',100,:old,NULL,NULL,NULL)"),{'old':p['hard_numbers_json']})
with patch.object(session,'get_engine',return_value=engine),patch.object(session,'_check_cooldown'):
with self.assertRaises(Exception):pms_repo.publish_prepared_proposal(p,c,v)
with engine.begin() as db:
self.assertEqual(db.execute(text('SELECT qty FROM pms_proposal')).scalar(),100)
db.execute(text('CREATE TABLE pms_action_ledger(ts_code TEXT,decided_at TEXT,action TEXT,arbiter TEXT,verdict TEXT,price_at REAL,hard_numbers_json TEXT,failed_checks_json TEXT,reason TEXT,ref_id TEXT,outcome_scored INTEGER)'))
self.assertEqual(pms_repo.publish_prepared_proposal(p,c,v),1)
self.assertEqual(pms_repo.publish_prepared_proposal(p,c,v),0)
self.assertEqual(pms_repo.decide_proposal('p1','ACCEPTED',expected_hard=p['hard_numbers_json']),0)
with engine.begin() as db:
self.assertEqual(db.execute(text('SELECT COUNT(*) FROM pms_action_ledger')).scalar(),1)
def test_quantity_changed_requires_new_confirmation_identity(self):
h={'plan_basis':{'risk':[]}};v={'verdict':'PASS'}
self.assertNotEqual(pr.confirmation_revision(100,h,v),pr.confirmation_revision(200,h,v))
self.assertEqual(pr.confirmation_revision(100,h,v),pr.confirmation_revision(100,dict(h,decision={'id':'v2'},research_rank=99),v))
def test_real_fast_checks_capacity_and_funds_without_changing_quantity(self):
from app.services import market,portfolio,param_store,execution_conflicts
p=proposal()
view={'held':[],'params':{'max_names':5,'portfolio_cap':0.7,'scale':10000},
'totals':{'names_count':1,'portfolio_mv':1000,'cash_source':portfolio.CASH_WS,'cash_avail':2000}}
check=current(view=view,commitments={'names':set(),'amount':0})
with patch.object(execution_conflicts,'buy_blocker',return_value=None), \
patch.object(param_store,'get',return_value='propose_only'), \
patch.object(param_store,'get_int',return_value=0), \
patch.object(param_store,'get_bool',return_value=True), \
patch.object(market,'get_price',return_value=10), \
patch.object(market,'day_snapshot',return_value={}), \
patch.object(market,'get_ma5',return_value=10), \
patch.object(ps,'check_candidate_rules',return_value={'passed':True}) as gate:
self.assertIsNone(rc.fast_checks(p,check))
view['totals']['cash_avail']=900
self.assertIn('资金不足',rc.fast_checks(p,check))
view['totals']['cash_avail']=2000
check['commitments']['names']={'a','b','c','d'}
self.assertIn('名额不足',rc.fast_checks(p,check))
self.assertEqual(p['qty'],100)
gate.assert_called_once()
def test_route_reuses_prepared_verdict_without_judge(self):
from app.services import judge,param_store
c={'ts_code':CODE,'action':'OPEN','side':'buy','qty':100,'price':10,'reason':'原依据','hard_numbers':{}}
with patch.object(ps,'check_candidate_rules',return_value={'passed':True}), \
patch.object(ps,'_pos_of',return_value={}), \
patch.object(ps,'_self_decide',return_value={}), \
patch.object(ps,'_apply_advice_tier'), \
patch.object(judge,'request',side_effect=AssertionError('不许重复调用模型')), \
patch.object(param_store,'get_bool',return_value=False):
out=ps._route_one(c,{}, {},{},False,datetime.now(),True,{'autonomy':'propose_only'},
review_only=True,prepared_verdict={'verdict':'PASS','reason':'原结论'})
self.assertEqual(out['verdict']['reason'],'原结论')
def test_background_failure_stays_pending_and_does_not_publish(self):
with patch.object(rc,'current_basis',return_value=current(ok=False,portfolio_changed=True)), \
patch.object(pms_repo,'record_proposal_check',return_value=1), \
patch.object(ps,'_scan_open',return_value=[]), \
patch.object(pms_repo,'publish_prepared_proposal',side_effect=AssertionError('无结果不能发布')):
out=rc.refresh_one(proposal())
self.assertFalse(out['ok'])
self.assertEqual(out['proposal']['hard_numbers']['preparation']['status'],'pending')
def test_prepare_final_source_failure_cannot_publish(self):
c={'ts_code':CODE,'qty':100,'hard_numbers':{'consumption_id':'c'}}
with patch.object(rc,'current_basis',side_effect=[current(ok=False,research_changed=True),{'ok':False,'error':'研究更新中'}]), \
patch.object(pms_repo,'record_proposal_check',return_value=1), \
patch.object(ps,'_scan_open',return_value=[c]), \
patch.object(ps,'_route_one',return_value={'verdict':{'verdict':'PASS'}}), \
patch.object(ps.param_store,'get_int',return_value=0), \
patch.object(pms_repo,'publish_prepared_proposal',side_effect=AssertionError('旧研究不能发布')):
out=rc.refresh_one(proposal())
self.assertTrue(out['pending'])
if __name__=='__main__':unittest.main()