三源合议工作包二(四):接入下单链路(合议分流·增持门·质地排序·提议卡合议行)
把三块大脑接进新建仓与持仓的下单链路,每处守开关关掉即逐字回旧: - 新建仓扫描前批量装配合议挂候选;scan_open 按合议路由分流 (跳过/观察不占名额、观察带 wait_tech、交人强制确认、放行照常、缺合议退旧路) - 合议开着时定档改用 advise_v2 仓位矩阵;六键进硬数字但不进送研判白名单 - 持仓增持门:合议看空停三类、技术面看空停回踩补足与盈利加仓、基本面看空停补仓,减持不受影响 - 候选先按基本面立场分桶再按分数;观察档质地看多进池开关(默认关) - 提议卡加合议行与详情弹窗;候选处置新增「等技术面开口」 台账 007/008/009;第二十九批 24 例,本地全量 ALL SUITES PASS。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
2f4f9cbf52
commit
51815d5588
36
DEVLOG.md
36
DEVLOG.md
|
|
@ -1367,6 +1367,42 @@ app/web/static/index.html(CSS 加来源行与弹窗样式、模板卡面重构
|
||||||
**还欠着什么**
|
**还欠着什么**
|
||||||
合议行、候选芯片仍是工作包二。提议卡的技术面立场依赖工作包一的技术面映射(已上,未真机判收)。
|
合议行、候选芯片仍是工作包二。提议卡的技术面立场依赖工作包一的技术面映射(已上,未真机判收)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-09-11 · 三源合议接入下单链路:工作包二的接入部分(后端加页面,未真机判收)
|
||||||
|
|
||||||
|
**做了什么**
|
||||||
|
|
||||||
|
把三源合议这三块「大脑」(纯逻辑、装配服务、仓位矩阵,上一轮已写好并全量绿)接进了新建仓与持仓的下单链路。落地《技术面接入与三源合议方案_2026-09-11》第五节工作包二的接入部分,也就是进度交接文档第五节留给下一轮的活。每一处都守「开关关掉即逐字回旧」,并先写这条单测再改。
|
||||||
|
|
||||||
|
一,候选装配合议。提议服务的新建仓扫描在候选进动作引擎之前,批量取一次昨夜定性与技术面映射,逐只装配三票,把合议路由、四块意见、六个硬数字键挂到候选上。装不上就当没有合议,退回旧路,绝不拦扫描。开关 PMS_CONSENSUS_ROUTE 关掉整段不做。
|
||||||
|
|
||||||
|
二,新建仓合议分流。动作引擎的候选扫描里加合议分流,放在选股判决分流之后、名额判断之前。跳过与观察不占名额不占金额,观察带处置词 wait_tech。交人打强制人工确认。放行照常。缺合议退回旧路。
|
||||||
|
|
||||||
|
三,定档改用仓位矩阵。合议开着且候选装配了四块意见时,定档从旧的 advise 改用 advise_v2 的基本面乘技术面矩阵。六个硬数字键进评审账本与提议卡,但一个都不进送研判白名单——机器证明:judge 裁剪后确实不含这六键。
|
||||||
|
|
||||||
|
四,持仓增持门。持仓行早上装配一份合议四块。合议看空停回踩补足、盈利加仓、补仓三类。技术面看空停回踩补足与盈利加仓。基本面看空停补仓。减持侧一律不受门影响。开关 PMS_TECH_GATE_INCREASE 关掉即不设门。
|
||||||
|
|
||||||
|
五,候选按质地排序。候选先按基本面立场分桶,质地看多的桶排前,再按分数,在主题限额与截断之前生效。技术面不参与排序只当门。观察档里质地看多的行进池另有开关,默认关。
|
||||||
|
|
||||||
|
六,页面。提议卡在四行来源之后加了「合议」一行,显示三方多数方向与强弱,点开弹窗看三票与路由理由;合议读的是下单当时记进硬数字的那份。候选处置新增「等技术面开口」,归到在盯那一档而不是今天没建仓那一档。
|
||||||
|
|
||||||
|
**动了哪些文件**
|
||||||
|
|
||||||
|
app/core/action_engine.py(合议分流五处:开关、跳过观察分流、advise_v2 定档、六键进硬数字、交人确认;增持门 consensus_gate_why 与 scan 里那道门;处置词常量 DISP_WAIT_TECH);app/services/consensus_service.py(decorate_positions 给持仓装配四块);app/services/proposal_service.py(_attach_consensus 批量装配挂候选、扫描主流程装配持仓合议、处置快照透传 disp、扫描参数加两个开关、导入 consensus_service);app/services/plan_feed.py(select_candidates 按质地分桶排序与观察档进池、_fund_stance_of、候选入口传两个开关与 _params 登记);app/services/param_store.py(登记 PMS_TECH_GATE_INCREASE、PMS_PLAN_RANK_BY_QUALITY、PMS_PLAN_OBSERVE_IF_FUND_BULL 三个开关);app/web/static/index.html(提议卡合议行、合议详情弹窗、候选等技术面开口、三个函数导出);scripts/test_batch29_units.py(新增二十四例);scripts/run_tests.py(登记第二十九批与例数、总数改 787);docs/复盘决定台账.md(台账 007 基本面立场、008 三源合议方向与路由、009 候选排序与增持门)。
|
||||||
|
|
||||||
|
**部署方式**
|
||||||
|
|
||||||
|
改了 Python 与页面,make deploy 重建镜像加 force-recreate(绝不能用 restart)。收盘后在模拟仓 155 部署,先经用户审批,看容器内北京时间避开盘中。部署时一起拉上一轮已推远程但 155 未拉的 advise_v2(0dd5b68)。真实仓 188 本次不动。
|
||||||
|
|
||||||
|
**真机判收**
|
||||||
|
|
||||||
|
未判收。开发机全量单测 ALL SUITES PASS(第二十九批二十四例新加,含每组的开关关掉逐字回旧),两道页面守卫 ALL OK。155 判收要看:提议卡出现合议一行且点开弹窗有三票与路由;候选栏出现「等技术面开口」;基本面看空技术面看多的票进待确认队列;关掉三个开关后行为与接入前一致。
|
||||||
|
|
||||||
|
**还欠着什么**
|
||||||
|
|
||||||
|
一,155 部署与真机判收(收盘后、经审批)。二,工作包三离场纪律未做:eval_tech_exit 转空离场、盘中 SAR 止损线、弱基本面试探仓的紧止盈自动挂载。三,工作包四选股打分在另一仓库,单独审批后再动。四,工作包五其余文档:README 调度总表补到十三位、BIONIC_PMS_INTERFACE 技术面只读接口一节。五,页面管理视图持仓总览「证据列印两遍」的显示错误与研究面列,本轮未碰。
|
||||||
|
|
||||||
---
|
---
|
||||||
<!--
|
<!--
|
||||||
下一条节点从这里往下写,格式照抄上面:
|
下一条节点从这里往下写,格式照抄上面:
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,10 @@ A_OPEN = "OPEN" # 新建仓 (与 planner.A_OPEN、executor.BUY_AC
|
||||||
A_EXIT = "EXIT"
|
A_EXIT = "EXIT"
|
||||||
BUY, SELL = "buy", "sell"
|
BUY, SELL = "buy", "sell"
|
||||||
|
|
||||||
|
# 候选处置词 (2026-09-11 三源合议工作包二): 合议判「观察」的新建仓候选写这个 disp,
|
||||||
|
# 页面把它渲染成「等技术面开口」。与既有的 would / deny 并列, 走 disposition_snapshot 那条路。
|
||||||
|
DISP_WAIT_TECH = "wait_tech"
|
||||||
|
|
||||||
# 同一轮里出现多条减持时留哪一条 (2026-09-03): 数字小的优先。用户自己设的目标价到价排在
|
# 同一轮里出现多条减持时留哪一条 (2026-09-03): 数字小的优先。用户自己设的目标价到价排在
|
||||||
# 系统按规则算出来的保垫减仓前面 —— 人已经说了到价就清, 这一轮就不该再自作主张先卖一部分。
|
# 系统按规则算出来的保垫减仓前面 —— 人已经说了到价就清, 这一轮就不该再自作主张先卖一部分。
|
||||||
# 只在同一只票的同一轮里比较, 不影响不同票, 也不影响下一轮。
|
# 只在同一只票的同一轮里比较, 不影响不同票, 也不影响下一轮。
|
||||||
|
|
@ -139,6 +143,32 @@ def logic_confirm_why(logic_state):
|
||||||
return logic_doubt_text(logic_state, WHY_LOGIC_DOUBT_CONFIRM)
|
return logic_doubt_text(logic_state, WHY_LOGIC_DOUBT_CONFIRM)
|
||||||
|
|
||||||
|
|
||||||
|
# ================================================================ 三源合议增持门 (2026-09-11 工作包二)
|
||||||
|
# 合议由 consensus_service 早上装配后挂到持仓行上 (blocks = {fund, tech, timing, consensus}),
|
||||||
|
# 动作引擎只读, 不算。增持侧三类 (回踩补足 / 盈利加仓 / 补仓) 按方案第五节工作包二第 6 点设三道门:
|
||||||
|
# 合议方向看空 → 停三类; 技术面看空 → 停回踩补足与盈利加仓; 基本面看空 → 停补仓。
|
||||||
|
# 减持侧 (保垫减仓 / 到价清仓) 不受任何一道门影响。开关 PMS_TECH_GATE_INCREASE 关掉即整段不走。
|
||||||
|
# 立场词只有看多/看空/中性/无读数; 无读数是弃权, 不设门 (设计原则二)。
|
||||||
|
def consensus_gate_why(blocks, action):
|
||||||
|
"""这条增持动作要不要被合议门拦下: 拦则回一句原因, 不拦回 None。
|
||||||
|
|
||||||
|
blocks 是 consensus_service.assemble 的四块结果; 缺块或缺读数一律不拦 (弃权不设门)。
|
||||||
|
只对增持侧动作 (FILL / ADD / DCA) 判, 减持侧调用方根本不会传进来。
|
||||||
|
"""
|
||||||
|
if not isinstance(blocks, dict):
|
||||||
|
return None
|
||||||
|
con = (blocks.get("consensus") or {}).get("direction")
|
||||||
|
tech = (blocks.get("tech") or {}).get("stance")
|
||||||
|
fund = (blocks.get("fund") or {}).get("stance")
|
||||||
|
if con == "看空":
|
||||||
|
return "三源合议看空,停增持侧自主动作(回踩补足、盈利加仓、补仓)"
|
||||||
|
if action in (A_FILL, A_ADD) and tech == "看空":
|
||||||
|
return "技术面看空,暂停回踩补足与盈利加仓(减持不受影响,转向后再增持)"
|
||||||
|
if action == A_DCA and fund == "看空":
|
||||||
|
return "基本面看空,暂停补仓(减持不受影响)"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
# 冻结的三种来路,对交易员是三件不同的事:自己下的命令、自己手设的、系统刹车期间。
|
# 冻结的三种来路,对交易员是三件不同的事:自己下的命令、自己手设的、系统刹车期间。
|
||||||
# 原先一句「该股 COMMAND_HALT, 禁增持」三种都不分,还把枚举值印在了页面上。
|
# 原先一句「该股 COMMAND_HALT, 禁增持」三种都不分,还把枚举值印在了页面上。
|
||||||
_FROZEN_WHY = {
|
_FROZEN_WHY = {
|
||||||
|
|
@ -538,6 +568,11 @@ def scan(*, positions: list, params: dict, market: dict, skip=None,
|
||||||
# 逻辑存疑停增持侧 (2026-09-07 第三件): 持仓行上挂着早上查回的逻辑状态 (logic_state_service.attach),
|
# 逻辑存疑停增持侧 (2026-09-07 第三件): 持仓行上挂着早上查回的逻辑状态 (logic_state_service.attach),
|
||||||
# 存疑的票补足 / 加仓 / 补仓一律不评, 减持侧照评。没挂这个键 = 没有读数, 不拦。
|
# 存疑的票补足 / 加仓 / 补仓一律不评, 减持侧照评。没挂这个键 = 没有读数, 不拦。
|
||||||
doubt = bool(params.get("logic_state_route")) and is_logic_doubt(p.get("logic_state"))
|
doubt = bool(params.get("logic_state_route")) and is_logic_doubt(p.get("logic_state"))
|
||||||
|
# 三源合议增持门 (2026-09-11 工作包二): 持仓行上挂着早上装配的合议四块 consensus_blocks
|
||||||
|
# (consensus_service.decorate_positions; 四块指 fund/tech/timing/consensus)。开关
|
||||||
|
# PMS_TECH_GATE_INCREASE 关掉时 cblocks 为 None, 下面整段不走, 行为与接入前逐字相同。
|
||||||
|
# 减持侧不受门影响, 所以只在增持动作上判。
|
||||||
|
cblocks = p.get("consensus_blocks") if params.get("tech_gate_increase") else None
|
||||||
# 跨轮的减持让路 (2026-09-07 审查修)。同轮只发一条减持那条规矩 (见下) 只管一次扫描;
|
# 跨轮的减持让路 (2026-09-07 审查修)。同轮只发一条减持那条规矩 (见下) 只管一次扫描;
|
||||||
# 下一分钟再扫时, 等人拍板的到价清仓已成在途、按 (代码, EXIT) 被跳过, 而保垫减仓
|
# 下一分钟再扫时, 等人拍板的到价清仓已成在途、按 (代码, EXIT) 被跳过, 而保垫减仓
|
||||||
# 单独产出、不需确认、卖出方向又不走强制入队 —— 系统刚说「卖不卖由你定」, 一分钟后
|
# 单独产出、不需确认、卖出方向又不走强制入队 —— 系统刚说「卖不卖由你定」, 一分钟后
|
||||||
|
|
@ -567,6 +602,11 @@ def scan(*, positions: list, params: dict, market: dict, skip=None,
|
||||||
skipped.append({"ts_code": code, "action": action,
|
skipped.append({"ts_code": code, "action": action,
|
||||||
"why": logic_doubt_text(p.get("logic_state"), WHY_LOGIC_DOUBT_PAUSE)})
|
"why": logic_doubt_text(p.get("logic_state"), WHY_LOGIC_DOUBT_PAUSE)})
|
||||||
continue
|
continue
|
||||||
|
if cblocks and action not in SELL_SIDE_ACTIONS:
|
||||||
|
gate_why = consensus_gate_why(cblocks, action)
|
||||||
|
if gate_why:
|
||||||
|
skipped.append({"ts_code": code, "action": action, "why": gate_why})
|
||||||
|
continue
|
||||||
try:
|
try:
|
||||||
c = fn(p, params, mkt)
|
c = fn(p, params, mkt)
|
||||||
except Exception as e: # 单票异常不能拖垮整轮扫描
|
except Exception as e: # 单票异常不能拖垮整轮扫描
|
||||||
|
|
@ -756,6 +796,10 @@ def scan_open(*, candidates: list, params: dict, caps: dict, room_amt: float,
|
||||||
slots = int(slots or 0)
|
slots = int(slots or 0)
|
||||||
left = _f(room_amt)
|
left = _f(room_amt)
|
||||||
route = bool(params.get("open_route_by_verdict"))
|
route = bool(params.get("open_route_by_verdict"))
|
||||||
|
# 三源合议分流 (2026-09-11 工作包二): 候选上挂好的合议 (proposal_service._scan_open 批量装配)
|
||||||
|
# 决定这条候选往哪走。开关 PMS_CONSENSUS_ROUTE 关掉时 consensus_on 为假, 下面所有合议相关
|
||||||
|
# 的代码一行都不执行, 定档退回旧的 advise、分流退回只按选股判决 —— 行为与接入前逐字相同。
|
||||||
|
consensus_on = bool(params.get("consensus_route"))
|
||||||
if slots <= 0:
|
if slots <= 0:
|
||||||
return {"candidates": [], "skipped": [
|
return {"candidates": [], "skipped": [
|
||||||
{"ts_code": "*", "action": A_OPEN,
|
{"ts_code": "*", "action": A_OPEN,
|
||||||
|
|
@ -790,6 +834,18 @@ def scan_open(*, candidates: list, params: dict, caps: dict, room_amt: float,
|
||||||
skipped.append({"ts_code": code, "action": A_OPEN,
|
skipped.append({"ts_code": code, "action": A_OPEN,
|
||||||
"why": display_only_why(c)})
|
"why": display_only_why(c)})
|
||||||
continue
|
continue
|
||||||
|
# 合议判「跳过」「观察」的候选: 不占名额、不占金额, 放在名额判断之前 (与仅展示同一手法)。
|
||||||
|
# 跳过多因「没有买方评析」或「方向看空」; 观察多因「等技术面开口」或「等技术面转向」,
|
||||||
|
# 处置词打 wait_tech, 页面据此显示「等技术面开口」。交人与放行落到后面正常产出候选那条路。
|
||||||
|
con = c.get("consensus") if consensus_on else None
|
||||||
|
if con and con.get("route") == "跳过":
|
||||||
|
skipped.append({"ts_code": code, "action": A_OPEN,
|
||||||
|
"why": con.get("route_reason") or "三源合议判为跳过"})
|
||||||
|
continue
|
||||||
|
if con and con.get("route") == "观察":
|
||||||
|
skipped.append({"ts_code": code, "action": A_OPEN, "disp": DISP_WAIT_TECH,
|
||||||
|
"why": con.get("route_reason") or "三源合议判为观察,等技术面开口"})
|
||||||
|
continue
|
||||||
if slots <= 0:
|
if slots <= 0:
|
||||||
skipped.append({"ts_code": code, "action": A_OPEN,
|
skipped.append({"ts_code": code, "action": A_OPEN,
|
||||||
"why": "本轮新仓名额已用完 (下一跳按最新持仓数重算)"})
|
"why": "本轮新仓名额已用完 (下一跳按最新持仓数重算)"})
|
||||||
|
|
@ -804,8 +860,14 @@ def scan_open(*, candidates: list, params: dict, caps: dict, room_amt: float,
|
||||||
continue
|
continue
|
||||||
# 建议方案 (2026-09-09 接入方案第六之二节): 先按质地、估值、赔率、逻辑状态、定价状态出仓位档与分批;
|
# 建议方案 (2026-09-09 接入方案第六之二节): 先按质地、估值、赔率、逻辑状态、定价状态出仓位档与分批;
|
||||||
# 开关 PMS_PLAN_BY_ADVICE 开着时它就是评估参数, 关着时只并排显示。研判那一档在提议落账前补。
|
# 开关 PMS_PLAN_BY_ADVICE 开着时它就是评估参数, 关着时只并排显示。研判那一档在提议落账前补。
|
||||||
|
# 合议开着且这只票装配到了四块意见时, 定档改用 advise_v2 的基本面×技术面仓位矩阵 (方案附录乙);
|
||||||
|
# 否则退回旧 advise, 与接入前逐字相同。
|
||||||
try:
|
try:
|
||||||
from app.services import advice_service as _adv
|
from app.services import advice_service as _adv
|
||||||
|
cb = c.get("consensus_blocks") if consensus_on else None
|
||||||
|
if cb:
|
||||||
|
adv = _adv.advise_v2(c, params, fund=cb.get("fund") or {}, tech=cb.get("tech") or {})
|
||||||
|
else:
|
||||||
adv = _adv.advise(c, params)
|
adv = _adv.advise(c, params)
|
||||||
p_use = _adv.params_for(adv, params) if params.get("plan_by_advice") else params
|
p_use = _adv.params_for(adv, params) if params.get("plan_by_advice") else params
|
||||||
except Exception as e: # noqa: BLE001 —— 建议算不出不拦评估
|
except Exception as e: # noqa: BLE001 —— 建议算不出不拦评估
|
||||||
|
|
@ -828,6 +890,14 @@ def scan_open(*, candidates: list, params: dict, caps: dict, room_amt: float,
|
||||||
cand["confirm_why"] = (cand.get("confirm_why") or "") + "建议方案为试探仓,只在人工确认后下;"
|
cand["confirm_why"] = (cand.get("confirm_why") or "") + "建议方案为试探仓,只在人工确认后下;"
|
||||||
except Exception: # noqa: BLE001
|
except Exception: # noqa: BLE001
|
||||||
pass
|
pass
|
||||||
|
# 合议六键进硬数字 (方案附录丁): 基本面立场、技术面读数、择时立场、合议方向与路由等,
|
||||||
|
# 随提议进评审账本与提议卡给人看。这六键一个都不进 judge.OPEN_JUDGE_KEYS, 送研判时
|
||||||
|
# 会被 judge._judge_hard_numbers 按白名单过滤掉 (决策系统不重判基本面与技术面)。
|
||||||
|
if consensus_on and c.get("consensus_hard"):
|
||||||
|
try:
|
||||||
|
cand.setdefault("hard_numbers", {}).update(c["consensus_hard"])
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
if route:
|
if route:
|
||||||
confirm_why = verdict_confirm_why(c.get("verdict"))
|
confirm_why = verdict_confirm_why(c.get("verdict"))
|
||||||
if confirm_why:
|
if confirm_why:
|
||||||
|
|
@ -843,6 +913,12 @@ def scan_open(*, candidates: list, params: dict, caps: dict, room_amt: float,
|
||||||
if lw:
|
if lw:
|
||||||
cand["needs_user_confirm"] = True
|
cand["needs_user_confirm"] = True
|
||||||
cand["confirm_why"] = lw
|
cand["confirm_why"] = lw
|
||||||
|
# 合议判「交人」的候选强制人工确认 (方案附录丙: 基本面看空技术面看多试探仓、方向看多但只有
|
||||||
|
# 一方表态、方向看多但择时系统有异议)。放在最后并守 not needs_user_confirm: 前面已被别的
|
||||||
|
# 理由 (试探仓、关注判决、逻辑存疑) 认领的不覆盖原因, 但结果同样是进人工队列, 交人不落空。
|
||||||
|
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 "三源合议判为交人裁决"
|
||||||
out.append(cand)
|
out.append(cand)
|
||||||
used = _f(cand.get("target_amount"))
|
used = _f(cand.get("target_amount"))
|
||||||
left -= used
|
left -= used
|
||||||
|
|
|
||||||
|
|
@ -99,3 +99,38 @@ def state_map():
|
||||||
except Exception as e: # noqa: BLE001
|
except Exception as e: # noqa: BLE001
|
||||||
logger.warning("[合议] 技术面映射取不到: %s", e)
|
logger.warning("[合议] 技术面映射取不到: %s", e)
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def decorate_positions(rows, *, nightly=None, tech_states=None, company_reviews=None,
|
||||||
|
flip_map=None, params=None):
|
||||||
|
"""给持仓行装配三源合议四块 (原地改), 挂 consensus_blocks 一键 —— 动作引擎的增持门读它。
|
||||||
|
|
||||||
|
持仓票在候选筛选第一步就被整行剔掉, 计划里读不到它, 所以基本面那一票的买方评析要由调用方
|
||||||
|
从逻辑状态映射里带出 (company_reviews: {code: company_review})。取不到就当无读数弃权, 绝不
|
||||||
|
折成看空。任何一行失败只记日志、把这一行的 consensus_blocks 抹掉 (按无合议), 页面与扫描不塌。
|
||||||
|
开关的判断由调用方负责 (关掉时根本不调本函数); 这里只管装配。"""
|
||||||
|
rows = rows or []
|
||||||
|
if not rows:
|
||||||
|
return rows
|
||||||
|
p = params or _params()
|
||||||
|
codes = [normalize_code(r.get("ts_code") or "") for r in rows if r.get("ts_code")]
|
||||||
|
if nightly is None:
|
||||||
|
nightly = nightly_map(codes)
|
||||||
|
if tech_states is None:
|
||||||
|
tech_states = state_map()
|
||||||
|
company_reviews = company_reviews or {}
|
||||||
|
flip_map = flip_map or {}
|
||||||
|
for r in rows:
|
||||||
|
code = normalize_code(r.get("ts_code") or "")
|
||||||
|
if not code:
|
||||||
|
r.pop("consensus_blocks", None)
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
row = {"ts_code": code, "company_review": company_reviews.get(code)}
|
||||||
|
r["consensus_blocks"] = assemble(row, nightly=nightly.get(code),
|
||||||
|
tech_states=tech_states,
|
||||||
|
flip_at=flip_map.get(code), params=p)
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
logger.warning("[合议] 持仓装配失败 %s (本行按无合议): %s", code, e)
|
||||||
|
r.pop("consensus_blocks", None)
|
||||||
|
return rows
|
||||||
|
|
|
||||||
|
|
@ -112,6 +112,10 @@ RUNTIME_EXTRA = {
|
||||||
"PMS_FUND_STALE_DAYS": (120, int, "买方评析报告超过这么多天算无读数"),
|
"PMS_FUND_STALE_DAYS": (120, int, "买方评析报告超过这么多天算无读数"),
|
||||||
"PMS_FUND_CONSENSUS_GOOD_MIN": (8, int, "十视角好达这么多个且估值贵 → 打共识已定价标记, 让首批推迟"),
|
"PMS_FUND_CONSENSUS_GOOD_MIN": (8, int, "十视角好达这么多个且估值贵 → 打共识已定价标记, 让首批推迟"),
|
||||||
"PMS_CONSENSUS_ROUTE": (True, bool, "三源合议参与新建仓分流 (拍板: 上线即开; 关掉即回到只按选股判决分流)"),
|
"PMS_CONSENSUS_ROUTE": (True, bool, "三源合议参与新建仓分流 (拍板: 上线即开; 关掉即回到只按选股判决分流)"),
|
||||||
|
# ── 2026-09-11 三源合议工作包二·接入 (台账 006/009): 持仓增持门与候选按质地排序。
|
||||||
|
"PMS_TECH_GATE_INCREASE": (True, bool, "持仓增持门: 合议看空停回踩补足/盈利加仓/补仓, 技术面看空停回踩补足与盈利加仓, 基本面看空停补仓 (减持不受影响; 关掉即回到不设门)"),
|
||||||
|
"PMS_PLAN_RANK_BY_QUALITY": (True, bool, "候选先按基本面立场分桶 (质地看多的桶排前) 再按分数, 在主题限额与截断之前生效; 技术面不参与排序 (关掉即回到只按分数排)"),
|
||||||
|
"PMS_PLAN_OBSERVE_IF_FUND_BULL": (False, bool, "把观察档里质地看多的票也纳入候选池 (默认关, 守宁缺毋滥; 开了它们与主榜一同参与分桶排序)"),
|
||||||
}
|
}
|
||||||
|
|
||||||
# **读不到时必须按"已暂停"处理的键 (fail-closed)。**
|
# **读不到时必须按"已暂停"处理的键 (fail-closed)。**
|
||||||
|
|
|
||||||
|
|
@ -419,10 +419,24 @@ def assert_fresh(plan: dict, *, max_stale_tdays: int = 1, today=None) -> int:
|
||||||
|
|
||||||
|
|
||||||
# ================================================================ 纯逻辑: 筛选
|
# ================================================================ 纯逻辑: 筛选
|
||||||
|
def _fund_stance_of(row) -> str:
|
||||||
|
"""行上的公司深度评析 → 基本面立场 (只取立场词, 候选排序分桶用)。缺评析或算不出返回无读数。
|
||||||
|
|
||||||
|
只用于排序: 立场词取默认阈值下的合成结果 (排序只区分「是不是看多」, 阈值的细微差别不影响分桶),
|
||||||
|
真正定档与设门另有一份带页面参数的合成 (advice_v2 / consensus_service)。技术面不参与排序。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from app.core import fund_rules
|
||||||
|
return fund_rules.synthesize(row.get("company_review")).get("stance") or "无读数"
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
return "无读数"
|
||||||
|
|
||||||
|
|
||||||
def select_candidates(plan: dict, *, held=(), black=(), top_n: int = 30, tiers=None,
|
def select_candidates(plan: dict, *, held=(), black=(), top_n: int = 30, tiers=None,
|
||||||
include_observe: bool = False, min_score=None,
|
include_observe: bool = False, min_score=None,
|
||||||
min_sources: int = 0, min_upside=None, theme_cap: int = 0,
|
min_sources: int = 0, min_upside=None, theme_cap: int = 0,
|
||||||
exclude_st: bool = True, route_by_verdict: bool = False) -> dict:
|
exclude_st: bool = True, route_by_verdict: bool = False,
|
||||||
|
rank_by_quality: bool = False, observe_if_fund_bull: bool = False) -> dict:
|
||||||
"""排序池 → 候选清单。
|
"""排序池 → 候选清单。
|
||||||
|
|
||||||
route_by_verdict: 按上游判决分流时, 判为「仅展示」的行在 top_n 截断**之前**剔掉并列进
|
route_by_verdict: 按上游判决分流时, 判为「仅展示」的行在 top_n 截断**之前**剔掉并列进
|
||||||
|
|
@ -457,12 +471,25 @@ def select_candidates(plan: dict, *, held=(), black=(), top_n: int = 30, tiers=N
|
||||||
pool = list(plan.get("main") or [])
|
pool = list(plan.get("main") or [])
|
||||||
if include_observe:
|
if include_observe:
|
||||||
pool += list(plan.get("observe") or [])
|
pool += list(plan.get("observe") or [])
|
||||||
|
elif observe_if_fund_bull:
|
||||||
|
# 观察档里质地看多的行也进池 (2026-09-11 工作包二, 默认关): 让基本面好但暂未进主榜的公司
|
||||||
|
# 有机会被看见。默认关是守「宁缺毋滥」。include_observe 已开时观察档全在, 不必再叠。
|
||||||
|
pool += [r for r in (plan.get("observe") or []) if _fund_stance_of(r) == "看多"]
|
||||||
|
|
||||||
theme_cap = int(theme_cap or 0)
|
theme_cap = int(theme_cap or 0)
|
||||||
dropped = {"held": 0, "black": 0, "tier": 0, "score": 0, "sources": 0, "upside": 0,
|
dropped = {"held": 0, "black": 0, "tier": 0, "score": 0, "sources": 0, "upside": 0,
|
||||||
"st": 0, "theme": 0, "dup": 0, "capped": 0, "display": 0}
|
"st": 0, "theme": 0, "dup": 0, "capped": 0, "display": 0}
|
||||||
passed, seen, per_theme, st_unknown, display_only = [], set(), {}, [], []
|
passed, seen, per_theme, st_unknown, display_only = [], set(), {}, [], []
|
||||||
for r in sorted(pool, key=lambda x: (-(x.get("score") or 0.0), x.get("rank") or 10 ** 9)):
|
# 排序: 默认按分数降序、同分按 rank 升序 (与 2026-09-11 之前逐字相同)。开了按质地排序后, 先按
|
||||||
|
# 基本面立场分桶 (质地看多的桶排在前) 再按分数, 在主题限额与截断之前生效; 技术面不参与排序。
|
||||||
|
if rank_by_quality:
|
||||||
|
def _sort_key(x):
|
||||||
|
return (0 if _fund_stance_of(x) == "看多" else 1,
|
||||||
|
-(x.get("score") or 0.0), x.get("rank") or 10 ** 9)
|
||||||
|
else:
|
||||||
|
def _sort_key(x):
|
||||||
|
return (-(x.get("score") or 0.0), x.get("rank") or 10 ** 9)
|
||||||
|
for r in sorted(pool, key=_sort_key):
|
||||||
c = r["ts_code"]
|
c = r["ts_code"]
|
||||||
if c in seen:
|
if c in seen:
|
||||||
dropped["dup"] += 1
|
dropped["dup"] += 1
|
||||||
|
|
@ -598,6 +625,9 @@ def _params() -> dict:
|
||||||
"diff_rank_jump": ps.get_int("PMS_PLAN_DIFF_RANK_JUMP", 50),
|
"diff_rank_jump": ps.get_int("PMS_PLAN_DIFF_RANK_JUMP", 50),
|
||||||
"diff_tail_guard": ps.get_float("PMS_PLAN_DIFF_TAIL_GUARD", 0.5),
|
"diff_tail_guard": ps.get_float("PMS_PLAN_DIFF_TAIL_GUARD", 0.5),
|
||||||
"route_by_verdict": ps.get_bool("PMS_PLAN_ROUTE_BY_VERDICT", True),
|
"route_by_verdict": ps.get_bool("PMS_PLAN_ROUTE_BY_VERDICT", True),
|
||||||
|
# 三源合议工作包二 (2026-09-11): 候选先按基本面立场分桶再按分数; 观察档里质地看多的行是否进池。
|
||||||
|
"rank_by_quality": ps.get_bool("PMS_PLAN_RANK_BY_QUALITY", True),
|
||||||
|
"observe_if_fund_bull": ps.get_bool("PMS_PLAN_OBSERVE_IF_FUND_BULL", False),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1053,7 +1083,9 @@ def candidates(*, held=(), black=(), route_by_verdict: bool = False) -> dict:
|
||||||
min_sources=p["min_sources"],
|
min_sources=p["min_sources"],
|
||||||
min_upside=(p["min_upside"] or None),
|
min_upside=(p["min_upside"] or None),
|
||||||
theme_cap=p["theme_cap_local"], exclude_st=p["exclude_st"],
|
theme_cap=p["theme_cap_local"], exclude_st=p["exclude_st"],
|
||||||
route_by_verdict=route)
|
route_by_verdict=route,
|
||||||
|
rank_by_quality=p["rank_by_quality"],
|
||||||
|
observe_if_fund_bull=p["observe_if_fund_bull"])
|
||||||
out["age_tdays"] = plan.get("age_tdays")
|
out["age_tdays"] = plan.get("age_tdays")
|
||||||
out["theme_cap"] = plan.get("theme_cap")
|
out["theme_cap"] = plan.get("theme_cap")
|
||||||
out["route_by_verdict"] = route
|
out["route_by_verdict"] = route
|
||||||
|
|
|
||||||
|
|
@ -39,8 +39,8 @@ from app.core import rule_gate
|
||||||
from app.core import signal_rules as sr
|
from app.core import signal_rules as sr
|
||||||
from app.core import tradedays as td
|
from app.core import tradedays as td
|
||||||
from app.repo import pms_repo
|
from app.repo import pms_repo
|
||||||
from app.services import (candidate_pub, command_service, executor, industry, judge, market,
|
from app.services import (candidate_pub, command_service, consensus_service, executor, industry,
|
||||||
param_store, reask_service,
|
judge, market, param_store, reask_service,
|
||||||
plan_feed, portfolio)
|
plan_feed, portfolio)
|
||||||
|
|
||||||
logger = logging.getLogger("pms.proposal")
|
logger = logging.getLogger("pms.proposal")
|
||||||
|
|
@ -125,6 +125,19 @@ def scan_and_route(*, now=None, dry_run: bool = False) -> dict:
|
||||||
logic_state_service.attach(view["held"])
|
logic_state_service.attach(view["held"])
|
||||||
except Exception as e: # noqa: BLE001
|
except Exception as e: # noqa: BLE001
|
||||||
logger.warning("[逻辑状态] 挂到持仓行失败, 本轮按没有读数: %s", e)
|
logger.warning("[逻辑状态] 挂到持仓行失败, 本轮按没有读数: %s", e)
|
||||||
|
# 三源合议增持门 (2026-09-11 工作包二): 给持仓行装配合议四块供动作引擎的增持门读。持仓票
|
||||||
|
# 的买方评析从逻辑状态映射里带出 (计划里读不到持仓票)。开关 PMS_TECH_GATE_INCREASE 关掉
|
||||||
|
# 时整段不做, 持仓行不挂 consensus_blocks, 增持门不设 —— 行为与接入前逐字相同。
|
||||||
|
# 装配失败只记日志、不拦扫描 (合议是加不改)。
|
||||||
|
if params.get("tech_gate_increase"):
|
||||||
|
try:
|
||||||
|
from app.services import logic_state_service as _lss
|
||||||
|
_lmap = _lss.state_map()
|
||||||
|
_crs = {code: (st or {}).get("company_review")
|
||||||
|
for code, st in (_lmap or {}).items()}
|
||||||
|
consensus_service.decorate_positions(view["held"], company_reviews=_crs)
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
logger.warning("[合议] 持仓增持门装配失败, 本轮按无合议 (不设门): %s", e)
|
||||||
scanned = ae.scan(positions=view["held"], params=params, market=mkt, skip=skip,
|
scanned = ae.scan(positions=view["held"], params=params, market=mkt, skip=skip,
|
||||||
strategy_codes=strategy_codes, stock_params=stock_params)
|
strategy_codes=strategy_codes, stock_params=stock_params)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -286,6 +299,12 @@ def _scan_open(view, params, stock_params, skip, mkt, out) -> list:
|
||||||
f"原因:{t.get('cash_why') or '原因未知'}"})
|
f"原因:{t.get('cash_why') or '原因未知'}"})
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
# 三源合议装配 (2026-09-11 工作包二): 开着时给每只候选凑齐基本面 / 技术面 / 择时三票, 合成方向
|
||||||
|
# 与路由, 挂到候选上供 scan_open 分流与定档。一次批量取昨夜定性与技术面映射, 逐只装配。
|
||||||
|
# 开关关掉整段不做, cands 原样进 scan_open —— 与接入前逐字相同。装配失败只记日志、不拦扫描
|
||||||
|
# (合议是加不改: 装不上就当没有合议, scan_open 里 con 为 None 自然退回旧路)。
|
||||||
|
if params.get("consensus_route"):
|
||||||
|
_attach_consensus(cands)
|
||||||
res = ae.scan_open(candidates=cands, params=params, caps=portfolio.caps_ctx(view),
|
res = ae.scan_open(candidates=cands, params=params, caps=portfolio.caps_ctx(view),
|
||||||
room_amt=room, slots=slots, skip=skip)
|
room_amt=room, slots=slots, skip=skip)
|
||||||
out["skipped"].extend(res["skipped"])
|
out["skipped"].extend(res["skipped"])
|
||||||
|
|
@ -304,6 +323,37 @@ def _scan_open(view, params, stock_params, skip, mkt, out) -> list:
|
||||||
return res["candidates"]
|
return res["candidates"]
|
||||||
|
|
||||||
|
|
||||||
|
def _attach_consensus(cands) -> None:
|
||||||
|
"""给每只候选装配三源合议, 挂三样到候选上 (原地改):
|
||||||
|
consensus —— 合议块 (direction / votes / strength / reason / route / route_reason), scan_open 分流读它;
|
||||||
|
consensus_blocks —— 四块意见 (fund / tech / timing / consensus), scan_open 定档时取 fund/tech 喂 advise_v2;
|
||||||
|
consensus_hard —— 提议硬数字六键 (方案附录丁), 进评审账本与提议卡, 不进送研判名单。
|
||||||
|
批量取一次昨夜定性与技术面映射; 盘中转多留痕从候选自带的 sig_buy 取时刻。任何一步失败都不抛 ——
|
||||||
|
装不上就当这只票没有合议 (scan_open 里 con 为 None 自然退回旧路), 合议是加不改。"""
|
||||||
|
if not cands:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
codes = [c.get("ts_code") for c in cands if c.get("ts_code")]
|
||||||
|
nightly = consensus_service.nightly_map(codes)
|
||||||
|
tech_states = consensus_service.state_map()
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
logger.warning("[合议] 批量取数失败, 本轮候选不装配合议: %s", e)
|
||||||
|
return
|
||||||
|
for c in cands:
|
||||||
|
code = cs.normalize_code(c.get("ts_code") or "")
|
||||||
|
if not code:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
flip_at = (c.get("sig_buy") or {}).get("at") or None
|
||||||
|
blocks = consensus_service.assemble(
|
||||||
|
c, nightly=nightly.get(code), tech_states=tech_states, flip_at=flip_at)
|
||||||
|
c["consensus"] = blocks["consensus"]
|
||||||
|
c["consensus_blocks"] = blocks
|
||||||
|
c["consensus_hard"] = consensus_service.hard_keys(blocks)
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
logger.warning("[合议] 装配失败 %s (本票按无合议): %s", code, e)
|
||||||
|
|
||||||
|
|
||||||
# 候选一只都不剩时, 这一栏要说清是被哪几道筛掉的。键名是代码里的, 翻成人话再显示。
|
# 候选一只都不剩时, 这一栏要说清是被哪几道筛掉的。键名是代码里的, 翻成人话再显示。
|
||||||
# 顺序就是筛选实际发生的顺序, 照着念下来正好是一条票走过的路。
|
# 顺序就是筛选实际发生的顺序, 照着念下来正好是一条票走过的路。
|
||||||
_DROP_CN = [
|
_DROP_CN = [
|
||||||
|
|
@ -485,8 +535,9 @@ def disposition_snapshot(now=None) -> dict:
|
||||||
if s.get("why"):
|
if s.get("why"):
|
||||||
res["notes"].append(s["why"])
|
res["notes"].append(s["why"])
|
||||||
else: # 单票原因; 不覆盖已判 would 的
|
else: # 单票原因; 不覆盖已判 would 的
|
||||||
|
# 合议判观察的候选带 disp=wait_tech (等技术面开口), 其余不产出的按 deny。
|
||||||
res["by_code"].setdefault(
|
res["by_code"].setdefault(
|
||||||
code, {"disp": "deny", "why": s.get("why") or "未产出候选"})
|
code, {"disp": s.get("disp") or "deny", "why": s.get("why") or "未产出候选"})
|
||||||
return res
|
return res
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -913,6 +964,10 @@ def _scan_params(view: dict) -> dict:
|
||||||
"plan_by_advice": param_store.get_bool("PMS_PLAN_BY_ADVICE", True),
|
"plan_by_advice": param_store.get_bool("PMS_PLAN_BY_ADVICE", True),
|
||||||
"logic_doubt_trim": param_store.get_bool("PMS_LOGIC_DOUBT_TRIM", False),
|
"logic_doubt_trim": param_store.get_bool("PMS_LOGIC_DOUBT_TRIM", False),
|
||||||
"logic_doubt_trim_ratio": param_store.get_float("PMS_LOGIC_DOUBT_TRIM_RATIO", 1.0 / 3),
|
"logic_doubt_trim_ratio": param_store.get_float("PMS_LOGIC_DOUBT_TRIM_RATIO", 1.0 / 3),
|
||||||
|
# 三源合议 (2026-09-11 工作包二): 合议参与新建仓分流 (scan_open 读它), 持仓增持门 (scan 读它)。
|
||||||
|
# 两个都关掉时, 合议既不装配也不分流、增持门不设 —— 行为与接入前逐字相同。
|
||||||
|
"consensus_route": param_store.get_bool("PMS_CONSENSUS_ROUTE", True),
|
||||||
|
"tech_gate_increase": param_store.get_bool("PMS_TECH_GATE_INCREASE", True),
|
||||||
})
|
})
|
||||||
return p
|
return p
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1146,6 +1146,11 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
|
||||||
<span class="ptag" :class="propTechTag(p).cls">{{ propTechTag(p).text }}</span>
|
<span class="ptag" :class="propTechTag(p).cls">{{ propTechTag(p).text }}</span>
|
||||||
<span class="sub">{{ propTechSub(p) }}</span><i class="car">›</i>
|
<span class="sub">{{ propTechSub(p) }}</span><i class="car">›</i>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="prop-src-row" v-if="propConsensusTag(p)" @click="openPropDetail(p,'consensus')">
|
||||||
|
<span class="lbl">合议</span>
|
||||||
|
<span class="ptag" :class="propConsensusTag(p).cls">{{ propConsensusTag(p).text }}</span>
|
||||||
|
<span class="sub">{{ propConsensusSub(p) }}</span><i class="car">›</i>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="margin-top:6px">
|
<div style="margin-top:6px">
|
||||||
<el-button type="success" size="small" @click="decide(p,'ACCEPTED')">采纳</el-button>
|
<el-button type="success" size="small" @click="decide(p,'ACCEPTED')">采纳</el-button>
|
||||||
|
|
@ -2008,6 +2013,23 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
|
||||||
<div class="st muted" v-if="propTechFull.reason">{{ propTechFull.reason }}</div>
|
<div class="st muted" v-if="propTechFull.reason">{{ propTechFull.reason }}</div>
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="propDetailKind==='consensus'">
|
||||||
|
<div class="st muted" v-if="!propCon(propDetailP)">这只票没有三源合议(合议开关没开,或三方都没有读数)。</div>
|
||||||
|
<template v-else>
|
||||||
|
<div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;flex-wrap:wrap">
|
||||||
|
<span class="ptag" :class="propConsensusTag(propDetailP).cls">{{ propConsensusTag(propDetailP).text }}</span>
|
||||||
|
<span class="ptag neutral" v-if="propCon(propDetailP).route">路由:{{ propCon(propDetailP).route }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="prop-metric">
|
||||||
|
<div class="m"><span class="nm">基本面</span><span class="ptag neutral">{{ (propCon(propDetailP).votes||{}).fund || '无读数' }}</span></div>
|
||||||
|
<div class="m"><span class="nm">技术面</span><span class="ptag neutral">{{ (propCon(propDetailP).votes||{}).tech || '无读数' }}</span></div>
|
||||||
|
<div class="m"><span class="nm">择时系统</span><span class="ptag neutral">{{ (propCon(propDetailP).votes||{}).timing || '无读数' }}</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="cs-sect" v-if="propCon(propDetailP).route_reason"><div class="h">为什么这么走</div><div class="st">{{ propCon(propDetailP).route_reason }}</div></div>
|
||||||
|
<div class="st muted" v-if="propCon(propDetailP).reason" style="margin-top:6px">{{ propCon(propDetailP).reason }}</div>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -2853,6 +2875,9 @@ createApp({
|
||||||
const d = (openScan.value.by_code || {})[code];
|
const d = (openScan.value.by_code || {})[code];
|
||||||
if (d) {
|
if (d) {
|
||||||
if (d.disp === 'would') return { cls: 'would', label: '将建仓', why: d.why };
|
if (d.disp === 'would') return { cls: 'would', label: '将建仓', why: d.why };
|
||||||
|
// 三源合议判观察 (2026-09-11): 等技术面开口, 是「在盯」不是「被否决」——
|
||||||
|
// 归到 watch 那一档而不是今天没建仓那一档, 因为它还会回来。
|
||||||
|
if (d.disp === 'wait_tech') return { cls: 'wait', label: '等技术面开口', why: d.why };
|
||||||
return { cls: 'deny', label: '被否决', why: d.why };
|
return { cls: 'deny', label: '被否决', why: d.why };
|
||||||
}
|
}
|
||||||
return { cls: 'wait', label: '在盯', why: '' };
|
return { cls: 'wait', label: '在盯', why: '' };
|
||||||
|
|
@ -3014,10 +3039,28 @@ createApp({
|
||||||
if (t.sar_side) bits.push('SAR ' + t.sar_side);
|
if (t.sar_side) bits.push('SAR ' + t.sar_side);
|
||||||
return bits.join(' · ');
|
return bits.join(' · ');
|
||||||
};
|
};
|
||||||
|
// 合议 (2026-09-11 工作包二): 三方多数方向与强弱, 读下单当时记进硬数字的那份 (解释这条提议
|
||||||
|
// 为什么按合议路由走)。方向按页面口径红涨绿跌; 无读数用中性灰。点开看三票与路由理由。
|
||||||
|
const propCon = (p) => (p && p.hard_numbers && p.hard_numbers.consensus) || null;
|
||||||
|
const propConsensusTag = (p) => {
|
||||||
|
const c = propCon(p);
|
||||||
|
if (!c || !c.direction) return null;
|
||||||
|
const d = c.direction, s = c.strength ? ('·' + c.strength) : '';
|
||||||
|
return { text: d + s, cls: d === '看多' ? 'up' : (d === '看空' ? 'down' : 'neutral') };
|
||||||
|
};
|
||||||
|
const propConsensusSub = (p) => {
|
||||||
|
const v = (propCon(p) || {}).votes || {};
|
||||||
|
const parts = [];
|
||||||
|
if (v.fund) parts.push('基' + v.fund);
|
||||||
|
if (v.tech) parts.push('技' + v.tech);
|
||||||
|
if (v.timing) parts.push('择' + v.timing);
|
||||||
|
return parts.join(' ');
|
||||||
|
};
|
||||||
// 弹窗: 点某来源看它的完整依据。teleport 到 body, 层级同单票信号抽屉 (3000/2995) 盖住右栏。
|
// 弹窗: 点某来源看它的完整依据。teleport 到 body, 层级同单票信号抽屉 (3000/2995) 盖住右栏。
|
||||||
const propDetailOpen = ref(false), propDetailKind = ref(''), propDetailP = ref(null);
|
const propDetailOpen = ref(false), propDetailKind = ref(''), propDetailP = ref(null);
|
||||||
const propTechFull = ref(null), propTechErr = ref('');
|
const propTechFull = ref(null), propTechErr = ref('');
|
||||||
const PROP_SRC_LABEL = { plan: '选股系统', judge: '决策系统', company: '公司深度', tech: '技术面' };
|
const PROP_SRC_LABEL = { plan: '选股系统', judge: '决策系统', company: '公司深度',
|
||||||
|
tech: '技术面', consensus: '三源合议' };
|
||||||
function openPropDetail(p, kind) {
|
function openPropDetail(p, kind) {
|
||||||
propDetailP.value = p; propDetailKind.value = kind; propDetailOpen.value = true;
|
propDetailP.value = p; propDetailKind.value = kind; propDetailOpen.value = true;
|
||||||
propTechFull.value = null; propTechErr.value = '';
|
propTechFull.value = null; propTechErr.value = '';
|
||||||
|
|
@ -4228,6 +4271,7 @@ createApp({
|
||||||
propMoreOpen, togglePropMore,
|
propMoreOpen, togglePropMore,
|
||||||
// 2026-09-12 提议卡重构: 来源结论标签 + 详情弹窗
|
// 2026-09-12 提议卡重构: 来源结论标签 + 详情弹窗
|
||||||
cr, propPlanTag, propPlanSub, propJudgeTag, propCompanyTag, propTechTag, propTechSub,
|
cr, propPlanTag, propPlanSub, propJudgeTag, propCompanyTag, propTechTag, propTechSub,
|
||||||
|
propCon, propConsensusTag, propConsensusSub,
|
||||||
propDetailOpen, propDetailKind, propDetailP, propTechFull, propTechErr, PROP_SRC_LABEL,
|
propDetailOpen, propDetailKind, propDetailP, propTechFull, propTechErr, PROP_SRC_LABEL,
|
||||||
openPropDetail, closePropDetail,
|
openPropDetail, closePropDetail,
|
||||||
pHaltBuy, pResumeBuy, pHaltAll, pResumeAll, pReduce, pIncrease, pLiquidate, pSectorExit,
|
pHaltBuy, pResumeBuy, pHaltAll, pResumeAll, pReduce, pIncrease, pLiquidate, pSectorExit,
|
||||||
|
|
|
||||||
|
|
@ -111,3 +111,49 @@
|
||||||
**预期。** 技术面整体无读数占比应低于百分之三。每天被等技术面开口拦下的票在一到十五只之间,这一条要等工作包二接入分流后才看得到。
|
**预期。** 技术面整体无读数占比应低于百分之三。每天被等技术面开口拦下的票在一到十五只之间,这一条要等工作包二接入分流后才看得到。
|
||||||
|
|
||||||
**复核日期。** 工作包二的合议分流上线后满三个交易日。
|
**复核日期。** 工作包二的合议分流上线后满三个交易日。
|
||||||
|
|
||||||
|
## 007 · 2026-09-11 · 基本面立场规则
|
||||||
|
|
||||||
|
**改动。** 每只票多一份基本面立场,取值只有看多、看空、中性、无读数四个词。立场由选股系统的公司深度评析合成。质地好为看多。质地中但三类里两好零差、且十视角差不超过一个,也算看多,这就是整体方向为好就够。质地差或有硬疑点为看空。其余中性。缺评析或报告超一百二十天为无读数。
|
||||||
|
|
||||||
|
十视角好达八个且估值贵时,另打一个共识已定价的标记。这个标记不改立场,只让首批推迟。
|
||||||
|
|
||||||
|
**依据。** 按用户拍板,没有买方评析或评析超一百二十天的候选一律不建仓。无读数是弃权,绝不折成看空。已查实当天计划主榜前五十名全部有评析,所以这条门今天不损失任何候选。
|
||||||
|
|
||||||
|
**参数。** 无评析不建仓 PMS_FUND_REQUIRED 默认开。评析陈旧天数 PMS_FUND_STALE_DAYS 默认一百二十。共识已定价的好视角数 PMS_FUND_CONSENSUS_GOOD_MIN 默认八。
|
||||||
|
|
||||||
|
**预期。** 主榜候选里基本面看空的被合议拦下,无评析的被跳过。
|
||||||
|
|
||||||
|
**复核日期。** 与 008 同批,合议分流上线后满三个交易日。
|
||||||
|
|
||||||
|
## 008 · 2026-09-11 · 三源合议方向与路由
|
||||||
|
|
||||||
|
**改动。** 每只候选凑齐三票,基本面、技术面、择时系统各一票,三票多数决定方向。无读数弃权不计。平局中性。只有一方表态算弱,三方一致算强。
|
||||||
|
|
||||||
|
方向定了再定路由,路由有四种去向:放行、观察、交人、跳过。路由表照方案附录丙,从上到下第一个命中的就是结论。基本面无读数且开关开着就跳过,原因写没有买方评析。方向看空就跳过。中性且技术面收口等待且基本面非看空就观察,等技术面开口。中性且基本面看多技术面看空就观察,等技术面转向。基本面看空技术面看多就交人,只给试探仓。看多但择时系统有异议、或只有一方表态,也交人。其余看多放行。
|
||||||
|
|
||||||
|
定档按基本面与技术面的仓位矩阵,见方案附录乙。基本面定预算,技术面定首批比例。基本面看多技术面看多是标准仓五成二成半二成半。基本面看多估值贵减半仓。基本面中性技术面看多是标准仓三成三成半三成半。基本面看空技术面看多只给试探仓百分之一,交人,并配回撤百分之三、硬目标百分之八的紧止盈与 SAR 线。
|
||||||
|
|
||||||
|
合议的三票加方向加路由,折成提议的六个硬数字键,随提议进评审账本与提议卡给人看。这六个键一个都不送研判,因为基本面归选股系统判、技术面决策系统夜里自己算过,研判不重判这两样。
|
||||||
|
|
||||||
|
**依据。** 入场靠投票,离场靠纪律。方向由三方多数决定,止损止盈不投票。研判仍是合议之后的关口,不重复计入择时那一票。
|
||||||
|
|
||||||
|
**参数。** 合议参与分流 PMS_CONSENSUS_ROUTE 默认开,按用户拍板上线即开。关掉即回到只按选股系统的候选卡判决分流,行为与接入前逐字相同。
|
||||||
|
|
||||||
|
**预期。** 提议卡上出现合议一行,显示三方多数方向与强弱,点开看三票与路由理由。基本面看空技术面看多的票进待确认队列,配着紧止盈。
|
||||||
|
|
||||||
|
**复核日期。** 上线后满三个交易日,逐只记合议方向与人的判断对不上的情形。
|
||||||
|
|
||||||
|
## 009 · 2026-09-11 · 候选按质地排序与持仓增持门
|
||||||
|
|
||||||
|
**改动一,候选排序。** 候选先按基本面立场分桶,质地看多的桶排在前面,再按分数排。这一步在主题限额与截断之前生效,所以质地好的票不会因为分数略低就被挤到名额之外。技术面不参与排序,只当门。观察档里质地看多的行是否进池另有一个开关,默认关,守宁缺毋滥。
|
||||||
|
|
||||||
|
**改动二,持仓增持门。** 已持仓的票,合议看空就停三类增持侧的自主动作,也就是回踩补足、盈利加仓、补仓。技术面看空只停回踩补足与盈利加仓。基本面看空只停补仓。减持侧不受任何一道门影响。任何一路无读数一律弃权,不设门。
|
||||||
|
|
||||||
|
**依据。** 选股确实偏题材传导,已查实主榜前一百名里质地差占一半、质地好只有六只。质地不进排序就永远排在后面。增持门这三道的分工照方案第五节工作包二第六点。减持是纪律,不因合议看空就多卖或少卖,所以减持侧一律不受门影响。
|
||||||
|
|
||||||
|
**参数。** 候选按质地排序 PMS_PLAN_RANK_BY_QUALITY 默认开。观察档质地看多进池 PMS_PLAN_OBSERVE_IF_FUND_BULL 默认关。持仓增持门 PMS_TECH_GATE_INCREASE 默认开。三个都能在页面即时改,关掉即回到接入前的行为。
|
||||||
|
|
||||||
|
**预期。** 质地好的候选排到前面,更容易在名额里占到位置。合议或技术面看空的持仓不再自动补仓加仓,但保垫减仓照常。
|
||||||
|
|
||||||
|
**复核日期。** 上线后满三个交易日,看质地好的票进候选的比例有没有升,看增持门有没有误伤。
|
||||||
|
|
|
||||||
|
|
@ -76,15 +76,19 @@
|
||||||
test_batch28_units.py 三源合议·工作包二纯逻辑 (2026-09-11): 基本面质地档映射与共识已定价/择时定性
|
test_batch28_units.py 三源合议·工作包二纯逻辑 (2026-09-11): 基本面质地档映射与共识已定价/择时定性
|
||||||
映射与盘中转多升级/合议多数决与强弱/附录丙路由表逐条/装配服务凑齐四块与六键/
|
映射与盘中转多升级/合议多数决与强弱/附录丙路由表逐条/装配服务凑齐四块与六键/
|
||||||
advise_v2 基本面×技术面仓位矩阵逐格 (37 例)
|
advise_v2 基本面×技术面仓位矩阵逐格 (37 例)
|
||||||
|
test_batch29_units.py 三源合议·工作包二接入下单链路 (2026-09-11): scan_open 合议分流(跳过/观察带
|
||||||
|
wait_tech/交人强制确认/放行/缺合议退旧路/开关关掉逐字回旧)/定档改用 advise_v2
|
||||||
|
且六键进硬数字而不进送研判白名单/持仓增持门矩阵与 scan 端到端(减持不受影响、
|
||||||
|
开关关掉回旧)/装配服务持仓挂合议/候选按质地分桶排序与观察档进池开关 (24 例)
|
||||||
test_page_enum_guard.py 页面文案守卫 (静态扫描, 不连库不起浏览器): 枚举字段不许
|
test_page_enum_guard.py 页面文案守卫 (静态扫描, 不连库不起浏览器): 枚举字段不许
|
||||||
直接印到页面上 / 判据码显示前必须剥前缀 / 不许把整个对象
|
直接印到页面上 / 判据码显示前必须剥前缀 / 不许把整个对象
|
||||||
打给交易员看 / 翻译兜底不许让英文码单独当句子 (1 例)
|
打给交易员看 / 翻译兜底不许让英文码单独当句子 (1 例)
|
||||||
test_wiring.py 装配自检: 服务层→核心→落表 全链路 (内存桩) +
|
test_wiring.py 装配自检: 服务层→核心→落表 全链路 (内存桩) +
|
||||||
目标价到价必定入队 (档位 full 也不自动卖) +
|
目标价到价必定入队 (档位 full 也不自动卖) +
|
||||||
用户设的止损价与目标价单独成列显示 (70 例)
|
用户设的止损价与目标价单独成列显示 (70 例)
|
||||||
共 763 例
|
共 787 例
|
||||||
(总数按实跑逐批相加校正过两次: 曾写 649 是笔误, 实为 650; 09-03 先后加了同轮只发一条
|
(总数按实跑逐批相加校正过两次: 曾写 649 是笔误, 实为 650; 09-03 先后加了同轮只发一条
|
||||||
减持与研究理由两键各一例, 到 652; 09-04 加了仅展示跳过原因与空候选说明各一例, 到 654; 又加了页面文案守卫一例, 到 655; 09-07 审查修复加了跨轮减持等五例, 到 660; 第二件低把握驳回交人一例, 到 661; 第三件逻辑状态接入第二十二批十五例, 到 676; 第四件安全边际整句透传一例, 到 677; 参考目标价一例, 到 678; 催化事件与定价状态透传一例, 到 679; 两个期限的头一例, 现为 680; 09-10 建议档位对齐第二十六批十七例到 697; 09-11 技术面接入工作包一第二十七批二十七例到 724)
|
减持与研究理由两键各一例, 到 652; 09-04 加了仅展示跳过原因与空候选说明各一例, 到 654; 又加了页面文案守卫一例, 到 655; 09-07 审查修复加了跨轮减持等五例, 到 660; 第二件低把握驳回交人一例, 到 661; 第三件逻辑状态接入第二十二批十五例, 到 676; 第四件安全边际整句透传一例, 到 677; 参考目标价一例, 到 678; 催化事件与定价状态透传一例, 到 679; 两个期限的头一例, 现为 680; 09-10 建议档位对齐第二十六批十七例到 697; 09-11 技术面接入工作包一第二十七批二十七例到 724; 三源合议工作包二纯逻辑第二十八批三十七例到 763; 接入下单链路第二十九批二十四例, 现为 787)
|
||||||
任一子集失败即整体失败 (退出码 1)。
|
任一子集失败即整体失败 (退出码 1)。
|
||||||
|
|
||||||
哨兵位置清单 (2026-09-03 抄录; 改了对应的东西就得来这些地方改断言, 断言不动就是漏了):
|
哨兵位置清单 (2026-09-03 抄录; 改了对应的东西就得来这些地方改断言, 断言不动就是漏了):
|
||||||
|
|
@ -135,6 +139,7 @@ SUITES = ["test_core_units.py", "test_batch2_units.py", "test_batch3_units.py",
|
||||||
"test_batch26_units.py",
|
"test_batch26_units.py",
|
||||||
"test_batch27_units.py",
|
"test_batch27_units.py",
|
||||||
"test_batch28_units.py",
|
"test_batch28_units.py",
|
||||||
|
"test_batch29_units.py",
|
||||||
"test_page_enum_guard.py",
|
"test_page_enum_guard.py",
|
||||||
"test_page_wiring_guard.py",
|
"test_page_wiring_guard.py",
|
||||||
"test_wiring.py"]
|
"test_wiring.py"]
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,359 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""三源合议 · 工作包二接入下单链路 (2026-09-11 方案第五节, 台账 006/009)。全部离线, 不连库。
|
||||||
|
|
||||||
|
工作包二的三块「大脑」(纯逻辑 / 装配服务 / 仓位矩阵) 由第二十八批守; 本批守它们**接进下单链路**
|
||||||
|
那一段, 也就是交接文档第五节的活。每一组都带一条「开关关掉逐字回旧」——这是「加不改」的机器证明。
|
||||||
|
|
||||||
|
A scan_open 合议分流: 跳过/观察不占名额、交人强制确认、放行照常、缺合议退回旧路、开关关掉回旧。
|
||||||
|
B 定档改用 advise_v2 与六键进硬数字, 且六键一个都不进 judge.OPEN_JUDGE_KEYS。
|
||||||
|
C 持仓增持门 consensus_gate_why 矩阵与 scan 端到端 (减持不受影响、开关关掉回旧)。
|
||||||
|
D 装配服务 _attach_consensus 与持仓 decorate_positions。
|
||||||
|
E 候选按质地排序 (质地看多分桶在前、技术面不参与、观察档进池开关、开关关掉回旧)。
|
||||||
|
F 三个新参数登记。
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
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.services import judge as jd # noqa: E402
|
||||||
|
from app.services import plan_feed as pf # noqa: E402
|
||||||
|
from app.services import param_store as ps # noqa: E402
|
||||||
|
|
||||||
|
RESULTS = []
|
||||||
|
|
||||||
|
|
||||||
|
def case(name):
|
||||||
|
def deco(fn):
|
||||||
|
RESULTS.append((name, fn))
|
||||||
|
return fn
|
||||||
|
return deco
|
||||||
|
|
||||||
|
|
||||||
|
# ================================================================ 夹具
|
||||||
|
def 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}
|
||||||
|
p.update(kw)
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def caps(**kw):
|
||||||
|
c = {"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"}
|
||||||
|
c.update(kw)
|
||||||
|
return c
|
||||||
|
|
||||||
|
|
||||||
|
def cand(code, price=10.0, score=100.0, rank=1, **kw):
|
||||||
|
d = {"ts_code": code, "price": price, "score": score, "sector": kw.pop("sector", None),
|
||||||
|
"theme": kw.pop("theme", None), "tier": kw.pop("tier", "强传导"),
|
||||||
|
"upside": kw.pop("upside", 0.3), "heat": kw.pop("heat", 0.5),
|
||||||
|
"rank": rank, "bucket": "main", "src": "plan_api"}
|
||||||
|
d.update(kw)
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
def with_con(c, route, *, fund="看多", tech="看多", timing="看多", reason=None):
|
||||||
|
"""给候选手挂一份合议 (直接控制路由, 不经装配服务) —— 单测 scan_open 分流本身。"""
|
||||||
|
c = dict(c)
|
||||||
|
con = {"route": route, "route_reason": reason or ("合议判" + route),
|
||||||
|
"direction": "看多", "votes": {"fund": fund, "tech": tech, "timing": timing},
|
||||||
|
"strength": "强", "reason": "三票 → 看多"}
|
||||||
|
c["consensus"] = con
|
||||||
|
c["consensus_blocks"] = {"fund": {"stance": fund}, "tech": {"stance": tech},
|
||||||
|
"timing": {"stance": timing}, "consensus": con}
|
||||||
|
c["consensus_hard"] = {"fund_stance": {"stance": fund}, "tech": {"stance": tech},
|
||||||
|
"tech_text": "技术面理由", "timing": {"stance": timing},
|
||||||
|
"consensus": con, "consensus_text": "合议理由"}
|
||||||
|
return c
|
||||||
|
|
||||||
|
|
||||||
|
PARAMS = {"scale": 2_000_000, "stock_target_default": 0.06,
|
||||||
|
"batch_split": (0.5, 0.25, 0.25), "cushion_solid": 0.03,
|
||||||
|
"trim_peak": 0.06, "trim_giveback": 0.5,
|
||||||
|
"dca_triggers": (-0.08, -0.15), "dca_deep_confirm": -0.15, "dca_max_ratio": 0.5,
|
||||||
|
"no_chase_ma5": 0.06, "build_window_tdays": 10, "fill_max_loss": -0.03}
|
||||||
|
|
||||||
|
|
||||||
|
def pos(**kw):
|
||||||
|
p = {"ts_code": "600000.SH", "price": 10.0, "avg_cost": 10.0, "total_qty": 6000,
|
||||||
|
"base_qty": 6000, "add_qty": 0, "dca_qty": 0, "market_value": 60_000,
|
||||||
|
"cushion_pct": 0.0, "cushion_peak": 0.0, "target_pct": 0.06,
|
||||||
|
"support_ref": None, "pressure_ref": None, "stop_ref": None,
|
||||||
|
"fill_count": 0, "dca_count": 0, "frozen_reason": "NONE"}
|
||||||
|
p.update(kw)
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def mkt(**kw):
|
||||||
|
m = {"ma5": 10.0, "high5": 10.0, "tdays_since_open": 3, "tdays_since_last_add": 5}
|
||||||
|
m.update(kw)
|
||||||
|
return m
|
||||||
|
|
||||||
|
|
||||||
|
def _blocks(direction="中性", tech="中性", fund="中性"):
|
||||||
|
return {"consensus": {"direction": direction}, "tech": {"stance": tech}, "fund": {"stance": fund}}
|
||||||
|
|
||||||
|
|
||||||
|
# ================================================================ A scan_open 合议分流
|
||||||
|
@case("A 分流·开关关掉逐字回旧: 挂了跳过的合议也照出候选, 与不挂时一字不差")
|
||||||
|
def _():
|
||||||
|
base = cand("600000.SH")
|
||||||
|
r_plain = ae.scan_open(candidates=[dict(base)], params=params(consensus_route=False),
|
||||||
|
caps=caps(), room_amt=1_400_000, slots=20)
|
||||||
|
r_con = ae.scan_open(candidates=[with_con(base, "跳过")], params=params(consensus_route=False),
|
||||||
|
caps=caps(), room_amt=1_400_000, slots=20)
|
||||||
|
assert r_plain == r_con, "关掉合议时, 挂不挂合议、路由是不是跳过, 结果必须一字不差"
|
||||||
|
assert len(r_plain["candidates"]) == 1
|
||||||
|
|
||||||
|
|
||||||
|
@case("A 分流·跳过: 不产候选、不占名额 (后面的票补上)")
|
||||||
|
def _():
|
||||||
|
cs = [with_con(cand("600000.SH", score=200), "跳过", reason="没有买方评析"),
|
||||||
|
with_con(cand("000001.SZ", score=100), "放行")]
|
||||||
|
r = ae.scan_open(candidates=cs, params=params(consensus_route=True),
|
||||||
|
caps=caps(), room_amt=1_400_000, slots=1)
|
||||||
|
assert [c["ts_code"] for c in r["candidates"]] == ["000001.SZ"], "跳过的票不该占掉唯一名额"
|
||||||
|
assert any(s["ts_code"] == "600000.SH" and "买方评析" in s["why"] for s in r["skipped"])
|
||||||
|
|
||||||
|
|
||||||
|
@case("A 分流·观察: 不产候选、带处置词 wait_tech")
|
||||||
|
def _():
|
||||||
|
r = ae.scan_open(candidates=[with_con(cand("600000.SH"), "观察", reason="等技术面开口")],
|
||||||
|
params=params(consensus_route=True), caps=caps(), room_amt=1_400_000, slots=20)
|
||||||
|
assert r["candidates"] == []
|
||||||
|
s = next(s for s in r["skipped"] if s["ts_code"] == "600000.SH")
|
||||||
|
assert s.get("disp") == ae.DISP_WAIT_TECH and "技术面" in s["why"]
|
||||||
|
|
||||||
|
|
||||||
|
@case("A 分流·交人: 照常产候选、占名额, 但强制人工确认并带原因")
|
||||||
|
def _():
|
||||||
|
r = ae.scan_open(candidates=[with_con(cand("600000.SH"), "交人", reason="基本面看空、技术面看多,试探仓")],
|
||||||
|
params=params(consensus_route=True), caps=caps(), room_amt=1_400_000, slots=20)
|
||||||
|
assert len(r["candidates"]) == 1
|
||||||
|
c = r["candidates"][0]
|
||||||
|
assert c.get("needs_user_confirm") is True and "试探" in (c.get("confirm_why") or "")
|
||||||
|
|
||||||
|
|
||||||
|
@case("A 分流·放行: 正常产候选, 不强制确认")
|
||||||
|
def _():
|
||||||
|
r = ae.scan_open(candidates=[with_con(cand("600000.SH"), "放行")],
|
||||||
|
params=params(consensus_route=True), caps=caps(), room_amt=1_400_000, slots=20)
|
||||||
|
assert len(r["candidates"]) == 1 and not r["candidates"][0].get("needs_user_confirm")
|
||||||
|
|
||||||
|
|
||||||
|
@case("A 分流·缺合议但开关开: 退回旧路照常产出 (装不上不拦)")
|
||||||
|
def _():
|
||||||
|
r = ae.scan_open(candidates=[cand("600000.SH")], params=params(consensus_route=True),
|
||||||
|
caps=caps(), room_amt=1_400_000, slots=20)
|
||||||
|
assert len(r["candidates"]) == 1
|
||||||
|
|
||||||
|
|
||||||
|
# ================================================================ B advise_v2 定档与六键
|
||||||
|
@case("B 定档·合议开着用 advise_v2 (矩阵, version=2); 关着用旧 advise (version=1)")
|
||||||
|
def _():
|
||||||
|
on = ae.scan_open(candidates=[with_con(cand("600000.SH"), "放行", fund="看多", tech="看多")],
|
||||||
|
params=params(consensus_route=True), caps=caps(), room_amt=1_400_000, slots=20)
|
||||||
|
adv_on = on["candidates"][0]["hard_numbers"]["advice"]
|
||||||
|
assert adv_on.get("version") == "2", "合议开着定档应改用 advise_v2"
|
||||||
|
off = ae.scan_open(candidates=[cand("600000.SH")], params=params(consensus_route=False),
|
||||||
|
caps=caps(), room_amt=1_400_000, slots=20)
|
||||||
|
assert off["candidates"][0]["hard_numbers"]["advice"].get("version") == "1"
|
||||||
|
|
||||||
|
|
||||||
|
@case("B 六键·合议开着六键进硬数字")
|
||||||
|
def _():
|
||||||
|
r = ae.scan_open(candidates=[with_con(cand("600000.SH"), "放行")],
|
||||||
|
params=params(consensus_route=True), caps=caps(), room_amt=1_400_000, slots=20)
|
||||||
|
hn = r["candidates"][0]["hard_numbers"]
|
||||||
|
for k in ("fund_stance", "tech", "tech_text", "timing", "consensus", "consensus_text"):
|
||||||
|
assert k in hn, f"六键 {k} 应进硬数字"
|
||||||
|
|
||||||
|
|
||||||
|
@case("B 六键·一个都不进 judge.OPEN_JUDGE_KEYS (基本面技术面不重判)")
|
||||||
|
def _():
|
||||||
|
six = {"fund_stance", "tech", "tech_text", "timing", "consensus", "consensus_text"}
|
||||||
|
assert six.isdisjoint(set(jd.OPEN_JUDGE_KEYS)), "六键混进送研判白名单会让决策系统重判基本面/技术面"
|
||||||
|
|
||||||
|
|
||||||
|
@case("B 六键·送研判裁剪后确实不含这六键 (机器证明)")
|
||||||
|
def _():
|
||||||
|
r = ae.scan_open(candidates=[with_con(cand("600000.SH"), "放行")],
|
||||||
|
params=params(consensus_route=True), caps=caps(), room_amt=1_400_000, slots=20)
|
||||||
|
sent = jd._judge_hard_numbers("OPEN", r["candidates"][0]["hard_numbers"])
|
||||||
|
for k in ("fund_stance", "tech", "tech_text", "timing", "consensus", "consensus_text"):
|
||||||
|
assert k not in sent
|
||||||
|
|
||||||
|
|
||||||
|
# ================================================================ C 持仓增持门 (纯逻辑矩阵)
|
||||||
|
@case("C 增持门·合议看空停三类 (回踩补足/盈利加仓/补仓)")
|
||||||
|
def _():
|
||||||
|
for act in (ae.A_FILL, ae.A_ADD, ae.A_DCA):
|
||||||
|
assert ae.consensus_gate_why(_blocks(direction="看空"), act), act
|
||||||
|
|
||||||
|
|
||||||
|
@case("C 增持门·技术面看空停回踩补足与盈利加仓, 不停补仓")
|
||||||
|
def _():
|
||||||
|
b = _blocks(direction="中性", tech="看空", fund="中性")
|
||||||
|
assert ae.consensus_gate_why(b, ae.A_FILL) and ae.consensus_gate_why(b, ae.A_ADD)
|
||||||
|
assert ae.consensus_gate_why(b, ae.A_DCA) is None
|
||||||
|
|
||||||
|
|
||||||
|
@case("C 增持门·基本面看空停补仓, 不停回踩补足与盈利加仓")
|
||||||
|
def _():
|
||||||
|
b = _blocks(direction="中性", tech="中性", fund="看空")
|
||||||
|
assert ae.consensus_gate_why(b, ae.A_DCA)
|
||||||
|
assert ae.consensus_gate_why(b, ae.A_FILL) is None and ae.consensus_gate_why(b, ae.A_ADD) is None
|
||||||
|
|
||||||
|
|
||||||
|
@case("C 增持门·三方非看空不设门; 缺块或无读数不设门 (弃权)")
|
||||||
|
def _():
|
||||||
|
b = _blocks(direction="看多", tech="看多", fund="看多")
|
||||||
|
for act in (ae.A_FILL, ae.A_ADD, ae.A_DCA):
|
||||||
|
assert ae.consensus_gate_why(b, act) is None
|
||||||
|
assert ae.consensus_gate_why(None, ae.A_FILL) is None
|
||||||
|
assert ae.consensus_gate_why({}, ae.A_DCA) is None
|
||||||
|
assert ae.consensus_gate_why(_blocks("无读数", "无读数", "无读数"), ae.A_DCA) is None
|
||||||
|
|
||||||
|
|
||||||
|
@case("C 增持门·scan 端到端: 合议看空真拦下 FILL")
|
||||||
|
def _():
|
||||||
|
p = pos(price=9.8, cushion_pct=-0.02, support_ref=9.7, market_value=58_800)
|
||||||
|
p["consensus_blocks"] = _blocks(direction="看空", tech="看空", fund="看空")
|
||||||
|
r = ae.scan(positions=[p], params={**PARAMS, "tech_gate_increase": True},
|
||||||
|
market={p["ts_code"]: mkt()})
|
||||||
|
assert not any(c["side"] == "buy" for c in r["candidates"]), "合议看空时不该出增持候选"
|
||||||
|
assert any(s["action"] == ae.A_FILL and "看空" in s["why"] for s in r["skipped"])
|
||||||
|
|
||||||
|
|
||||||
|
@case("C 增持门·scan 端到端: 减持侧 (保垫减仓) 不受合议看空影响")
|
||||||
|
def _():
|
||||||
|
p = pos(cushion_peak=0.08, cushion_pct=0.04)
|
||||||
|
p["consensus_blocks"] = _blocks(direction="看空", tech="看空", fund="看空")
|
||||||
|
r = ae.scan(positions=[p], params={**PARAMS, "tech_gate_increase": True},
|
||||||
|
market={p["ts_code"]: mkt()})
|
||||||
|
assert any(c["action"] == ae.A_TRIM for c in r["candidates"]), "减持不该被增持门拦"
|
||||||
|
|
||||||
|
|
||||||
|
@case("C 增持门·开关关掉逐字回旧: 挂了合议看空也照出 FILL")
|
||||||
|
def _():
|
||||||
|
base = pos(price=9.8, cushion_pct=-0.02, support_ref=9.7, market_value=58_800)
|
||||||
|
r_plain = ae.scan(positions=[dict(base)], params=PARAMS, market={base["ts_code"]: mkt()})
|
||||||
|
p2 = dict(base)
|
||||||
|
p2["consensus_blocks"] = _blocks(direction="看空", tech="看空", fund="看空")
|
||||||
|
r_con = ae.scan(positions=[p2], params=PARAMS, market={base["ts_code"]: mkt()}) # PARAMS 无 tech_gate_increase
|
||||||
|
assert [c["action"] for c in r_plain["candidates"]] == [c["action"] for c in r_con["candidates"]]
|
||||||
|
assert any(c["action"] == ae.A_FILL for c in r_con["candidates"])
|
||||||
|
|
||||||
|
|
||||||
|
# ================================================================ D 装配服务
|
||||||
|
_ASM_P = {"fund_required": True, "fund_stale_days": 120, "fund_consensus_good_min": 8,
|
||||||
|
"consensus_route": True, "timing_stale_tdays": 3}
|
||||||
|
|
||||||
|
|
||||||
|
def _cr(overall="好"):
|
||||||
|
return {"overall": overall, "valuation": "便宜",
|
||||||
|
"grade_counts": {"好": 8, "中": 2, "差": 0}, "groups": {}, "doubt_hard": False, "age_days": 0}
|
||||||
|
|
||||||
|
|
||||||
|
@case("D 装配·decorate_positions 给持仓挂 consensus_blocks, 评析从 company_reviews 带出")
|
||||||
|
def _():
|
||||||
|
from app.services import consensus_service as csv
|
||||||
|
rows = [{"ts_code": "600000.SH"}, {"ts_code": "000001.SZ"}]
|
||||||
|
csv.decorate_positions(rows, nightly={}, tech_states={},
|
||||||
|
company_reviews={"600000.SH": _cr("好")}, params=_ASM_P)
|
||||||
|
assert rows[0]["consensus_blocks"]["fund"]["stance"] == "看多"
|
||||||
|
assert rows[1]["consensus_blocks"]["fund"]["stance"] == "无读数" # 没带评析 = 无读数, 绝不折看空
|
||||||
|
|
||||||
|
|
||||||
|
@case("D 装配·decorate_positions 单行异常不塌 (缺 ts_code 抹掉键)")
|
||||||
|
def _():
|
||||||
|
from app.services import consensus_service as csv
|
||||||
|
rows = [{"ts_code": ""}]
|
||||||
|
csv.decorate_positions(rows, nightly={}, tech_states={}, company_reviews={}, params=_ASM_P)
|
||||||
|
assert "consensus_blocks" not in rows[0]
|
||||||
|
|
||||||
|
|
||||||
|
# ================================================================ E 候选按质地排序
|
||||||
|
def _row(code, score, overall=None, rank=1):
|
||||||
|
r = {"ts_code": code, "name": "股票", "score": score, "rank": rank, "tier": "强传导",
|
||||||
|
"bucket": "main"}
|
||||||
|
if overall:
|
||||||
|
r["company_review"] = {"overall": overall, "valuation": "便宜", "age_days": 0,
|
||||||
|
"grade_counts": {}, "groups": {}}
|
||||||
|
return r
|
||||||
|
|
||||||
|
|
||||||
|
@case("E 排序·开关关掉逐字回旧: 纯按分数降序")
|
||||||
|
def _():
|
||||||
|
plan = {"date": "2026-09-11", "main": [_row("600000.SH", 100), _row("000001.SZ", 90, "好")],
|
||||||
|
"observe": []}
|
||||||
|
out = pf.select_candidates(plan, rank_by_quality=False)
|
||||||
|
assert [x["ts_code"] for x in out["items"]] == ["600000.SH", "000001.SZ"]
|
||||||
|
|
||||||
|
|
||||||
|
@case("E 排序·按质地: 质地看多分桶在前, 低分看多排在高分无评析之前")
|
||||||
|
def _():
|
||||||
|
plan = {"date": "2026-09-11", "main": [_row("600000.SH", 100), _row("000001.SZ", 90, "好")],
|
||||||
|
"observe": []}
|
||||||
|
out = pf.select_candidates(plan, rank_by_quality=True)
|
||||||
|
assert [x["ts_code"] for x in out["items"]] == ["000001.SZ", "600000.SH"]
|
||||||
|
|
||||||
|
|
||||||
|
@case("E 排序·同桶内仍按分数 (质地差与无评析同桶, 高分在前)")
|
||||||
|
def _():
|
||||||
|
plan = {"date": "2026-09-11",
|
||||||
|
"main": [_row("600000.SH", 80, "差"), _row("000001.SZ", 90)], "observe": []}
|
||||||
|
out = pf.select_candidates(plan, rank_by_quality=True)
|
||||||
|
assert [x["ts_code"] for x in out["items"]] == ["000001.SZ", "600000.SH"]
|
||||||
|
|
||||||
|
|
||||||
|
@case("E 排序·观察档进池开关: 默认关不进, 开了质地看多的进")
|
||||||
|
def _():
|
||||||
|
plan = {"date": "2026-09-11", "main": [_row("600000.SH", 100)],
|
||||||
|
"observe": [_row("000001.SZ", 50, "好"), _row("000002.SZ", 60)]}
|
||||||
|
off = pf.select_candidates(plan, rank_by_quality=False, observe_if_fund_bull=False)
|
||||||
|
assert [x["ts_code"] for x in off["items"]] == ["600000.SH"]
|
||||||
|
on = pf.select_candidates(plan, rank_by_quality=False, observe_if_fund_bull=True)
|
||||||
|
codes = {x["ts_code"] for x in on["items"]}
|
||||||
|
assert "000001.SZ" in codes and "000002.SZ" not in codes # 只进质地看多的观察行
|
||||||
|
|
||||||
|
|
||||||
|
# ================================================================ F 参数登记
|
||||||
|
@case("F 参数·三个新开关登记进 RUNTIME_EXTRA, 类型与默认对")
|
||||||
|
def _():
|
||||||
|
for k in ("PMS_TECH_GATE_INCREASE", "PMS_PLAN_RANK_BY_QUALITY", "PMS_PLAN_OBSERVE_IF_FUND_BULL"):
|
||||||
|
assert k in ps.RUNTIME_EXTRA, k
|
||||||
|
assert ps.RUNTIME_EXTRA[k][1] is bool
|
||||||
|
assert ps.RUNTIME_EXTRA["PMS_TECH_GATE_INCREASE"][0] is True
|
||||||
|
assert ps.RUNTIME_EXTRA["PMS_PLAN_RANK_BY_QUALITY"][0] is True
|
||||||
|
assert ps.RUNTIME_EXTRA["PMS_PLAN_OBSERVE_IF_FUND_BULL"][0] is False # 守宁缺毋滥, 默认关
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ok = 0
|
||||||
|
for name, fn in RESULTS:
|
||||||
|
try:
|
||||||
|
fn()
|
||||||
|
ok += 1
|
||||||
|
print(" ok " + name)
|
||||||
|
except Exception:
|
||||||
|
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())
|
||||||
Loading…
Reference in New Issue