系统自决包:把新建仓/深亏补仓/目标价的该不该做交回合议与研判(台账 015)

PMS_SELF_DECIDE 四档 off/record/decline/full,人只处理四类例外(研判不可用、上游硬风险、
合议装不上、判决词认不出)。合议加择时异议观察(wait_timing);动作引擎打 decide_kind;
proposal_service._self_decide 按决定表判采纳/放弃/交人,自动采纳走建议档重算数量、自动清仓
按可卖量夹紧并先撤方案;裁决接口改收预设理由码(decide_reasons);观察读数加七类、复核加
两张表、日报加自决注意项。撤销台账 001 与 008 两句。全量单测 894 例 ALL SUITES PASS
(既有 871 例全绿即 off 逐字回旧)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
zlt 2026-09-14 15:07:41 +08:00
parent da272cc1c1
commit 44ec6d4a5d
21 changed files with 1029 additions and 73 deletions

View File

@ -22,7 +22,7 @@
**第六层个股策略就是你特意挂的网格、做T和跟踪止盈。** 挂了活跃策略的票自主提议引擎让开不再对它自动提议。策略动作按命令口径过规则闸不过研判闸。做T的指令当日强制平回绝不过夜。
**第七层,自主提议,系统自己的想法,排最末。** 档位有三档,关、只提议、全自动,关是总闸。每条提议过两道闸:规则闸查合规,研判闸问决策系统该不该。分流时减持提议自动执行,增持提议按档位走,浮亏百分之十五及更深的补仓永远要人点头。自主新建仓有独立档位,而且比命令更严:拿不到真实资金快照就不建仓。
**第七层,自主提议,系统自己的想法,排最末。** 档位有三档,关、只提议、全自动,关是总闸。每条提议过两道闸:规则闸查合规,研判闸问决策系统该不该。分流时减持提议自动执行,增持提议按档位走。浮亏百分之十五及更深的补仓,自决开关 PMS_SELF_DECIDE 开着时按研判必答题「杀逻辑还是杀情绪」自动判、不再一律要人点头(撤销于 2026-09-14 台账 015关着时仍要人点头。自主新建仓有独立档位,而且比命令更严:拿不到真实资金快照就不建仓。
## 三、贯穿线:减持畅通,增持重重设卡

View File

@ -1549,6 +1549,25 @@ app/services/strategy_runner.py跟踪止盈评估器 _eval_trail 加盘中 SA
**还欠着什么**
按规格顺序开发选股打分包akg-factor-bridge工作包四重启桥先经审批系统自决包后端部署后页面设 PMS_SELF_DECIDE 为 full、PMS_SELF_DECIDE_TRIAL 开);三,页面专业化包。文档:台账 015、README 313 到 315、COMMAND_PRIORITY 第 25 行撤销、真实仓初始参数清单加三条、选股系统台账 058。
---
## 2026-09-14 · 系统自决包与选股打分包(开发机完成,未部署)
**做了什么**
把新建仓、深亏补仓、目标价到价三类的「该不该做」交回给已有的三源合议与研判闸,人只处理四类例外(研判不可用、上游硬风险、合议装不上、判决词认不出)。四档开关 PMS_SELF_DECIDEoff 逐字回旧 / record 只记 / decline 只放弃 / full 全自动),试探仓类由 PMS_SELF_DECIDE_TRIAL 控,每日自动新建仓上限 PMS_SELF_DECIDE_DAILY_MAX 默认三。合议路由加择时异议观察(方向看多但择时看空改回观察等择时转多,处置词「择时待转多」)。动作引擎给候选打决定种类 decide_kindproposal_service 新增 _self_decide 按 §3.5 决定表判采纳/放弃/交人自动采纳走建议档重算数量、自动清仓落指令前按可卖量夹紧并先撤该票交易方案。裁决接口改收预设理由码decide_reasons 两张码表加拼句),旧自由文本兼容。观察读数加七个自决类别,复核脚本加第五张系统自决逐条、第六张研判不可用占比,日报加自决注意项。撤销台账 001「重问放行一律交人」与 008「只有一方表态或择时异议交人」两句落进台账 015。另做选股打分包akg-factor-bridge 工作包四):计划装配按公司质地重排主榜与观察档的组内名次,不跨档,每行加 score_adjPLAN_QUALITY_BONUS 默认 8 设 0 逐字回旧,台账 058。
**动了哪些文件**
PMS 侧app/core/consensus.pytiming_dissent、app/core/action_engine.pydecide_kind 常量与 mark_decide、scan_open 打标签、eval_dca/eval_target 打 decide_kind、app/core/decide_reasons.py、app/services/consensus_service.py、app/services/proposal_service.py_self_decide、_apply_advice_tier、自决块、清仓两前置、放弃记账、跳过集合、app/services/consensus_stats.py、app/services/strategy_service.pycancel_by_code、app/services/ledger_service.py、app/repo/pms_repo.pysystem_decided_today / system_pass_count_today、app/services/param_store.py三参数、app/web/main.pyapi_decide 收码、decide-reasons 接口、app/web/static/index.htmldispOf 加 wait_timing、scripts/consensus_review.py、scripts/test_batch34_units.py22 例、scripts/test_wiring.pyFakeRepo 两桩、目标价自动清仓一例、路由清单、scripts/test_batch4_units.pyeval_target 自决断言、scripts/run_tests.py登记共 894 例、docs/复盘决定台账.md015。akg 侧plan.py、config.py、.env.example、test_plan_quality_rank.py、docs/复盘决定台账.md058
**部署方式**
两个包都在开发机做完、提交。akg 提交 3d3f317 已在因子桥仓库PMS 侧本次提交见提交信息。**未部署**PMS 走 make deploy收盘后、先经审批部署后页面设 PMS_SELF_DECIDE=full、PMS_SELF_DECIDE_TRIAL 开akg 走 155 拉码后 docker compose restart先经审批避开 07:00-07:30 与 08:30-09:05重启后预热一次计划接口。
**真机判收**
未判收。开发机全量单测 894 例 ALL SUITES PASS含既有 871 例全绿即是「off 逐字回旧」的证据akg test_plan_quality_rank.py 四条不变式全绿。
**还欠着什么**
一,收盘后审批部署 PMS 系统自决包并页面设 full/开 trial下一交易日盘中只读试算看 executed/rejected收盘跑 make consensus-review 看第五、六张表。二,审批重启因子桥出选股打分包,次日 07:10 起入池与评析名单按新序,看 §5.4 curl 质地好只数与 rank 连续。三,页面专业化包(第三个包,未做):文案改交易术语、抽屉三段重构、候选可点击、裁决弹窗前后端、文案守卫,以及自决的「系统已采纳/已放弃」显示与来源字段芯片。四README 313-315、COMMAND_PRIORITY 第 25 行、接口契约 decide 请求体、UI_COPY_AUDIT 第二版词表等文档。
---
<!--
下一条节点从这里往下写,格式照抄上面:

View File

@ -312,7 +312,9 @@ make t-gate # 随时: 规则闸/研判闸拒了什么、为什么
2. **研判闸**(二级,仅补足/加仓/补仓,委托决策系统)——`PMS_JUDGE_API_BASE` 为空即视为未接通,按设计**自动降级为人工确认**并记 ERROR绝不把「研判拿不到」当成「研判通过」。
3. **按自主档位分流**——`full` 执行、`propose_only` 入队(一期默认)、`off` 不扫描。
两条无条件覆盖档位的规矩:**减持方向不设确认门槛**TRIM 保垫减仓任何档位都直接落指令);**15% 及更深的补仓永远需用户确认**(即便档位是 full 也强制入队)。同一只票的同一动作若已有在途提议或在途指令,不重复提。
两条无条件覆盖档位的规矩:**减持方向不设确认门槛**TRIM 保垫减仓任何档位都直接落指令);**15% 及更深的补仓的裁决由系统自决开关 `PMS_SELF_DECIDE` 决定**——关off时永远需用户确认full时按研判必答题「杀逻辑还是杀情绪」自动判2026-09-14 台账 015 撤销「永远需用户确认」)。同一只票的同一动作若已有在途提议或在途指令,不重复提。
系统自决(`PMS_SELF_DECIDE`,四档 off/record/decline/full默认 off、读不到按 off把新建仓、深亏补仓、目标价到价这三类的「该不该做」交回三源合议与研判闸full 时自动采纳或自动放弃,人只处理四类例外——研判不可用、上游硬风险、合议装不上、判决词认不出。试探仓类采纳受 `PMS_SELF_DECIDE_TRIAL` 控,每日自动新建仓上限 `PMS_SELF_DECIDE_DAILY_MAX`(默认 3
## 榜单变化提示(只提示,不产生指令)

View File

@ -54,6 +54,9 @@ DISP_WAIT_TECH = "wait_tech"
# 盘中确认包 (2026-09-14): 开口向上未确认 / 收口等待时段外 的候选进观察, 处置词写这个,
# 页面渲染成「等盘中确认」。与 wait_tech 并列, 走 disposition_snapshot 同一条路。
DISP_WAIT_CONFIRM = "wait_confirm"
# 系统自决包 (2026-09-14, 台账 015): 方向看多但择时看空、自决开着时改观察, 处置词写这个,
# 页面渲染成「择时待转多」。与 wait_tech / wait_confirm 并列, 走 disposition_snapshot 同一条路。
DISP_WAIT_TIMING = "wait_timing"
# 观察读数包 (2026-09-14, 台账 013): 合议相关跳过项打的类别标签, 值即 pms_consensus_stat.kind。
# consensus_stats.record_round 认这些标签落表。**只在合议开着 (con 非 None) 或增持门开着
@ -64,9 +67,52 @@ TAG_OPEN_SKIP_BEAR = "open_skip_bear" # 合议判跳过, 其余原因 (方向
TAG_OPEN_WAIT_TECH = "open_wait_tech" # 合议判观察 (等技术面开口/转向)
TAG_OPEN_WAIT_CONFIRM = "open_wait_confirm" # 盘中确认包: 开口未确认/时段外, 等盘中确认
TAG_GATE_BLOCK = "gate_block" # 持仓增持门拦下 (detail 里带 gate_kind: fill/add/dca)
TAG_OPEN_WAIT_TIMING = "open_wait_timing" # 系统自决: 方向看多但择时看空进观察, 等择时转多
# open_breakout (盘中收口突破视为开口) 不打在跳过项上 —— 突破的候选会被放行、不进 skipped,
# 由 proposal_service 记进 consensus_seen 的 intraday 字段, consensus_stats 据此落表。
# 决定种类 (2026-09-14 系统自决包, 台账 015)。候选上的 c["decide_kind"], **只在自决非 off 时写**:
# 替代旧的 needs_user_confirm 标记, 让 proposal_service._self_decide 据它查 3.5 节的决定表。
# scan_open 尾段写前七种 (新建仓那几类), _route_one 写 reask, eval_dca/eval_target 各写一种。
DK_VERDICT_UNKNOWN = "verdict_unknown" # 判决词认不出 → 交人 (最保守, 优先级最高)
DK_LOGIC_DOUBT = "logic_doubt" # 逻辑存疑 → 自动放弃
DK_VERDICT_WATCH = "verdict_watch" # 判决关注 → 自动试探仓
DK_FUND_BEAR_TECH_BULL = "fund_bear_tech_bull" # 合议交人·基本面看空技术面看多 → 试探仓加紧止盈
DK_WEAK_CONSENSUS = "weak_consensus" # 合议交人·只有一方表态 → 试探仓
DK_TECH_BEAR = "tech_bear" # 异议只在技术面 (advise_v2 那格回观察) → 试探仓
DK_ADVICE_TRIAL = "advice_trial" # 建议档位为试探仓 → 试探仓
DK_REASK = "reask" # 重问放行 → 试探仓 (在 _route_one 写)
DK_DCA_DEEP = "dca_deep" # 深亏补仓 → 按研判必答题自动
DK_TARGET_PRICE = "target_price" # 目标价到价 → 自动清仓
# scan_open 里多种同时命中时留哪一种: 数字小的优先 (认不出最先, 试探仓最后)。
# 逻辑存疑排在判决关注之前 —— 研究证据在走弱要放弃, 比"关注→试探"更保守。
_DECIDE_PRIORITY = {DK_VERDICT_UNKNOWN: 0, DK_LOGIC_DOUBT: 1, DK_VERDICT_WATCH: 2,
DK_FUND_BEAR_TECH_BULL: 3, DK_WEAK_CONSENSUS: 3, DK_TECH_BEAR: 4,
DK_ADVICE_TRIAL: 5}
# 决定种类的中文说法 (账本理由与页面用)。
DK_CN = {DK_VERDICT_UNKNOWN: "判决词认不出", DK_LOGIC_DOUBT: "研究证据走弱(逻辑存疑)",
DK_VERDICT_WATCH: "判决关注", DK_FUND_BEAR_TECH_BULL: "基本面看空技术面看多",
DK_WEAK_CONSENSUS: "只有一方表态", DK_TECH_BEAR: "异议只在技术面",
DK_ADVICE_TRIAL: "建议试探仓", DK_REASK: "重问放行", DK_DCA_DEEP: "深亏补仓",
DK_TARGET_PRICE: "目标价到价"}
# 落成试探仓那几类 (受 PMS_SELF_DECIDE_TRIAL 控制; 关掉它们仍交人)。
TRIAL_DECIDE_KINDS = {DK_VERDICT_WATCH, DK_FUND_BEAR_TECH_BULL, DK_WEAK_CONSENSUS,
DK_TECH_BEAR, DK_ADVICE_TRIAL, DK_REASK}
def self_decide_on(params) -> bool:
"""系统自决是否开着 (非 off)。off 或读不到都回 False —— 逐字回旧的判据。"""
return str((params or {}).get("self_decide") or "off").strip() != "off"
def mark_decide(cand, kind):
"""自决开着时给候选登记决定种类: 高优先级 (数字小) 覆盖低优先级, 同级与低级不覆盖。
只写 decide_kind, 不碰 confirm_why 交人的原因仍由既有那几处 (关注/逻辑存疑/合议交人)
照旧写, record/decline 档队列里的人看; full 档自动决定时用的是 _self_decide 给的理由"""
cur = cand.get("decide_kind")
if cur is None or _DECIDE_PRIORITY.get(kind, 9) < _DECIDE_PRIORITY.get(cur, 9):
cand["decide_kind"] = kind
# 同一轮里出现多条减持时留哪一条 (2026-09-03): 数字小的优先。用户自己设的目标价到价排在
# 系统按规则算出来的保垫减仓前面 —— 人已经说了到价就清, 这一轮就不该再自作主张先卖一部分。
# 只在同一只票的同一轮里比较, 不影响不同票, 也不影响下一轮。
@ -124,6 +170,9 @@ def source_confirm_why(source):
是因为两者问的不是同一件事: 判决是上游对这只票的结论, 词表变了说明我们看不懂它的意思;
而来源是本系统内部给候选打的标记, 没打标记的就是动作引擎自己那几类老动作, 行为必须
与从前逐字一致新来源要不要强制交人, 由加它的人写进上面那份名单
目标价到价的自动清仓 (系统自决 full) 不在这里放行: 它照旧回强制入队的原因, force_queue
默认交人; _self_decide _route_one 里对采纳的那条显式覆盖 auto_exec, 而不是这里松口
"""
return FORCE_QUEUE_SOURCES.get(str(source or "").strip()) or None
@ -383,13 +432,18 @@ def eval_dca(p: dict, params: dict, mkt: dict):
deep = _f(params.get("dca_deep_confirm"), -0.15)
is_deep = _f(cushion) <= deep + 1e-12
depth = sorted(triggers, reverse=True)[stage - 1]
return _cand(p, A_DCA, BUY, qty,
c = _cand(p, A_DCA, BUY, qty,
f"补仓评估: 浮亏 {_f(cushion):.2%} 触及 {depth:.0%} 档 (第 {stage} 档), "
f"拟补 {qty} 股 (≤底仓 {max_ratio:.0%})"
+ ("; **深档: 必须用户确认**, 研判须回答杀逻辑还是杀情绪" if is_deep else ""),
{"cushion_pct": cushion, "stage": stage, "trigger": depth,
"base_qty": base_qty, "price": price, "stop_ref": p.get("stop_ref")},
confirm=is_deep)
# 系统自决 (2026-09-14): 深档补仓打上 decide_kind, 由 _self_decide 按研判必答题自动判。
# confirm 保持不变 (关掉/record 下仍走人工队列); full 时 _self_decide 覆盖 auto_exec 自动补。
if is_deep and self_decide_on(params):
c["decide_kind"] = DK_DCA_DEEP
return c
def eval_trim(p: dict, params: dict, mkt: dict = None):
@ -462,6 +516,10 @@ def eval_target(p: dict, params: dict = None, mkt: dict = None):
"cushion_pct": p.get("cushion_pct"), "avg_cost": p.get("avg_cost")},
confirm=True, source=SRC_TARGET_PRICE)
c["confirm_why"] = WHY_TARGET_PRICE_CONFIRM
# 系统自决 (2026-09-14): 到价清仓打上 decide_kind, confirm/来源标记保持不变 (关掉/record/decline
# 下仍进人工队列); full 时 _self_decide 覆盖为自动清仓, 落指令前再按可卖量夹一次、先撤该票方案。
if self_decide_on(params):
c["decide_kind"] = DK_TARGET_PRICE
return c
@ -890,6 +948,9 @@ def scan_open(*, candidates: list, params: dict, caps: dict, room_amt: float,
# 决定这条候选往哪走。开关 PMS_CONSENSUS_ROUTE 关掉时 consensus_on 为假, 下面所有合议相关
# 的代码一行都不执行, 定档退回旧的 advise、分流退回只按选股判决 —— 行为与接入前逐字相同。
consensus_on = bool(params.get("consensus_route"))
# 系统自决 (2026-09-14): 开着时下面五处交人标记改写成 decide_kind、不置 needs_user_confirm,
# 由 proposal_service._self_decide 据它自动判; 关掉 (off) 时这些分支一行不走, 逐字回旧。
sd_on = self_decide_on(params)
if slots <= 0:
return {"candidates": [], "skipped": [
{"ts_code": "*", "action": A_OPEN,
@ -936,10 +997,11 @@ def scan_open(*, candidates: list, params: dict, caps: dict, room_amt: float,
"tag": TAG_OPEN_SKIP_FUND if "买方评析" in _rr else TAG_OPEN_SKIP_BEAR})
continue
if con and con.get("route") == "观察":
# 处置词: con 带 disp 就用它 (盘中确认包给 opened_wait/outside_window 写 wait_confirm),
# 否则用 wait_tech (等技术面开口)。tag 随之分 open_wait_confirm / open_wait_tech
# 处置词: con 带 disp 就用它 (盘中确认包给 opened_wait/outside_window 写 wait_confirm;
# 系统自决给择时异议写 wait_timing), 否则用 wait_tech (等技术面开口)。tag 随之分。
_disp = con.get("disp") or DISP_WAIT_TECH
_tag = TAG_OPEN_WAIT_CONFIRM if _disp == DISP_WAIT_CONFIRM else TAG_OPEN_WAIT_TECH
_tag = {DISP_WAIT_CONFIRM: TAG_OPEN_WAIT_CONFIRM,
DISP_WAIT_TIMING: TAG_OPEN_WAIT_TIMING}.get(_disp, TAG_OPEN_WAIT_TECH)
skipped.append({"ts_code": code, "action": A_OPEN, "disp": _disp,
"why": con.get("route_reason") or "三源合议判为观察,等技术面开口",
"tag": _tag})
@ -1017,6 +1079,25 @@ def scan_open(*, candidates: list, params: dict, caps: dict, room_amt: float,
if con and con.get("route") == "交人" and not cand.get("needs_user_confirm"):
cand["needs_user_confirm"] = True
cand["confirm_why"] = con.get("route_reason") or "三源合议判为交人裁决"
# ---- 系统自决 (2026-09-14 台账 015): 独立算 decide_kind, 不受上面 needs_user_confirm 的
# 先来后到影响 (那几道守卫会让靠后的理由不评, 而自决要按优先级取最保守的一种)。
# off 时 sd_on 为假, 这一段一行不走, needs_user_confirm 与今天逐字相同; record/decline/full
# 时既留着上面的 needs_user_confirm (供 record/decline 排队与显示), 又打上 decide_kind
# 供 _self_decide 判。mark_decide 里 verdict_unknown < logic_doubt < 判决关注 < 合议交人 <
# 异议只在技术面 < 建议试探仓, 数字小的优先。
if sd_on:
if isinstance(adv, dict) and adv.get("tier") == "试探仓":
mark_decide(cand, DK_ADVICE_TRIAL)
if isinstance(adv, dict) and adv.get("route") == "观察":
mark_decide(cand, DK_TECH_BEAR) # 异议只在技术面 (advise_v2 那格)
if route and verdict_confirm_why(c.get("verdict")):
v = str(c.get("verdict") or "").strip()
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") == "交人":
rr = con.get("route_reason") or ""
mark_decide(cand, DK_FUND_BEAR_TECH_BULL if "基本面看空" in rr else DK_WEAK_CONSENSUS)
out.append(cand)
used = _f(cand.get("target_amount"))
left -= used

View File

@ -10,13 +10,17 @@ from __future__ import annotations
_BULL, _BEAR, _NEUT, _NA = "看多", "看空", "中性", "无读数"
# 方向看多但择时看空时的两种去向 (2026-09-14 系统自决包): 默认交人; 自决开着时改回观察等择时转多。
_R_WAIT_TIMING = "等择时转多"
def _reason(fund, tech, timing, direction, strength):
head = "".join([f"基本面{fund}", f"技术面{tech}", f"择时{timing}"])
return head + "" + direction + (("·" + strength) if strength else "")
def _route(fund, tech, timing, direction, tech_phase, fund_required, weak_confirm=True):
def _route(fund, tech, timing, direction, tech_phase, fund_required, weak_confirm=True,
timing_dissent="confirm"):
"""附录丙路由表, 从上到下第一个命中。返回 (路由, 原因)。路由取值: 放行/观察/交人/跳过。"""
if fund_required and fund == _NA:
return "跳过", "没有买方评析"
@ -31,6 +35,9 @@ def _route(fund, tech, timing, direction, tech_phase, fund_required, weak_confir
if direction == _BULL:
voiced = [v for v in (fund, tech, timing) if v in (_BULL, _BEAR)]
if timing == _BEAR: # 异议在择时系统
# 默认交人 (逐字回旧); 系统自决开着时改回观察, 等择时转多再走放行 (2026-09-14)。
if timing_dissent == "observe":
return "观察", _R_WAIT_TIMING
return "交人", "方向看多但择时系统有异议"
if len(voiced) <= 1: # 只有一方表态
# 弱表态: weak_confirm 开着交人, 关掉放行 (2026-09-14 加固, 登记 PMS_CONSENSUS_WEAK_CONFIRM)
@ -43,11 +50,13 @@ def _route(fund, tech, timing, direction, tech_phase, fund_required, weak_confir
return "观察", "方向中性"
def decide(fund, tech, timing, *, tech_phase=None, fund_required=True, weak_confirm=True) -> dict:
def decide(fund, tech, timing, *, tech_phase=None, fund_required=True, weak_confirm=True,
timing_dissent="confirm") -> dict:
"""fund/tech/timing 是三个立场字符串 (看多/看空/中性/无读数)。tech_phase 是技术面相位
(收口等待 ), 路由用fund_required: 无买方评析是否拦 (开关 PMS_FUND_REQUIRED)
weak_confirm: 方向看多但只有一方表态时是否交人 (开关 PMS_CONSENSUS_WEAK_CONFIRM, 默认真;
关掉则放行)"""
关掉则放行)timing_dissent: 方向看多但择时看空时的去向, confirm 交人 (默认, 逐字回旧) /
observe 改观察等择时转多 (系统自决开着时传, 观察结果带 disp=wait_timing)"""
votes = {"fund": fund, "tech": tech, "timing": timing}
voiced = [v for v in (fund, tech, timing) if v in (_BULL, _BEAR)]
bulls = sum(1 for v in voiced if v == _BULL)
@ -72,7 +81,11 @@ def decide(fund, tech, timing, *, tech_phase=None, fund_required=True, weak_conf
strength = ""
route, route_reason = _route(fund, tech, timing, direction, tech_phase, fund_required,
weak_confirm)
return {"direction": direction, "votes": votes, "strength": strength,
weak_confirm, timing_dissent)
out = {"direction": direction, "votes": votes, "strength": strength,
"reason": _reason(fund, tech, timing, direction, strength),
"route": route, "route_reason": route_reason}
# 择时异议改观察这一种, 处置词记 wait_timing (页面显示「择时待转多」; 动作引擎观察分支据它选标签)。
if route == "观察" and route_reason == _R_WAIT_TIMING:
out["disp"] = "wait_timing"
return out

View File

@ -0,0 +1,68 @@
# -*- coding: utf-8 -*-
"""人工裁决理由的码表与拼句 (2026-09-14 系统自决包, 台账 015)。纯逻辑, 零外部依赖, 可单测。
系统自决把绝大多数该不该做接了过去, 剩下真交人的那几张提议, 人裁决时从预设理由里选一条,
其他再补一句 不再每次手写一整段这里放两张码表 (采纳 / 驳回) 与一个拼句函数,
决定 + + 补充 + 自由文本拼成落进评审账本的一句话, 并回出用到的码 (进硬数字备查)
页面拿这两张表铺预设按钮 (GET /api/proposals/decide-reasons); 后端在裁决入口再校验一次
其他不填补充或码与决定对不上, 一律回中文错误, 不落账 (与研判不可用交人同一层的严)
旧的自由文本 reason 仍然接受 (向后兼容: 脚本旧缓存页面照发照收)
"""
from __future__ import annotations
# 驳回理由码 → 中文标签。最后一个是「其他」, 选它必须填补充。
DECLINE_REASONS = {
"DECLINE_CONSENSUS_MISMATCH": "与研判结论不符",
"DECLINE_TECH_WEAK": "技术面已转弱",
"DECLINE_POSITION_FULL": "仓位已达上限",
"DECLINE_SECTOR_CONCENTRATED": "行业集中度过高",
"DECLINE_WAIT_PULLBACK": "等待回调再议",
"DECLINE_MARKET_RISK": "大盘风险偏高",
"DECLINE_OTHER": "其他",
}
# 采纳理由码 → 中文标签。最后一个是「其他」, 选它必须填补充。
ACCEPT_REASONS = {
"ACCEPT_CONSENSUS": "合议放行,按系统建议执行",
"ACCEPT_MANUAL_JUDGE": "人工判断机会成立",
"ACCEPT_PER_COMMAND": "按既有命令执行",
"ACCEPT_RISK_CONTROL": "风控需要",
"ACCEPT_OTHER": "其他",
}
def reasons_for(decision) -> dict:
"""按决定取对应的码表。ACCEPTED 用采纳表, 其余 (DECLINED) 用驳回表。"""
return ACCEPT_REASONS if str(decision or "").upper() == "ACCEPTED" else DECLINE_REASONS
def compose(decision, reason_code=None, reason_note=None, reason=None):
"""把裁决理由拼成一句话。返回 (账本句, 用到的码或 None)。
decision: ACCEPTED / DECLINED, 决定用哪张码表
reason_code: 预设理由码 (可空)给了就必须落在对应码表里, 否则报错
reason_note: 补充说明 (其他时必填)
reason: 旧的自由文本 (向后兼容)只有它没有码时, 原样当账本句
码与自由文本都空 报错 (空理由不收)码不在表里 / 其他不填补充 报错
错误一律是能直接给人看的中文 ValueError
"""
decision = str(decision or "").upper()
table = reasons_for(decision)
code = (reason_code or "").strip() or None
note = (reason_note or "").strip()
free = (reason or "").strip()
label_side = "采纳" if decision == "ACCEPTED" else "驳回"
if not code and not free:
raise ValueError("采纳或驳回都要给理由:选一个预设理由,或写一句自由说明")
if code:
if code not in table:
raise ValueError(f"理由码 {code} 不适用于本次{label_side}")
if code.endswith("_OTHER") and not note:
raise ValueError("选「其他」时必须填补充说明")
sentence = table[code] + (f";补充:{note}" if note else "")
return sentence[:500], code
# 只有自由文本 (旧字段兼容): 原样当账本句, 不带码。
return free[:500], None

View File

@ -618,6 +618,37 @@ def judge_rejected_today(since) -> set:
return {(r["ts_code"], r["action"]) for r in rows}
# 系统自决自动采纳的账本行 reason 以此开头 (数据契约 §3.8)。system_pass_count_today 按它数,
# consensus_stats / 复核脚本也认这个前缀, 集中一处免得各写各的。
SYSTEM_ACCEPT_REASON_PREFIX = "系统采纳"
def system_decided_today(since) -> set:
"""今天被**系统自决放弃**过的 (代码, 动作) —— 自主扫描据此当日不再重评 (明日重评)。
评审方 system REJECT 就是自决放弃 (逻辑存疑合议引起的放弃)研判 REJECT 引起的
放弃写评审方 judge, judge_rejected_today 那条去重, 不在此列 那条还要参与解锁重问
只碰账本一张表读不到返回空 (去重是降噪不是纪律, rule/judge 两条)
"""
rows = fetch_all("SELECT DISTINCT ts_code, action FROM pms_action_ledger "
"WHERE verdict = 'REJECT' AND arbiter = 'system' AND decided_at >= :d",
{"d": since})
return {(r["ts_code"], r["action"]) for r in rows}
def system_pass_count_today(since) -> int:
"""今天系统自决**自动采纳**的新建仓只数, 供每日上限 PMS_SELF_DECIDE_DAILY_MAX 判断。
自动采纳的账本行评审方是 judge/rule PASS (它确实过了研判与规则), 与普通自动执行
同评审方, 只能靠 reason 前缀区分 采纳理由一律以系统采纳开头只碰账本一张表
"""
r = fetch_one("SELECT COUNT(*) AS n FROM pms_action_ledger "
"WHERE action = 'OPEN' AND verdict = 'PASS' AND decided_at >= :d "
"AND reason LIKE :pfx",
{"d": since, "pfx": SYSTEM_ACCEPT_REASON_PREFIX + "%"})
return int((r or {}).get("n") or 0)
def judge_rejected_open_rows(since) -> dict:
"""今天被研判闸驳回的**新建仓**, 每只票取当日**最新一条** → {代码: {at, price, hard}}。

View File

@ -22,12 +22,16 @@ logger = logging.getLogger("pms.consensus")
def _params() -> dict:
gi, gb = param_store.get_int, param_store.get_bool
# 系统自决非 off 时, 方向看多但择时看空的合议改回观察 (等择时转多), 不再交人 (2026-09-14)。
self_decide = str(param_store.get("PMS_SELF_DECIDE", "off") or "off").strip()
return {
"fund_required": gb("PMS_FUND_REQUIRED", True),
"fund_stale_days": gi("PMS_FUND_STALE_DAYS", 120),
"fund_consensus_good_min": gi("PMS_FUND_CONSENSUS_GOOD_MIN", 8),
"consensus_route": gb("PMS_CONSENSUS_ROUTE", True),
"weak_confirm": gb("PMS_CONSENSUS_WEAK_CONFIRM", True),
"self_decide": self_decide,
"timing_dissent": "observe" if self_decide != "off" else "confirm",
"timing_stale_tdays": gi("PMS_REF_STALE_TDAYS", 3),
}
@ -74,7 +78,8 @@ def assemble(row, *, nightly=None, tech_states=None, flip_at=None, params=None)
con = consensus.decide(fund["stance"], tech.get("stance") or "无读数", tm["stance"],
tech_phase=tech.get("phase"),
fund_required=(p["fund_required"] and p["consensus_route"]),
weak_confirm=p.get("weak_confirm", True))
weak_confirm=p.get("weak_confirm", True),
timing_dissent=p.get("timing_dissent", "confirm"))
return {"fund": fund, "tech": tech, "timing": tm, "consensus": con}

View File

@ -53,6 +53,14 @@ K_TIMING_NOREAD = "timing_noread"
K_TECH_EXIT = "tech_exit"
K_TECH_NOREAD = "tech_noread"
K_MAP_COVER = "map_cover"
# 系统自决 (2026-09-14 台账 015): 从 out["self_decide_seen"] 派生, 随时写 (不受检查点限制)。
K_AUTO_ACCEPT = "auto_accept" # full 档自动采纳
K_AUTO_DECLINE = "auto_decline" # full/decline 档自动放弃
K_AUTO_CONFIRM = "auto_confirm" # 自决判仍交人 (研判不可用/硬风险/上限等)
K_AUTO_WOULD_ACCEPT = "auto_would_accept" # record 档: 本会采纳
K_AUTO_WOULD_DECLINE = "auto_would_decline" # record 档: 本会放弃
K_JUDGE_UNAVAIL = "judge_unavail" # 研判不可用 (研判不可用占比复核用)
K_OPEN_WAIT_TIMING = ae.TAG_OPEN_WAIT_TIMING # 方向看多但择时看空, 进观察等择时转多 (跳过项 tag)
# ================================================================ 时刻与行
@ -141,6 +149,32 @@ def _skip_tag_rows(out, day, now) -> list:
return rows
def _self_decide_rows(out, day, now) -> list:
"""从 out["self_decide_seen"] 生成自决读数 (2026-09-14, 随时写, 不受检查点限制)。
full 档记 auto_accept / auto_decline / auto_confirm; record 档本会采纳/放弃记 auto_would_*;
研判不可用的另记一行 judge_unavail (研判不可用占比复核用)seen _route_one 逐条追加"""
rows = []
for s in out.get("self_decide_seen") or []:
code = s.get("ts_code")
if not code:
continue
decision, would, why = s.get("decision"), s.get("would"), s.get("why")
detail = {"rule": s.get("decide_kind"), "would": would,
"tier": s.get("tier"), "action": s.get("action"),
"direction": s.get("direction"), "route": s.get("route"),
"conf": s.get("conf")}
kind = {"accept": K_AUTO_ACCEPT, "decline": K_AUTO_DECLINE,
"confirm": K_AUTO_CONFIRM}.get(decision)
if kind is None and decision == "none": # record 档: 只记本会怎么判
kind = {"accept": K_AUTO_WOULD_ACCEPT, "decline": K_AUTO_WOULD_DECLINE}.get(would)
if kind:
rows.append(_row(day, code, kind, reason=why, detail=detail, now=now))
if (why or "").startswith("研判不可用"):
rows.append(_row(day, code, K_JUDGE_UNAVAIL, reason=why, now=now))
return rows
def _tech_exit_rows(out, scanned, day, now) -> list:
"""从 scanned["candidates"] 里来源是 tech_exit 且没被闸拒的项生成 tech_exit 行。
确认转空清仓走 EXIT, 未确认减三分之一走 TRIM 据动作名记 confirm"""
@ -169,7 +203,9 @@ def record_round(out, scanned, params, now=None) -> dict:
if not isinstance(out, dict) or out.get("dry_run"):
return {"ok": True, "rows": 0, "note": "dry_run 或无 out, 不落表"}
day = int(now.strftime("%Y%m%d")) # 北京日期 (容器时钟)
# tech_exit 与自决读数随时写 (稀有且要看全); 路由与装配两类只在检查点写 (每分钟太密)。
rows = list(_tech_exit_rows(out, scanned, day, now))
rows += _self_decide_rows(out, day, now)
times = param_store.get(STAT_TIMES_KEY, "0935,1030,1330,1445")
if _at_checkpoint(now, times):
rows += _seen_rows(out, day, now)

View File

@ -1484,10 +1484,20 @@ def build_daily_report(ymd: int = None) -> dict:
# 合议观察小节 (2026-09-14 观察读数包, 台账 013): 当天按类别的计数。取不到写空, 不拖垮日报。
try:
from app.services import consensus_stats
report["consensus"] = consensus_stats.daily_summary(ymd)
cstat = consensus_stats.daily_summary(ymd)
report["consensus"] = cstat
# 系统自决注意项 (2026-09-14 台账 015): 今天自动采纳/放弃/交人各几只, 研判不可用几只。
def _crows(k):
return int((cstat.get(k) or {}).get("rows", 0))
report["self_decide"] = {
"accept": _crows(consensus_stats.K_AUTO_ACCEPT),
"decline": _crows(consensus_stats.K_AUTO_DECLINE),
"confirm": _crows(consensus_stats.K_AUTO_CONFIRM),
"judge_unavail": _crows(consensus_stats.K_JUDGE_UNAVAIL)}
except Exception as e: # noqa: BLE001
logger.warning("[日报] 合议观察小节取数失败 (写空): %s", e)
report["consensus"] = {}
report["self_decide"] = {}
try:
pms_repo.upsert_report(ymd, report)
except Exception as e:

View File

@ -138,6 +138,10 @@ RUNTIME_EXTRA = {
"PMS_TECH_INTRADAY_BREAKOUT": (True, bool, "收口突破规则开关 (关掉只剩开口确认规则)"),
"PMS_TECH_VOL_CONFIRM": (1.5, float, "盘中确认的时段折算量比阈值 (与解锁重问 PMS_OPEN_REASK_VOL_MIN 同值同义)"),
"PMS_TECH_INTRADAY_WINDOW": ("0945-1430", str, "盘中确认时段 (与解锁重问 PMS_OPEN_REASK_WINDOW 同义; 时段外开口向上候选进观察等确认)"),
# ── 2026-09-14 系统自决包 (台账 015): 把「该不该做」交回三源合议与研判闸。四档留作回退。
"PMS_SELF_DECIDE": ("off", str, "系统自决档: off 逐字回旧 / record 只记不做 / decline 只自动放弃 / full 自动采纳加自动放弃 (155 上线设 full; 读不到按 off)"),
"PMS_SELF_DECIDE_TRIAL": (False, bool, "试探仓类情形是否自动采纳 (只有一方表态/基本面看空技术面看多/异议只在技术面/判决关注/建议试探仓/重问放行); 关掉这几类仍交人"),
"PMS_SELF_DECIDE_DAILY_MAX": (3, int, "每日自动新建仓只数上限 (超过转交人; 0 = 不限)"),
}
# **读不到时必须按"已暂停"处理的键 (fail-closed)。**
@ -159,6 +163,9 @@ FAIL_CLOSED = {
# 2026-08-25 策略自动挂载: 同宏观层的理由 —— 文件初值 True (模拟仓拍板全自动),
# 表读不到时退文件初值等于故障期间照常自动挂策略。安全方向是关, 本轮不挂不撤。
"PMS_AUTO_STRATEGY_ENABLED": False,
# 系统自决读不到 → off (逐字回旧, 只交人不自动)。与新建仓档位同一个安全方向:
# 参数表抖一下就自动采纳新建仓、自动放弃候选, 都是读不到约束时不该发生的动作。
"PMS_SELF_DECIDE": "off",
}
# ================================================================================
@ -534,6 +541,8 @@ _RANGES = {
"PMS_AUTO_GRID_CAP_RATIO": (0, 1), "PMS_AUTO_HEAT_TH": (0, 1),
"PMS_AUTO_TRAIL_GIVEBACK": (0.01, 0.5), "PMS_AUTO_TRAIL_SELL_RATIO": (0.1, 1),
"PMS_AUTO_HANDOFF_COOLDOWN_TDAYS": (0, 60), "PMS_AUTO_OPTOUT_COOLDOWN_TDAYS": (0, 60),
# 系统自决 (2026-09-14)
"PMS_SELF_DECIDE_DAILY_MAX": (0, 50),
}
@ -547,6 +556,8 @@ def _range_check(key, v):
return f"{key} 只能是 zone_enter (进区即动) / zone_exit (极值回落再动)"
if key == "PMS_OPEN_AUTONOMY" and v not in ("full", "propose_only", "off"):
return "PMS_OPEN_AUTONOMY 只能是 full / propose_only / off"
if key == "PMS_SELF_DECIDE" and v not in ("off", "record", "decline", "full"):
return "PMS_SELF_DECIDE 只能是 off / record / decline / full"
if key == "PMS_EXEC_IMPL" and str(v).strip().upper() not in ("A", "B"):
return "PMS_EXEC_IMPL 只能是 A (委托决策系统) / B (内置保守择时)"
if key == "PMS_SECTOR_SOURCE" and v not in ("", "gp_hybk", "custom_table",

View File

@ -43,7 +43,7 @@ from app.core import tradedays as td
from app.repo import pms_repo
from app.services import (candidate_pub, command_service, consensus_service, executor, industry,
judge, market, param_store, reask_service,
plan_feed, portfolio)
plan_feed, portfolio, strategy_service)
logger = logging.getLogger("pms.proposal")
@ -68,7 +68,9 @@ def scan_and_route(*, now=None, dry_run: bool = False) -> dict:
now = now or datetime.now()
out = {"ok": True, "autonomy": None, "open_autonomy": None, "candidates": 0,
"open_candidates": 0, "executed": [], "queued": [],
"rejected": [], "skipped": [], "errors": [], "degraded": False, "dry_run": dry_run}
"rejected": [], "skipped": [], "errors": [], "degraded": False, "dry_run": dry_run,
# 系统自决本轮自动采纳的新建仓只数 (每日上限累计用; observe 读数从 self_decide_seen 取)
"_sd_accepts": 0}
autonomy = param_store.get("PMS_AUTONOMY", AUTONOMY_PROPOSE)
out["autonomy"] = autonomy
@ -89,6 +91,15 @@ def scan_and_route(*, now=None, dry_run: bool = False) -> dict:
stock_params = command_service.effective_stock_params()
mkt = _market_ctx(view["held"], now)
params["_mkt"] = mkt # 规则闸要用同一份 MA5, 不再重取
# 系统自决每日新建仓上限的基数: 今天已自动采纳几只 (评审账本按前缀数)。只读一次,
# 本轮内 _route_one 每采纳一只再往 out["_sd_accepts"] 加, 两者相加即当日累计。off 时不查库。
if ae.self_decide_on(params):
try:
_t0 = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
params["_sd_base_count"] = pms_repo.system_pass_count_today(_t0)
except Exception as e: # noqa: BLE001
logger.warning("[系统自决] 读当日已采纳数失败 (本轮按 0 起算): %s", e)
params["_sd_base_count"] = 0
# 跳过三类: ①已有在途提议或指令的 ②今天已被规则闸拒过的 ③今天已被研判闸驳回的新建仓。
# ② 是 2026-07-29 的教训 —— 组合已超总仓上限时, 16 只深亏票的补仓候选每分钟被拒
# 一次, 一天往评审账本灌几千行一模一样的记录。闸门结论当天基本不会变, 记一次就够。
@ -112,8 +123,11 @@ def scan_and_route(*, now=None, dry_run: bool = False) -> dict:
_jk.pop(_k, None)
out["reask_unlocked"] = [{"ts_code": c, "action": a, "why": v.get("why"),
"hits": v.get("hits")} for (c, a), v in _unlocked.items()]
# 系统自决当日已放弃的新建仓也并入跳过集合 (改动六): 排在用户驳回之后、在途之前 ——
# 「今日系统已判放弃明日重评」。off 时 _system_declined_today_keys 返回空, 逐字回旧。
skip = {**_jk, **_rejected_today_keys(),
**_declined_today_keys(), **_inflight_keys()}
**_declined_today_keys(), **_system_declined_today_keys(),
**_inflight_keys()}
# 挂了 ACTIVE 交易方案(策略)的票交策略层接管, 动作引擎不再对它自动提议
# (读库失败按空集 —— 宁可这轮不排除, 也不能因读不到把全体持仓都排除)
try:
@ -457,7 +471,8 @@ def _apply_intraday(code, blocks, cfg, now, cache) -> str | None:
(blocks.get("timing") or {}).get("stance") or "无读数",
tech_phase=new_tech.get("phase"),
fund_required=(cp.get("fund_required") and cp.get("consensus_route")),
weak_confirm=cp.get("weak_confirm", True))
weak_confirm=cp.get("weak_confirm", True),
timing_dissent=cp.get("timing_dissent", "confirm"))
new_tech["intraday"] = {"kind": kind, "reads": r["reads"]}
elif kind in ("opened_wait", "outside_window"):
con = blocks.get("consensus") or {}
@ -690,6 +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(): # 系统自决今日已放弃 (明日重评)
skip[_k] = "今日系统已判放弃,明日重评"
for _k in _inflight_keys(): # 在途; 前端多半已按指令/提议标成「已下单/待你确认」
skip[_k] = "系统已经在处理这只票(见『等我拍板』或『今日在办』)"
sink = {"skipped": []} # 丢弃用: _scan_open 只往里 append/extend, 不读它
@ -798,7 +815,9 @@ def _route_one(c, view, params, stock_params, brake_active, now, dry_run, out,
# 「证据不足以判断 → 不可用」, 但模型未必每次守得住; 它给的把握度是现成的读数,
# 低于阈值的驳回按「不可用」处理 —— 进人的待确认队列, 不记驳回、不杀提议。
# 只对新建仓: 加仓类的驳回口径不动。阈值一次定死, 不按复盘读数回调。
if is_open and verdict["verdict"] == judge.REJECT:
# **系统自决开着时不做这个转换** (2026-09-14, §3.5): 自决下 REJECT 一律放弃并记账 judge,
# 不再"低把握改不可用交人"——低把握的 REJECT 也走下面的放弃路, 解锁重问对它照常生效。
if is_open and verdict["verdict"] == judge.REJECT and not ae.self_decide_on(params):
conf = verdict.get("confidence")
floor_conf = param_store.get_int("PMS_JUDGE_REJECT_CONF_MIN", 60)
if conf is not None and float(conf) < floor_conf:
@ -886,6 +905,38 @@ def _route_one(c, view, params, stock_params, brake_active, now, dry_run, out,
force_queue = (bool(c.get("needs_user_confirm")) or bool(src_why)
or bool(verdict.get("degraded")) or bool(reask_why) or bool(risk_why))
auto_exec = (not force_queue) and (side == "sell" or autonomy == AUTONOMY_FULL)
# ---- 系统自决 (2026-09-14 台账 015): 新建仓/深亏补仓/目标价由合议+研判自动决定 ----
# 位置在 force_queue 之后、四条件旁路之前: 自决非 off 时它先决定, 旁路不再插手。
# 重问放行也归自决 (试探仓): 先给它打上 decide_kind, 再交 _self_decide 判。
if c.get("reask") and ae.self_decide_on(params) and not c.get("decide_kind"):
c["decide_kind"] = ae.DK_REASK
sd = _self_decide(c, verdict, params, params.get("_sd_base_count", 0) + out.get("_sd_accepts", 0))
if sd.get("would") and sd["would"] != "none":
_con = _sd_consensus_brief(c)
out.setdefault("self_decide_seen", []).append(
{"ts_code": code, "action": action, "decide_kind": sd.get("rule"),
"would": sd["would"], "decision": sd.get("decision"),
"tier": sd.get("tier"), "why": sd.get("why"),
"direction": _con.get("direction"), "route": _con.get("route"),
"conf": verdict.get("confidence")})
sd_decision = sd.get("decision", "none")
if sd_decision == "accept":
auto_exec, force_queue = True, False
if is_open:
out["_sd_accepts"] = out.get("_sd_accepts", 0) + 1
elif sd_decision == "decline":
# 自动放弃: 落账 (评审方 system) + 追加 rejected by system + 当日不重评 + 返回
if dry_run:
out["rejected"].append({**_brief(c), "by": "system", "route": "decline",
"dry_run": True, "why": sd.get("why")})
return
_sd_decline_ledger(code, action, c, verdict, price, sd)
out["rejected"].append({"ts_code": code, "action": action, "by": "system",
"failed": [sd.get("why") or "系统自决放弃"]})
return
elif sd_decision == "confirm":
auto_exec, force_queue = False, True
c["confirm_why"] = sd.get("why") or c.get("confirm_why")
# 自动执行开关 (2026-09-03, PMS_OPEN_AUTO_EXEC_ON_VERDICT): 新建仓档位是 propose_only 时,
# 「判决候选 + 决策系统研判真回了通过 + 规则闸通过 (走到这里就是通过了) + 上游风险列表
# 为空」四条齐, 这一条按 full 处理。强制入队的 (关注 / 深档补仓 / 研判不可用) 永远不走。
@ -903,19 +954,52 @@ def _route_one(c, view, params, stock_params, brake_active, now, dry_run, out,
return
if auto_exec:
sd_accept = sd_decision == "accept"
strat_cancelled = []
if sd_accept and side == "sell":
# 目标价自动清仓两道前置 (2026-09-14): 按当前可卖量夹紧; 该票挂着方案先撤,
# 免得清仓单与网格买入腿对倒 (附录乙那处冲突)。可卖量为零则不落, 明日重评。
posrow = pms_repo.get_position(code) or {}
clamped = ae.clamp_sell_qty(int(c.get("qty") or 0), posrow)
if clamped <= 0:
out["skipped"].append({"ts_code": code, "action": action,
"why": "系统到价自动清仓: 当前可卖量为零, 本轮不清 (明日重评)"})
return
c["qty"] = clamped
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 []
except Exception as e: # noqa: BLE001
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"] # 指令与账本理由一致
iid = _make_instruction(c, price, now)
if sd_accept:
reason = sd.get("why") or 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)}
else:
reason = verdict.get("reason") or c["reason"]
if auto_why:
reason = f"判决候选自动执行: {reason}"
hard = c["hard_numbers"]
pms_repo.insert_ledger(ts_code=code, action=action,
arbiter="judge" if c.get("judge_required") else "rule",
verdict="PASS", price_at=price, hard_numbers=c["hard_numbers"],
verdict="PASS", price_at=price, hard_numbers=hard,
ref_id=iid, reason=reason[:500])
out["executed"].append({**_brief(c), "instruction_id": iid,
"why": ("减持自动执行 (规则触发, 来源没有要求交人裁决)"
"why": (sd.get("why") if sd_accept else
("减持自动执行 (规则触发, 来源没有要求交人裁决)"
if side == "sell"
else (auto_why or ("新建仓档位 full" if is_open
else "档位 full")))})
else "档位 full"))))})
else:
pid = _make_proposal(c, price, verdict)
# 交人的原因按从具体到笼统取: 重问放行 → 上游标的硬风险 → 候选自带的 (关注判决等,
@ -1056,6 +1140,154 @@ def bump_once_guards(ts_code: str, action: str, hard_numbers=None, now=None):
return {"ok": True, "fields": fields}
def _apply_advice_tier(c, hn, verdict, *, force_trial=False):
"""按建议档位补研判一档、重算数量与仓位 (原 _make_proposal 内联那段抽出, 2026-09-14)。
自动路在落指令前也调它, 让自动采纳的试探仓真下的是试探仓的股数与分批, 弱基本面那类还带上
紧止盈标记 (advice.tight_trail)force_trial=True 时把建议直接压到试探仓 系统自决按
decide_kind 判要试探仓时, 研判把握度未必低, apply_judge 不会降档, 所以显式压一次
原地改 hn c["qty"], 返回 (hn, qty, is_trial)建议算不出不拦 (与从前一样吞掉异常)
"""
is_trial = False
try:
from app.services import advice_service as _adv
adv2 = _adv.apply_judge(hn.get("advice"), verdict,
param_store.get_int("PMS_ADVICE_TRIAL_CONF_MIN", 60))
if force_trial and isinstance(adv2, dict) \
and adv2.get("tier") not in (_adv.TIER_NONE, _adv.TIER_TRIAL):
adv2 = {**adv2, "tier": _adv.TIER_TRIAL, "factor": 1.0 / 6.0, "splits": (1.0,)}
if isinstance(adv2, dict):
hn["advice"], hn["advice_text"] = adv2, adv2.get("text")
is_trial = adv2.get("tier") == _adv.TIER_TRIAL
if adv2.get("needs_user_confirm"):
hn["needs_user_confirm"] = True
fix, resize_why = _adv.resize_to_advice(adv2, hn, c["ts_code"])
if fix:
old_qty = c.get("qty")
c["qty"] = fix["qty"]
hn["target_pct"] = fix["target_pct"]
hn["target_amount"] = fix["target_amount"]
hn["base_amount"] = fix["base_amount"]
hn["batch_scheme"] = fix["batch_scheme"]
hn["advice_resized"] = {"from_qty": old_qty, "to_qty": fix["qty"],
"tier": adv2.get("tier"), "note": fix.get("note") or ""}
logger.info("提议按建议档位重算数量 %s: %s%s 股 (%s)%s",
c["ts_code"], old_qty, fix["qty"], adv2.get("tier"),
"; " + fix["note"] if fix.get("note") else "")
elif resize_why and "档位没变" not in resize_why:
# 算不出来要留痕, 让人在卡上看得见「为什么数量还是原来那个」,
# 而不是默默按旧数量下单。
hn["advice_resize_note"] = resize_why
except Exception: # noqa: BLE001 —— 建议方案出错不拦
pass
return hn, c.get("qty"), is_trial
def _self_decide(c, verdict, params, today_auto_count) -> dict:
"""系统自决 (2026-09-14 台账 015): 一条候选归不归自决管、自决怎么判。
返回 {decision, tier, why, rule, would}decision accept / decline / confirm / none:
none 表示不归自决管 (走旧路)would 是未经档位降级 (record/decline) 的原判, 供观察读数记
本会怎么判判定次序按规格书 §3.6 改动三, 决定表见 §3.5
"""
mode = str((params or {}).get("self_decide") or "off").strip()
action, dk = c.get("action"), c.get("decide_kind")
is_open = action == ae.A_OPEN
is_dca_deep = action == ae.A_DCA and dk == ae.DK_DCA_DEEP
is_target = action == ae.A_EXIT and dk == ae.DK_TARGET_PRICE
# 一, 不归自决管: 关掉, 或不是新建仓/深亏补仓/目标价这三类
if mode == "off" or not (is_open or is_dca_deep or is_target):
return {"decision": "none"}
conf = verdict.get("confidence")
trial_min = param_store.get_int("PMS_ADVICE_TRIAL_CONF_MIN", 60)
dk_cn = ae.DK_CN.get(dk, "")
def _mode(res):
"""按档位把原判降级: record 只记不做 (全变 none); decline 只放弃、不采纳 (accept 变 none)。
would 存原判供读数"""
res = {"tier": None, "rule": dk or "consensus_strong", **res}
res["would"] = res["decision"]
if mode == "record":
res["decision"] = "none"
elif mode == "decline" and res["decision"] == "accept":
res["decision"] = "none"
return res
# 目标价到价: 不进研判范围, 直接自动清仓 (卖出方向, 落指令前再按可卖量夹紧、先撤方案)
if is_target:
return _mode({"decision": "accept", "rule": ae.DK_TARGET_PRICE, "why": "目标价到价自动清仓"})
# 二, 前置四种交人: 研判不可用 / 上游硬风险 / 合议装不上 / 判决词认不出
if verdict.get("degraded"):
return _mode({"decision": "confirm", "why": "研判不可用,降级人工确认"})
if (c.get("hard_numbers") or {}).get("risk"):
return _mode({"decision": "confirm", "why": "上游标了硬风险,交人裁决"})
if is_open and not (c.get("consensus") or (c.get("hard_numbers") or {}).get("consensus")):
return _mode({"decision": "confirm", "why": "三源合议装不上,交人裁决"})
if dk == ae.DK_VERDICT_UNKNOWN:
return _mode({"decision": "confirm", "why": "判决词认不出,交人裁决"})
# 三, 逻辑存疑: 自动放弃, 评审方 system
if dk == ae.DK_LOGIC_DOUBT:
return _mode({"decision": "decline", "why": "研究证据走弱(逻辑存疑),自动放弃"})
# 深亏补仓: 研判必答「杀逻辑还是杀情绪」, 契约规定杀逻辑或说不清一律 REJECT (REJECT 已在前面
# 早退当放弃)。所以走到这里的 PASS 即杀情绪: 把握度不低于 60 自动补, 否则放弃。
if is_dca_deep:
if verdict.get("verdict") == judge.PASS and conf is not None and float(conf) >= trial_min:
return _mode({"decision": "accept", "rule": ae.DK_DCA_DEEP,
"why": f"深亏补仓:研判通过(把握度 {int(conf)}),按研判必答题自动补仓"})
return _mode({"decision": "decline", "rule": ae.DK_DCA_DEEP,
"why": "深亏补仓:把握度不足,自动放弃"})
# 新建仓 (走到这: verdict 是真 PASS —— REJECT 与 degraded 都已在前面处理)
conf_ok = conf is not None and float(conf) >= trial_min
is_trial = (dk in ae.TRIAL_DECIDE_KINDS) or (not conf_ok)
if is_trial:
if not (params or {}).get("self_decide_trial"):
return _mode({"decision": "confirm",
"why": f"{dk_cn or '把握度偏低'}:试探仓自动采纳未开,交人裁决"})
why = ("系统采纳试探仓:" + (dk_cn or "把握度偏低")
+ (f";研判通过(把握度 {int(conf)}" if conf is not None else ""))
res = {"decision": "accept", "tier": "trial", "why": why}
else:
res = {"decision": "accept", "tier": "standard",
"why": "系统采纳:合议放行" + (f",研判通过(把握度 {int(conf)}" if conf is not None else "")}
# 五, 每日自动新建仓上限: 超过转交人 (0 = 不限)
dmax = int((params or {}).get("self_decide_daily_max") or 0)
if res["decision"] == "accept" and is_open and dmax and today_auto_count >= dmax:
return _mode({"decision": "confirm", "why": f"今日自动新建仓已达上限 {dmax} 只,本只转交人裁决"})
return _mode(res)
def _sd_consensus_brief(c) -> dict:
"""从候选取合议方向/强弱/路由三项 (self_decide 块用)。硬数字里的合议六键或候选自带的都行。"""
con = (c.get("hard_numbers") or {}).get("consensus") or c.get("consensus") or {}
return {"direction": con.get("direction"), "strength": con.get("strength"),
"route": con.get("route")}
def _self_decide_hard(c, verdict, sd, params, strat_cancelled, daily_count) -> 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"),
"consensus": _sd_consensus_brief(c),
"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 [])}
def _sd_decline_ledger(code, action, c, verdict, price, sd) -> None:
"""系统自决放弃落账 (改动六): 评审方 system, 结论 REJECT, 硬数字带原硬数字、合议六键、
研判把握度与 self_decide 研判 REJECT 引起的放弃走的是研判闸那条 (评审方 judge), 不到这里"""
hard = {**(c.get("hard_numbers") or {}), "judge_conf": verdict.get("confidence"),
"self_decide": {"rule": sd.get("rule"), "decision": "decline",
"consensus": _sd_consensus_brief(c),
"judge": {"verdict": verdict.get("verdict"),
"confidence": verdict.get("confidence")}}}
pms_repo.insert_ledger(ts_code=code, action=action, arbiter="system", verdict="REJECT",
price_at=price, hard_numbers=hard,
reason=(sd.get("why") or "系统自决放弃")[:500])
def _make_proposal(c, price, verdict) -> str:
ttl = param_store.get_int("PMS_PROPOSAL_TTL_HOURS", 24)
pid = f"PRP_{td.ymd()}_{c['ts_code'].replace('.', '')}_{c['action']}"
@ -1076,32 +1308,7 @@ def _make_proposal(c, price, verdict) -> str:
# 而采纳后真下的是标准仓 6% 的第一批。实测 8 张待确认提议里 7 张是这个样子, 差三倍。
# 采纳按钮那边直接抄提议里的股数原样落单 (只有卖出侧会按可卖量重算), 所以不在这里
# 改, 后面就没有第二次机会了。
try:
from app.services import advice_service as _adv
adv2 = _adv.apply_judge(hn.get("advice"), verdict, param_store.get_int("PMS_ADVICE_TRIAL_CONF_MIN", 60))
if isinstance(adv2, dict):
hn["advice"], hn["advice_text"] = adv2, adv2.get("text")
if adv2.get("needs_user_confirm"):
hn["needs_user_confirm"] = True
fix, resize_why = _adv.resize_to_advice(adv2, hn, c["ts_code"])
if fix:
old_qty = c.get("qty")
c["qty"] = fix["qty"]
hn["target_pct"] = fix["target_pct"]
hn["target_amount"] = fix["target_amount"]
hn["base_amount"] = fix["base_amount"]
hn["batch_scheme"] = fix["batch_scheme"]
hn["advice_resized"] = {"from_qty": old_qty, "to_qty": fix["qty"],
"tier": adv2.get("tier"), "note": fix.get("note") or ""}
logger.info("提议按建议档位重算数量 %s: %s%s 股 (%s)%s",
c["ts_code"], old_qty, fix["qty"], adv2.get("tier"),
"; " + fix["note"] if fix.get("note") else "")
elif resize_why and "档位没变" not in resize_why:
# 算不出来要留痕, 让人在卡上看得见「为什么数量还是原来那个」,
# 而不是默默按旧数量下单。
hn["advice_resize_note"] = resize_why
except Exception: # noqa: BLE001 —— 建议方案出错不拦提议
pass
_apply_advice_tier(c, hn, verdict)
try:
pms_repo.insert_proposal(
proposal_id=pid, ts_code=c["ts_code"], action=c["action"], qty=c["qty"],
@ -1149,6 +1356,11 @@ def _scan_params(view: dict) -> dict:
# 两个都关掉时, 合议既不装配也不分流、增持门不设 —— 行为与接入前逐字相同。
"consensus_route": param_store.get_bool("PMS_CONSENSUS_ROUTE", True),
"tech_gate_increase": param_store.get_bool("PMS_TECH_GATE_INCREASE", True),
# 系统自决 (2026-09-14 台账 015): 档位 off/record/decline/full 由 action_engine 与
# _self_decide 读; off 时 scan_open/eval_dca/eval_target 的自决分支一行不走, 逐字回旧。
"self_decide": str(param_store.get("PMS_SELF_DECIDE", "off") or "off").strip(),
"self_decide_trial": param_store.get_bool("PMS_SELF_DECIDE_TRIAL", False),
"self_decide_daily_max": param_store.get_int("PMS_SELF_DECIDE_DAILY_MAX", 3),
# 技术面转空自动离场 (2026-09-11 工作包三, 台账 010)。档位 off/propose_only/full:
# off 不评 (tech_exit_on 为假)、propose_only 交人、full 自动执行 (与保垫减仓同档)。
# tech_exit_done 去重集在 scan_and_route 里按当轮技术面映射填 (这里给空占位)。
@ -1245,6 +1457,21 @@ def _judge_rejected_open_keys() -> dict:
for code, act in keys if act == ae.A_OPEN}
def _system_declined_today_keys() -> dict:
"""今天已被**系统自决放弃**过的新建仓等 {(代码, 动作): 原因} —— 当日不再重评, 明日重评。
评审方 system REJECT (逻辑存疑合议引起的自动放弃) 就是这类研判 REJECT 引起的放弃
评审方是 judge, _judge_rejected_open_keys 那条 (还要参与解锁重问), 不在此列
off 时账本里没有 system 放弃行, 自然返回空 逐字回旧读不到就返回空 (去重是降噪)"""
try:
keys = pms_repo.system_decided_today(datetime.now().replace(
hour=0, minute=0, second=0, microsecond=0))
except Exception as e: # noqa: BLE001
logger.warning("读当日系统自决放弃记录失败 (按未放弃过继续扫描): %s", e)
return {}
return {k: "今日系统已判放弃,明日重评" for k in keys}
def _declined_today_keys() -> dict:
"""今天已被用户**驳回**的提议 {(代码, 动作): 原因} —— 当日不再重提 (2026-08-28)。

View File

@ -137,6 +137,42 @@ def set_status(strategy_id: str, status: str, by: str = "user") -> dict:
return {"ok": True, "strategy_id": strategy_id, "ts_code": st.get("ts_code"), "status": status}
def cancel_by_code(ts_code: str, reason: str = "") -> dict:
"""撤下某只票当前 ACTIVE 的全部交易方案 (2026-09-14 系统自决包)。
系统自决到价自动清仓前调它: 该票若挂着网格, 网格的买入腿会与清仓单对倒 (附录乙那处冲突),
所以落清仓指令前先撤方案与命令服务清仓覆盖冲突那段 (command_service) 同一手法, 抽到这里
供两处复用返回撤掉的方案编号列表; 读库或撤下失败只记进 errors, 不抛 撤不成也不该拦住清仓
"""
out = {"ok": True, "ts_code": ts_code, "cancelled": [], "errors": []}
try:
actives = pms_repo.list_strategies(ts_code=ts_code, statuses=["ACTIVE"], limit=20)
except Exception as e: # noqa: BLE001
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 {}
if r.get("ok"):
out["cancelled"].append(s["strategy_id"])
try:
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])
except Exception as e: # noqa: BLE001
logger.warning("[自决清仓] 撤方案留痕失败 %s: %s", ts_code, e)
else:
out["ok"] = False
out["errors"].append(f"{s.get('strategy_id')} 撤下未成: {r.get('error')}")
except Exception as e: # noqa: BLE001
out["ok"] = False
out["errors"].append(f"{s.get('strategy_id')} 撤下失败: {type(e).__name__}: {e}")
return out
# ================================================================ 买入暂停表 (决策系统风控预警)
# 挂了策略的票, 决策系统的风控卖出只提示、不自动清仓 (强制离场会推翻你特意设的策略);
# 这里把该票策略的**买入这一侧**暂停 —— 不平仓、不动卖出、可页面恢复。

View File

@ -22,6 +22,7 @@ from fastapi.responses import FileResponse, JSONResponse
from config.settings import settings
from app.core import command_spec as cs
from app.core import decide_reasons
from app.core import tradedays as td
from app.db import session as dbs
from app.repo import downstream_repo, pms_repo
@ -454,6 +455,13 @@ def api_proposals(status: str = Query("WAIT_USER"), limit: int = 100,
return ok(_list)
@app.get("/api/proposals/decide-reasons")
def api_decide_reasons():
"""裁决预设理由的两张码表 (采纳 / 驳回), 页面据此铺按钮。台账 015。"""
return {"ok": True, "accept": decide_reasons.ACCEPT_REASONS,
"decline": decide_reasons.DECLINE_REASONS}
@app.post("/api/proposals/{proposal_id}/decide")
def api_decide(request: Request, proposal_id: str, payload: dict = Body(default={})):
"""采纳/驳回一条自主提议。采纳 = 先落指令表 (先记账后动作), 下发由择时执行器负责。"""
@ -463,9 +471,14 @@ def api_decide(request: Request, proposal_id: str, payload: dict = Body(default=
# 理由在服务端强制 (2026-09-07): 页面 09-03 起就弹必填框, 但只在浏览器里成立 ——
# 任何脚本、旧缓存页面都能发一条没有理由的裁决, 账本上落一句「页面人工裁决」的
# 默认文案, 与真裁决看不出区别。复盘要的是「人为什么这么判」, 没理由的不收。
reason = str(payload.get("reason") or "").strip()
if not reason:
return {"ok": False, "error": "采纳或驳回都要写理由, 空理由不收"}
# 2026-09-14 台账 015: 改收预设理由码 reason_code 加补充 reason_note; 旧的自由文本 reason
# 继续接受 (向后兼容)。三者全空、码不合法、选「其他」不填补充, 由 decide_reasons.compose 拒。
try:
reason, reason_code = decide_reasons.compose(
decision, payload.get("reason_code"), payload.get("reason_note"),
payload.get("reason"))
except ValueError as e:
return {"ok": False, "error": str(e)}
def _decide():
from app.core import action_engine as ae
@ -477,9 +490,11 @@ def api_decide(request: Request, proposal_id: str, payload: dict = Body(default=
if not pms_repo.decide_proposal(proposal_id, decision):
return {"ok": False, "error": "提议状态已变更, 请刷新"}
hn = p.get("hard_numbers") or {}
# 用到的预设理由码进硬数字备查 (复盘要能按码聚合"人常用哪条理由驳回")。
hn_led = {**hn, "decide_reason_code": reason_code} if reason_code else hn
pms_repo.insert_ledger(ts_code=p["ts_code"], action=p["action"], arbiter="user",
verdict="PASS" if decision == "ACCEPTED" else "REJECT",
price_at=float(hn.get("price") or 0), hard_numbers=hn,
price_at=float(hn.get("price") or 0), hard_numbers=hn_led,
ref_id=proposal_id, reason=reason)
instruction_id = None
if decision == "ACCEPTED":

View File

@ -2922,6 +2922,8 @@ createApp({
if (d.disp === 'wait_tech') return { cls: 'wait', label: '等技术面开口', why: d.why };
// 盘中确认包 (2026-09-14): 开口未确认/时段外, 等盘中放量确认, 同样是「在盯」不是「被否决」。
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 };
return { cls: 'deny', label: '被否决', why: d.why };
}
return { cls: 'wait', label: '在盯', why: '' };

View File

@ -205,3 +205,29 @@
**台账线(只作提示,调阈值先回台账)。** 技术面无读数占比应低于百分之三。每天等技术面开口应在一到十五只。转空退出每周零到三条。合议链上线第一天转空离场应为零。
**复核日期。** 观察读数包部署后的下一个交易日先跑一次 consensus-review 看有没有读数落下来。台账 006 到 009 的第一次正式复核在合议分流上线满三个交易日,也就是 09-17。
## 015 · 2026-09-14 · 系统自决:把「该不该做」交回三源合议与研判闸
**改动。** 新建仓、深亏补仓、目标价到价这三类,原来大多要人在页面上点采纳或驳回。现在系统按已有的三源合议与研判闸自己决定、自己写理由、自己记账,当天不重复评估。人只处理四类真交人的例外:研判不可用、上游标了硬风险、合议装不上、判决词认不出。
四档开关 PMS_SELF_DECIDE 留作回退off 逐字回旧record 只记不做把本会怎么判落进观察读数decline 只自动放弃、不自动采纳full 自动采纳加自动放弃。模拟仓 155 上线设 full真实仓 188 初始设 off。
**决定表。** 合议放行且研判通过,把握度不低于六十按仓位矩阵档位采纳,低于六十采纳试探仓一批。只有一方表态、基本面看空技术面看多、异议只在技术面、判决关注、建议试探仓、重问放行这几类,研判通过则采纳试探仓;研判驳回一律放弃并记账。深亏补仓靠研判必答题「下跌是杀逻辑还是杀情绪」,契约规定杀逻辑或说不清一律驳回,所以研判通过即杀情绪,把握度不低于六十自动补仓。目标价到价不进研判范围,直接自动清仓,落指令前按可卖量夹紧,该票挂着交易方案的先撤方案再落指令,免得清仓单与网格买入腿对倒。
**撤销两条旧决定。** 台账 001 里「重问放行一律交人」这一句撤销:自决开着时重问放行改自动试探仓(研判引起的放弃仍记评审方 judge解锁重问对它照常生效。台账 008 里「只有一方表态或择时有异议交人」这一句撤销:只有一方表态改自动试探仓;方向看多但择时看空不再交人,改在合议路由层回观察、等择时转多(处置词「择时待转多」)。
**一处既有口径冲突的落法。** 仓位矩阵里「基本面看多、择时看多、技术面看空」那一格advise_v2 早就标了「观察」,但动作引擎从来没读这个字段——卡面写着「技术面看空进观察」,实际却按标准仓出了提议。自决开着时这一格落成决定种类 tech_bear采纳试探仓自决关着时保持今天的行为不动。
**试探仓与每日上限。** 试探仓类的自动采纳受第二个开关 PMS_SELF_DECIDE_TRIAL 控制,关掉则这几类仍交人。每日自动新建仓只数上限 PMS_SELF_DECIDE_DAILY_MAX默认三只超过转交人并在原因里写明设零表示不限。
**记账与不重评。** 自动采纳先落指令再进账本,评审方按动作走 judge 或 rule理由以「系统采纳」开头每日上限的计数按这个前缀数。自动放弃先进账本再进跳过集合评审方 system当天不再重评、明日重评。账本硬数字带一个 self_decide 块,记决定种类、档、仓位档、合议三项、研判把握度、当日累计、撤掉的方案编号。
**裁决理由改预设码。** 仍交人的那几张提议,人裁决时从两张预设码表里选一条(采纳五条、驳回七条),选「其他」再补一句。旧的自由文本理由继续接受。用到的码进账本硬数字,复盘能按码聚合。
**依据。** 采纳驳回都要人手写理由偏离了三源合议自决的设计目标——既然合议与研判已经把「该不该做」判清楚了剩下的机械动作不该每次都等人。off 逐字回旧由全量单测守(第三十四批与既有八百多例全绿即是逐字回旧的证据)。
**参数。** PMS_SELF_DECIDE 默认 off、读不到按 offfail-closed、合法值 off/record/decline/full。PMS_SELF_DECIDE_TRIAL 默认关。PMS_SELF_DECIDE_DAILY_MAX 默认三、范围零到五十。
**台账线(只作提示,调阈值先回台账)。** 研判不可用占比应低于两成,超过要查决策系统那侧。上线头几天自动采纳的股数应是试探仓那批,不是标准仓。
**复核日期。** 部署后的下一个交易日盘中先只读试算一次,看 executed 里有没有 route 为 auto、why 含「系统采纳」的条目,或 rejected 里有没有评审方 system 的条目。收盘后跑 make consensus-review 看第五张表系统自决逐条、第六张表研判不可用占比。

View File

@ -1,12 +1,14 @@
# -*- coding: utf-8 -*-
"""三源合议观察读数复核 (2026-09-14 观察读数包, 台账 013)。只读, 不写任何表, 随时可跑。
回答台账 006 011 复核要的四个读数, 打四张表:
回答台账 006 011015 复核要的读数, 打六张表:
, 逐日: 相关票数技术面无读数占比基本面无读数只数择时无读数只数 (占比用 map_cover )
, 逐日: 等开口只数等盘中确认只数跳过原因分布交人只数放行只数增持门拦截次数
, 转空离场逐条: 日期代码确认与否数量
, 合议方向与人工裁决比对: 带合议六键且已被人裁决的提议里, 合议看多而人驳回
合议交人而人采纳的, 逐只列出
, 系统自决逐条 (台账 015): 日期代码决定 (采纳/放弃/交人)档位规则合议研判把握度
, 研判不可用占比: 自决触及的候选里研判不可用的比例 (台账线低于两成)
台账里的对照阈值 (只作提示, 不下结论 调阈值要先回台账):
技术面无读数占比应低于 3%; 每天等技术面开口应在 1 15 ; 转空退出每周 0 3
@ -185,7 +187,48 @@ def main():
for d, code, con in sorted(handoff_ok):
print(" %-8s %-10s (合议: %s)" % (d, code, con.get("reason") or ""))
print("\n复核口径提醒: 本报告只陈述读数, 阈值调整要先回台账 006 到 011 写一条, "
# ---- 五、系统自决逐条 (2026-09-14 台账 015) ----
print("\n【五】系统自决逐条 (日期 代码 决定 档位 规则 合议 研判把握度)")
try:
sd_rows = consensus_stat_repo.list_range(
a, b, kinds=[cst.K_AUTO_ACCEPT, cst.K_AUTO_DECLINE, cst.K_AUTO_CONFIRM,
cst.K_AUTO_WOULD_ACCEPT, cst.K_AUTO_WOULD_DECLINE])
except Exception as e: # noqa: BLE001
print(f" ✗ 自决读数读取失败: {type(e).__name__}: {e}")
sd_rows = []
_DEC_CN = {cst.K_AUTO_ACCEPT: "采纳", cst.K_AUTO_DECLINE: "放弃", cst.K_AUTO_CONFIRM: "交人",
cst.K_AUTO_WOULD_ACCEPT: "本会采纳", cst.K_AUTO_WOULD_DECLINE: "本会放弃"}
if not sd_rows:
print(" 窗口内没有系统自决记录 (自决还没开, 或没有归自决管的候选)。")
for r in sd_rows:
d = _loads(r.get("detail"))
con = "%s/%s" % (d.get("direction") or "", d.get("route") or "")
conf = d.get("conf")
print(" %-8s %-10s %-6s %-5s %-16s %-7s 把握度 %s (记到 %s 次)"
% (r["stat_date"], r["ts_code"], _DEC_CN.get(r["kind"], r["kind"]),
d.get("tier") or "", d.get("rule") or "", con,
conf if conf is not None else "", r.get("rounds")))
# ---- 六、研判不可用占比 (2026-09-14; 台账线: 超两成报警) ----
print("\n【六】研判不可用占比 (judge_unavail / 自决触及的候选; 台账线: < 20%)")
print(" 日期 研判不可用 自决触及 占比")
for d in _dates_in(counts):
c = counts.get(d) or {}
def _n2(k):
return (c.get(k) or {}).get("rows", 0)
un = _n2(cst.K_JUDGE_UNAVAIL)
touched = sum(_n2(k) for k in (cst.K_AUTO_ACCEPT, cst.K_AUTO_DECLINE, cst.K_AUTO_CONFIRM,
cst.K_AUTO_WOULD_ACCEPT, cst.K_AUTO_WOULD_DECLINE))
ratio = (un / touched) if touched else None
flag = " ⚠ 超 20%" if (ratio is not None and ratio > 0.2) else ""
if un or touched:
print(" %-8s %8d %8d %6s%s" % (d, un, touched, _fmt_pct(ratio), flag))
if not any((counts.get(d) or {}).get(cst.K_JUDGE_UNAVAIL) or
(counts.get(d) or {}).get(cst.K_AUTO_CONFIRM) for d in _dates_in(counts)):
print(" 窗口内没有自决读数 (自决还没开)。")
print("\n复核口径提醒: 本报告只陈述读数, 阈值调整要先回台账 006 到 011、015 写一条, "
"不按单次复盘读数回调 (原则一)。")

View File

@ -101,16 +101,22 @@
test_batch33_units.py 页面收尾包·单票研究面接口 (2026-09-14): _research_st 来源解析 (持仓/候选/都没有)/
api_research 六块齐全且来源逐路标 ok / 无读数票 company None 且来源标明
前端三处 (抽屉四芯片候选栏芯片管理研究面列) 由两道页面守卫与真机视觉判收兜 (5 )
test_batch34_units.py 系统自决包 (2026-09-14 台账 015): 合议择时异议改观察带 wait_timing/裁决理由码表
与拼句 (预设码/其他必填补充/旧自由文本兼容/码串表报错)/决策核心 _self_decide
(off 不管放行强采标准仓低把握转试探仓只有一方表态与异议只在技术面按 TRIAL
开关四类交人逻辑存疑放弃深亏补仓三分支目标价直接采纳每日上限record/decline
降级)/mark_decide 优先级/eval_dca 深档打 decide_kind/三参数登记与范围/两个 repo 查询 (22 )
test_page_enum_guard.py 页面文案守卫 (静态扫描, 不连库不起浏览器): 枚举字段不许
直接印到页面上 / 判据码显示前必须剥前缀 / 不许把整个对象
打给交易员看 / 翻译兜底不许让英文码单独当句子 (1 )
test_wiring.py 装配自检: 服务层核心落表 全链路 (内存桩) +
目标价到价必定入队 (档位 full 也不自动卖) +
用户设的止损价与目标价单独成列显示 (70 )
871
目标价到价自决关时入队自决开时自动清仓按可卖量夹紧 +
用户设的止损价与目标价单独成列显示 (71 )
894
(总数按实跑逐批相加校正过两次: 曾写 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 观察读数包第三十一批二十例到 840; 加固包给第二十八批加三例第三十批加三例到 846; 评审修订第三十一批加一例到 847; 09-14 盘中确认包第三十二批十九例到 866; 页面收尾包第三十三批五例到 871;
09-14 系统自决包第三十四批二十二例test_wiring 目标价自动清仓加一例, 现为 894)
任一子集失败即整体失败 (退出码 1)
哨兵位置清单 (2026-09-03 抄录; 改了对应的东西就得来这些地方改断言, 断言不动就是漏了):
@ -167,6 +173,7 @@ SUITES = ["test_core_units.py", "test_batch2_units.py", "test_batch3_units.py",
"test_batch31_units.py",
"test_batch32_units.py",
"test_batch33_units.py",
"test_batch34_units.py",
"test_page_enum_guard.py",
"test_page_wiring_guard.py",
"test_wiring.py"]

View File

@ -0,0 +1,275 @@
# -*- coding: utf-8 -*-
"""系统自决包 (2026-09-14 台账 015)。后端离线纯逻辑单测, 不连库。
该不该做交回三源合议与研判闸人只处理四类例外: 研判不可用上游硬风险合议装不上
判决词认不出这里测四块:
A 合议路由的择时异议观察 (consensus.decide timing_dissent)
B 裁决理由码表与拼句 (decide_reasons.compose)
C 决策核心 _self_decide: 决定表逐格 (放行强/试探仓/逻辑存疑/深亏补仓/目标价/上限/四类交人)
四档 off/record/decline/full 的降级
D 动作引擎 scan_open decide_kind (关掉不打, 逐字回旧) mark_decide 优先级参数与哨兵
端到端的自动清仓/自动采纳落表见 test_wiring.py (目标价那两例)
"""
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app.core import action_engine as ae # noqa: E402
from app.core import consensus # noqa: E402
from app.core import decide_reasons as dr # noqa: E402
from app.services import judge # noqa: E402
from app.services import param_store # noqa: E402
from app.services.proposal_service import _self_decide # noqa: E402
RESULTS = []
def case(name):
def deco(fn):
RESULTS.append((name, fn))
return fn
return deco
def _v(verdict=judge.PASS, conf=78, degraded=False):
return {"verdict": verdict, "confidence": conf, "degraded": degraded, "reason": ""}
def _cand(action=ae.A_OPEN, decide_kind=None, consensus=True, risk=None):
c = {"ts_code": "600000.SH", "action": action, "side": "buy",
"qty": 1000, "reason": "x", "hard_numbers": {}}
if action in (ae.A_EXIT, ae.A_TRIM):
c["side"] = "sell"
if decide_kind:
c["decide_kind"] = decide_kind
if consensus:
c["consensus"] = {"direction": "看多", "route": "放行", "strength": ""}
if risk:
c["hard_numbers"]["risk"] = risk
return c
# ================================================================ A 合议择时异议
@case("A 合议·方向看多但择时看空: 默认交人 (逐字回旧)")
def _():
d = consensus.decide("看多", "看多", "看空")
assert d["route"] == "交人" and "择时" in d["route_reason"], d
assert "disp" not in d, d
@case("A 合议·自决开 (timing_dissent=observe): 改观察, 等择时转多, disp=wait_timing")
def _():
d = consensus.decide("看多", "看多", "看空", timing_dissent="observe")
assert d["route"] == "观察" and d["route_reason"] == "等择时转多", d
assert d.get("disp") == "wait_timing", d
# 择时不看空时 observe 不改别的格 (三方看多仍放行)
assert consensus.decide("看多", "看多", "看多", timing_dissent="observe")["route"] == "放行"
# ================================================================ B 裁决理由拼句
@case("B decide_reasons·采纳预设码 → 句子与码")
def _():
s, code = dr.compose("ACCEPTED", "ACCEPT_CONSENSUS", None, None)
assert code == "ACCEPT_CONSENSUS" and "合议放行" in s, (s, code)
@case("B decide_reasons·选其他不填补充 → 报错; 填了 → 带补充")
def _():
try:
dr.compose("DECLINED", "DECLINE_OTHER", "", None)
assert False, "空补充应报错"
except ValueError as e:
assert "其他" in str(e) or "补充" in str(e), e
s, code = dr.compose("DECLINED", "DECLINE_OTHER", "估值太贵", None)
assert code == "DECLINE_OTHER" and "估值太贵" in s, (s, code)
@case("B decide_reasons·旧自由文本兼容 / 全空报错 / 码串表报错")
def _():
s, code = dr.compose("ACCEPTED", None, None, "我自己判断成立")
assert code is None and s == "我自己判断成立", (s, code)
try:
dr.compose("ACCEPTED", None, None, None)
assert False
except ValueError:
pass
try:
dr.compose("ACCEPTED", "DECLINE_TECH_WEAK", None, None) # 采纳用了驳回码
assert False
except ValueError as e:
assert "不适用" in str(e), e
# ================================================================ C 决策核心
@case("C 不变式·off 一律不归自决管 (decision none)")
def _():
d = _self_decide(_cand(), _v(), {"self_decide": "off"}, 0)
assert d["decision"] == "none", d
@case("C 放行强·PASS 把握度 78, full → 采纳标准仓")
def _():
d = _self_decide(_cand(), _v(conf=78), {"self_decide": "full"}, 0)
assert d["decision"] == "accept" and d["tier"] == "standard", d
assert d["rule"] == "consensus_strong", d
assert d["why"].startswith("系统采纳"), d
@case("C 放行强·PASS 把握度 40: TRIAL 关转交人 / 开则采纳试探仓")
def _():
off = _self_decide(_cand(), _v(conf=40), {"self_decide": "full", "self_decide_trial": False}, 0)
assert off["decision"] == "confirm", off
on = _self_decide(_cand(), _v(conf=40), {"self_decide": "full", "self_decide_trial": True}, 0)
assert on["decision"] == "accept" and on["tier"] == "trial", on
@case("C 只有一方表态 (weak_consensus): TRIAL 开采纳试探 / 关交人")
def _():
c = _cand(decide_kind=ae.DK_WEAK_CONSENSUS)
on = _self_decide(c, _v(conf=78), {"self_decide": "full", "self_decide_trial": True}, 0)
assert on["decision"] == "accept" and on["tier"] == "trial", on
off = _self_decide(c, _v(conf=78), {"self_decide": "full", "self_decide_trial": False}, 0)
assert off["decision"] == "confirm", off
@case("C 异议只在技术面 (tech_bear): 采纳试探仓")
def _():
d = _self_decide(_cand(decide_kind=ae.DK_TECH_BEAR), _v(conf=78),
{"self_decide": "full", "self_decide_trial": True}, 0)
assert d["decision"] == "accept" and d["tier"] == "trial", d
@case("C 四类交人·研判不可用 / 硬风险 / 合议装不上 / 判决认不出 → confirm")
def _():
fp = {"self_decide": "full", "self_decide_trial": True}
assert _self_decide(_cand(), _v(degraded=True), fp, 0)["decision"] == "confirm"
assert _self_decide(_cand(risk=["高位派发"]), _v(), fp, 0)["decision"] == "confirm"
assert _self_decide(_cand(consensus=False), _v(), fp, 0)["decision"] == "confirm"
assert _self_decide(_cand(decide_kind=ae.DK_VERDICT_UNKNOWN), _v(), fp, 0)["decision"] == "confirm"
@case("C 研判不可用带 judge_unavail 标记 (研判不可用占比复核用)")
def _():
d = _self_decide(_cand(), _v(degraded=True), {"self_decide": "full"}, 0)
assert d["why"].startswith("研判不可用"), d
@case("C 逻辑存疑 (logic_doubt): 自动放弃 decline")
def _():
d = _self_decide(_cand(decide_kind=ae.DK_LOGIC_DOUBT), _v(), {"self_decide": "full"}, 0)
assert d["decision"] == "decline", d
@case("C 深亏补仓: PASS≥60 自动补 / PASS<60 放弃 / 不可用交人")
def _():
dca = _cand(action=ae.A_DCA, decide_kind=ae.DK_DCA_DEEP, consensus=False)
dca["side"] = "buy"
fp = {"self_decide": "full"}
assert _self_decide(dca, _v(conf=70), fp, 0)["decision"] == "accept"
assert _self_decide(dca, _v(conf=40), fp, 0)["decision"] == "decline"
assert _self_decide(dca, _v(degraded=True), fp, 0)["decision"] == "confirm"
@case("C 目标价到价: EXIT 直接采纳 (不进研判范围)")
def _():
ex = _cand(action=ae.A_EXIT, decide_kind=ae.DK_TARGET_PRICE, consensus=False)
d = _self_decide(ex, _v(verdict=judge.PASS, conf=None), {"self_decide": "full"}, 0)
assert d["decision"] == "accept" and d["rule"] == ae.DK_TARGET_PRICE, d
@case("C 每日上限: 达上限 3 时第四只转交人")
def _():
fp = {"self_decide": "full", "self_decide_daily_max": 3}
assert _self_decide(_cand(), _v(conf=78), fp, 2)["decision"] == "accept" # 第三只
over = _self_decide(_cand(), _v(conf=78), fp, 3) # 第四只
assert over["decision"] == "confirm" and "上限" in over["why"], over
@case("C 档位降级·record 只记不做 (would 留原判); decline 只放弃不采纳")
def _():
# record: 本会采纳但 decision 降为 none, would 留 accept
rec = _self_decide(_cand(), _v(conf=78), {"self_decide": "record"}, 0)
assert rec["decision"] == "none" and rec["would"] == "accept", rec
# decline: 采纳降 none, 放弃仍放弃
dec_acc = _self_decide(_cand(), _v(conf=78), {"self_decide": "decline"}, 0)
assert dec_acc["decision"] == "none" and dec_acc["would"] == "accept", dec_acc
dec_dec = _self_decide(_cand(decide_kind=ae.DK_LOGIC_DOUBT), _v(),
{"self_decide": "decline"}, 0)
assert dec_dec["decision"] == "decline", dec_dec
# ================================================================ D 动作引擎与参数
@case("D mark_decide·按优先级取最保守: 认不出 < 逻辑存疑 < 关注 < 试探仓")
def _():
c = {}
ae.mark_decide(c, ae.DK_ADVICE_TRIAL)
assert c["decide_kind"] == ae.DK_ADVICE_TRIAL
ae.mark_decide(c, ae.DK_VERDICT_WATCH) # 更高优先, 覆盖
assert c["decide_kind"] == ae.DK_VERDICT_WATCH
ae.mark_decide(c, ae.DK_LOGIC_DOUBT) # 再高, 覆盖
assert c["decide_kind"] == ae.DK_LOGIC_DOUBT
ae.mark_decide(c, ae.DK_ADVICE_TRIAL) # 更低, 不覆盖
assert c["decide_kind"] == ae.DK_LOGIC_DOUBT
@case("D self_decide_on: off/空 → False; record/decline/full → True")
def _():
assert ae.self_decide_on({"self_decide": "off"}) is False
assert ae.self_decide_on({}) is False
assert ae.self_decide_on({"self_decide": "record"}) is True
assert ae.self_decide_on({"self_decide": "full"}) is True
@case("D eval_dca 深档·自决关不打 decide_kind、confirm 真; 自决开打 dca_deep")
def _():
p = {"ts_code": "688001.SH", "cushion_pct": -0.20, "dca_qty": 0, "dca_count": 0,
"price": 10.0, "base_qty": 2000}
pr = {"dca_triggers": (-0.08, -0.15), "dca_deep_confirm": -0.15, "dca_max_ratio": 0.5}
off = ae.eval_dca(p, pr, {})
assert off and off["needs_user_confirm"] is True and "decide_kind" not in off, off
on = ae.eval_dca(p, {**pr, "self_decide": "full"}, {})
assert on and on.get("decide_kind") == ae.DK_DCA_DEEP, on
@case("D 参数登记·三键在 RUNTIME_EXTRA, 合法值与范围校验")
def _():
for k in ("PMS_SELF_DECIDE", "PMS_SELF_DECIDE_TRIAL", "PMS_SELF_DECIDE_DAILY_MAX"):
assert k in param_store.RUNTIME_EXTRA, k
assert param_store.RUNTIME_EXTRA["PMS_SELF_DECIDE"][0] == "off" # 代码默认 off
assert param_store.FAIL_CLOSED.get("PMS_SELF_DECIDE") == "off" # 读不到按 off
assert param_store._range_check("PMS_SELF_DECIDE", "full") is None
assert param_store._range_check("PMS_SELF_DECIDE", "wtf") is not None # 非法值被拒
assert param_store._range_check("PMS_SELF_DECIDE_DAILY_MAX", 3) is None
assert param_store._range_check("PMS_SELF_DECIDE_DAILY_MAX", 99) is not None
@case("D repo 两个自决查询函数存在且单表")
def _():
from app.repo import pms_repo
assert callable(pms_repo.system_decided_today) and callable(pms_repo.system_pass_count_today)
assert pms_repo.SYSTEM_ACCEPT_REASON_PREFIX == "系统采纳"
def main():
ok = 0
for name, fn in RESULTS:
try:
fn()
ok += 1
print(" ok " + name)
except Exception:
import traceback
print(" FAIL " + name)
traceback.print_exc()
print("-" * 60)
if ok == len(RESULTS):
print("ALL PASS (%d cases)" % ok)
return 0
print("FAILED %d/%d" % (len(RESULTS) - ok, len(RESULTS)))
return 1
if __name__ == "__main__":
sys.exit(main())

View File

@ -153,6 +153,13 @@ def _():
assert c["source"] == ae.SRC_TARGET_PRICE, c
assert c["confirm_why"] == ae.WHY_TARGET_PRICE_CONFIRM, c
assert c["hard_numbers"]["target_price"] == 12.5, c["hard_numbers"]
assert c.get("decide_kind") is None, c # 自决关: 不打 decide_kind (逐字回旧)
# 系统自决开着时 (2026-09-14 台账 015): 候选多打 decide_kind=target_price, confirm 与来源不变;
# 是否真自动清仓由 _self_decide 在分流层决定 (自决关时必定入队, 见 test_wiring 那两例)。
c2 = ae.eval_target(pos(price=13.0, target_price=12.5, total_qty=6050, avail_qty=6050,
cushion_pct=0.30), {**PARAMS, "self_decide": "full"})
assert c2.get("decide_kind") == ae.DK_TARGET_PRICE, c2
assert c2["needs_user_confirm"] is True and c2["source"] == ae.SRC_TARGET_PRICE, c2
# 恰好等于目标价也算到价 (不设缓冲、不做平滑 —— 那个数是用户自己填的)
assert ae.eval_target(pos(price=12.5, target_price=12.5), PARAMS) is not None

View File

@ -406,6 +406,17 @@ class FakeRepo:
return {s["ts_code"] for s in self.strategies.values()
if s["status"] == "ACTIVE" and s.get("ts_code")}
def system_decided_today(self, since):
# 系统自决放弃过的 (代码, 动作): 账本里评审方 system 的 REJECT (2026-09-14 台账 015)
return {(r["ts_code"], r["action"]) for r in self.ledger
if r.get("verdict") == "REJECT" and r.get("arbiter") == "system"}
def system_pass_count_today(self, since):
# 今天系统自决自动采纳的新建仓只数: 账本里 OPEN 的 PASS 且 reason 以「系统采纳」开头
return sum(1 for r in self.ledger
if r.get("action") == "OPEN" and r.get("verdict") == "PASS"
and str(r.get("reason") or "").startswith("系统采纳"))
def insert_strategy(self, *, strategy_id, ts_code, stype, autonomy="auto", params=None,
state=None, status="ACTIVE", note=None):
if strategy_id in self.strategies: # 真表 strategy_id 唯一键
@ -643,7 +654,9 @@ def _():
"/api/commands/catalog", "/api/commands/active", "/api/commands/{command_id}",
"/api/commands/{command_id}/cancel", "/api/plans", "/api/positions",
"/api/positions/{ts_code}/lots", "/api/instructions", "/api/ledger", "/api/report",
"/api/proposals", "/api/proposals/{proposal_id}/decide", "/api/ops/replay",
"/api/proposals", "/api/proposals/{proposal_id}/decide",
"/api/proposals/decide-reasons", # 2026-09-14 系统自决包: 裁决预设理由码表
"/api/ops/replay",
"/api/ops/reconcile", "/api/ops/daily-settle", "/api/ops/report",
"/api/ops/downstream-schema", "/api/industry",
"/api/upstream/plan", "/api/ops/plan-refresh",
@ -1741,10 +1754,10 @@ def _target_fakes(price, target, **kw):
return fake, restore
@case("自主提议·目标价到价: 必定入队等人拍板, 档位 full 也不自动卖")
@case("自主提议·目标价到价: 系统自决关时必定入队 (档位 full 也不自动卖)")
def _():
from app.services import proposal_service as ps
# 档位 full = 「闸门与研判通过即执行」, 而目标价这条必须无视档位入队
# 档位 full = 「闸门与研判通过即执行」, 而目标价这条 (自决关) 必须无视档位入队
fake, restore = _target_fakes(13.0, 12.5, params={"PMS_AUTONOMY": "full"})
try:
r = ps.scan_and_route()
@ -1777,6 +1790,35 @@ def _():
assert any("在途提议" in (s.get("why") or "") for s in r2["skipped"]), r2["skipped"]
@case("自主提议·目标价到价: 系统自决开 (full) 时自动清仓, 按可卖量夹紧")
def _():
from app.services import proposal_service as ps
# 自决 full: 目标价到价不再入队, 直接落 EXIT 指令 (卖出方向); 按可卖量夹紧 (avail<total)
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": 3000,
"base_qty": 6000, "avg_cost": 10.0, "cushion_peak": 0.0,
"target_pct": 0.06}])
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
# 自动清仓: 落进 executed 而不是 queued, 生成 EXIT 指令
assert any(x["action"] == "EXIT" for x in r["executed"]), r["executed"]
assert not any(x["action"] == "EXIT" for x in r["queued"]), r["queued"]
exits = [i for i in fake.instructions.values() if i["action"] == "EXIT"]
assert exits, fake.instructions
assert exits[0]["qty"] == 3000, exits[0] # 按可卖量 3000 夹紧, 不是总持仓 6050
# 账本一行 system 自动清仓 (理由含目标价)
led = [x for x in fake.ledger if x["action"] == "EXIT" and x["verdict"] == "PASS"]
assert led and "目标价" in (led[0]["reason"] or ""), led
@case("自主提议·目标价未到 / 没设 一律不产出 (拿不到价绝不折成到价)")
def _():
from app.services import proposal_service as ps