diff --git a/DEVLOG.md b/DEVLOG.md index ae932ff..05ac67b 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -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。 diff --git a/app/core/plan_revision.py b/app/core/plan_revision.py index 563b0b1..9e58e96 100644 --- a/app/core/plan_revision.py +++ b/app/core/plan_revision.py @@ -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')}) diff --git a/app/db/session.py b/app/db/session.py index 1eef764..95a1aff 100644 --- a/app/db/session.py +++ b/app/db/session.py @@ -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 diff --git a/app/repo/pms_repo.py b/app/repo/pms_repo.py index ebf07be..4f0e6ac 100644 --- a/app/repo/pms_repo.py +++ b/app/repo/pms_repo.py @@ -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) diff --git a/app/services/plan_feed.py b/app/services/plan_feed.py index fc3e947..a666868 100644 --- a/app/services/plan_feed.py +++ b/app/services/plan_feed.py @@ -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__ diff --git a/app/services/plan_recheck.py b/app/services/plan_recheck.py index 7316987..90e6049 100644 --- a/app/services/plan_recheck.py +++ b/app/services/plan_recheck.py @@ -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__}'} diff --git a/app/services/proposal_service.py b/app/services/proposal_service.py index 644bb17..e8d8d12 100644 --- a/app/services/proposal_service.py +++ b/app/services/proposal_service.py @@ -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"], diff --git a/app/web/main.py b/app/web/main.py index fab6e6b..b3782bd 100644 --- a/app/web/main.py +++ b/app/web/main.py @@ -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") diff --git a/app/web/static/index.html b/app/web/static/index.html index 76e7f91..df65023 100644 --- a/app/web/static/index.html +++ b/app/web/static/index.html @@ -1229,7 +1229,11 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;} · {{ proposals.length }} 条{{ sectClosed.prop?'▸':'▾' }}
暂无待确认提议。
-
采纳后生成待执行指令;驳回后今日不再提议。
+
+ {{ nm(p.ts_code) }} · {{ p.prepare_reason }} + +
+
采纳后生成待执行指令;驳回后今日不再提议。
{{ nm(p.ts_code) }} {{ p.ts_code }} @@ -1410,7 +1414,7 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;} -
@@ -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">
{{ decideDlg.row.judge_reason || propWhy(decideDlg.row) }}
- 正在核对采纳条件;依据有变化时会重新研判,可能需要一至两分钟。请勿重复提交。 + 正在确认交易条件,请勿重复提交。
@@ -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, diff --git a/docs/开发规格_提议先准备后采纳_2026-10-20.md b/docs/开发规格_提议先准备后采纳_2026-10-20.md new file mode 100644 index 0000000..2fc8229 --- /dev/null +++ b/docs/开发规格_提议先准备后采纳_2026-10-20.md @@ -0,0 +1,44 @@ +# 提议先准备后采纳开发规格 + +2026-10-20,北京时间。用户已批准本方案及155模拟仓部署,188不操作。 + +## 目标与问题 + +待确认提议必须已经完成研究、数量、规则和消费记录。采纳请求只做快速检查,不调用模型。 +今天600183的提议在10:19被请求内重评,10:20又因统一技术错误提示被拦,第三次才成功。 +原实现将研究变化、组合变化、记录失败混在一起。后台只跳过待确认项,不主动维护它们。 + +## 处理边界 + +复用已有交易时段的自主扫描,不新增任务,不改交易时段和策略阈值。 +新建仓提议增加准备状态,仍保存在原JSON。旧提议由后台准备后才开放采纳。 +数量、研判与必要留痕准备好后进入可确认状态,当日失效时刻沿用现有计算。 +读取页面不运行研判。准备中的条目单列简短状态,不给采纳按钮,不堆技术字段。 + +研究变化由后台重跑原合议、研判、数量与规则闸。明确资格失效或研判否决撤下。 +组合或参数变化重算数量与规则,复用相同研究依据的已有研判,不无故重问模型。 +资金不足或来源暂不可用保留为准备中,后台恢复后重试;失败原因分开记录。 +排序或无关计划更新不改变确认内容。可见数量、依据或风险变化才更换确认标识。 +后台更新先使旧内容不可确认,再计算并用原状态与原JSON条件更新,防止并发覆盖。 + +采纳时检查确认内容、当日有效期、当前研究是否仍适用,以及资金、仓位、暂停与买卖冲突。 +最新计划只读取完整发布结果,不新装配,不新增消费快照。必要交易动作审计仍必须落账。 +依据发生变化时快速返回等待后台更新,不在HTTP请求里重评,也不暗中改数量。 +已准备的数量仍在当前额度内时,不因其他持仓或资金读数变化强制重新确认。 +正常下发继续执行既有择时及交易前终检。 + +## 预判与测试 + +新增离线反例:新提议直接确认零模型调用;纯资金变化复用研判;研究变化后台更新; +旧确认标识拒绝;准备中没有采纳按钮;明确失效撤下;消费记录失败不发布可确认提议; +后台预算耗尽不调用模型;并发确认只生成一次;当前计划取数失败不下单; +正常到期及卖出、策略确认保持。必须运行完整隔离禁网回归与三页面守卫。 +浏览器使用替身核验交互,再只读核对生产页面或接口;不得代用户采纳或制造交易。 + +## 部署与回退 + +部署前备份155参数、持仓、批次、提议、指令和出口委托,记录旧提交与镜像。 +候选镜像断网测试通过后统一重建四容器,避开盘前维护窗口。 +部署后核对正常调度、准备状态、模型调用边界、参数与账务。自然事件不足明确未判收。 +回退须反向提交并拉取后重建,保留准备记录、真实成交与审计,不恢复已撤销指令。 +同步四仓规格与交接,桥决定台账登记本轮选择。 diff --git a/scripts/run_tests.py b/scripts/run_tests.py index f8eca4a..1b1b391 100644 --- a/scripts/run_tests.py +++ b/scripts/run_tests.py @@ -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", diff --git a/scripts/test_batch38_units.py b/scripts/test_batch38_units.py index abf48ad..dff3724 100644 --- a/scripts/test_batch38_units.py +++ b/scripts/test_batch38_units.py @@ -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']) diff --git a/scripts/test_batch42_units.py b/scripts/test_batch42_units.py new file mode 100644 index 0000000..2128b66 --- /dev/null +++ b/scripts/test_batch42_units.py @@ -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()