开始处理系统整体细节

This commit is contained in:
zlt 2026-08-18 14:10:26 +08:00
parent 48a3267299
commit 940f4ea688
7 changed files with 447 additions and 3 deletions

View File

@ -30,6 +30,51 @@
---
## 2026-08-18 · 决策系统卖出采纳复核:降门槛+全留痕 / 确认即加速 / 清仓完成清场
**背景**
用户观测"系统似乎不采纳决策系统的卖出"复核整条链bionic 生产端 → PMS 消费端)。结论:
接线、代码格式(都是 Tushare 点式,能对上持仓)、置信度量纲(风控 0~100 归一到 0~1都没
问题;"不采纳"是门槛标定 + 挂策略路由所致。真机取数印证bionic 对持仓票的卖出置信度几乎
全落在 60~84002015 60/70/70、300474 65、300433/300354 72、688256 70…而原门槛是
低于 75 忽略、75~84 落提议、≥85 才自动清仓——于是持仓票的卖出大多被静默忽略或排队等拍板。
**做了什么(三件,用户经 AskUserQuestion 拍板)**
一,降门槛 + 全留痕。`PMS_SIGNAL_SELL_CONF_MIN` 默认 0.75 → 0.60页面可再调60~84 一律
浮上来变提议、可见可一键采纳≥85 仍自动清仓;并在 signal_service 对**持仓票**的"未采纳
卖出"(低于门槛的 IGNORE也留痕action=SIGNAL / verdict=NOTE当日按来源+股票+动作去重
一条),不再让一只连日被喊卖的持仓票在账本里查不到。
二,确认即加速。决策系统卖出命中一只**已有在途清仓指令**的票时,不再简单跳过,而是把该股
在途的卖出**指令**升级为紧急直通progress.urgent=True下一跳 run_tick 按现价直卖、不等
均价不分桶),每条升级账本留痕。仅挂着提议(还没成指令)的不动。人工清仓 + 风控独立确认
= 最强离场信号,走最快的路。
清仓完成清场。持仓归零status=CLOSED 且 qty≤0自动撤下该股 策略(网格/做T/跟踪)、
在途建仓方案与买单、挂着的相关提议每只闭仓票靠标记PMS_EXIT_CLEANUP_DONE**只清一次**
避免把闭仓后新下的建仓命令误撤(重开的票掉出标记,下次闭仓再清)。挂在 command_poll 每分钟
一跳、幂等。解决"清仓离场后网格还挂着 ACTIVE、一旦持仓重建又逢跌买入"的隐患。
**动了哪些文件**
config/settings.py门槛 0.60app/services/signal_service.py持仓票未采纳留痕 +
`_escalate_inflight_sells` 确认加速app/services/command_service.py`cleanup_exited_positions`
+ 纯逻辑 `_cleanup_todo` 标记app/scheduler.pycommand_poll 挂清场);
scripts/test_batch13_units.py新增 4 例:门槛分档 / 清场标记 / 确认加速 / 闭仓清场,打桩 repo
+ scripts/run_tests.py登记 batch13
**部署与判收**
factorevaluation `make deploy`(动了后端与配置,要重建容器),`make test` 见 ALL SUITES PASS
(已含 batch13。门槛降了默认值若表里 pms_runtime_param 已覆盖过该键,需在页面把
PMS_SIGNAL_SELL_CONF_MIN 也改成 0.60 才生效。真机待看:① 持仓票被喊卖时账本出现"未采纳"
留痕或提议;② 人工清仓一只票后决策系统再喊卖,该清仓指令 progress.urgent 变 True、当即出手
③ 清仓走完后该股策略变 CANCELLED、在途建仓/提议被撤、账本有 CLEANUP 行。
**还欠着什么**
当日去重仍是"先到先赢"(同股同动作一天只消化第一条够格的卖出)——先到的若落成提议、把去重键
烧掉,后到本可自动清仓的高置信卖出会被当重复丢;本轮未动它(避免过度扩面),若实盘发现高置信
卖出被前面的弱卖出挡住,再加"更强的卖出可升级已有提议为清仓"。自动清仓线 0.85 未动,按用户
选择保留,可后续按盘中体感调。
---
## 2026-08-18 · 上游信号面板改版:删下线框 + 按重要度分档 + 资金异动 z 可读化 + 待触发条
**做了什么**

View File

@ -132,6 +132,13 @@ def command_poll():
from app.services import command_service
r = command_service.plan_pending()
r.update(command_service.refresh_progress())
# 清仓完成后清场: 撤该股策略/在途建仓/相关提议 (幂等, 每只闭仓票只清一次)。
# 挂在这里而非盘中执行位, 是因为它与持仓状态相关、非交易日照样该清干净。
try:
r["cleanup"] = command_service.cleanup_exited_positions()
except Exception as e:
logger.error("[command_poll] 清场失败 (不影响命令轮询): %s", e)
r["cleanup_error"] = str(e)
return r

View File

@ -650,3 +650,143 @@ def _ledger_rejects(rejects: list, command_id: str):
reason="命令规划期规则闸拦截")
except Exception as e:
logger.warning("拒绝留痕失败: %s", e)
# ================================================================ 清仓完成后清场
CLEANUP_MARK_KEY = "PMS_EXIT_CLEANUP_DONE" # 已闭仓且已清场的代码集 (JSON)
BUILD_ACTIONS = ("OPEN", "FILL", "ADD", "DCA") # 建仓类方案/指令 (买入)
def _cleanup_todo(closed_codes, done_codes) -> tuple:
"""纯逻辑: 算出本轮要清场的代码与新的标记集 (2026-08-18, 便于单测)。
每只票在**一次闭仓周期里只清一次**: todo = 现在闭仓的 已清过的
新标记 = 现在仍闭仓的那部分 重新建仓的票会掉出闭仓集从而掉出标记,
下次它再闭仓时会被重新清一遍这条"只清一次"是防呆的关键: 闭仓后你若又下建仓命令,
持仓状态短暂还是 CLOSED, 标记挡住清场不会把你刚下的建仓计划误撤
"""
closed = set(closed_codes or ())
done = set(done_codes or ())
todo = sorted(closed - done)
new_done = closed # 只保留仍闭仓的; 重开的自然移除
return todo, new_done
def _load_cleanup_done() -> set:
try:
raw = pms_repo.get_param(CLEANUP_MARK_KEY)
import json
return set(json.loads(raw)) if raw else set()
except Exception:
return set()
def _save_cleanup_done(codes: set):
try:
import json
pms_repo.set_param(CLEANUP_MARK_KEY, json.dumps(sorted(codes)), "system")
except Exception as e:
logger.warning("[清场] 标记写入失败: %s", e)
def cleanup_exited_positions(limit: int = 500) -> dict:
"""持仓归零(清仓完成)后清场: 撤该股策略 / 在途建仓计划与买单 / 相关提议 (设计 2026-08-18)。
每分钟一跳, 幂等且防呆:
* 只对 status=CLOSED total_qty<=0 的持仓动手;
* 每次闭仓只清一次 ( _cleanup_todo 的标记), 避免把闭仓后新下的建仓命令误撤;
* 四类残留一次性清: 策略(网格/做T/跟踪) 撤下在途建仓方案作废在途买单撤回
挂着的相关提议驳回清场动作在账本留一行 (action=CLEANUP)
你清仓离场后, 网格不再空转挂着也不会因持仓被任何原因重建而复活逢跌买入
"""
from app.services import executor, strategy_service
out = {"scanned": 0, "cleaned": [], "errors": [], "note": ""}
try:
positions = pms_repo.list_positions()
except Exception as e:
return {**out, "errors": [f"读持仓失败: {type(e).__name__}: {e}"]}
closed = {p["ts_code"] for p in positions
if str(p.get("status")) == "CLOSED" and int(p.get("total_qty") or 0) <= 0}
done = _load_cleanup_done()
todo, new_done = _cleanup_todo(closed, done)
out["scanned"] = len(closed)
if not todo:
_save_cleanup_done(new_done) # 重开的票及时移出标记
out["note"] = "无新闭仓待清场" if closed else "当前无闭仓持仓"
return out
todo_set = set(todo)
# 各类残留一次性拉取, 再按待清代码过滤 (省得逐票查库)
try:
strategies = [s for s in pms_repo.list_strategies(statuses=["ACTIVE", "PAUSED"], limit=limit)
if s.get("ts_code") in todo_set]
except Exception as e:
strategies = []
out["errors"].append(f"读策略失败: {e}")
try:
plans = [pl for pl in pms_repo.list_plans(statuses=["PENDING", "GATED", "EXECUTING"],
limit=limit)
if pl.get("ts_code") in todo_set and pl.get("action") in BUILD_ACTIONS]
except Exception as e:
plans = []
out["errors"].append(f"读方案失败: {e}")
try:
buys = [i for i in pms_repo.list_instructions(statuses=list(LIVE_INSTR), side="buy",
limit=limit)
if i.get("ts_code") in todo_set]
except Exception as e:
buys = []
out["errors"].append(f"读指令失败: {e}")
try:
props = [pr for pr in pms_repo.list_proposals(statuses=("WAIT_USER",), limit=limit)
if pr.get("ts_code") in todo_set]
except Exception as e:
props = []
out["errors"].append(f"读提议失败: {e}")
per_code = {c: {"ts_code": c, "strategies": [], "plans": [], "instructions": [],
"proposals": []} for c in todo}
for s in strategies:
try:
r = strategy_service.set_status(s["strategy_id"], "CANCELLED", by="system")
if r.get("ok") or r.get("status"):
per_code[s["ts_code"]]["strategies"].append(s["strategy_id"])
except Exception as e:
out["errors"].append(f"{s.get('ts_code')} 撤策略失败: {e}")
for pl in plans:
try:
pms_repo.update_plan(pl["plan_id"], status="CANCELLED")
per_code[pl["ts_code"]]["plans"].append(pl["plan_id"])
except Exception as e:
out["errors"].append(f"{pl.get('ts_code')} 撤建仓方案失败: {e}")
for ins in buys:
try:
r = executor.cancel_instruction(ins["instruction_id"], reason="清仓完成清场: 撤在途建仓") or {}
if r.get("ok"):
per_code[ins["ts_code"]]["instructions"].append(ins["instruction_id"])
else:
out["errors"].append(f"{ins.get('ts_code')} 撤买单未成: {r.get('message') or r.get('error')}")
except Exception as e:
out["errors"].append(f"{ins.get('ts_code')} 撤买单失败: {e}")
for pr in props:
try:
if pms_repo.decide_proposal(pr["proposal_id"], "DECLINED"):
per_code[pr["ts_code"]]["proposals"].append(pr["proposal_id"])
except Exception as e:
out["errors"].append(f"{pr.get('ts_code')} 撤提议失败: {e}")
for c, acted in per_code.items():
if any(acted[k] for k in ("strategies", "plans", "instructions", "proposals")):
out["cleaned"].append(acted)
try:
pms_repo.insert_ledger(ts_code=c, action="CLEANUP", arbiter="system",
verdict="PASS", price_at=0, hard_numbers=acted,
reason="清仓完成清场: 撤策略/在途建仓/相关提议")
except Exception as e:
logger.warning("[清场] %s 留痕失败: %s", c, e)
logger.warning("[清场] %s 清仓完成, 已撤 策略%d/方案%d/买单%d/提议%d",
c, len(acted["strategies"]), len(acted["plans"]),
len(acted["instructions"]), len(acted["proposals"]))
_save_cleanup_done(new_done) # 标记本轮闭仓集 (含刚清过的), 只清一次
return out

View File

@ -145,6 +145,24 @@ def _handle(sig, view, prm, seen, ymd, dry_run, out, strat_codes=frozenset()):
if act == sr.ACT_IGNORE:
out["ignored"] += 1
# **持仓票的卖出被忽略也要留痕** (2026-08-18)。digest 对"低于门槛"返回 IGNORE,
# 而 IGNORE 原来一个字不留 —— 一只你正持有、bionic 连日喊卖的票(实测 002015 一天
# 三条、300474/300433 等), 在账本里查不到, 等于风控在示警而你看不见。未持有的票
# 不留(会被全市场卖出冲垮), 只给持仓票留, 且当日按(来源,股票,动作)去重一条。
if (not dry_run and str(sig.get("action") or "").upper() == "SELL"
and pos and int(pos.get("total_qty") or 0) > 0):
k = sr.dedup_key(sig, ymd) + ":IGN"
if k not in seen:
try:
pms_repo.insert_ledger(
ts_code=code, action="SIGNAL", arbiter="rule", verdict="NOTE",
price_at=float(pos.get("price") or 0),
hard_numbers={**d["hard_numbers"], "msg_id": sig.get("msg_id")},
reason="决策系统卖出未采纳(置信度低于门槛), 持仓票请留意: " + d["reason"][:400])
seen.add(k)
out.setdefault("held_sell_noted", []).append(code)
except Exception as e:
logger.warning("[信号消化] 持仓票卖出留痕失败 %s: %s", code, e)
return
if act == sr.ACT_RECORD:
# 只给持有的票留痕, 否则全市场广播会把评审账本冲垮
@ -187,8 +205,16 @@ def _handle(sig, view, prm, seen, ymd, dry_run, out, strat_codes=frozenset()):
out["ignored"] += 1
return
if _has_inflight(code):
# **确认即加速** (2026-08-18): 走到这里 act 已是 EXIT/PROPOSE —— 决策系统确认要卖。
# 若这只票已有在途清仓指令(多为你人工下的清仓命令), 不再简单跳过, 而是把它升级成
# 紧急直通(马上按现价卖、不等均价不分桶): 人工意图 + 风控独立确认 = 最强离场信号。
# 只升级已下发的卖出**指令**; 仅挂着提议(还没成指令)的不动, 那本就等你拍板。
esc = [] if dry_run else _escalate_inflight_sells(code, d["reason"])
out["ignored"] += 1
out.setdefault("skipped_inflight", []).append(code)
if esc:
out.setdefault("escalated", []).append({"ts_code": code, "instructions": esc})
else:
out.setdefault("skipped_inflight", []).append(code)
return
brief = {"ts_code": code, "qty": d["qty"], "confidence": d["hard_numbers"]["confidence"],
@ -328,6 +354,37 @@ def _has_inflight(code: str) -> bool:
return False
def _escalate_inflight_sells(code: str, reason: str) -> list:
"""把该股在途的卖出**指令**升级为紧急直通 (确认即加速, 2026-08-18)。
只动已下发的卖出指令 (progress.urgent True, 下一跳 run_tick 就按现价直卖);
已是紧急的不重复动每条升级在账本留一行, 供复盘"为什么这条清仓突然加速了"
仅挂着提议(还没成指令)的不在此列 那本就等用户拍板, 不该被信号自动推成紧急
"""
ids = []
try:
for ins in pms_repo.list_instructions(statuses=list(executor.LIVE), ts_code=code,
limit=50):
if str(ins.get("side") or "").lower() != "sell":
continue
prog = dict(ins.get("progress") or {})
if prog.get("urgent"):
continue
prog["urgent"] = True
prog["escalated_by_signal"] = str(reason)[:200]
pms_repo.update_instruction(ins["instruction_id"], progress=prog)
ids.append(ins["instruction_id"])
pms_repo.insert_ledger(
ts_code=code, action="EXIT", arbiter="rule", verdict="PASS", price_at=0,
ref_id=ins["instruction_id"],
reason="决策系统卖出确认, 在途清仓升级为紧急直通: " + str(reason)[:300])
logger.warning("[信号消化] %s 决策系统确认卖出, 在途清仓 %s 升级紧急直通",
code, ins["instruction_id"])
except Exception as e:
logger.warning("[信号消化] 升级在途清仓失败 %s: %s", code, e)
return ids
def _pos_of(view: dict, ts_code: str):
for x in view["positions"]:
if x["ts_code"] == ts_code:

View File

@ -244,7 +244,10 @@ class Settings(BaseSettings):
PMS_SIGNAL_CONSUMER: str = "pms_1"
PMS_SIGNAL_STREAM_INTRADAY: str = "intraday_signals:{ymd}" # db2, 每日一条流
PMS_SIGNAL_STREAM_SELL: str = "bionic:signals:llm_sell_actions" # db3, 固定 key
PMS_SIGNAL_SELL_CONF_MIN: float = 0.75 # 低于此置信度的卖出信号不消化
# 2026-08-18 实测: bionic 对持仓票的卖出置信度(0~100 证据一致性)多落在 60~84,
# 原门槛 0.75 把大量持仓票的卖出静默忽略。降到 0.60: 60~84 一律浮上来变提议(可见、
# 一键采纳), ≥85 仍自动清仓; 加上 signal_service 对持仓票的"未采纳也留痕", 不再丢信号。
PMS_SIGNAL_SELL_CONF_MIN: float = 0.60 # 低于此置信度的卖出信号不消化(仅持仓票留痕)
PMS_SIGNAL_AUTO_EXIT_CONF: float = 0.85 # 高于此置信度直接转清仓指令, 之间则落提议
PMS_SIGNAL_TRIM_RATIO: float = 0.3333 # 中等置信度时的减仓比例

View File

@ -33,7 +33,7 @@ SUITES = ["test_core_units.py", "test_batch2_units.py", "test_batch3_units.py",
"test_batch4_units.py", "test_batch5_units.py", "test_batch6_units.py",
"test_batch7_units.py", "test_batch8_units.py", "test_batch9_units.py",
"test_batch10_units.py", "test_batch11_units.py", "test_batch12_units.py",
"test_wiring.py"]
"test_batch13_units.py", "test_wiring.py"]
def main():

View File

@ -0,0 +1,192 @@
# -*- coding: utf-8 -*-
"""
第十三批模块单测 (实机运行, 零外部依赖)
========================================
运行: tradingSystem 仓库根目录执行 python scripts/test_batch13_units.py
覆盖 (2026-08-18 决策系统卖出采纳复核后的三处改动):
* 消化门槛降到 0.60 digest 的分档 (纯逻辑);
* 清仓完成清场的"每次闭仓只清一次"标记 (_cleanup_todo 纯逻辑);
* 确认即加速: 决策系统卖出命中在途清仓 只把卖出**指令**升级紧急 (打桩 repo);
* 闭仓清场: 撤策略/在途建仓/买单/提议, 幂等第二遍无动作 (打桩 repo/service)
"""
import os
import sys
import traceback
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app.core import signal_rules as sr # noqa: E402
from app.services import command_service as csvc # noqa: E402
from app.services import signal_service as ssvc # noqa: E402
from app.repo import pms_repo # noqa: E402
RESULTS = []
def case(name):
def deco(fn):
RESULTS.append((name, fn))
return fn
return deco
HELD = {"ts_code": "300474.SZ", "total_qty": 3000, "avail_qty": 3000, "price": 8.0}
PRM60 = {"sell_conf_min": 0.60, "auto_exit_conf": 0.85, "trim_ratio": 1 / 3}
# ================================================================ 门槛分档
@case("门槛 0.60·持仓票卖出: <60 忽略 / 60~84 提议 / ≥85 清仓")
def _():
def sell(conf):
return {"source": "risk_sell", "ts_code": "300474.SZ", "action": "SELL",
"confidence": conf}
assert sr.digest(sell(0.55), HELD, PRM60)["action"] == sr.ACT_IGNORE
assert sr.digest(sell(0.60), HELD, PRM60)["action"] == sr.ACT_PROPOSE # 恰好到线, 采纳
assert sr.digest(sell(0.72), HELD, PRM60)["action"] == sr.ACT_PROPOSE # 实测这一带最多
assert sr.digest(sell(0.84), HELD, PRM60)["action"] == sr.ACT_PROPOSE
assert sr.digest(sell(0.85), HELD, PRM60)["action"] == sr.ACT_EXIT
assert sr.digest(sell(0.92), HELD, PRM60)["action"] == sr.ACT_EXIT
# 未持有仍一律忽略 (不因降门槛就对没持仓的票动手)
assert sr.digest(sell(0.95), {"total_qty": 0}, PRM60)["action"] == sr.ACT_IGNORE
# ================================================================ 清场标记 (纯逻辑)
@case("清场标记·每次闭仓只清一次, 重开的票掉出标记下次再清")
def _():
# 首轮: A/B 刚闭仓, 都要清
todo, done = csvc._cleanup_todo({"A", "B"}, set())
assert todo == ["A", "B"] and done == {"A", "B"}
# 次轮: 还是 A/B 闭仓且已清过 → 不再清
todo, done = csvc._cleanup_todo({"A", "B"}, {"A", "B"})
assert todo == [] and done == {"A", "B"}
# A 重新建仓(掉出闭仓集), 新来 C 闭仓 → 只清 C; A 从标记移除
todo, done = csvc._cleanup_todo({"B", "C"}, {"A", "B"})
assert todo == ["C"] and done == {"B", "C"} and "A" not in done
# A 又闭仓 → 因已移出标记, 会被重新清
todo, done = csvc._cleanup_todo({"A", "B", "C"}, {"B", "C"})
assert todo == ["A"] and done == {"A", "B", "C"}
# ---------------------------------------------------------------- 打桩工具
class _Rec:
"""记录型假 repo/service: 存住调用, 供断言。"""
def __init__(self):
self.instr = [] # 在途指令
self.updates = {} # instruction_id -> progress
self.ledger = []
self.plans = []
self.strategies = []
self.proposals = []
self.positions = []
self.plan_cancelled = []
self.prop_declined = []
self.strat_cancelled = []
self.instr_cancelled = []
self.param = {}
def _patch_signal(rec):
pms_repo.list_instructions = lambda **kw: [i for i in rec.instr
if (kw.get("ts_code") in (None, i["ts_code"]))
and (kw.get("side") in (None, i.get("side")))]
def _upd(iid, **kw):
if "progress" in kw:
rec.updates[iid] = kw["progress"]
return 1
pms_repo.update_instruction = _upd
pms_repo.insert_ledger = lambda **kw: rec.ledger.append(kw)
@case("确认加速·只升级在途卖出指令, 买单与已紧急的不动")
def _():
rec = _Rec()
rec.instr = [
{"instruction_id": "S1", "ts_code": "600150.SH", "side": "sell", "progress": {}},
{"instruction_id": "S2", "ts_code": "600150.SH", "side": "sell",
"progress": {"urgent": True}}, # 已紧急, 不重复
{"instruction_id": "B1", "ts_code": "600150.SH", "side": "buy", "progress": {}},
]
_patch_signal(rec)
ids = ssvc._escalate_inflight_sells("600150.SH", "决策系统确认卖出")
assert ids == ["S1"], ids # 只 S1 被升级
assert rec.updates["S1"]["urgent"] is True
assert "escalated_by_signal" in rec.updates["S1"]
assert "B1" not in rec.updates and "S2" not in rec.updates
assert any(l.get("action") == "EXIT" and "紧急直通" in l.get("reason", "")
for l in rec.ledger)
def _patch_cleanup(rec):
import app.services.executor as ex
import app.services.strategy_service as strat
pms_repo.list_positions = lambda **kw: rec.positions
pms_repo.list_strategies = lambda **kw: [s for s in rec.strategies
if s.get("status") in (kw.get("statuses") or [s.get("status")])]
pms_repo.list_plans = lambda **kw: rec.plans
pms_repo.list_instructions = lambda **kw: [i for i in rec.instr
if kw.get("side") in (None, i.get("side"))]
pms_repo.list_proposals = lambda **kw: rec.proposals
pms_repo.update_plan = lambda pid, **kw: rec.plan_cancelled.append(pid) or 1
pms_repo.decide_proposal = lambda pid, st: rec.prop_declined.append((pid, st)) or 1
pms_repo.insert_ledger = lambda **kw: rec.ledger.append(kw)
pms_repo.get_param = lambda k: rec.param.get(k)
pms_repo.set_param = lambda k, v, by=None: rec.param.__setitem__(k, v)
strat.set_status = lambda sid, st, by=None: (rec.strat_cancelled.append((sid, st))
or {"ok": True})
ex.cancel_instruction = lambda iid, reason=None: (rec.instr_cancelled.append(iid)
or {"ok": True})
@case("闭仓清场·撤策略/建仓方案/买单/提议, 幂等第二遍无动作")
def _():
rec = _Rec()
rec.positions = [
{"ts_code": "300474.SZ", "status": "CLOSED", "total_qty": 0}, # 刚闭仓, 要清
{"ts_code": "600150.SH", "status": "HOLDING", "total_qty": 1700}, # 在持, 不碰
]
rec.strategies = [{"strategy_id": "G1", "ts_code": "300474.SZ", "status": "ACTIVE"},
{"strategy_id": "G2", "ts_code": "600150.SH", "status": "ACTIVE"}]
rec.plans = [{"plan_id": "P1", "ts_code": "300474.SZ", "action": "OPEN"},
{"plan_id": "P2", "ts_code": "300474.SZ", "action": "EXIT"}] # 卖出方案不撤
rec.instr = [{"instruction_id": "IB", "ts_code": "300474.SZ", "side": "buy"}]
rec.proposals = [{"proposal_id": "PR1", "ts_code": "300474.SZ", "action": "ADD"}]
_patch_cleanup(rec)
r = csvc.cleanup_exited_positions()
assert len(r["cleaned"]) == 1 and r["cleaned"][0]["ts_code"] == "300474.SZ", r
assert rec.strat_cancelled == [("G1", "CANCELLED")] # 只撤闭仓票的策略
assert rec.plan_cancelled == ["P1"] # 只撤建仓方案, 不撤 EXIT
assert rec.instr_cancelled == ["IB"]
assert rec.prop_declined == [("PR1", "DECLINED")]
assert any(l.get("action") == "CLEANUP" for l in rec.ledger)
assert "300474.SZ" in rec.param.get(csvc.CLEANUP_MARK_KEY, "")
# 幂等: 第二遍 (标记已记 300474) → 不再动任何东西
rec.strat_cancelled.clear(); rec.plan_cancelled.clear()
rec.instr_cancelled.clear(); rec.prop_declined.clear()
r2 = csvc.cleanup_exited_positions()
assert r2["cleaned"] == [] and not rec.strat_cancelled and not rec.plan_cancelled, r2
# ================================================================ runner
def main():
passed = failed = 0
for name, fn in RESULTS:
try:
fn()
print(f" PASS {name}")
passed += 1
except Exception:
print(f" FAIL {name}")
traceback.print_exc()
failed += 1
print("-" * 60)
if failed:
print(f"FAILED: {failed} / {passed + failed}")
sys.exit(1)
print(f"ALL PASS ({passed} cases)")
if __name__ == "__main__":
main()