三源合议工作包三(一):技术面转空自动离场(part 1+2)

动作引擎加转空离场评估器 eval_tech_exit:相位转空且翻空≤2 交易日才评,
确认转空清仓全部可卖量、未确认减三分之一;默认自动执行(与保垫减仓同档),
档位 PMS_TECH_EXIT_AUTONOMY 三档 off/propose_only/full;同一翻空只处理一次
(PMS_TECH_EXIT_DONE 去重,SAR 翻多即清,不动表结构)。
- EVALUATORS 加一条改七条(TRIM、TRIM、EXIT、EXIT、ADD、FILL、DCA)
- 卖出优先级改来源分档:目标价<研究走弱<技术面转空<保垫减仓(既有相对次序不变)
- 策略票判据改按函数判:策略票只评到价清仓、不评转空离场(SAR 归策略层)

台账 010;第三十批 18 例;开发机全量 ALL SUITES PASS。
策略层两部分(盘中 SAR 止损线、弱基本面试探仓紧止盈)留下一增量。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
zlt 2026-09-11 16:52:35 +08:00
parent cb6b308620
commit 8fdcc2ac16
8 changed files with 424 additions and 12 deletions

View File

@ -1403,6 +1403,36 @@ app/core/action_engine.py合议分流五处开关、跳过观察分流、a
工作包三离场纪律未做eval_tech_exit 转空离场、盘中 SAR 止损线、弱基本面试探仓的紧止盈自动挂载。二工作包四选股打分在另一仓库单独审批后再动。三工作包五其余文档README 调度总表补到十三位、BIONIC_PMS_INTERFACE 技术面只读接口一节。四,页面管理视图持仓总览「证据列印两遍」的显示错误与研究面列,本轮未碰。五,提议卡合议行与增持门实际拦截,等有新提议、有持仓触发增持时在 155 补看。 工作包三离场纪律未做eval_tech_exit 转空离场、盘中 SAR 止损线、弱基本面试探仓的紧止盈自动挂载。二工作包四选股打分在另一仓库单独审批后再动。三工作包五其余文档README 调度总表补到十三位、BIONIC_PMS_INTERFACE 技术面只读接口一节。四,页面管理视图持仓总览「证据列印两遍」的显示错误与研究面列,本轮未碰。五,提议卡合议行与增持门实际拦截,等有新提议、有持仓触发增持时在 155 补看。
---
## 2026-09-11 · 工作包三离场纪律part 1+2技术面转空自动离场
**做了什么**
把技术面从「只管入场投票」推进到「也管离场纪律」。这次做工作包三的前两部分,即动作引擎里的转空离场评估器,策略层的两部分(盘中 SAR 止损线、弱基本面试探仓紧止盈自动挂载)留到下一增量。
一,新评估器 eval_tech_exit。持仓票的技术面相位是「转空」且 SAR 翻空不超过两个交易日时评。确认转空清仓全部可卖量,未确认减三分之一。入场靠投票、离场靠纪律:转空像保垫减仓一样按规则自动执行,不进合议投票。档位 PMS_TECH_EXIT_AUTONOMY 三档off 不评、propose_only 交人、full 自动执行,默认 full。同一次翻空只处理一次用运行参数 PMS_TECH_EXIT_DONE 承载去重SAR 翻多即清除,不动表结构。
二,卖出优先级改成来源分档。同一轮多条减持留一条,次序是目标价到价、研究证据走弱、技术面转空、保垫减仓。既有三类的相对先后一字不动,转空插在研究走弱与保垫减仓之间。
三,策略票判据改按函数判。到价清仓与转空离场都是 EXIT 动作,但策略票的 SAR 该由策略层的盘中止损线管,所以策略票只评到价清仓、不评转空离场。原先按动作名判会把转空离场对策略票误放行。
**动了哪些文件**
app/core/action_engine.pyeval_tech_exit、SRC_TECH_EXIT、EVALUATORS 加一条改成七条、_sell_priority 来源分档、策略票判据按函数判app/services/proposal_service.py扫描参数加离场档位、持仓挂技术面状态、去重集加载与写回两个辅助函数app/services/param_store.py登记 PMS_TECH_EXIT_AUTONOMY、PMS_TECH_EXIT_TRIM_RATIO、PMS_TECH_EXIT_DONE减持比例加校验范围scripts/test_batch30_units.py新增十八例scripts/test_batch12_units.pyEVALUATORS 次序哨兵改成七条scripts/run_tests.py登记第三十批、总数 805docs/复盘决定台账.md台账 010
**部署方式**
改了 Pythonmake deploy 重建镜像加 force-recreate。收盘后 155本次与工作包二接入同批已授权直接部署。
**真机判收**
待部署后补。开发机全量 ALL SUITES PASS第三十批十八例含开关关掉逐字回旧。这一增量对策略票不回退接入前策略票也没有 SAR 离场,本次只是给常规持仓加了转空自动离场,策略票的 SAR 线是下一部分。
**还欠着什么**
工作包三剩两部分:跟踪止盈策略加盘中 SAR 止损线09:45 后现价低于昨日 SAR 千分之三即卖、当日一次,每天 09:40 把映射里的 SAR 刷进自动挂载的跟踪止盈);弱基本面加技术面看多的试探仓次日 09:40 自动挂回撤 3%、硬目标 8%、带 SAR 线的紧止盈(台账 011。工作包四选股打分另一仓库单独审批。工作包五文档。
--- ---
<!-- <!--
下一条节点从这里往下写,格式照抄上面: 下一条节点从这里往下写,格式照抄上面:

View File

@ -59,13 +59,20 @@ _SELL_PRIORITY = {A_EXIT: 0, A_TRIM: 1}
def _sell_priority(c: dict) -> int: def _sell_priority(c: dict) -> int:
"""同一轮多条减持留哪一条 (数字小的优先; 2026-09-07 第三件起按动作加来源): """同一轮多条减持留哪一条 (数字小的优先; 2026-09-11 起按来源分档):
到价清仓 (人的意思) 研究证据走弱的减持 (必交人) 保垫减仓 (规则自动)""" 到价清仓 (人的意思) 研究证据走弱 (必交人) 技术面转空离场 保垫减仓 (规则自动)
if c.get("action") == A_EXIT: 只影响相对次序: 既有三类的先后与从前一字不差, 技术面转空插在研究走弱与保垫减仓之间
转空是决策系统的趋势止损信号, 比常规的保垫锁盈更该先执行, 但仍让位于人的目标价与研究走弱"""
src = c.get("source")
if src == SRC_TARGET_PRICE:
return 0 return 0
if c.get("source") == SRC_RESEARCH_WEAK: if src == SRC_RESEARCH_WEAK:
return 1 return 1
return 2 if src == SRC_TECH_EXIT:
return 2
if c.get("action") == A_EXIT: # 兜底: 其它 EXIT 当人的意思 (历史行为)
return 0
return 3
# 候选来源 (2026-09-03)。每条候选都带一个来源, 提议分流那一步拿它决定「这条减持能不能不等人」。 # 候选来源 (2026-09-03)。每条候选都带一个来源, 提议分流那一步拿它决定「这条减持能不能不等人」。
# #
@ -80,6 +87,10 @@ def _sell_priority(c: dict) -> int:
SRC_ENGINE = "engine" # 动作引擎按规则算出来的 (FILL / ADD / DCA / TRIM / OPEN) SRC_ENGINE = "engine" # 动作引擎按规则算出来的 (FILL / ADD / DCA / TRIM / OPEN)
SRC_RESEARCH_WEAK = "research_weak" # 研究证据走弱触发的减持 —— 必定交人裁决, 绝不自动卖出 SRC_RESEARCH_WEAK = "research_weak" # 研究证据走弱触发的减持 —— 必定交人裁决, 绝不自动卖出
SRC_TARGET_PRICE = "target_price" # 用户设的目标价到价 —— 必定交人裁决, 绝不自动卖出 SRC_TARGET_PRICE = "target_price" # 用户设的目标价到价 —— 必定交人裁决, 绝不自动卖出
# 技术面转空自动离场 (2026-09-11 工作包三)。**不进 FORCE_QUEUE_SOURCES** —— 按用户拍板默认
# 自动执行, 与保垫减仓同档; 档位由 PMS_TECH_EXIT_AUTONOMY 控制 (propose_only 时评估器自己打
# needs_user_confirm 交人, off 时根本不评估)。
SRC_TECH_EXIT = "tech_exit"
WHY_RESEARCH_WEAK_CONFIRM = "研究证据走弱触发的减持,必须交人裁决,不自动卖出" WHY_RESEARCH_WEAK_CONFIRM = "研究证据走弱触发的减持,必须交人裁决,不自动卖出"
WHY_TARGET_PRICE_CONFIRM = "你设的目标价到价,卖不卖由你决定,系统不自动卖出" WHY_TARGET_PRICE_CONFIRM = "你设的目标价到价,卖不卖由你决定,系统不自动卖出"
@ -485,9 +496,66 @@ def clamp_sell_qty(qty: int, pos: dict) -> int:
return max(0, min(q, int(avail or 0))) return max(0, min(q, int(avail or 0)))
def eval_tech_exit(p: dict, params: dict, mkt: dict = None):
"""技术面转空自动离场 (2026-09-11 工作包三, 台账 010)。入场靠投票, 离场靠纪律 ——
SAR 翻空像保垫减仓一样按规则执行, 不进入场那套合议投票
只在技术面相位是转空且翻向不超过 flip_fresh_days 天时评 (相位本身已含这个窗口,
这里再显式判一次)确认转空 (多空布林线空头区或价格贴下轨) 清仓全部可卖量; 未确认减三分之一
默认自动执行 (来源 SRC_TECH_EXIT 不在强制入队名单; 与保垫减仓同档); 档位 propose_only
评估器自己打交人标记, off 时调用方根本不评估 (tech_exit_on 为假)
同一次翻向只处理一次: params["tech_exit_done"] 本次翻空已处理过的代码集, 服务层
在真执行后写入SAR 转多后清除 (不动表结构, tech_state_map 同一手法承载跨轮状态)
技术面状态挂在 p["tech"] (tech_service.attach 早上写)没有读数不评 无读数弃权
"""
if not params.get("tech_exit_on"):
return None
t = p.get("tech")
if not isinstance(t, dict) or t.get("phase") != "转空":
return None
fd = t.get("sar_flip_days")
if fd is None or int(fd) > int(params.get("tech_exit_fresh_days", 2)):
return None
code = p.get("ts_code")
if code in (params.get("tech_exit_done") or {}):
return None # 同一次翻空已处理过
total = int(p.get("total_qty") or 0)
if total <= 0:
return None
confirm = bool(t.get("confirm"))
if confirm:
qty = clamp_sell_qty(total, p) # 确认转空: 清仓全部可卖量
action, ratio_txt = A_EXIT, "全部可卖量"
else:
ratio = _f(params.get("tech_exit_trim_ratio"), 1.0 / 3)
if ratio <= 0 or ratio > 1:
ratio = 1.0 / 3
qty = clamp_sell_qty(int(total * ratio // LOT) * LOT, p)
action, ratio_txt = A_TRIM, f"{ratio:.0%}"
if qty < lot_of(code):
return None
why = (f"技术面转空自动离场: SAR 翻空 {fd}"
f"{'(确认)' if confirm else '(未确认)'}, "
f"{'清仓' if confirm else ''} {qty} 股({ratio_txt}; {t.get('reason') or ''}")
c = _cand(p, action, SELL, qty, why,
{"tech": t, "sar_value": t.get("sar_value"), "sar_flip_days": fd,
"confirm": confirm, "total_qty": total, "avail_qty": p.get("avail_qty"),
"price": p.get("price"), "cushion_pct": p.get("cushion_pct")},
source=SRC_TECH_EXIT)
# 档位 propose_only: 不自动执行, 打交人标记落进待确认队列 (与深档补仓走同一个字段)。
if params.get("tech_exit_propose_only"):
c["needs_user_confirm"] = True
c["confirm_why"] = "技术面转空离场(档位 propose_only交你确认后再卖"
return c
# 研究走弱的减持排在保垫减仓前面 (2026-09-07 第三件): 两者动作名都是 TRIM, 同轮同现时按 # 研究走弱的减持排在保垫减仓前面 (2026-09-07 第三件): 两者动作名都是 TRIM, 同轮同现时按
# _sell_priority 留研究走弱那条 (它必交人), 保垫减仓让路 —— 证据在走弱的票, 不该由规则自动先卖一刀。 # _sell_priority 留研究走弱那条 (它必交人), 保垫减仓让路 —— 证据在走弱的票, 不该由规则自动先卖一刀。
# 2026-09-11 加技术面转空离场 eval_tech_exit: 次序改为 TRIM、TRIM、EXIT、EXIT、ADD、FILL、DCA
# (转空离场排在到价清仓之后、增持之前; 同轮多条减持按 _sell_priority 收敛成一条)。
EVALUATORS = ((A_TRIM, eval_weak_research), (A_TRIM, eval_trim), (A_EXIT, eval_target), EVALUATORS = ((A_TRIM, eval_weak_research), (A_TRIM, eval_trim), (A_EXIT, eval_target),
(A_EXIT, eval_tech_exit),
(A_ADD, eval_add), (A_FILL, eval_fill), (A_DCA, eval_dca)) (A_ADD, eval_add), (A_FILL, eval_fill), (A_DCA, eval_dca))
# 减持方向的动作 —— 冻结只禁增持, 这几类照评 (与规则闸 _check_sell 同一口径)。 # 减持方向的动作 —— 冻结只禁增持, 这几类照评 (与规则闸 _check_sell 同一口径)。
@ -586,8 +654,11 @@ def scan(*, positions: list, params: dict, market: dict, skip=None,
if why: if why:
skipped.append({"ts_code": code, "action": action, "why": why}) skipped.append({"ts_code": code, "action": action, "why": why})
continue continue
if on_strategy and action != A_EXIT: if on_strategy and fn is not eval_target:
continue # 策略票只看目标价, 原因已在上面记过 continue # 策略票只看目标价 (用户命令), 其余都由策略层接管
# 注: 判「只看目标价」按函数判而不是按动作名 (2026-09-11 工作包三): 到价清仓与技术面
# 转空离场都是 EXIT, 但策略票的 SAR 由策略层的盘中 SAR 止损线管 (part 3), 动作引擎这条
# 转空离场不该对策略票再评一遍 —— 按动作名判会把它误放行。
if inflight_sell and action in SELL_SIDE_ACTIONS: if inflight_sell and action in SELL_SIDE_ACTIONS:
skipped.append({"ts_code": code, "action": action, skipped.append({"ts_code": code, "action": action,
"why": f"这只票已有一条减持 ({inflight_sell}) 在处理中, " "why": f"这只票已有一条减持 ({inflight_sell}) 在处理中, "

View File

@ -116,6 +116,10 @@ RUNTIME_EXTRA = {
"PMS_TECH_GATE_INCREASE": (True, bool, "持仓增持门: 合议看空停回踩补足/盈利加仓/补仓, 技术面看空停回踩补足与盈利加仓, 基本面看空停补仓 (减持不受影响; 关掉即回到不设门)"), "PMS_TECH_GATE_INCREASE": (True, bool, "持仓增持门: 合议看空停回踩补足/盈利加仓/补仓, 技术面看空停回踩补足与盈利加仓, 基本面看空停补仓 (减持不受影响; 关掉即回到不设门)"),
"PMS_PLAN_RANK_BY_QUALITY": (True, bool, "候选先按基本面立场分桶 (质地看多的桶排前) 再按分数, 在主题限额与截断之前生效; 技术面不参与排序 (关掉即回到只按分数排)"), "PMS_PLAN_RANK_BY_QUALITY": (True, bool, "候选先按基本面立场分桶 (质地看多的桶排前) 再按分数, 在主题限额与截断之前生效; 技术面不参与排序 (关掉即回到只按分数排)"),
"PMS_PLAN_OBSERVE_IF_FUND_BULL": (False, bool, "把观察档里质地看多的票也纳入候选池 (默认关, 守宁缺毋滥; 开了它们与主榜一同参与分桶排序)"), "PMS_PLAN_OBSERVE_IF_FUND_BULL": (False, bool, "把观察档里质地看多的票也纳入候选池 (默认关, 守宁缺毋滥; 开了它们与主榜一同参与分桶排序)"),
# ── 2026-09-11 三源合议工作包三·离场纪律 (台账 010): SAR 转空自动离场。翻向新鲜天数复用 PMS_TECH_FLIP_FRESH_DAYS。
"PMS_TECH_EXIT_AUTONOMY": ("full", str, "技术面转空离场档位: off 不评 / propose_only 交人确认 / full 自动执行 (与保垫减仓同档, 按拍板默认)"),
"PMS_TECH_EXIT_TRIM_RATIO": (0.3333, float, "转空未确认时的减持比例 (占总持仓; 确认转空则清仓全部可卖量; 默认三分之一)"),
"PMS_TECH_EXIT_DONE": ("", str, "本次翻空已处理过的代码集 (scan_and_route 写入的 JSON: codes/at; 跨轮去重, 页面只读, 勿手改)"),
} }
# **读不到时必须按"已暂停"处理的键 (fail-closed)。** # **读不到时必须按"已暂停"处理的键 (fail-closed)。**
@ -494,6 +498,7 @@ _RANGES = {
"PMS_TECH_FLIP_FRESH_DAYS": (1, 10), "PMS_TECH_FLIP_FRESH_DAYS": (1, 10),
# 三源合议 (2026-09-11) # 三源合议 (2026-09-11)
"PMS_FUND_STALE_DAYS": (0, 3650), "PMS_FUND_CONSENSUS_GOOD_MIN": (0, 10), "PMS_FUND_STALE_DAYS": (0, 3650), "PMS_FUND_CONSENSUS_GOOD_MIN": (0, 10),
"PMS_TECH_EXIT_TRIM_RATIO": (0, 1),
# 宏观择时层 # 宏观择时层
"PMS_MACRO_HOT_TH": (0, 100), "PMS_MACRO_COLD_TH": (-100, 0), "PMS_MACRO_HOT_TH": (0, 100), "PMS_MACRO_COLD_TH": (-100, 0),
"PMS_MACRO_EXIT_BAND": (0, 100), "PMS_MACRO_CONFIRM_DAYS": (1, 10), "PMS_MACRO_EXIT_BAND": (0, 100), "PMS_MACRO_CONFIRM_DAYS": (1, 10),

View File

@ -138,6 +138,20 @@ def scan_and_route(*, now=None, dry_run: bool = False) -> dict:
consensus_service.decorate_positions(view["held"], company_reviews=_crs) consensus_service.decorate_positions(view["held"], company_reviews=_crs)
except Exception as e: # noqa: BLE001 except Exception as e: # noqa: BLE001
logger.warning("[合议] 持仓增持门装配失败, 本轮按无合议 (不设门): %s", e) logger.warning("[合议] 持仓增持门装配失败, 本轮按无合议 (不设门): %s", e)
# 技术面转空自动离场 (2026-09-11 工作包三): 给持仓行挂技术面状态供 eval_tech_exit 读,
# 并备好「本次翻空已处理」去重集 (清掉 SAR 已不在空头的, 那是新一轮翻向)。挂不上只记日志、
# 本轮不评转空离场 (无读数弃权)。离场档位 off 时 tech_exit_on 已为假, 评估器自然不产出。
if params.get("tech_exit_on"):
try:
from app.services import tech_service as _ts
_tech_states = _ts.state_map()
_ts.attach(view["held"], _tech_states)
_done = _load_tech_exit_done()
params["tech_exit_done"] = {c for c in _done
if (_tech_states.get(c) or {}).get("sar_side") == ""}
except Exception as e: # noqa: BLE001
logger.warning("[技术面离场] 挂技术面状态失败, 本轮不评转空离场: %s", e)
params["tech_exit_done"] = set()
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:
@ -198,10 +212,48 @@ def scan_and_route(*, now=None, dry_run: bool = False) -> dict:
logger.exception("提议分流失败 %s", c.get("ts_code")) logger.exception("提议分流失败 %s", c.get("ts_code"))
out["errors"].append(f"{c.get('ts_code')} {c.get('action')}: " out["errors"].append(f"{c.get('ts_code')} {c.get('action')}: "
f"{type(e).__name__}: {e}") f"{type(e).__name__}: {e}")
# 技术面转空离场去重集写回 (2026-09-11 工作包三): 本轮真处理过 (执行或入队, 未被闸拒) 的
# 转空离场记进去重集, 下次同一翻空不再重复处理。试算不写。清掉 SAR 已翻多的那步在扫描前做过了。
if not dry_run:
try:
_rej = {(r.get("ts_code"), r.get("action")) for r in out["rejected"]}
_acted = {c["ts_code"] for c in scanned["candidates"]
if c.get("source") == ae.SRC_TECH_EXIT
and (c["ts_code"], c["action"]) not in _rej}
if _acted:
_save_tech_exit_done(set(params.get("tech_exit_done") or set()) | _acted)
except Exception as e: # noqa: BLE001
logger.warning("[技术面离场] 去重集写回失败 (下轮可能重评一次): %s", e)
out["ok"] = not out["errors"] out["ok"] = not out["errors"]
return out return out
_TECH_EXIT_DONE_KEY = "PMS_TECH_EXIT_DONE"
def _load_tech_exit_done() -> set:
"""本次翻空已处理过的代码集 (跨轮去重, 不动表结构; 与 tech_state_map 同一手法用运行参数承载)。
读不到或坏了返回空集清除 (SAR 翻多=新一轮) scan_and_route 扫描前按当轮技术面映射做"""
import json
raw = param_store.get(_TECH_EXIT_DONE_KEY, "") or ""
if not raw:
return set()
try:
m = json.loads(raw)
except (TypeError, ValueError):
return set()
return set(m.get("codes") or []) if isinstance(m, dict) else set()
def _save_tech_exit_done(codes) -> None:
import json
r = param_store.set_param(_TECH_EXIT_DONE_KEY, json.dumps(
{"codes": sorted(codes), "at": datetime.now().strftime("%Y-%m-%d %H:%M:%S")},
ensure_ascii=False), "system")
if not r.get("ok"):
logger.error("[技术面离场] 去重集写入失败: %s —— 同一翻空下轮可能再处理一次", r.get("error"))
# ================================================================ 新建仓的取数与筛选 # ================================================================ 新建仓的取数与筛选
def _scan_open(view, params, stock_params, skip, mkt, out) -> list: def _scan_open(view, params, stock_params, skip, mkt, out) -> list:
"""候选池 → 新建仓候选。产出的候选数天生不超过剩余名额 (名额与金额在纯逻辑里边走边扣)。 """候选池 → 新建仓候选。产出的候选数天生不超过剩余名额 (名额与金额在纯逻辑里边走边扣)。
@ -968,6 +1020,16 @@ def _scan_params(view: dict) -> dict:
# 两个都关掉时, 合议既不装配也不分流、增持门不设 —— 行为与接入前逐字相同。 # 两个都关掉时, 合议既不装配也不分流、增持门不设 —— 行为与接入前逐字相同。
"consensus_route": param_store.get_bool("PMS_CONSENSUS_ROUTE", True), "consensus_route": param_store.get_bool("PMS_CONSENSUS_ROUTE", True),
"tech_gate_increase": param_store.get_bool("PMS_TECH_GATE_INCREASE", True), "tech_gate_increase": param_store.get_bool("PMS_TECH_GATE_INCREASE", True),
# 技术面转空自动离场 (2026-09-11 工作包三, 台账 010)。档位 off/propose_only/full:
# off 不评 (tech_exit_on 为假)、propose_only 交人、full 自动执行 (与保垫减仓同档)。
# tech_exit_done 去重集在 scan_and_route 里按当轮技术面映射填 (这里给空占位)。
"tech_exit_on": (str(param_store.get("PMS_TECH_EXIT_AUTONOMY", "full") or "full").strip()
in ("full", "propose_only")) and param_store.get_bool("PMS_TECH_ENABLED", True),
"tech_exit_propose_only": str(param_store.get("PMS_TECH_EXIT_AUTONOMY", "full")
or "full").strip() == "propose_only",
"tech_exit_trim_ratio": param_store.get_float("PMS_TECH_EXIT_TRIM_RATIO", 1.0 / 3),
"tech_exit_fresh_days": param_store.get_int("PMS_TECH_FLIP_FRESH_DAYS", 2),
"tech_exit_done": set(),
}) })
return p return p

View File

@ -157,3 +157,21 @@
**预期。** 质地好的候选排到前面,更容易在名额里占到位置。合议或技术面看空的持仓不再自动补仓加仓,但保垫减仓照常。 **预期。** 质地好的候选排到前面,更容易在名额里占到位置。合议或技术面看空的持仓不再自动补仓加仓,但保垫减仓照常。
**复核日期。** 上线后满三个交易日,看质地好的票进候选的比例有没有升,看增持门有没有误伤。 **复核日期。** 上线后满三个交易日,看质地好的票进候选的比例有没有升,看增持门有没有误伤。
## 010 · 2026-09-11 · 技术面转空自动离场
**改动。** 持仓票的技术面相位转为「转空」且 SAR 翻空不超过两个交易日时系统自动提议离场。确认转空清仓全部可卖量未确认减三分之一。这是纪律不是投票SAR 翻空像保垫减仓一样按规则执行,不进入场那套三源合议。
自动执行,与保垫减仓同档。同一次翻空只处理一次。同一轮里若与别的减持同时出现,优先级是目标价到价最高,其次研究证据走弱,再次技术面转空,保垫减仓最低。
挂了交易方案的策略票不走这条离场,它的 SAR 由策略层的盘中 SAR 止损线管,那是工作包三的另一部分。这条判断按评估器函数判而不是按动作名判,否则策略票会被误评。
**依据。** SAR 是跟踪止损指标,翻空即趋势止损位被击穿。确认转空的两种确认是多空布林线在空头区、或价格贴下轨。震荡市里 SAR 翻向不当信号,所以「转空」相位本身已排除震荡市。
**参数。** 离场档位 PMS_TECH_EXIT_AUTONOMY 有三档off 不评、propose_only 交人确认、full 自动执行,默认 full。未确认减持比例 PMS_TECH_EXIT_TRIM_RATIO 默认三分之一。翻向新鲜天数复用 PMS_TECH_FLIP_FRESH_DAYS。「已处理过的翻空」用运行参数 PMS_TECH_EXIT_DONE 承载,不动表结构。
**预期。** 技术面退出每周零到三条误伤逐只记这条台账。off 或改 propose_only 即可收紧或转人工。
**复核日期。** 上线后每周复核一次退出条数与误伤。
**尚未完成的两部分(属工作包三,随后补台账)。** 一是跟踪止盈策略加盘中 SAR 止损线,二是弱基本面加技术面看多的试探仓次日自动挂紧止盈(记台账 011

View File

@ -80,15 +80,19 @@
wait_tech/交人强制确认/放行/缺合议退旧路/开关关掉逐字回旧)/定档改用 advise_v2 wait_tech/交人强制确认/放行/缺合议退旧路/开关关掉逐字回旧)/定档改用 advise_v2
且六键进硬数字而不进送研判白名单/持仓增持门矩阵与 scan 端到端(减持不受影响 且六键进硬数字而不进送研判白名单/持仓增持门矩阵与 scan 端到端(减持不受影响
开关关掉回旧)/装配服务持仓挂合议/候选按质地分桶排序与观察档进池开关 (24 ) 开关关掉回旧)/装配服务持仓挂合议/候选按质地分桶排序与观察档进池开关 (24 )
test_batch30_units.py 三源合议·工作包三离场纪律 (2026-09-11): eval_tech_exit 转空自动离场(确认清仓/
未确认减三分之一/翻向超期不评/同一翻空一次/无读数弃权/propose_only 交人/可卖量
夹紧/开关关掉逐字回旧)/卖出优先级目标价<研究走弱<技术面转空<保垫减仓/scan 端到端/
策略票只看目标价按函数判(技术面转空对策略票不评)/参数登记 (18 )
test_page_enum_guard.py 页面文案守卫 (静态扫描, 不连库不起浏览器): 枚举字段不许 test_page_enum_guard.py 页面文案守卫 (静态扫描, 不连库不起浏览器): 枚举字段不许
直接印到页面上 / 判据码显示前必须剥前缀 / 不许把整个对象 直接印到页面上 / 判据码显示前必须剥前缀 / 不许把整个对象
打给交易员看 / 翻译兜底不许让英文码单独当句子 (1 ) 打给交易员看 / 翻译兜底不许让英文码单独当句子 (1 )
test_wiring.py 装配自检: 服务层核心落表 全链路 (内存桩) + test_wiring.py 装配自检: 服务层核心落表 全链路 (内存桩) +
目标价到价必定入队 (档位 full 也不自动卖) + 目标价到价必定入队 (档位 full 也不自动卖) +
用户设的止损价与目标价单独成列显示 (70 ) 用户设的止损价与目标价单独成列显示 (70 )
787 805
(总数按实跑逐批相加校正过两次: 曾写 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; 三源合议工作包二纯逻辑第二十八批三十七例到 763; 接入下单链路第二十九批二十四例, 现为 787) 减持与研究理由两键各一例, 652; 09-04 加了仅展示跳过原因与空候选说明各一例, 654; 又加了页面文案守卫一例, 655; 09-07 审查修复加了跨轮减持等五例, 660; 第二件低把握驳回交人一例, 661; 第三件逻辑状态接入第二十二批十五例, 676; 第四件安全边际整句透传一例, 677; 参考目标价一例, 678; 催化事件与定价状态透传一例, 679; 两个期限的头一例, 现为 680; 09-10 建议档位对齐第二十六批十七例到 697; 09-11 技术面接入工作包一第二十七批二十七例到 724; 三源合议工作包二纯逻辑第二十八批三十七例到 763; 接入下单链路第二十九批二十四例787; 工作包三离场纪律第三十批十八例, 现为 805)
任一子集失败即整体失败 (退出码 1) 任一子集失败即整体失败 (退出码 1)
哨兵位置清单 (2026-09-03 抄录; 改了对应的东西就得来这些地方改断言, 断言不动就是漏了): 哨兵位置清单 (2026-09-03 抄录; 改了对应的东西就得来这些地方改断言, 断言不动就是漏了):
@ -140,6 +144,7 @@ SUITES = ["test_core_units.py", "test_batch2_units.py", "test_batch3_units.py",
"test_batch27_units.py", "test_batch27_units.py",
"test_batch28_units.py", "test_batch28_units.py",
"test_batch29_units.py", "test_batch29_units.py",
"test_batch30_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"]

View File

@ -369,10 +369,13 @@ def _():
# 次序与判据一个字没动, 减持照旧不送研判。 # 次序与判据一个字没动, 减持照旧不送研判。
# 2026-09-07 第三件: 研究走弱的减持 (eval_weak_research, 动作名同为 TRIM) 排在保垫减仓前面, # 2026-09-07 第三件: 研究走弱的减持 (eval_weak_research, 动作名同为 TRIM) 排在保垫减仓前面,
# 其余五个的名单与次序一个字不动。 # 其余五个的名单与次序一个字不动。
assert [a for a, _ in ae.EVALUATORS] == ["TRIM", "TRIM", "EXIT", "ADD", "FILL", "DCA"] # 2026-09-11 工作包三: 加技术面转空离场 (eval_tech_exit, 动作名 EXIT) 排在到价清仓之后、
# 三类买入之前 —— 次序变为 TRIM、TRIM、EXIT、EXIT、ADD、FILL、DCA。老六个的相对次序一个字不动。
assert [a for a, _ in ae.EVALUATORS] == ["TRIM", "TRIM", "EXIT", "EXIT", "ADD", "FILL", "DCA"]
assert [f.__name__ for _, f in ae.EVALUATORS] == [ assert [f.__name__ for _, f in ae.EVALUATORS] == [
"eval_weak_research", "eval_trim", "eval_target", "eval_add", "eval_fill", "eval_dca"] "eval_weak_research", "eval_trim", "eval_target", "eval_tech_exit",
assert "EXIT" not in ae.JUDGE_ACTIONS, "减持永远不送研判 (目标价到价也是减持)" "eval_add", "eval_fill", "eval_dca"]
assert "EXIT" not in ae.JUDGE_ACTIONS, "减持永远不送研判 (目标价到价、技术面转空离场也是减持)"
# 持仓那条扫描的输入与产出一个字没改: 空持仓进去, 空候选出来, 不会去碰候选池 # 持仓那条扫描的输入与产出一个字没改: 空持仓进去, 空候选出来, 不会去碰候选池
r = ae.scan(positions=[], params=params(), market={}) r = ae.scan(positions=[], params=params(), market={})
assert r == {"candidates": [], "skipped": []}, r assert r == {"candidates": [], "skipped": []}, r

View File

@ -0,0 +1,218 @@
# -*- coding: utf-8 -*-
"""三源合议 · 工作包三离场纪律 (2026-09-11 方案第五节, 台账 010)。全部离线, 不连库。
入场靠投票, 离场靠纪律: SAR 转空像保垫减仓一样按规则执行, 不进入场那套合议投票
A eval_tech_exit: 相位转空才评/确认清仓·未确认减三分之一/翻向超期不评/同一翻空一次/
无读数弃权/propose_only 交人/可卖量夹紧/不足一手不评/开关关掉逐字回旧
B 卖出优先级: 目标价 < 研究走弱 < 技术面转空 < 保垫减仓
C scan 端到端: 产出来源 tech_exit / 同轮与保垫减仓并现留转空 / 目标价优先于转空
D 策略票只看目标价 (按函数判, 不按动作名) 技术面转空对策略票不评
E 参数登记
"""
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 param_store as ps # noqa: E402
RESULTS = []
def case(name):
def deco(fn):
RESULTS.append((name, fn))
return fn
return deco
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", "avail_qty": 6000}
p.update(kw)
return p
def tech(phase="转空", flip=1, confirm=False, sar_value=9.5, reason="SAR 翻空"):
return {"phase": phase, "sar_flip_days": flip, "confirm": confirm,
"sar_value": sar_value, "reason": reason, "stance": "看空"}
def tpos(t, **kw):
p = pos(**kw)
p["tech"] = t
return p
def tparams(**kw):
p = {"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,
"tech_exit_on": True, "tech_exit_propose_only": False,
"tech_exit_trim_ratio": 1.0 / 3, "tech_exit_fresh_days": 2, "tech_exit_done": set()}
p.update(kw)
return p
# ================================================================ A eval_tech_exit
@case("A 转空离场·开关关掉逐字回旧 (tech_exit_on 假 → 不评)")
def _():
assert ae.eval_tech_exit(tpos(tech(confirm=True)), tparams(tech_exit_on=False)) is None
@case("A 转空离场·相位非转空不评")
def _():
assert ae.eval_tech_exit(tpos(tech(phase="趋势空", confirm=True)), tparams()) is None
assert ae.eval_tech_exit(tpos(tech(phase="趋势多", confirm=False)), tparams()) is None
@case("A 转空离场·确认转空清仓全部可卖量 (EXIT, 来源 tech_exit)")
def _():
c = ae.eval_tech_exit(tpos(tech(confirm=True), total_qty=6000, avail_qty=6000), tparams())
assert c and c["action"] == "EXIT" and c["side"] == "sell"
assert c["qty"] == 6000 and c["source"] == ae.SRC_TECH_EXIT
@case("A 转空离场·未确认减三分之一 (TRIM)")
def _():
c = ae.eval_tech_exit(tpos(tech(confirm=False), total_qty=6000, avail_qty=6000), tparams())
assert c and c["action"] == "TRIM" and c["qty"] == 2000
@case("A 转空离场·翻向超期不评 (超过 fresh_days)")
def _():
assert ae.eval_tech_exit(tpos(tech(flip=3, confirm=True)), tparams()) is None
assert ae.eval_tech_exit(tpos(tech(flip=2, confirm=True)), tparams()) is not None
@case("A 转空离场·同一翻空已处理过不再评")
def _():
p = tpos(tech(confirm=True), ts_code="600000.SH")
assert ae.eval_tech_exit(p, tparams(tech_exit_done={"600000.SH"})) is None
assert ae.eval_tech_exit(p, tparams(tech_exit_done=set())) is not None
@case("A 转空离场·无技术面读数不评 (无读数弃权)")
def _():
p = pos()
p.pop("tech", None)
assert ae.eval_tech_exit(p, tparams()) is None
p2 = pos()
p2["tech"] = None
assert ae.eval_tech_exit(p2, tparams()) is None
@case("A 转空离场·propose_only 打交人标记")
def _():
c = ae.eval_tech_exit(tpos(tech(confirm=True), total_qty=6000, avail_qty=6000),
tparams(tech_exit_propose_only=True))
assert c and c.get("needs_user_confirm") is True and "propose_only" in (c.get("confirm_why") or "")
@case("A 转空离场·按 T+1 可卖量夹紧 (确认时清可卖量而非总持仓)")
def _():
c = ae.eval_tech_exit(tpos(tech(confirm=True), total_qty=6000, avail_qty=3000), tparams())
assert c and c["qty"] == 3000
@case("A 转空离场·不足一手不评")
def _():
assert ae.eval_tech_exit(tpos(tech(confirm=True), total_qty=50, avail_qty=50), tparams()) is None
# ================================================================ B 卖出优先级
@case("B 卖出优先级·目标价 < 研究走弱 < 技术面转空 < 保垫减仓")
def _():
pr = lambda a, s: ae._sell_priority({"action": a, "source": s})
assert (pr("EXIT", ae.SRC_TARGET_PRICE) < pr("TRIM", ae.SRC_RESEARCH_WEAK)
< pr("EXIT", ae.SRC_TECH_EXIT) < pr("TRIM", ae.SRC_ENGINE))
# ================================================================ C scan 端到端
@case("C scan·确认转空产出 EXIT 候选")
def _():
p = tpos(tech(confirm=True), total_qty=6000, avail_qty=6000)
r = ae.scan(positions=[p], params=tparams(), market={p["ts_code"]: {}})
exits = [c for c in r["candidates"] if c.get("source") == ae.SRC_TECH_EXIT]
assert len(exits) == 1 and exits[0]["action"] == "EXIT"
@case("C scan·同轮与保垫减仓并现留技术面转空 (优先级更高)")
def _():
p = tpos(tech(confirm=True), total_qty=6000, avail_qty=6000,
cushion_peak=0.08, cushion_pct=0.04)
r = ae.scan(positions=[p], params=tparams(), market={p["ts_code"]: {}})
sells = [c for c in r["candidates"] if c["side"] == "sell"]
assert len(sells) == 1 and sells[0]["source"] == ae.SRC_TECH_EXIT
@case("C scan·目标价到价优先于技术面转空")
def _():
p = tpos(tech(confirm=True), total_qty=6000, avail_qty=6000, price=13.0)
r = ae.scan(positions=[p], params=tparams(), market={p["ts_code"]: {}},
stock_params={"600000.SH": {"target_price": 12.5}})
sells = [c for c in r["candidates"] if c["side"] == "sell"]
assert len(sells) == 1 and sells[0]["source"] == ae.SRC_TARGET_PRICE
@case("C scan·开关关掉逐字回旧: 挂了转空读数也不产出转空离场")
def _():
p = tpos(tech(confirm=True), total_qty=6000, avail_qty=6000)
r = ae.scan(positions=[p], params=tparams(tech_exit_on=False), market={p["ts_code"]: {}})
assert not any(c.get("source") == ae.SRC_TECH_EXIT for c in r["candidates"])
# ================================================================ D 策略票只看目标价 (Part 2)
@case("D 策略票·目标价照评但技术面转空不评 (按函数判)")
def _():
p = tpos(tech(confirm=True), total_qty=6000, avail_qty=6000, price=13.0)
r = ae.scan(positions=[p], params=tparams(), market={p["ts_code"]: {}},
strategy_codes={"600000.SH"}, stock_params={"600000.SH": {"target_price": 12.5}})
assert any(c.get("source") == ae.SRC_TARGET_PRICE for c in r["candidates"]), "策略票目标价照评"
assert not any(c.get("source") == ae.SRC_TECH_EXIT for c in r["candidates"]), "策略票技术面转空不评"
@case("D 策略票·无目标价时技术面转空也不产出 (策略层接管)")
def _():
p = tpos(tech(confirm=True), total_qty=6000, avail_qty=6000)
r = ae.scan(positions=[p], params=tparams(), market={p["ts_code"]: {}},
strategy_codes={"600000.SH"})
assert not any(c.get("source") == ae.SRC_TECH_EXIT for c in r["candidates"])
# ================================================================ E 参数登记
@case("E 参数·转空离场三键登记, 档位默认 full")
def _():
for k in ("PMS_TECH_EXIT_AUTONOMY", "PMS_TECH_EXIT_TRIM_RATIO", "PMS_TECH_EXIT_DONE"):
assert k in ps.RUNTIME_EXTRA, k
assert ps.RUNTIME_EXTRA["PMS_TECH_EXIT_AUTONOMY"][0] == "full"
assert ps.RUNTIME_EXTRA["PMS_TECH_EXIT_AUTONOMY"][1] is str
assert abs(ps.RUNTIME_EXTRA["PMS_TECH_EXIT_TRIM_RATIO"][0] - 1.0 / 3) < 1e-3
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())