diff --git a/app/core/action_engine.py b/app/core/action_engine.py index 26875b2..4cd7790 100644 --- a/app/core/action_engine.py +++ b/app/core/action_engine.py @@ -1095,7 +1095,10 @@ def scan_open(*, candidates: list, params: dict, caps: dict, room_amt: float, mark_decide(cand, DK_VERDICT_WATCH if v == VERDICT_WATCH else DK_VERDICT_UNKNOWN) if params.get("open_route_by_logic") and logic_confirm_why(c.get("logic_state")): mark_decide(cand, DK_LOGIC_DOUBT) - if con and con.get("route") == "交人": + # 合议交人的两种, 以及**强弱为弱的一律按只有一方表态** (09-14 评审补): 弱表态开关 + # PMS_CONSENSUS_WEAK_CONFIRM 关掉时路由是放行而不是交人, 不看强弱就会按标准仓采纳 —— + # 决定表里没有「放行·弱」这一格, 弱就是试探仓。 + if con and (con.get("route") == "交人" or con.get("strength") == "弱"): rr = con.get("route_reason") or "" mark_decide(cand, DK_FUND_BEAR_TECH_BULL if "基本面看空" in rr else DK_WEAK_CONSENSUS) out.append(cand) diff --git a/app/services/proposal_service.py b/app/services/proposal_service.py index 15e1e81..d9976f1 100644 --- a/app/services/proposal_service.py +++ b/app/services/proposal_service.py @@ -705,7 +705,8 @@ def disposition_snapshot(now=None) -> dict: for _k in _rejected_today_keys(): _w = _rr.get(_k[0]) skip[_k] = ("未通过合规检查:" + _w) if _w else "今天没通过合规检查,明天开盘会重新评估" - for _k in _system_declined_today_keys(): # 系统自决今日已放弃 (明日重评) + _sd_keys = _system_declined_today_keys() # 系统自决今日已放弃 (明日重评) + for _k in _sd_keys: skip[_k] = "今日系统已判放弃,明日重评" for _k in _inflight_keys(): # 在途; 前端多半已按指令/提议标成「已下单/待你确认」 skip[_k] = "系统正在处理这只票(见『待确认提议』或『当日指令』)" @@ -727,6 +728,10 @@ def disposition_snapshot(now=None) -> dict: # 合议判观察的候选带 disp=wait_tech/wait_confirm, 其余不产出的按 deny。 res["by_code"].setdefault( code, {"disp": s.get("disp") or "deny", "why": s.get("why") or "未产出候选"}) + # 系统自决今日放弃的候选打处置词 sys_decline (09-14 评审补): 页面显示「系统已放弃」而不是「未建仓」。 + for (_code, _act) in _sd_keys: + if _act == ae.A_OPEN and _code in res["by_code"]: + res["by_code"][_code]["disp"] = "sys_decline" # 候选栏三小块 (2026-09-14 页面收尾包): 每只候选的合议摘要, 从 consensus_seen 来。 # 基本面立场与质地档 / 技术面立场与相位 / 合议方向与路由。挂到对应代码的 by_code 行上。 for rec in (sink.get("consensus_seen") or []): @@ -787,7 +792,14 @@ def _route_one(c, view, params, stock_params, brake_active, now, dry_run, out, # ---- 二级: 研判闸 (补足/加仓/补仓/新建仓; 减持不送研判) ---- verdict = {"verdict": judge.PASS, "reason": "", "degraded": False} - if c.get("judge_required"): + # 系统自决 decline/full 档: 逻辑存疑的新建仓不送研判 (09-14 评审补) —— 自决必然放弃它, 送研判 + # 只是白占一次调用与本轮时间预算。record 档不跳: 那一档要照旧进人工队列, 提议上得有真研判结论。 + _sd_mode = str((params or {}).get("self_decide") or "off").strip() + if (is_open and c.get("decide_kind") == ae.DK_LOGIC_DOUBT + and _sd_mode in ("decline", "full")): + verdict = {"verdict": "SKIPPED", "reason": "逻辑存疑,不送研判(系统自决直接放弃)", + "degraded": False, "raw": None, "confidence": None} + elif c.get("judge_required"): if not _judge_budget_left(deadline): # 本轮研判时间用尽 —— 整条跳过, 下一跳重来。 # **不当成「研判不可用」入人工队列**: 那会在自动档位下凭空造出一个人工确认队列, @@ -955,7 +967,7 @@ def _route_one(c, view, params, stock_params, brake_active, now, dry_run, out, if auto_exec: sd_accept = sd_decision == "accept" - strat_cancelled = [] + strat_cancelled, buys_cancelled = [], [] if sd_accept and side == "sell": # 目标价自动清仓两道前置 (2026-09-14): 按当前可卖量夹紧; 该票挂着方案先撤, # 免得清仓单与网格买入腿对倒 (附录乙那处冲突)。可卖量为零则不落, 明日重评。 @@ -966,25 +978,34 @@ def _route_one(c, view, params, stock_params, brake_active, now, dry_run, out, "why": "系统到价自动清仓: 当前可卖量为零, 本轮不清 (明日重评)"}) return c["qty"] = clamped + # 停买入侧三件 (09-14 评审补): 撤方案、驳回买入提议、撤在途买单 —— 不只在有活动方案时, + # 一张在途的补仓/网格买单同样会与清仓单对倒。撤不成只记日志, 清仓照落。 try: - if code in (pms_repo.active_strategy_codes() or set()): - r = strategy_service.cancel_by_code( - code, reason="系统到价自动清仓: 先撤该票交易方案, 免得清仓单与方案买入腿对倒") - strat_cancelled = r.get("cancelled") or [] + r = strategy_service.cancel_by_code( + code, reason="系统到价自动清仓: 先撤该票交易方案与在途买单, 免得清仓单与买入腿对倒") + strat_cancelled = r.get("cancelled") or [] + buys_cancelled = r.get("instructions") or [] + if r.get("errors"): + logger.warning("[自决清仓] 停买入侧有未成项 %s: %s", code, r["errors"]) except Exception as e: # noqa: BLE001 - logger.warning("[自决清仓] 撤方案失败 %s (清仓照落): %s", code, e) + logger.warning("[自决清仓] 停买入侧失败 %s (清仓照落): %s", code, e) elif sd_accept and is_open: # 试探仓类采纳: 把数量重算成建议档 (弱基本面那类还带上紧止盈标记 advice.tight_trail)。 _apply_advice_tier(c, c.setdefault("hard_numbers", {}), verdict, force_trial=(sd.get("tier") == "trial")) if sd_accept: - c["reason"] = sd.get("why") or c["reason"] # 指令与账本理由一致 + # 指令与账本理由一致。到价清仓把原句 (现价/目标价/股数) 接在后面, 复盘看得见到的是什么价 (09-14 评审补)。 + _why = sd.get("why") or c["reason"] + if sd.get("rule") == ae.DK_TARGET_PRICE and c.get("reason"): + _why = f"{_why};{c['reason']}" + c["reason"] = _why iid = _make_instruction(c, price, now) if sd_accept: - reason = sd.get("why") or c["reason"] + reason = c["reason"] _dc = params.get("_sd_base_count", 0) + out.get("_sd_accepts", 0) hard = {**(c.get("hard_numbers") or {}), - "self_decide": _self_decide_hard(c, verdict, sd, params, strat_cancelled, _dc)} + "self_decide": _self_decide_hard(c, verdict, sd, params, strat_cancelled, _dc, + buys_cancelled=buys_cancelled)} else: reason = verdict.get("reason") or c["reason"] if auto_why: @@ -1228,6 +1249,12 @@ def _self_decide(c, verdict, params, today_auto_count) -> dict: # 三, 逻辑存疑: 自动放弃, 评审方 system if dk == ae.DK_LOGIC_DOUBT: return _mode({"decision": "decline", "why": "研究证据走弱(逻辑存疑),自动放弃"}) + # 四, 真通过 (规格 §3.5): 只认带应答体的 PASS。动作不在研判范围时研判直接回 PASS 且没有应答体, + # 那不是决策系统的结论, 不能据此自动入场 —— 交人 (09-14 评审补; 把 OPEN 从研判范围里拿掉时 + # 全部新建仓都会落到这里, 方向是保守)。 + if not isinstance(verdict.get("raw"), dict): + return _mode({"decision": "confirm", + "why": "研判没有真正应答(动作不在研判范围或无应答体),交人裁决"}) # 深亏补仓: 研判必答「杀逻辑还是杀情绪」, 契约规定杀逻辑或说不清一律 REJECT (REJECT 已在前面 # 早退当放弃)。所以走到这里的 PASS 即杀情绪: 把握度不低于 60 自动补, 否则放弃。 if is_dca_deep: @@ -1263,8 +1290,10 @@ def _sd_consensus_brief(c) -> dict: "route": con.get("route")} -def _self_decide_hard(c, verdict, sd, params, strat_cancelled, daily_count) -> dict: - """账本硬数字的 self_decide 块 (数据契约 §3.8): 决定种类、档、仓位档、合议三项、研判、当日计数、撤掉的方案。""" +def _self_decide_hard(c, verdict, sd, params, strat_cancelled, daily_count, *, + buys_cancelled=None) -> dict: + """账本硬数字的 self_decide 块 (数据契约 §3.8): 决定种类、档、仓位档、合议三项、研判、当日计数、 + 撤掉的方案与在途买单。""" tier_cn = {"trial": "试探仓", "standard": "标准仓", "half": "减半仓"}.get(sd.get("tier")) return {"rule": sd.get("rule"), "mode": str((params or {}).get("self_decide") or "off"), "tier": tier_cn or sd.get("tier"), @@ -1272,7 +1301,8 @@ def _self_decide_hard(c, verdict, sd, params, strat_cancelled, daily_count) -> d "judge": {"verdict": verdict.get("verdict"), "confidence": verdict.get("confidence")}, "daily_count": daily_count, "daily_max": int((params or {}).get("self_decide_daily_max") or 0), - "strategy_cancelled": list(strat_cancelled or [])} + "strategy_cancelled": list(strat_cancelled or []), + "buys_cancelled": list(buys_cancelled or [])} def _sd_decline_ledger(code, action, c, verdict, price, sd) -> None: diff --git a/app/services/strategy_service.py b/app/services/strategy_service.py index 5f48312..e911f46 100644 --- a/app/services/strategy_service.py +++ b/app/services/strategy_service.py @@ -138,19 +138,26 @@ def set_status(strategy_id: str, status: str, by: str = "user") -> dict: def cancel_by_code(ts_code: str, reason: str = "") -> dict: - """撤下某只票当前 ACTIVE 的全部交易方案 (2026-09-14 系统自决包)。 + """停掉某只票的买入侧 (2026-09-14 系统自决包; 同日评审补齐): 与命令清仓那条 + command_service._stop_buyside_for_exit 同样的三件事 —— 撤该票 ACTIVE/PAUSED 的全部交易方案、 + 驳回该票待确认的买入提议、撤该票当前在途的买入指令。 - 系统自决到价自动清仓前调它: 该票若挂着网格, 网格的买入腿会与清仓单对倒 (附录乙那处冲突), - 所以落清仓指令前先撤方案。与命令服务清仓覆盖冲突那段 (command_service) 同一手法, 抽到这里 - 供两处复用。返回撤掉的方案编号列表; 读库或撤下失败只记进 errors, 不抛 —— 撤不成也不该拦住清仓。 + 系统自决到价自动清仓前调它: 该票若挂着网格, 网格的买入腿会与清仓单对倒 (规格附录乙那处冲突)。 + 只改方案状态不够 —— 已经下发到券商的那张网格买单还挂着继续成交, 命令清仓那条早就为此补过 + 「撤在途买单」这一层 (2026-09-08), 这里照做。三步各自幂等、失败只记进 errors 不抛 —— + 撤不成也不该拦住清仓 (清仓单落表后由执行器照卖; 下游没撤成的买单单独列出来别吞)。 + 返回 {ok, ts_code, cancelled: 方案编号, proposals: 驳回的提议, instructions: 撤掉的在途买单, errors}。 """ - out = {"ok": True, "ts_code": ts_code, "cancelled": [], "errors": []} + out = {"ok": True, "ts_code": ts_code, "cancelled": [], "proposals": [], "instructions": [], + "errors": []} + why = reason or "系统到价自动清仓: 先撤该票交易方案与在途买单, 免得清仓单与买入腿对倒" + # 一, 撤方案 (ACTIVE 与 PAUSED 都撤: 暂停的网格恢复后照样买) try: - actives = pms_repo.list_strategies(ts_code=ts_code, statuses=["ACTIVE"], limit=20) + actives = pms_repo.list_strategies(ts_code=ts_code, statuses=["ACTIVE", "PAUSED"], limit=20) except Exception as e: # noqa: BLE001 + actives = [] out["ok"] = False out["errors"].append(f"读策略失败: {type(e).__name__}: {e}") - return out for s in actives: try: r = set_status(s["strategy_id"], "CANCELLED", by="system") or {} @@ -160,8 +167,7 @@ def cancel_by_code(ts_code: str, reason: str = "") -> dict: pms_repo.insert_ledger( ts_code=ts_code, action="CLEANUP", arbiter="system", verdict="PASS", price_at=0, ref_id=s["strategy_id"], - hard_numbers={"strategy_id": s["strategy_id"]}, - reason=(reason or "系统到价自动清仓: 先撤该票交易方案, 免得清仓单与方案买入腿对倒")[:500]) + hard_numbers={"strategy_id": s["strategy_id"]}, reason=why[:500]) except Exception as e: # noqa: BLE001 logger.warning("[自决清仓] 撤方案留痕失败 %s: %s", ts_code, e) else: @@ -170,6 +176,46 @@ def cancel_by_code(ts_code: str, reason: str = "") -> dict: except Exception as e: # noqa: BLE001 out["ok"] = False out["errors"].append(f"{s.get('strategy_id')} 撤下失败: {type(e).__name__}: {e}") + # 二, 驳回该票待确认的买入提议 (清仓了还等人点头买, 是自相矛盾) + from app.services import command_service, executor + try: + props = [p for p in pms_repo.list_proposals(statuses=("WAIT_USER",), limit=500) + if p.get("ts_code") == ts_code and p.get("action") in command_service.BUILD_ACTIONS] + except Exception as e: # noqa: BLE001 + props = [] + out["ok"] = False + out["errors"].append(f"读提议失败: {type(e).__name__}: {e}") + for p in props: + try: + if pms_repo.decide_proposal(p["proposal_id"], "DECLINED"): + out["proposals"].append(p["proposal_id"]) + except Exception as e: # noqa: BLE001 + out["ok"] = False + out["errors"].append(f"驳回买入提议 {p.get('proposal_id')} 失败: {type(e).__name__}: {e}") + # 三, 撤该票当前在途的买入指令 (已下发到券商的网格/补仓买单, 撤了方案它还挂着) + try: + live_buys = [i for i in pms_repo.list_instructions( + statuses=list(command_service.LIVE_INSTR), side="buy", limit=500) + if i.get("ts_code") == ts_code] + except Exception as e: # noqa: BLE001 + live_buys = [] + out["ok"] = False + out["errors"].append(f"读在途买单失败: {type(e).__name__}: {e}") + for i in live_buys: + iid = i.get("instruction_id") + try: + r = executor.cancel_instruction(iid, reason=why) or {} + if r.get("ok"): + out["instructions"].append(iid) + else: + out["ok"] = False + out["errors"].append(f"撤在途买单 {iid} 未成: {r.get('error') or r.get('message')}") + except Exception as e: # noqa: BLE001 + out["ok"] = False + out["errors"].append(f"撤在途买单 {iid} 失败: {type(e).__name__}: {e}") + if out["cancelled"] or out["proposals"] or out["instructions"]: + logger.warning("[自决清仓] %s 停买入侧: 撤方案 %s, 驳回买入提议 %s, 撤在途买单 %s", + ts_code, out["cancelled"], out["proposals"], out["instructions"]) return out diff --git a/app/web/static/index.html b/app/web/static/index.html index 8cab49e..2b911f7 100644 --- a/app/web/static/index.html +++ b/app/web/static/index.html @@ -2937,7 +2937,11 @@ createApp({ function dispOf(code) { const inst = (instructions.value || []).find(i => i.ts_code === code && i.action === 'OPEN' && (_LIVE_INS.includes(i.status) || _sameDayIns(i))); // 旧的终态单不算「已下单」(2026-08-28) - if (inst) return { cls: 'done', label: '已下单', why: '在『当日指令』(' + tx('insStatus', inst.status) + ')' }; + if (inst) { + // 系统自决自动采纳的指令 (2026-09-14): 进度里 auto 为真且理由以「系统采纳」开头, 显示「系统已采纳」。 + const autoAcc = inst.progress && inst.progress.auto && String(inst.progress.reason || '').startsWith('系统采纳'); + return { cls: 'done', label: autoAcc ? '系统已采纳' : '已下单', why: '在『当日指令』(' + tx('insStatus', inst.status) + ')' }; + } const prop = (proposals.value || []).find(p => p.ts_code === code && p.action === 'OPEN'); if (prop) return { cls: 'queue', label: '待确认', why: '已生成提议,在『待确认提议』等人工裁决' }; const d = (openScan.value.by_code || {})[code]; @@ -2950,6 +2954,8 @@ createApp({ if (d.disp === 'wait_confirm') return { cls: 'wait', label: '等盘中确认', why: d.why }; // 系统自决 (2026-09-14): 方向看多但择时看空, 进观察等择时转多, 也是「在盯」不是「被否决」。 if (d.disp === 'wait_timing') return { cls: 'wait', label: '择时待转多', why: d.why }; + // 系统自决今日已放弃 (2026-09-14): 明日重评, 与「未建仓」分开显示。 + if (d.disp === 'sys_decline') return { cls: 'deny', label: '系统已放弃', why: d.why }; return { cls: 'deny', label: '未建仓', why: d.why }; } return { cls: 'wait', label: '候选观察', why: '' }; diff --git a/docs/下一阶段开发方案与规格书_2026-09-14.md b/docs/下一阶段开发方案与规格书_2026-09-14.md index d797390..054325e 100644 --- a/docs/下一阶段开发方案与规格书_2026-09-14.md +++ b/docs/下一阶段开发方案与规格书_2026-09-14.md @@ -514,6 +514,9 @@ consensus.decide 加参数 weak_confirm,默认真。_route 里「只有一方 | PMS_TECH_ENABLED | true | 前提是接口根地址指向 188 的决策系统 | | PMS_FUND_REQUIRED | true | 无评析不建仓 | | PMS_TOTAL_SCALE | 按真实资金 | 与账户实际资金要对得上 | +| PMS_SELF_DECIDE | off | 系统自决关,全部交人(2026-09-14 下午加) | +| PMS_SELF_DECIDE_TRIAL | false | 试探仓类不自动采纳(2026-09-14 下午加) | +| PMS_SELF_DECIDE_DAILY_MAX | 1 | 每日自动新建仓上限一只(2026-09-14 下午加) | ### 脚本 diff --git a/docs/复盘决定台账.md b/docs/复盘决定台账.md index a735aef..fad32e1 100644 --- a/docs/复盘决定台账.md +++ b/docs/复盘决定台账.md @@ -231,3 +231,5 @@ **台账线(只作提示,调阈值先回台账)。** 研判不可用占比应低于两成,超过要查决策系统那侧。上线头几天自动采纳的股数应是试探仓那批,不是标准仓。 **复核日期。** 部署后的下一个交易日盘中先只读试算一次,看 executed 里有没有 route 为 auto、why 含「系统采纳」的条目,或 rejected 里有没有评审方 system 的条目。收盘后跑 make consensus-review 看第五张表系统自决逐条、第六张表研判不可用占比。 + +**评审补记(2026-09-14 下午,同日评审后修订)。** 四处补齐,不改上面的决定。一,到价自动清仓前停买入侧改成与命令清仓同样的三件:撤方案、驳回该票待确认的买入提议、撤该票在途的买入指令;原来只撤方案,已下发的网格买单会与清仓单对倒。不再只在有活动方案时才做,一张在途补仓买单同样要撤。二,只有一方表态改按合议强弱打标记:强弱为弱一律按试探仓,不看路由;原来只在路由为交人时打,弱表态开关 PMS_CONSENSUS_WEAK_CONFIRM 关掉后会按标准仓采纳。三,自动采纳只认带应答体的真通过:动作不在研判范围时研判直接回通过且没有应答体,这种一律交人。四,decline 与 full 档下逻辑存疑的新建仓不送研判,直接放弃,省一次研判调用与时间预算;record 档照旧送研判。页面加两种处置词:系统已采纳、系统已放弃。 diff --git a/scripts/run_tests.py b/scripts/run_tests.py index a892c15..1316230 100644 --- a/scripts/run_tests.py +++ b/scripts/run_tests.py @@ -106,7 +106,8 @@ 与拼句 (预设码/其他必填补充/旧自由文本兼容/码串表报错)/决策核心 _self_decide (off 不管、放行强采标准仓、低把握转试探仓、只有一方表态与异议只在技术面按 TRIAL 开关、四类交人、逻辑存疑放弃、深亏补仓三分支、目标价直接采纳、每日上限、record/decline - 降级)/mark_decide 优先级/eval_dca 深档打 decide_kind/三参数登记与范围/两个 repo 查询 (22 例) + 降级)/mark_decide 优先级/eval_dca 深档打 decide_kind/三参数登记与范围/两个 repo 查询 + + 评审补: 真通过只认带应答体的 PASS、弱表态按强弱打标记 (24 例) test_page_copy_guard.py 页面文案守卫·口语化 (2026-09-14 页面专业化包): 禁用词清单 (第一二人称/拟人口语/生造动词) 扫显示文本, 剥三种注释、成对豁免、 网址不误截、自检样例 (1 例) @@ -114,14 +115,15 @@ 直接印到页面上 / 判据码显示前必须剥前缀 / 不许把整个对象 打给交易员看 / 翻译兜底不许让英文码单独当句子 (1 例) test_wiring.py 装配自检: 服务层→核心→落表 全链路 (内存桩) + - 目标价到价自决关时入队、自决开时自动清仓按可卖量夹紧 + - 用户设的止损价与目标价单独成列显示 (71 例) - 共 894 例 + 目标价到价自决关时入队、自决开时自动清仓按可卖量夹紧、挂着方案且有在途买单时先撤再清 + + 用户设的止损价与目标价单独成列显示 (72 例) + 共 900 例 (总数按实跑逐批相加校正过两次: 曾写 649 是笔误, 实为 650; 09-03 先后加了同轮只发一条 减持与研究理由两键各一例, 到 652; 09-04 加了仅展示跳过原因与空候选说明各一例, 到 654; 又加了页面文案守卫一例, 到 655; 09-07 审查修复加了跨轮减持等五例, 到 660; 第二件低把握驳回交人一例, 到 661; 第三件逻辑状态接入第二十二批十五例, 到 676; 第四件安全边际整句透传一例, 到 677; 参考目标价一例, 到 678; 催化事件与定价状态透传一例, 到 679; 两个期限的头一例, 现为 680; 09-10 建议档位对齐第二十六批十七例到 697; 09-11 技术面接入工作包一第二十七批二十七例到 724; 三源合议工作包二纯逻辑第二十八批三十七例到 763; 接入下单链路第二十九批二十四例到 787; 工作包三离场纪律第三十批十八例到 805; part3 盘中 SAR 止损线补九例到 814; part4 弱基本面紧止盈补六例到 820; 09-14 观察读数包第三十一批二十例到 840; 加固包给第二十八批加三例、第三十批加三例到 846; 评审修订第三十一批加一例到 847; 09-14 盘中确认包第三十二批十九例到 866; 页面收尾包第三十三批五例到 871; 09-14 系统自决包第三十四批二十二例、test_wiring 目标价自动清仓加一例到 894; - 09-14 选股打分包 §5.3 来源字段给第七批加两例到 896; 页面专业化包加文案守卫一例, 现为 897) + 09-14 选股打分包 §5.3 来源字段给第七批加两例到 896; 页面专业化包加文案守卫一例到 897; + 09-14 评审补第三十四批两例、test_wiring 撤方案与在途买单一例, 现为 900) 任一子集失败即整体失败 (退出码 1)。 哨兵位置清单 (2026-09-03 抄录; 改了对应的东西就得来这些地方改断言, 断言不动就是漏了): diff --git a/scripts/test_batch34_units.py b/scripts/test_batch34_units.py index 0f8df38..f4fe9e3 100644 --- a/scripts/test_batch34_units.py +++ b/scripts/test_batch34_units.py @@ -33,8 +33,12 @@ def case(name): return deco -def _v(verdict=judge.PASS, conf=78, degraded=False): - return {"verdict": verdict, "confidence": conf, "degraded": degraded, "reason": ""} +def _v(verdict=judge.PASS, conf=78, degraded=False, raw="auto"): + """研判结果桩。raw 默认给一个字典 (带应答体的真通过); 传 None 模拟「动作不在研判范围」那种 + 没有应答体的 PASS。""" + r = {"verdict": verdict, "confidence": conf, "degraded": degraded, "reason": ""} + r["raw"] = {"verdict": verdict} if raw == "auto" else raw + return r def _cand(action=ae.A_OPEN, decide_kind=None, consensus=True, risk=None): @@ -150,6 +154,20 @@ def _(): assert _self_decide(_cand(decide_kind=ae.DK_VERDICT_UNKNOWN), _v(), fp, 0)["decision"] == "confirm" +@case("C 真通过·PASS 但没有应答体 (动作不在研判范围) → 交人, 不按试探仓采纳") +def _(): + fp = {"self_decide": "full", "self_decide_trial": True} + d = _self_decide(_cand(), _v(conf=None, raw=None), fp, 0) + assert d["decision"] == "confirm" and "应答" in d["why"], d + # 深亏补仓同样只认带应答体的 PASS + dca = _cand(action=ae.A_DCA, decide_kind=ae.DK_DCA_DEEP, consensus=False) + assert _self_decide(dca, _v(conf=70, raw=None), fp, 0)["decision"] == "confirm" + # 逻辑存疑排在真通过之前: 不送研判 (raw 为空) 也照样放弃 + ld = _cand(decide_kind=ae.DK_LOGIC_DOUBT) + assert _self_decide(ld, {"verdict": "SKIPPED", "raw": None, "degraded": False, + "confidence": None, "reason": ""}, fp, 0)["decision"] == "decline" + + @case("C 研判不可用带 judge_unavail 标记 (研判不可用占比复核用)") def _(): d = _self_decide(_cand(), _v(degraded=True), {"self_decide": "full"}, 0) @@ -233,6 +251,51 @@ def _(): assert on and on.get("decide_kind") == ae.DK_DCA_DEEP, on +def _so_params(**kw): + p = {"scale": 2_000_000.0, "stock_target_default": 0.06, + "batch_split": (0.5, 0.25, 0.25), "min_lot_merge": True, + "consensus_route": True, "self_decide": "full"} + p.update(kw) + return p + + +def _so_caps(): + return {"scale": 2_000_000.0, "portfolio_cap": 0.70, "stock_cap": 0.08, + "max_names": 20, "portfolio_mv": 0.0, "names_count": 0, + "stock_mv": 0.0, "is_new_name": False, "sector": None, + "sector_names": 0, "sector_mv": 0.0, "sector_names_map": {}, "sector_mv_map": {}, + "sector_max_names": 4, "sector_max_ratio": 0.40, "cash_reserve": 0.0, + "sector_source_ready": True, "cash_avail": None, "cash_source": "estimate"} + + +def _so_cand(route, strength, reason): + con = {"route": route, "route_reason": reason, "direction": "看多", "strength": strength, + "votes": {"fund": "中性", "tech": "看多", "timing": "无读数"}, "reason": "一票 → 看多"} + return {"ts_code": "600000.SH", "price": 10.0, "score": 100.0, "sector": None, "theme": None, + "tier": "强传导", "upside": 0.3, "heat": 0.5, "rank": 1, "bucket": "main", + "src": "plan_api", "consensus": con, + "consensus_blocks": {"fund": {"stance": "中性"}, "tech": {"stance": "看多"}, + "timing": {"stance": "无读数"}, "consensus": con}} + + +@case("D scan_open·弱表态开关关掉 (路由放行但强弱为弱) → 仍打 weak_consensus; 关掉自决不打") +def _(): + weak_pass = _so_cand("放行", "弱", "方向看多但只有一方表态(弱表态放行)") + r = ae.scan_open(candidates=[dict(weak_pass)], params=_so_params(), caps=_so_caps(), + room_amt=1e6, slots=10, skip={}) + assert r["candidates"], r["skipped"] + assert r["candidates"][0].get("decide_kind") == ae.DK_WEAK_CONSENSUS, r["candidates"][0] + # 弱表态开关开着 (路由交人) 同样是 weak_consensus + weak_confirm = _so_cand("交人", "弱", "方向看多但只有一方表态") + r2 = ae.scan_open(candidates=[dict(weak_confirm)], params=_so_params(), caps=_so_caps(), + room_amt=1e6, slots=10, skip={}) + assert r2["candidates"][0].get("decide_kind") == ae.DK_WEAK_CONSENSUS, r2["candidates"][0] + # 自决 off: 不打 decide_kind (逐字回旧) + r3 = ae.scan_open(candidates=[dict(weak_pass)], params=_so_params(self_decide="off"), + caps=_so_caps(), room_amt=1e6, slots=10, skip={}) + assert "decide_kind" not in r3["candidates"][0], r3["candidates"][0] + + @case("D 参数登记·三键在 RUNTIME_EXTRA, 合法值与范围校验") def _(): for k in ("PMS_SELF_DECIDE", "PMS_SELF_DECIDE_TRIAL", "PMS_SELF_DECIDE_DAILY_MAX"): diff --git a/scripts/test_wiring.py b/scripts/test_wiring.py index ec75b69..9c124fa 100644 --- a/scripts/test_wiring.py +++ b/scripts/test_wiring.py @@ -1819,6 +1819,44 @@ def _(): assert led and "目标价" in (led[0]["reason"] or ""), led +@case("自主提议·目标价到价自决开: 挂着方案且有在途买单时, 先撤方案、撤在途买单再清仓") +def _(): + from app.services import proposal_service as ps + # 09-14 评审补: 只撤方案不撤在途买单, 已下发的网格买单会与清仓单对倒。三件事要一起做。 + fake = install_fakes( + prices={"600000.SH": 13.0}, high5={"600000.SH": 19.5}, + params={"PMS_TOTAL_SCALE": "2000000", "PMS_AUTONOMY": "full", + "PMS_SELF_DECIDE": "full", "PMS_SELF_DECIDE_TRIAL": "1"}, + positions=[{"ts_code": "600000.SH", "total_qty": 6050, "avail_qty": 6050, + "base_qty": 6000, "avg_cost": 10.0, "cushion_peak": 0.0, + "target_pct": 0.06}]) + fake.insert_strategy(strategy_id="S_GRID_600000", ts_code="600000.SH", stype="GRID", + autonomy="auto", params={}, state={}, status="ACTIVE") + fake.insert_instruction(instruction_id="I_BUY_600000", origin_type="strategy", + origin_id="S_GRID_600000", ts_code="600000.SH", action="ADD", + side="buy", qty=500, status="DISPATCHED", progress={"children": []}) + from app.services import command_service as csvc + restore = _stub_sp(csvc, {"600000.SH": {"target_price": 12.5}}) + try: + r = ps.scan_and_route() + finally: + restore() + assert r["ok"], r + assert any(x["action"] == "EXIT" for x in r["executed"]), (r["executed"], r["queued"], r["skipped"]) + # 方案撤了, 在途买单撤了, 清仓单落了 + assert fake.strategies["S_GRID_600000"]["status"] == "CANCELLED", fake.strategies["S_GRID_600000"] + assert fake.instructions["I_BUY_600000"]["status"] == "CANCELLED", fake.instructions["I_BUY_600000"] + exits = [i for i in fake.instructions.values() if i["action"] == "EXIT"] + assert exits and exits[0]["qty"] == 6050, exits + # 账本 self_decide 块记下撤了什么 + led = [x for x in fake.ledger if x["action"] == "EXIT" and x["verdict"] == "PASS"] + assert led, fake.ledger + sd = (led[0].get("hard_numbers") or {}).get("self_decide") or {} + assert sd.get("strategy_cancelled") == ["S_GRID_600000"], sd + assert sd.get("buys_cancelled") == ["I_BUY_600000"], sd + assert "目标价" in (led[0]["reason"] or "") and "12.5" in (led[0]["reason"] or ""), led[0]["reason"] + + @case("自主提议·目标价未到 / 没设 一律不产出 (拿不到价绝不折成到价)") def _(): from app.services import proposal_service as ps