资金快照不可信时整轮跳过刹车结算
This commit is contained in:
parent
2ae59df497
commit
889e2ef5b8
14
README.md
14
README.md
|
|
@ -67,11 +67,13 @@ scripts/
|
||||||
test_batch7_units.py 上游选股计划: 解析/新鲜度/候选筛选/取数守卫 32 例
|
test_batch7_units.py 上游选股计划: 解析/新鲜度/候选筛选/取数守卫 32 例
|
||||||
test_batch8_units.py 榜单变化: 名册指纹/三种语义/尾部闸/落库往返 60 例
|
test_batch8_units.py 榜单变化: 名册指纹/三种语义/尾部闸/落库往返 60 例
|
||||||
test_batch9_units.py 成本价体检 / 对账按日推进 / 行业闸 / 取整记账 47 例
|
test_batch9_units.py 成本价体检 / 对账按日推进 / 行业闸 / 取整记账 47 例
|
||||||
|
test_batch10_units.py 静默失败专项: 关键路径不许丢返回值 + 八条实例 33 例
|
||||||
test_wiring.py 装配自检: 服务层→核心→落表 全链路 (内存桩) 58 例
|
test_wiring.py 装配自检: 服务层→核心→落表 全链路 (内存桩) 58 例
|
||||||
init_db.py 建表 (应用 ddl_pms_v1.sql, 幂等, 默认演练; 含 DDL 体检)
|
init_db.py 建表 (应用 ddl_pms_v1.sql, 幂等, 默认演练; 含 DDL 体检)
|
||||||
check_db.py 实机连通性与表结构自检 (需真实 .env)
|
check_db.py 实机连通性与表结构自检 (需真实 .env)
|
||||||
gen_keys.py ws 通道密钥: 生成 / 只取公钥(--pubkey) / PEM(--pem) / 自检(--check)
|
gen_keys.py ws 通道密钥: 生成 / 只取公钥(--pubkey) / PEM(--pem) / 自检(--check)
|
||||||
reset_ledger.py 清空账本并把回放游标对齐到当前 (影子运行期重来一次; 不碰下游表)
|
reset_ledger.py 清空账本 + 回放游标对齐 + 归零四个账本派生参数 (连续不一致天数、
|
||||||
|
高水位、刹车解除日); 影子运行期重来一次, 绝不碰下游表
|
||||||
probe_plan_api.py 上游计划实机探活: 通不通 / 字段口径 / 候选筛选结果 / 有没有价
|
probe_plan_api.py 上游计划实机探活: 通不通 / 字段口径 / 候选筛选结果 / 有没有价
|
||||||
/ 榜单变化 (只读; --snapshot 才落库, 那是它唯一的写操作)
|
/ 榜单变化 (只读; --snapshot 才落库, 那是它唯一的写操作)
|
||||||
rebuild_ledger.py 账本重建: 预检 → 执行 → 判收 (默认只预检; --yes 才改账)
|
rebuild_ledger.py 账本重建: 预检 → 执行 → 判收 (默认只预检; --yes 才改账)
|
||||||
|
|
@ -298,7 +300,7 @@ make t-gate # 随时: 规则闸/研判闸拒了什么、为什么
|
||||||
|
|
||||||
## 已实现 / 待开发
|
## 已实现 / 待开发
|
||||||
|
|
||||||
**已实现**:建表 DDL 与建表脚本;配置与运行参数中心;仓位规划器与安全垫账;命令系统(27 类命令全目录 + 双状态机 + 冲突识别);方案生成器(降仓凑额四档、升仓、建仓分批、清仓/减至、行业清仓与限额、暂停买入撤单);账本回放与对账引擎(成交认领、外部成交并入 BASE 告警、以下游为准修正、除权检测、T+1 可用量、连续不一致升级);规则闸终检;择时执行器实现 B(分日配额、分笔、VWAP/回踩/不追高、14:45 兜底、停牌一字板顺延、窗口耗尽收口、挂单有效期);动作引擎四类自主动作 + 研判闸客户端 + 提议分流;决策系统信号消化(两条流独立消费组订阅、置信度分档转清仓指令或提议);管理页面四块 + 运维/日报抽屉;调度器九个调度位;**上游选股计划接口接入**(`/plan` 取候选池、交易日龄硬校验、`theme` 灌行业映射表、页面预览抽屉与不可用横幅);**榜单变化提示**(名册快照 + 新进/掉榜/档位升降/覆盖翻转/名次跳变,持仓票单列,榜尾截断噪音闸);**ws 直连通道的连接层**(常驻进程 + 出口队列 + 签名 + seq 水位与累积确认,见下);**单测 378 例**。
|
**已实现**:建表 DDL 与建表脚本;配置与运行参数中心;仓位规划器与安全垫账;命令系统(27 类命令全目录 + 双状态机 + 冲突识别);方案生成器(降仓凑额四档、升仓、建仓分批、清仓/减至、行业清仓与限额、暂停买入撤单);账本回放与对账引擎(成交认领、外部成交并入 BASE 告警、以下游为准修正、除权检测、T+1 可用量、连续不一致升级);规则闸终检;择时执行器实现 B(分日配额、分笔、VWAP/回踩/不追高、14:45 兜底、停牌一字板顺延、窗口耗尽收口、挂单有效期);动作引擎四类自主动作 + 研判闸客户端 + 提议分流;决策系统信号消化(两条流独立消费组订阅、置信度分档转清仓指令或提议);管理页面四块 + 运维/日报抽屉;调度器九个调度位;**上游选股计划接口接入**(`/plan` 取候选池、交易日龄硬校验、`theme` 灌行业映射表、页面预览抽屉与不可用横幅);**榜单变化提示**(名册快照 + 新进/掉榜/档位升降/覆盖翻转/名次跳变,持仓票单列,榜尾截断噪音闸);**ws 直连通道的连接层**(常驻进程 + 出口队列 + 签名 + seq 水位与累积确认,见下);**单测 384 例**。
|
||||||
|
|
||||||
### 静默失败专项(2026-07-31,八条已修)
|
### 静默失败专项(2026-07-31,八条已修)
|
||||||
|
|
||||||
|
|
@ -325,6 +327,14 @@ make t-gate # 随时: 规则闸/研判闸拒了什么、为什么
|
||||||
|
|
||||||
这条纪律由 `test_batch10_units.py` 的 **[A] 组**守着——它是全套单测里唯一一条**静态**用例:AST 扫 `app/` 全库,凡是「整条语句就是一次这类调用、返回值没被任何人接住」的写法直接判失败(`await _db(qmt_repo.update_order, ...)` 这种线程池间接调用也认)。名单在 `SOFT_FAIL`,确实可以丢的写进 `ALLOWED` 并**必须带理由**。[A2] 反过来守着扫描器本身:造一个丢返回值的调用,扫不出来就算失败——守卫写错会让 [A1] 永远绿,那比没有守卫更糟。
|
这条纪律由 `test_batch10_units.py` 的 **[A] 组**守着——它是全套单测里唯一一条**静态**用例:AST 扫 `app/` 全库,凡是「整条语句就是一次这类调用、返回值没被任何人接住」的写法直接判失败(`await _db(qmt_repo.update_order, ...)` 这种线程池间接调用也认)。名单在 `SOFT_FAIL`,确实可以丢的写进 `ALLOWED` 并**必须带理由**。[A2] 反过来守着扫描器本身:造一个丢返回值的调用,扫不出来就算失败——守卫写错会让 [A1] 永远绿,那比没有守卫更糟。
|
||||||
|
|
||||||
|
**组合刹车的高水位改成按总资产算(2026-07-31)。** 原来拿 `portfolio_mv` 当基准,那量的是「仓位有多大」而不是「我值多少钱」,于是**每一次主动减仓都会被当成回撤**:一条降仓 40% 的命令执行完,回撤算出 40% ≥ 5%,自主增持立刻被刹停 3 个交易日——用户按纪律减了仓,系统当他亏了钱。清仓、止盈卖出、清账重来三条路全中。「自高水位回撤」在任何交易语境里都是**权益回撤**,减仓时钱从市值挪到现金、总资产不动,这才是它该有的样子。
|
||||||
|
|
||||||
|
配套两条:资金快照不可信时**整轮跳过刹车结算,不拿 `cash_est` 硬判**(那是 `scale − 市值` 的虚数,拿它当权益等于换个地方犯同一个错),但跳过这件事挂在 warnings 上说出来;口径切换不需要迁移,旧的 `PMS_HIGH_WATER` 是市值量级必然小于总资产,第一次跑就被抬上来,只会往上走不会凭空造出一段回撤。
|
||||||
|
|
||||||
|
实机踩到这条的路径是清账:账本里躺过 1100 股(市值 10461)→ 高水位记成 10461 → 清空账本 → 市值归 0 → 回撤 100%。改按总资产之后钱还在账户里,回撤是 0。另外 `PMS_HIGH_WATER` 本来就是账本的派生量,`reset_ledger` 现在把 `PMS_RECON_STREAK` / `PMS_RECON_STREAK_YMD` / `PMS_HIGH_WATER` / `PMS_BRAKE_UNTIL` 四个一起归零(`RESET_PARAMS`),[D3]~[D4] 四条单测钉住。
|
||||||
|
|
||||||
|
**另一头是「没事长得像出事」,同一种病的反面**:`make t-mat` 三个列表全空时读起来像「一条都没转成」,实际多半是「早就转完了」——它只吃 `PENDING` 状态的方案,已转指令的是 `EXECUTING`、等解锁的批是 `GATED`。现在输出多了 `scanned` 与 `note`,把当前方案分布直接写出来。
|
||||||
|
|
||||||
顺带修出来的第九条,是**本轮改动自己引入的**:`positions_view` 改成「取不到现价时拿摊薄成本顶住 `price`」之后,`planner._usable` 光看 `price > 0` 就漏了——拿成本价算出来的市值会被当成真市值去凑「释放 20 万」,凑够了报 DONE 而实际卖出金额对不上。现在 `_usable` 一并排掉 `price_ok is False` 的票。
|
顺带修出来的第九条,是**本轮改动自己引入的**:`positions_view` 改成「取不到现价时拿摊薄成本顶住 `price`」之后,`planner._usable` 光看 `price > 0` 就漏了——拿成本价算出来的市值会被当成真市值去凑「释放 20 万」,凑够了报 DONE 而实际卖出金额对不上。现在 `_usable` 一并排掉 `price_ok is False` 的票。
|
||||||
|
|
||||||
### 下一步(按可动工顺序)
|
### 下一步(按可动工顺序)
|
||||||
|
|
|
||||||
|
|
@ -43,10 +43,29 @@ BUY_ACTIONS = {"OPEN", "FILL", "ADD", "DCA"}
|
||||||
|
|
||||||
# ================================================================ 方案 → 指令
|
# ================================================================ 方案 → 指令
|
||||||
def materialize_plans(limit: int = 100) -> dict:
|
def materialize_plans(limit: int = 100) -> dict:
|
||||||
"""把在途命令的可执行方案条目转成指令。幂等: 已生成过指令的方案置 EXECUTING 不再重复。"""
|
"""把在途命令的可执行方案条目转成指令。幂等: 已生成过指令的方案置 EXECUTING 不再重复。
|
||||||
out = {"created": [], "skipped": [], "errors": []}
|
|
||||||
|
**三个列表全空必须能区分出是哪一种全空** (2026-07-31): 本函数只吃 PENDING 的方案,
|
||||||
|
而已转过指令的方案是 EXECUTING、等解锁的批是 GATED。三种情况输出一模一样都是
|
||||||
|
`{created: [], skipped: [], errors: []}`, 读起来像"一条都没转成", 实际多半是
|
||||||
|
"早就转完了"。加 `scanned` 与 `note` 把它说破 —— 这是同一类"失败长得像成功"的毛病,
|
||||||
|
只不过这里是"没事长得像出事", 一样会让人白排查半天。
|
||||||
|
"""
|
||||||
|
out = {"created": [], "skipped": [], "errors": [], "scanned": 0, "note": ""}
|
||||||
plans = pms_repo.list_plans(statuses=[PLAN_PENDING], limit=limit)
|
plans = pms_repo.list_plans(statuses=[PLAN_PENDING], limit=limit)
|
||||||
|
out["scanned"] = len(plans)
|
||||||
if not plans:
|
if not plans:
|
||||||
|
other = {}
|
||||||
|
for st in (PLAN_GATED, PLAN_EXEC, PLAN_DONE):
|
||||||
|
try:
|
||||||
|
other[st] = len(pms_repo.list_plans(statuses=[st], limit=limit))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
out["plans_by_status"] = other
|
||||||
|
out["note"] = (
|
||||||
|
"没有 PENDING 方案可转 —— 这**不是**失败。当前方案分布: "
|
||||||
|
+ (", ".join(f"{k} {v} 条" for k, v in other.items() if v) or "一条方案都没有")
|
||||||
|
+ "。EXECUTING = 已经转过指令了 (看 make t-ins); GATED = 等动作引擎解锁的批")
|
||||||
return out
|
return out
|
||||||
cmd_cache = {}
|
cmd_cache = {}
|
||||||
ymd = td.ymd()
|
ymd = td.ymd()
|
||||||
|
|
|
||||||
|
|
@ -975,6 +975,19 @@ def premarket() -> dict:
|
||||||
def _settle_brake() -> dict:
|
def _settle_brake() -> dict:
|
||||||
"""组合刹车: 自高水位回撤 ≥ 阈值 → 自主增持停 N 个交易日 (命令类不受限)。
|
"""组合刹车: 自高水位回撤 ≥ 阈值 → 自主增持停 N 个交易日 (命令类不受限)。
|
||||||
|
|
||||||
|
高水位的基准是**总资产 (现金 + 持仓市值), 不是持仓市值** (2026-07-31 改)
|
||||||
|
----------------------------------------------------------------------
|
||||||
|
原来拿 `portfolio_mv` 当基准, 那量的是"仓位有多大"而不是"我值多少钱", 于是**每一次
|
||||||
|
主动减仓都会被当成回撤**: 一条降仓 40% 的命令执行完, 回撤算出 40% ≥ 5%, 自主增持立刻
|
||||||
|
被刹停 3 个交易日 —— 用户按纪律减了仓, 系统当他亏了钱。清仓、止盈卖出、清账重来
|
||||||
|
三条路全中 (清账那条 07-31 实机踩到: 高水位 10461, 清完账市值归 0, 回撤 100%)。
|
||||||
|
「自高水位回撤」在任何交易语境里都是**权益回撤**, 减仓时钱从市值挪到现金、总资产不动,
|
||||||
|
这才是它该有的样子。
|
||||||
|
|
||||||
|
资金快照不可信时**整轮跳过, 不拿估算值硬判**。`cash_est = scale − 市值` 是从参数算出来
|
||||||
|
的虚数 (见 portfolio._cash_snapshot), 拿它当权益, 减仓照样会让"权益"跳水, 等于换个地方
|
||||||
|
犯同一个错。宁可这一轮不判, 也不误判 —— 但**跳过这件事必须说出来**, 挂在 warnings 上。
|
||||||
|
|
||||||
两处 set_param 的返回值都必须接 (2026-07-31 静默失败专项):
|
两处 set_param 的返回值都必须接 (2026-07-31 静默失败专项):
|
||||||
* PMS_BRAKE_UNTIL 写不进去 = 刹车根本没踩。proposal_service 每一跳重新去
|
* PMS_BRAKE_UNTIL 写不进去 = 刹车根本没踩。proposal_service 每一跳重新去
|
||||||
param_store 读这个键判 brake_active, 读不到就是 False, 自主增持照跑。而本函数
|
param_store 读这个键判 brake_active, 读不到就是 False, 自主增持照跑。而本函数
|
||||||
|
|
@ -982,23 +995,40 @@ def _settle_brake() -> dict:
|
||||||
日报、页面、日志三处都说停了, 实际一路买穿整个回撤。
|
日报、页面、日志三处都说停了, 实际一路买穿整个回撤。
|
||||||
* PMS_HIGH_WATER 写不进去 = 高水位永远停在旧值, 回撤按陈年高点算, 阈值再也够不着。
|
* PMS_HIGH_WATER 写不进去 = 高水位永远停在旧值, 回撤按陈年高点算, 阈值再也够不着。
|
||||||
风控是慢性失效, 没有任何一处会报错。
|
风控是慢性失效, 没有任何一处会报错。
|
||||||
|
|
||||||
|
口径切换不需要迁移: 旧的 PMS_HIGH_WATER 是市值量级, 必然小于总资产, 第一次跑就会被
|
||||||
|
抬到新基准上 —— 只会往上走, 不会凭空造出一段回撤。
|
||||||
"""
|
"""
|
||||||
v = portfolio.positions_view()
|
v = portfolio.positions_view()
|
||||||
mv = v["totals"]["portfolio_mv"]
|
t = v["totals"]
|
||||||
|
mv = t["portfolio_mv"]
|
||||||
hw = param_store.get_float("PMS_HIGH_WATER", 0.0)
|
hw = param_store.get_float("PMS_HIGH_WATER", 0.0)
|
||||||
dd_limit = param_store.get_float("PMS_BRAKE_DRAWDOWN", 0.05)
|
dd_limit = param_store.get_float("PMS_BRAKE_DRAWDOWN", 0.05)
|
||||||
days = param_store.get_int("PMS_BRAKE_DAYS", 3)
|
days = param_store.get_int("PMS_BRAKE_DAYS", 3)
|
||||||
until = param_store.get_int("PMS_BRAKE_UNTIL", 0)
|
until = param_store.get_int("PMS_BRAKE_UNTIL", 0)
|
||||||
today = td.ymd()
|
today = td.ymd()
|
||||||
warnings = []
|
warnings = []
|
||||||
if mv > hw:
|
|
||||||
w = param_store.set_param("PMS_HIGH_WATER", mv, "system") or {}
|
equity = t.get("total_asset")
|
||||||
|
if t.get("cash_source") != portfolio.CASH_WS or not equity or float(equity) <= 0:
|
||||||
|
why = (t.get("cash_why")
|
||||||
|
or f"资金来源是 {t.get('cash_source')!r}, 拿不到账户总资产")
|
||||||
|
logger.warning("[刹车] 本轮跳过刹车结算 —— %s。**这一轮没有回撤保护**", why)
|
||||||
|
return {"high_water": hw, "portfolio_mv": mv, "equity": None, "drawdown": None,
|
||||||
|
"brake_until": until, "active": today < until, "engaged": False,
|
||||||
|
"skipped": True,
|
||||||
|
"warnings": [f"刹车结算已跳过, 本轮无回撤保护: {why} —— "
|
||||||
|
f"高水位要按总资产算, 而总资产只有 ws 资金快照给得出"]}
|
||||||
|
equity = float(equity)
|
||||||
|
|
||||||
|
if equity > hw:
|
||||||
|
w = param_store.set_param("PMS_HIGH_WATER", equity, "system") or {}
|
||||||
if w.get("ok"):
|
if w.get("ok"):
|
||||||
hw = mv
|
hw = equity
|
||||||
else:
|
else:
|
||||||
logger.error("[刹车] 高水位写入失败 (仍按旧值 %s 算回撤): %s", hw, w.get("error"))
|
logger.error("[刹车] 高水位写入失败 (仍按旧值 %s 算回撤): %s", hw, w.get("error"))
|
||||||
warnings.append(f"高水位未能落库, 回撤按旧高点 {hw:,.0f} 计算: {w.get('error')}")
|
warnings.append(f"高水位未能落库, 回撤按旧高点 {hw:,.0f} 计算: {w.get('error')}")
|
||||||
drawdown = (1 - mv / hw) if hw > 0 else 0.0
|
drawdown = (1 - equity / hw) if hw > 0 else 0.0
|
||||||
engaged = False
|
engaged = False
|
||||||
if hw > 0 and drawdown >= dd_limit and today >= until:
|
if hw > 0 and drawdown >= dd_limit and today >= until:
|
||||||
nxt = td.ymd(td.next_trade_day(datetime.now().date(), days))
|
nxt = td.ymd(td.next_trade_day(datetime.now().date(), days))
|
||||||
|
|
@ -1013,8 +1043,9 @@ def _settle_brake() -> dict:
|
||||||
drawdown * 100, dd_limit * 100, w.get("error"))
|
drawdown * 100, dd_limit * 100, w.get("error"))
|
||||||
warnings.append(f"**刹车未生效**: 回撤 {drawdown:.1%} 已达阈值但 "
|
warnings.append(f"**刹车未生效**: 回撤 {drawdown:.1%} 已达阈值但 "
|
||||||
f"PMS_BRAKE_UNTIL 写不进去 ({w.get('error')}), 自主增持仍在放行")
|
f"PMS_BRAKE_UNTIL 写不进去 ({w.get('error')}), 自主增持仍在放行")
|
||||||
out = {"high_water": hw, "portfolio_mv": mv, "drawdown": round(drawdown, 4),
|
out = {"high_water": hw, "portfolio_mv": mv, "equity": equity,
|
||||||
"brake_until": until, "active": today < until, "engaged": engaged}
|
"drawdown": round(drawdown, 4), "brake_until": until,
|
||||||
|
"active": today < until, "engaged": engaged, "skipped": False}
|
||||||
if warnings:
|
if warnings:
|
||||||
out["warnings"] = warnings
|
out["warnings"] = warnings
|
||||||
return out
|
return out
|
||||||
|
|
|
||||||
|
|
@ -175,21 +175,32 @@ def main():
|
||||||
print(f" FAIL 游标重置: {type(e).__name__}: {e}")
|
print(f" FAIL 游标重置: {type(e).__name__}: {e}")
|
||||||
failed.append(CURSOR_KEY)
|
failed.append(CURSOR_KEY)
|
||||||
|
|
||||||
# 对账连续不一致天数归零 —— 账本刚清空, 上一轮攒下的 streak 会让下一次对账直接判 ERROR。
|
# 跟着账本一起归零的四个运行参数 —— 它们都是**账本的派生量**, 账本清了它们不清,
|
||||||
|
# 留下的就是「记着一段不存在的历史」的状态。
|
||||||
# set_param 写不进去是**返回 ok=False 而不抛异常**, 只 try/except 的话归零失败照样
|
# set_param 写不进去是**返回 ok=False 而不抛异常**, 只 try/except 的话归零失败照样
|
||||||
# 打印 "OK ... 归零", 而下一次对账带着旧 streak 直接升 ERROR。
|
# 打印 "OK ... 归零"。
|
||||||
|
#
|
||||||
|
# PMS_RECON_STREAK / _YMD 上一轮攒下的连续不一致天数, 不清则下次对账直接判 ERROR
|
||||||
|
# PMS_HIGH_WATER 组合刹车的高水位。**这条是 2026-07-31 实机踩到的**:
|
||||||
|
# 账本里躺着 1100 股 (市值 10461) 时高水位记成 10461, 清账后
|
||||||
|
# 组合市值归 0 → 回撤算出 100% ≥ 5% → 盘前准备一跑就把自主
|
||||||
|
# 增持刹停 3 个交易日。清完账的第一件事就被刹车拦住, 而日志
|
||||||
|
# 只会说"自高水位回撤 100%", 看着像出了大事
|
||||||
|
# PMS_BRAKE_UNTIL 同上, 上一轮踩下的刹车不该跨过清账继续生效
|
||||||
|
RESET_PARAMS = ("PMS_RECON_STREAK", "PMS_RECON_STREAK_YMD",
|
||||||
|
"PMS_HIGH_WATER", "PMS_BRAKE_UNTIL")
|
||||||
try:
|
try:
|
||||||
from app.services import param_store
|
from app.services import param_store
|
||||||
for _k in ("PMS_RECON_STREAK", "PMS_RECON_STREAK_YMD"):
|
for _k in RESET_PARAMS:
|
||||||
|
old = param_store.get(_k, 0)
|
||||||
w = param_store.set_param(_k, 0, "reset_ledger") or {}
|
w = param_store.set_param(_k, 0, "reset_ledger") or {}
|
||||||
if w.get("ok"):
|
if w.get("ok"):
|
||||||
print(f" OK {_k} 归零")
|
print(f" OK {_k} 归零 (原值 {old})")
|
||||||
else:
|
else:
|
||||||
print(f" FAIL {_k} 归零失败: {w.get('error')} —— "
|
print(f" FAIL {_k} 归零失败: {w.get('error')}")
|
||||||
f"下次对账会带着旧的连续天数直接升 ERROR")
|
|
||||||
failed.append(_k)
|
failed.append(_k)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f" FAIL 对账连续天数归零失败: {type(e).__name__}: {e}")
|
print(f" FAIL 账本派生参数归零失败: {type(e).__name__}: {e}")
|
||||||
failed.append("PMS_RECON_STREAK")
|
failed.append("PMS_RECON_STREAK")
|
||||||
|
|
||||||
if args.reset_ws:
|
if args.reset_ws:
|
||||||
|
|
|
||||||
|
|
@ -14,9 +14,9 @@
|
||||||
test_batch7_units.py 上游选股计划: 解析/新鲜度/候选筛选/取数守卫 (32 例)
|
test_batch7_units.py 上游选股计划: 解析/新鲜度/候选筛选/取数守卫 (32 例)
|
||||||
test_batch8_units.py 榜单变化: 名册指纹/三种语义/尾部闸/落库往返 (60 例)
|
test_batch8_units.py 榜单变化: 名册指纹/三种语义/尾部闸/落库往返 (60 例)
|
||||||
test_batch9_units.py 成本价体检 / 对账按日推进 / 行业闸 / 取整记账 (47 例)
|
test_batch9_units.py 成本价体检 / 对账按日推进 / 行业闸 / 取整记账 (47 例)
|
||||||
test_batch10_units.py 静默失败专项: 关键路径不许丢返回值 + 八条实例 (27 例)
|
test_batch10_units.py 静默失败专项: 关键路径不许丢返回值 + 八条实例 (33 例)
|
||||||
test_wiring.py 装配自检: 服务层→核心→落表 全链路 (内存桩) (58 例)
|
test_wiring.py 装配自检: 服务层→核心→落表 全链路 (内存桩) (58 例)
|
||||||
共 378 例
|
共 384 例
|
||||||
任一子集失败即整体失败 (退出码 1)。
|
任一子集失败即整体失败 (退出码 1)。
|
||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
|
|
|
||||||
|
|
@ -243,6 +243,13 @@ def _():
|
||||||
# ================================================================
|
# ================================================================
|
||||||
# [D] 组合刹车: 该踩没踩上必须报错
|
# [D] 组合刹车: 该踩没踩上必须报错
|
||||||
# ================================================================
|
# ================================================================
|
||||||
|
def _fake_totals(mv, equity, source="ws", why=""):
|
||||||
|
"""positions_view 的最小桩: 只喂刹车结算要看的那几个字段。"""
|
||||||
|
return lambda **kw: {"held": [], "positions": [], "params": {},
|
||||||
|
"totals": {"portfolio_mv": mv, "total_asset": equity,
|
||||||
|
"cash_source": source, "cash_why": why}}
|
||||||
|
|
||||||
|
|
||||||
@case("[D1] PMS_BRAKE_UNTIL 写入失败 → 盘前准备报 errors, 不许 ok=True")
|
@case("[D1] PMS_BRAKE_UNTIL 写入失败 → 盘前准备报 errors, 不许 ok=True")
|
||||||
def _():
|
def _():
|
||||||
from test_wiring import install_fakes
|
from test_wiring import install_fakes
|
||||||
|
|
@ -250,10 +257,8 @@ def _():
|
||||||
install_fakes(prices={})
|
install_fakes(prices={})
|
||||||
orig_set, orig_view = param_store.set_param, portfolio.positions_view
|
orig_set, orig_view = param_store.set_param, portfolio.positions_view
|
||||||
try:
|
try:
|
||||||
# 高水位 100 万, 现值 80 万 → 回撤 20%, 远超默认 5%, 必须踩刹车
|
# 高水位 100 万, 总资产 80 万 → 回撤 20%, 远超默认 5%, 必须踩刹车
|
||||||
portfolio.positions_view = lambda **kw: {
|
portfolio.positions_view = _fake_totals(mv=700000.0, equity=800000.0)
|
||||||
"held": [], "positions": [], "params": {},
|
|
||||||
"totals": {"portfolio_mv": 800000.0}}
|
|
||||||
param_store.set_param = lambda k, v, by="user": (
|
param_store.set_param = lambda k, v, by="user": (
|
||||||
{"ok": True} if k == "PMS_HIGH_WATER" else {"ok": False, "error": "库挂了"})
|
{"ok": True} if k == "PMS_HIGH_WATER" else {"ok": False, "error": "库挂了"})
|
||||||
orig_get = param_store.get_float
|
orig_get = param_store.get_float
|
||||||
|
|
@ -276,9 +281,7 @@ def _():
|
||||||
install_fakes(prices={})
|
install_fakes(prices={})
|
||||||
orig_set, orig_view = param_store.set_param, portfolio.positions_view
|
orig_set, orig_view = param_store.set_param, portfolio.positions_view
|
||||||
try:
|
try:
|
||||||
portfolio.positions_view = lambda **kw: {
|
portfolio.positions_view = _fake_totals(mv=100000.0, equity=500000.0)
|
||||||
"held": [], "positions": [], "params": {},
|
|
||||||
"totals": {"portfolio_mv": 500000.0}}
|
|
||||||
param_store.set_param = lambda k, v, by="user": {"ok": False, "error": "库挂了"}
|
param_store.set_param = lambda k, v, by="user": {"ok": False, "error": "库挂了"}
|
||||||
b = ls._settle_brake()
|
b = ls._settle_brake()
|
||||||
assert b["high_water"] == 0.0, b # 没写进去就不许当成写进去了
|
assert b["high_water"] == 0.0, b # 没写进去就不许当成写进去了
|
||||||
|
|
@ -287,6 +290,85 @@ def _():
|
||||||
param_store.set_param, portfolio.positions_view = orig_set, orig_view
|
param_store.set_param, portfolio.positions_view = orig_set, orig_view
|
||||||
|
|
||||||
|
|
||||||
|
@case("[D3] 高水位按总资产算: **主动减仓不该被当成回撤**")
|
||||||
|
def _():
|
||||||
|
from test_wiring import install_fakes
|
||||||
|
from app.services import ledger_service as ls, param_store, portfolio
|
||||||
|
install_fakes(prices={})
|
||||||
|
orig_view = portfolio.positions_view
|
||||||
|
try:
|
||||||
|
# 100 万全在持仓, 总资产 100 万 → 高水位 100 万
|
||||||
|
portfolio.positions_view = _fake_totals(mv=1000000.0, equity=1000000.0)
|
||||||
|
ls._settle_brake()
|
||||||
|
assert param_store.get_float("PMS_HIGH_WATER") == 1000000.0
|
||||||
|
|
||||||
|
# 一条降仓 40% 的命令执行完: 市值 60 万、现金 40 万, 总资产没变。
|
||||||
|
# 按市值算会判成"回撤 40%"直接刹停 3 天 —— 用户按纪律减了仓, 系统当他亏了钱。
|
||||||
|
portfolio.positions_view = _fake_totals(mv=600000.0, equity=1000000.0)
|
||||||
|
b = ls._settle_brake()
|
||||||
|
assert b["drawdown"] == 0.0 and b["engaged"] is False, b
|
||||||
|
|
||||||
|
# 真亏钱才该刹: 总资产掉到 92 万 → 回撤 8% ≥ 5%
|
||||||
|
portfolio.positions_view = _fake_totals(mv=520000.0, equity=920000.0)
|
||||||
|
b = ls._settle_brake()
|
||||||
|
assert abs(b["drawdown"] - 0.08) < 1e-9 and b["engaged"] is True, b
|
||||||
|
finally:
|
||||||
|
portfolio.positions_view = orig_view
|
||||||
|
|
||||||
|
|
||||||
|
@case("[D3b] 清空账本后不许误刹车 (账本清了但高水位还在的那一幕)")
|
||||||
|
def _():
|
||||||
|
from test_wiring import install_fakes
|
||||||
|
from app.services import ledger_service as ls, param_store, portfolio
|
||||||
|
install_fakes(prices={})
|
||||||
|
orig_view = portfolio.positions_view
|
||||||
|
try:
|
||||||
|
# 2026-07-31 实机那一幕: 账本里躺过 1100 股 → 高水位被顶起来, 然后账本被清空。
|
||||||
|
# 按市值算的话回撤 100%, 清个账就把自主增持刹停 3 天。按总资产算, 钱还在账户里,
|
||||||
|
# 回撤是 0 —— 这才对。
|
||||||
|
param_store.set_param("PMS_HIGH_WATER", 10461.0, "test")
|
||||||
|
param_store.set_param("PMS_BRAKE_UNTIL", 0, "test")
|
||||||
|
portfolio.positions_view = _fake_totals(mv=0.0, equity=2000000.0)
|
||||||
|
b = ls._settle_brake()
|
||||||
|
assert b["drawdown"] == 0.0 and b["engaged"] is False, b
|
||||||
|
# 旧口径的高水位是市值量级, 必然小于总资产 → 第一次跑就被抬上来, 不需要迁移
|
||||||
|
assert b["high_water"] == 2000000.0, b
|
||||||
|
finally:
|
||||||
|
portfolio.positions_view = orig_view
|
||||||
|
|
||||||
|
|
||||||
|
@case("[D3c] 资金快照不可信 → 整轮跳过刹车结算, 且**必须说出来**")
|
||||||
|
def _():
|
||||||
|
from test_wiring import install_fakes
|
||||||
|
from app.services import ledger_service as ls, portfolio
|
||||||
|
install_fakes(prices={})
|
||||||
|
orig_view = portfolio.positions_view
|
||||||
|
try:
|
||||||
|
# cash_est 是 scale−市值 的虚数, 拿它当权益, 减仓照样会让"权益"跳水 ——
|
||||||
|
# 换个地方犯同一个错。宁可这一轮不判, 但不判这件事不能没人知道。
|
||||||
|
portfolio.positions_view = _fake_totals(
|
||||||
|
mv=600000.0, equity=None, source="estimate",
|
||||||
|
why="ws 从未回过 funds 快照 (对端未实现 query_funds)")
|
||||||
|
b = ls._settle_brake()
|
||||||
|
assert b["skipped"] is True and b["engaged"] is False, b
|
||||||
|
assert b["drawdown"] is None, b
|
||||||
|
assert any("本轮无回撤保护" in w and "query_funds" in w
|
||||||
|
for w in b.get("warnings", [])), b
|
||||||
|
finally:
|
||||||
|
portfolio.positions_view = orig_view
|
||||||
|
|
||||||
|
|
||||||
|
@case("[D4] reset_ledger 的归零清单必须盖住这四个账本派生参数")
|
||||||
|
def _():
|
||||||
|
import re
|
||||||
|
src = open(os.path.join(ROOT, "scripts", "reset_ledger.py"), encoding="utf-8").read()
|
||||||
|
m = re.search(r"RESET_PARAMS\s*=\s*\(([^)]*)\)", src, re.S)
|
||||||
|
assert m, "reset_ledger 里找不到 RESET_PARAMS"
|
||||||
|
got = set(re.findall(r'"([A-Z_]+)"', m.group(1)))
|
||||||
|
need = {"PMS_RECON_STREAK", "PMS_RECON_STREAK_YMD", "PMS_HIGH_WATER", "PMS_BRAKE_UNTIL"}
|
||||||
|
assert need <= got, f"清账时漏了这些派生参数: {need - got}"
|
||||||
|
|
||||||
|
|
||||||
# ================================================================
|
# ================================================================
|
||||||
# [E] 安全垫连负天数: 写不上要让日终结算整体报失败
|
# [E] 安全垫连负天数: 写不上要让日终结算整体报失败
|
||||||
# ================================================================
|
# ================================================================
|
||||||
|
|
@ -600,6 +682,36 @@ def _():
|
||||||
assert param_store.get("PMS_GLOBAL_BUY_HALT") is not True, "误伤: 没设过被当成读不到"
|
assert param_store.get("PMS_GLOBAL_BUY_HALT") is not True, "误伤: 没设过被当成读不到"
|
||||||
|
|
||||||
|
|
||||||
|
# ================================================================
|
||||||
|
# [L] 「没事」不许长得像「出事」: 全空输出要说清是哪一种空
|
||||||
|
# ================================================================
|
||||||
|
@case("[L1] 没有 PENDING 方案时, t-mat 要说破是「早就转完了」而不是「一条都没转成」")
|
||||||
|
def _():
|
||||||
|
from test_wiring import install_fakes
|
||||||
|
from app.services import executor
|
||||||
|
fake = install_fakes(prices={})
|
||||||
|
fake.plans.extend([
|
||||||
|
{"id": 1, "plan_id": "P1", "command_id": "C1", "ts_code": "600000.SH",
|
||||||
|
"action": "OPEN", "qty": 100, "status": "EXECUTING", "filled_qty": 0},
|
||||||
|
{"id": 2, "plan_id": "P2", "command_id": "C1", "ts_code": "600000.SH",
|
||||||
|
"action": "FILL", "qty": 100, "status": "GATED", "filled_qty": 0}])
|
||||||
|
r = executor.materialize_plans()
|
||||||
|
assert r["created"] == [] and r["skipped"] == [] and r["errors"] == [], r
|
||||||
|
assert r["scanned"] == 0, r
|
||||||
|
assert "不是**失败" in r["note"], r["note"]
|
||||||
|
assert r["plans_by_status"].get("EXECUTING") == 1, r
|
||||||
|
assert r["plans_by_status"].get("GATED") == 1, r
|
||||||
|
|
||||||
|
|
||||||
|
@case("[L2] 一条方案都没有时也说得清 (与「转完了」是两回事)")
|
||||||
|
def _():
|
||||||
|
from test_wiring import install_fakes
|
||||||
|
from app.services import executor
|
||||||
|
install_fakes(prices={})
|
||||||
|
r = executor.materialize_plans()
|
||||||
|
assert r["scanned"] == 0 and "一条方案都没有" in r["note"], r
|
||||||
|
|
||||||
|
|
||||||
# ================================================================
|
# ================================================================
|
||||||
def main():
|
def main():
|
||||||
ok = fail = 0
|
ok = fail = 0
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue