处理对应bug
This commit is contained in:
parent
72d35e7fc9
commit
05a4fee86c
20
README.md
20
README.md
|
|
@ -66,7 +66,7 @@ scripts/
|
||||||
test_batch6_units.py ws 通道: 测试向量/签名/公钥/水位/DDL/逐笔入账 65 例
|
test_batch6_units.py ws 通道: 测试向量/签名/公钥/水位/DDL/逐笔入账 65 例
|
||||||
test_batch7_units.py 上游选股计划: 解析/新鲜度/候选筛选/取数守卫 32 例
|
test_batch7_units.py 上游选股计划: 解析/新鲜度/候选筛选/取数守卫 32 例
|
||||||
test_batch8_units.py 榜单变化: 名册指纹/三种语义/尾部闸/落库往返 60 例
|
test_batch8_units.py 榜单变化: 名册指纹/三种语义/尾部闸/落库往返 60 例
|
||||||
test_batch9_units.py 成本价体检 + 连续不一致按日推进 31 例
|
test_batch9_units.py 成本价体检 / 对账按日推进 / 行业闸双判据 42 例
|
||||||
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)
|
||||||
|
|
@ -247,6 +247,22 @@ make t-book # A9 看账本
|
||||||
make t-gate # 随时: 规则闸/研判闸拒了什么、为什么
|
make t-gate # 随时: 规则闸/研判闸拒了什么、为什么
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 行业占比闸:占比超 + 绝对敞口够大,两条同时成立才拦
|
||||||
|
|
||||||
|
`sizer.check_caps` 的 `SECTOR_RATIO` 原本只看「该行业占组合的比例」。这在建仓期是**结构性不可满足**的:空账本买第一只票,它按定义就是组合的 100%,必然超任何小于 100% 的上限;而它被拒之后组合市值不推进,第二只第三只面对的还是 100%——**哪怕候选分属十个不同行业也全军覆没**。数学上,上限 40% 时至少要 3 只不同行业的票同时在组合里才可能满足,而建仓是逐只累加的,所以调松阈值解决不了。
|
||||||
|
|
||||||
|
2026-07-31 实机撞上:清账后空账本 + 10 只强传导候选 + 一条 60% 升仓命令 → 一条方案都出不来,报出来却是「候选与补仓空间不足,缺口 1,200,000 元」。这个洞一直藏着,是因为行业源当天才通(此前 `sector` 恒为 `None`、整段判据被跳过),恰好又赶上账本清空。
|
||||||
|
|
||||||
|
改成两条判据同时成立才拦:**该行业占组合 > `PMS_SECTOR_MAX_RATIO`**,**且**该行业占总规模 > `PMS_SECTOR_MAX_RATIO × PMS_PORTFOLIO_CAP`(默认 40%×60% = 24%)。道理是集中度是风险的**放大器**而不是风险本身——敞口只有规模 6% 的时候,它 100% 集中在一个行业也谈不上风险。绝对线这样取还有个性质:组合在总仓上限以内时,「占规模超绝对线」必然蕴含「占组合超上限」,所以这条判据**只会放宽建仓初期、不会额外拦人**,组合建起来之后口径与原来完全一致(有单测锁住这条性质)。
|
||||||
|
|
||||||
|
同一行业的只数上限(`PMS_SECTOR_MAX_NAMES`,默认 4)不受影响,照常生效。
|
||||||
|
|
||||||
|
### 零方案的任务命令置 CANCELLED,不是 DONE
|
||||||
|
|
||||||
|
一条**该**产出方案的任务命令一条都没产出,那不是「完成」,是「没发生」。原来它和开关类命令(`HALT_BUY` 这种下达即完成的)共用 `DONE`,后果是页面显示已完成、`cancel()` 又因为 `DONE` 不在 `ACTIVE_TASK_STATES` 里而拒绝撤销——用户既看不出没执行成,也退不回来,日报还会把它算进完成的命令。现在这种情况置 `CANCELLED` 并把原因写进 `note`。
|
||||||
|
|
||||||
|
同时命令进度里加了 `reject_summary`:把 planner 的 `rejects` 聚合成一行人话(`SECTOR_RATIO 10 只 (600000.SH…); STOCK_CAP 2 只`)。planner 原来的 note 只会说「候选与补仓空间不足」,那读起来像「没票可买」,而真相往往是有一堆候选、全被同一道闸拒了——**「拒了 10 只」和「没有候选」是两件完全不同的事,长得却一样。**
|
||||||
|
|
||||||
**`make t-gate` 是联测时最该盯的一张表。** 方案里的票被拦掉时,它是唯一能回答「为什么这只没进去」的地方——单看方案和指令只能看到「少了几只」,看不到是撞了单股上限、一手不可行、行业集中度,还是不追高。
|
**`make t-gate` 是联测时最该盯的一张表。** 方案里的票被拦掉时,它是唯一能回答「为什么这只没进去」的地方——单看方案和指令只能看到「少了几只」,看不到是撞了单股上限、一手不可行、行业集中度,还是不追高。
|
||||||
|
|
||||||
## 自主提议的分流(设计 §6 / §7)
|
## 自主提议的分流(设计 §6 / §7)
|
||||||
|
|
@ -269,7 +285,7 @@ make t-gate # 随时: 规则闸/研判闸拒了什么、为什么
|
||||||
|
|
||||||
## 已实现 / 待开发
|
## 已实现 / 待开发
|
||||||
|
|
||||||
**已实现**:建表 DDL 与建表脚本;配置与运行参数中心;仓位规划器与安全垫账;命令系统(27 类命令全目录 + 双状态机 + 冲突识别);方案生成器(降仓凑额四档、升仓、建仓分批、清仓/减至、行业清仓与限额、暂停买入撤单);账本回放与对账引擎(成交认领、外部成交并入 BASE 告警、以下游为准修正、除权检测、T+1 可用量、连续不一致升级);规则闸终检;择时执行器实现 B(分日配额、分笔、VWAP/回踩/不追高、14:45 兜底、停牌一字板顺延、窗口耗尽收口、挂单有效期);动作引擎四类自主动作 + 研判闸客户端 + 提议分流;决策系统信号消化(两条流独立消费组订阅、置信度分档转清仓指令或提议);管理页面四块 + 运维/日报抽屉;调度器九个调度位;**上游选股计划接口接入**(`/plan` 取候选池、交易日龄硬校验、`theme` 灌行业映射表、页面预览抽屉与不可用横幅);**榜单变化提示**(名册快照 + 新进/掉榜/档位升降/覆盖翻转/名次跳变,持仓票单列,榜尾截断噪音闸);**ws 直连通道的连接层**(常驻进程 + 出口队列 + 签名 + seq 水位与累积确认,见下);**单测 335 例**。
|
**已实现**:建表 DDL 与建表脚本;配置与运行参数中心;仓位规划器与安全垫账;命令系统(27 类命令全目录 + 双状态机 + 冲突识别);方案生成器(降仓凑额四档、升仓、建仓分批、清仓/减至、行业清仓与限额、暂停买入撤单);账本回放与对账引擎(成交认领、外部成交并入 BASE 告警、以下游为准修正、除权检测、T+1 可用量、连续不一致升级);规则闸终检;择时执行器实现 B(分日配额、分笔、VWAP/回踩/不追高、14:45 兜底、停牌一字板顺延、窗口耗尽收口、挂单有效期);动作引擎四类自主动作 + 研判闸客户端 + 提议分流;决策系统信号消化(两条流独立消费组订阅、置信度分档转清仓指令或提议);管理页面四块 + 运维/日报抽屉;调度器九个调度位;**上游选股计划接口接入**(`/plan` 取候选池、交易日龄硬校验、`theme` 灌行业映射表、页面预览抽屉与不可用横幅);**榜单变化提示**(名册快照 + 新进/掉榜/档位升降/覆盖翻转/名次跳变,持仓票单列,榜尾截断噪音闸);**ws 直连通道的连接层**(常驻进程 + 出口队列 + 签名 + seq 水位与累积确认,见下);**单测 346 例**。
|
||||||
|
|
||||||
### 下一步(按可动工顺序)
|
### 下一步(按可动工顺序)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -550,3 +550,38 @@ def _open_batch_items(ts_code, sp, price, reason_prefix="建仓"):
|
||||||
f"{reason_prefix}: {nm} 批 {b['qty']} 股 —— {gate_reason.get(nm, '')}",
|
f"{reason_prefix}: {nm} 批 {b['qty']} 股 —— {gate_reason.get(nm, '')}",
|
||||||
tier=f"批次_{nm}", gated=(nm != "BASE")))
|
tier=f"批次_{nm}", gated=(nm != "BASE")))
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# ================================================================
|
||||||
|
# 四、拒绝原因聚合
|
||||||
|
# ================================================================
|
||||||
|
def summarize_rejects(rejects) -> str:
|
||||||
|
"""把 rejects 清单聚合成一行人话。
|
||||||
|
|
||||||
|
存在的理由 (2026-07-31 实机): 空账本 + 10 只强传导候选 + 一条 60% 升仓命令, 方案一条都
|
||||||
|
没出, 页面只显示 planner 的 note 「候选与补仓空间不足, 缺口 1,200,000 元」—— 读起来像
|
||||||
|
"没票可买", 而真相是十只候选全被同一道行业闸拒了。原因就在 `rejects` 里, 只是没人把它
|
||||||
|
端到面上。**"拒了 10 只"和"没有候选"是两件完全不同的事, 长得却一样。**
|
||||||
|
|
||||||
|
输出按拒绝码归类计数, 多的排前面:
|
||||||
|
"SECTOR_RATIO 10 只 (600000.SH…); STOCK_CAP 2 只 (600519.SH…)"
|
||||||
|
"""
|
||||||
|
if not rejects:
|
||||||
|
return ""
|
||||||
|
by = {}
|
||||||
|
for r in rejects or []:
|
||||||
|
code = r.get("ts_code")
|
||||||
|
for reason in (r.get("reasons") or []):
|
||||||
|
# 判据码是冒号前那一段 (SECTOR_RATIO / STOCK_CAP / MAX_NAMES …);
|
||||||
|
# 一手不可行那类没有码, 整句截断当分类名
|
||||||
|
text = str(reason)
|
||||||
|
key = text.split(":", 1)[0].strip() if ":" in text[:24] else text[:20]
|
||||||
|
slot = by.setdefault(key, {"n": 0, "codes": []})
|
||||||
|
slot["n"] += 1
|
||||||
|
if code and code not in slot["codes"]:
|
||||||
|
slot["codes"].append(code)
|
||||||
|
parts = []
|
||||||
|
for key, v in sorted(by.items(), key=lambda kv: -kv[1]["n"]):
|
||||||
|
codes = ", ".join(v["codes"][:3]) + ("…" if len(v["codes"]) > 3 else "")
|
||||||
|
parts.append(f"{key} {v['n']} 只" + (f" ({codes})" if codes else ""))
|
||||||
|
return "; ".join(parts)
|
||||||
|
|
|
||||||
|
|
@ -101,10 +101,30 @@ def check_caps(*, ts_code: str, add_amount: float, ctx: dict) -> list:
|
||||||
if ctx.get("is_new_name") and ctx.get("sector_names", 0) + 1 > ctx["sector_max_names"]:
|
if ctx.get("is_new_name") and ctx.get("sector_names", 0) + 1 > ctx["sector_max_names"]:
|
||||||
v.append(f"SECTOR_NAMES: 行业[{sector}]将达 {ctx['sector_names'] + 1} 只 "
|
v.append(f"SECTOR_NAMES: 行业[{sector}]将达 {ctx['sector_names'] + 1} 只 "
|
||||||
f"> 上限 {ctx['sector_max_names']}")
|
f"> 上限 {ctx['sector_max_names']}")
|
||||||
|
# 行业占比: **占组合超上限 且 绝对敞口够大**, 两条同时成立才拦 (2026-07-31 定)
|
||||||
|
# ---------------------------------------------------------------------
|
||||||
|
# 只看"占组合"的话, 这道闸在建仓期是**结构性不可满足**的: 空账本买第一只票,
|
||||||
|
# 它按定义就是组合的 100%, 必然 > 任何小于 100% 的上限; 而第一只被拒之后组合市值
|
||||||
|
# 不推进, 第二只第三只面对的还是 100% —— 哪怕候选分属十个不同行业也全军覆没。
|
||||||
|
# 实测 2026-07-31: 空账本 + 10 只强传导候选 + 一条 60% 升仓命令 → 一条方案都出不来,
|
||||||
|
# 报出来却是"候选与补仓空间不足"。行业源是当天才通的 (此前 sector 恒为 None、整段
|
||||||
|
# 跳过), 所以这个洞一直藏着。数学上上限 40% 至少要 3 只不同行业的票同时在组合里才
|
||||||
|
# 可能满足, 调松阈值解决不了。
|
||||||
|
#
|
||||||
|
# 加"绝对敞口"这条判据的道理: 集中度是风险的**放大器**, 不是风险本身 —— 敞口只有
|
||||||
|
# 规模 6% 的时候, 它 100% 集中在一个行业也谈不上风险。绝对线取
|
||||||
|
# `sector_max_ratio × portfolio_cap`, 于是组合建满到总仓上限时两条判据自然趋同
|
||||||
|
# (40% × 60% = 24% of scale ⟺ 40% of portfolio), 中间是连续过渡, 没有"第几只突然
|
||||||
|
# 开始生效"的台阶。
|
||||||
port_after = ctx["portfolio_mv"] + add_amount
|
port_after = ctx["portfolio_mv"] + add_amount
|
||||||
if port_after > 0 and (ctx.get("sector_mv", 0) + add_amount) / port_after > ctx["sector_max_ratio"] + 1e-9:
|
sector_after = ctx.get("sector_mv", 0) + add_amount
|
||||||
v.append(f"SECTOR_RATIO: 行业[{sector}]占总仓将达 "
|
ratio_max = ctx["sector_max_ratio"]
|
||||||
f"{(ctx['sector_mv'] + add_amount) / port_after:.1%} > 上限 {ctx['sector_max_ratio']:.0%}")
|
of_port = (sector_after / port_after) if port_after > 0 else 0.0
|
||||||
|
of_scale = sector_after / scale
|
||||||
|
floor = ratio_max * float(ctx.get("portfolio_cap") or 1.0)
|
||||||
|
if of_port > ratio_max + 1e-9 and of_scale > floor + 1e-9:
|
||||||
|
v.append(f"SECTOR_RATIO: 行业[{sector}]占组合将达 {of_port:.1%} > 上限 "
|
||||||
|
f"{ratio_max:.0%}, 且占规模 {of_scale:.1%} > 绝对线 {floor:.1%}")
|
||||||
return v
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -238,19 +238,42 @@ def plan_command(cmd: dict) -> dict:
|
||||||
if spec.get("switch_key") is not None:
|
if spec.get("switch_key") is not None:
|
||||||
param_store.set_param(spec["switch_key"], spec.get("switch_value"), "command")
|
param_store.set_param(spec["switch_key"], spec.get("switch_value"), "command")
|
||||||
|
|
||||||
|
rejects = result.get("rejects") or []
|
||||||
progress = {"target_amount": result.get("target_amount", 0.0),
|
progress = {"target_amount": result.get("target_amount", 0.0),
|
||||||
"planned_amount": result.get("planned_amount", 0.0),
|
"planned_amount": result.get("planned_amount", 0.0),
|
||||||
"done_amount": 0.0, "gap": result.get("gap", 0.0),
|
"done_amount": 0.0, "gap": result.get("gap", 0.0),
|
||||||
"plan_count": len(rows), "deadline": str(deadline),
|
"plan_count": len(rows), "deadline": str(deadline),
|
||||||
"notes": result.get("notes", []), "rejects": result.get("rejects", []),
|
"notes": result.get("notes", []), "rejects": rejects,
|
||||||
|
# 一行话说清"为什么只有这么少 / 一条都没有"。planner 的 notes 只会说
|
||||||
|
# "候选与补仓空间不足, 缺口 X 元" —— 那读起来像"没票可买", 而真相往往是
|
||||||
|
# 有一堆候选、全被同一道闸拒了。不聚合出来就得去翻 rejects 原始清单。
|
||||||
|
"reject_summary": pl.summarize_rejects(rejects),
|
||||||
"cancelled_instructions": cancelled}
|
"cancelled_instructions": cancelled}
|
||||||
|
|
||||||
instant = bool(spec.get("instant")) or not rows
|
# 零方案的两种情形必须分开 (2026-07-31 修)
|
||||||
status = cs.ST_DONE if instant else cs.ST_EXECUTING
|
# ------------------------------------------------------------------
|
||||||
pms_repo.update_command(command_id, status=status, progress=progress,
|
# 开关类命令 (HALT_BUY / RESUME_ALL …) 本来就不产出方案, 那是 DONE。
|
||||||
done_at=datetime.now() if status == cs.ST_DONE else None)
|
# 但一条**该**产出方案的任务命令一条都没产出, 它不是"完成", 是"没发生" —— 原来两者
|
||||||
|
# 都走 DONE, 于是页面显示已完成、`cancel()` 又因为 DONE 不在 ACTIVE_TASK_STATES 里而
|
||||||
|
# 拒绝撤销, 用户既看不出没执行成、也退不回来。日报统计也会把它算成完成的命令。
|
||||||
|
# 次序要紧: `instant` 是**命令规格**的属性 (下达即完成), 优先于有没有方案 ——
|
||||||
|
# HALT_BUY 这类开关命令照样会产出撤单动作 (rows 非空), 但它下达完就该是 DONE。
|
||||||
|
if spec.get("instant"):
|
||||||
|
status = cs.ST_DONE
|
||||||
|
elif rows:
|
||||||
|
status = cs.ST_EXECUTING
|
||||||
|
else:
|
||||||
|
status = cs.ST_CANCELLED
|
||||||
|
note = None
|
||||||
|
if status == cs.ST_CANCELLED:
|
||||||
|
note = ("未产出任何方案: " + (progress["reject_summary"] or "候选池为空"))[:280]
|
||||||
|
logger.warning("[命令] %s %s 未产出任何方案 → 置 CANCELLED。%s",
|
||||||
|
command_id, cmd_type, note)
|
||||||
|
pms_repo.update_command(command_id, status=status, progress=progress, note=note,
|
||||||
|
done_at=(datetime.now()
|
||||||
|
if status in (cs.ST_DONE, cs.ST_CANCELLED) else None))
|
||||||
|
|
||||||
_ledger_rejects(result.get("rejects") or [], command_id)
|
_ledger_rejects(rejects, command_id)
|
||||||
return {"ok": True, "command_id": command_id, "status": status, "plan": progress,
|
return {"ok": True, "command_id": command_id, "status": status, "plan": progress,
|
||||||
"items": items, "errors": []}
|
"items": items, "errors": []}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,9 +13,9 @@
|
||||||
test_batch6_units.py ws 通道: 测试向量/签名/公钥/水位/DDL/逐笔入账 (65 例)
|
test_batch6_units.py ws 通道: 测试向量/签名/公钥/水位/DDL/逐笔入账 (65 例)
|
||||||
test_batch7_units.py 上游选股计划: 解析/新鲜度/候选筛选/取数守卫 (32 例)
|
test_batch7_units.py 上游选股计划: 解析/新鲜度/候选筛选/取数守卫 (32 例)
|
||||||
test_batch8_units.py 榜单变化: 名册指纹/三种语义/尾部闸/落库往返 (60 例)
|
test_batch8_units.py 榜单变化: 名册指纹/三种语义/尾部闸/落库往返 (60 例)
|
||||||
test_batch9_units.py 成本价体检 + 连续不一致按日推进 (31 例)
|
test_batch9_units.py 成本价体检 / 对账按日推进 / 行业闸双判据 (42 例)
|
||||||
test_wiring.py 装配自检: 服务层→核心→落表 全链路 (内存桩) (58 例)
|
test_wiring.py 装配自检: 服务层→核心→落表 全链路 (内存桩) (58 例)
|
||||||
共 335 例
|
共 346 例
|
||||||
任一子集失败即整体失败 (退出码 1)。
|
任一子集失败即整体失败 (退出码 1)。
|
||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
|
|
|
||||||
|
|
@ -301,6 +301,131 @@ def t_e6():
|
||||||
assert s["streak"] == 3, "判不了是不是同一天就按保守走(照常推进), 别把计数清零"
|
assert s["streak"] == 3, "判不了是不是同一天就按保守走(照常推进), 别把计数清零"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# ================================================================ [F] 行业占比闸的双判据
|
||||||
|
# 2026-07-31 实机暴露: 空账本 + 10 只强传导候选 + 一条 60% 升仓命令 → 一条方案都出不来。
|
||||||
|
# 因为只看"占组合"的话, 第一只票按定义就是组合的 100%, 必然超任何小于 100% 的上限;
|
||||||
|
# 而它被拒后组合市值不推进, 后面每一只面对的还是 100% —— 哪怕分属十个不同行业。
|
||||||
|
# 行业源是当天才通的 (此前 sector 恒为 None、整段跳过), 所以这个洞一直藏着。
|
||||||
|
from app.core.sizer import check_caps # noqa: E402
|
||||||
|
|
||||||
|
SCALE = 2_000_000
|
||||||
|
|
||||||
|
|
||||||
|
def _ctx(**kw):
|
||||||
|
d = dict(scale=SCALE, portfolio_cap=0.60, stock_cap=0.08, max_names=15,
|
||||||
|
portfolio_mv=0.0, names_count=0, stock_mv=0.0, is_new_name=True,
|
||||||
|
sector="储能", sector_names=0, sector_mv=0.0,
|
||||||
|
sector_max_names=4, sector_max_ratio=0.40)
|
||||||
|
d.update(kw)
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
@case("[F1] 空账本的第一笔买入不该被行业占比闸拦下")
|
||||||
|
def t_f1():
|
||||||
|
bad = check_caps(ts_code="600000.SH", add_amount=0.06 * SCALE, ctx=_ctx())
|
||||||
|
assert bad == [], f"第一只票必然是组合的 100%, 拦它等于建不了仓: {bad}"
|
||||||
|
|
||||||
|
|
||||||
|
@case("[F2] 候选分属不同行业时, 能一路建到总仓上限")
|
||||||
|
def t_f2():
|
||||||
|
c, n = _ctx(), 0
|
||||||
|
for i in range(1, 15):
|
||||||
|
c["sector"], c["sector_mv"], c["sector_names"] = f"行业{i}", 0.0, 0
|
||||||
|
if check_caps(ts_code=f"股{i}", add_amount=0.06 * SCALE, ctx=c):
|
||||||
|
break
|
||||||
|
c["portfolio_mv"] += 0.06 * SCALE
|
||||||
|
c["names_count"] += 1
|
||||||
|
n += 1
|
||||||
|
assert n == 10 and abs(c["portfolio_mv"] / SCALE - 0.60) < 1e-9, (
|
||||||
|
f"6% 一只、总仓上限 60% → 应正好进 10 只, 实际 {n} 只")
|
||||||
|
|
||||||
|
|
||||||
|
@case("[F3] 组合建到接近上限时, 行业占比闸照常拦")
|
||||||
|
def t_f3():
|
||||||
|
# 组合 52%(=104万), 储能已占 38%(=76万), 再买 6% → 组合 58% (未撞总仓闸),
|
||||||
|
# 储能占组合 75.9% > 40%, 占规模 44% > 绝对线 24% —— 两条都成立, 该拦
|
||||||
|
c = _ctx(portfolio_mv=0.52 * SCALE, sector_mv=0.38 * SCALE, names_count=9, sector_names=3)
|
||||||
|
bad = check_caps(ts_code="X", add_amount=0.06 * SCALE, ctx=c)
|
||||||
|
assert any("SECTOR_RATIO" in b for b in bad), bad
|
||||||
|
assert not any("PORTFOLIO_CAP" in b for b in bad), "这条用例要单独验行业判据"
|
||||||
|
|
||||||
|
|
||||||
|
@case("[F4] 占比超但绝对敞口小 → 不拦 (集中度是风险的放大器, 不是风险本身)")
|
||||||
|
def t_f4():
|
||||||
|
# 组合只有 12%(=24万) 且全在储能: 占组合 100% 超上限, 但只占规模 18% < 绝对线 24%
|
||||||
|
c = _ctx(portfolio_mv=0.12 * SCALE, sector_mv=0.12 * SCALE, names_count=2, sector_names=2)
|
||||||
|
assert check_caps(ts_code="X", add_amount=0.06 * SCALE, ctx=c) == []
|
||||||
|
|
||||||
|
|
||||||
|
@case("[F5] 绝对线不会单独触发 —— 它只用来豁免建仓初期, 不会额外拦人")
|
||||||
|
def t_f5():
|
||||||
|
"""绝对线取 `sector_max_ratio × portfolio_cap` 是有讲究的: 组合在总仓上限以内时,
|
||||||
|
"占规模超绝对线" 必然蕴含 "占组合超上限"。所以这条判据**只会放宽、不会收紧** ——
|
||||||
|
它把建仓初期那段不可满足的区间豁免掉, 而不改变组合建起来之后的口径。
|
||||||
|
取值再大一点 (比如直接用 sector_max_ratio) 就会变成一道独立的、更严的闸。"""
|
||||||
|
ratio, cap = 0.40, 0.60
|
||||||
|
for port_pct in (0.06, 0.12, 0.24, 0.36, 0.48, 0.60):
|
||||||
|
for sec_pct in (0.02, 0.06, 0.12, 0.20, 0.28, 0.36):
|
||||||
|
if sec_pct > port_pct:
|
||||||
|
continue
|
||||||
|
over_scale = sec_pct > ratio * cap + 1e-9
|
||||||
|
over_port = (sec_pct / port_pct) > ratio + 1e-9
|
||||||
|
assert not (over_scale and not over_port), (
|
||||||
|
f"组合 {port_pct:.0%} 行业 {sec_pct:.0%}: 绝对线单独触发了")
|
||||||
|
|
||||||
|
|
||||||
|
@case("[F6] 同一行业的只数上限仍然管用 (占比放宽不等于行业闸失效)")
|
||||||
|
def t_f6():
|
||||||
|
c, n = _ctx(), 0
|
||||||
|
for i in range(1, 9):
|
||||||
|
if check_caps(ts_code=f"股{i}", add_amount=0.06 * SCALE, ctx=c):
|
||||||
|
break
|
||||||
|
c["portfolio_mv"] += 0.06 * SCALE
|
||||||
|
c["sector_mv"] += 0.06 * SCALE
|
||||||
|
c["names_count"] += 1
|
||||||
|
c["sector_names"] += 1
|
||||||
|
n += 1
|
||||||
|
assert n == 4, f"同一行业最多 4 只 (SECTOR_NAMES), 实际 {n}"
|
||||||
|
|
||||||
|
|
||||||
|
@case("[F7] 行业源没配时整段跳过 (约束停用而不是误拦)")
|
||||||
|
def t_f7():
|
||||||
|
assert check_caps(ts_code="X", add_amount=0.06 * SCALE, ctx=_ctx(sector=None)) == []
|
||||||
|
|
||||||
|
|
||||||
|
# ================================================================ [G] 拒绝原因聚合
|
||||||
|
from app.core.planner import summarize_rejects # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
@case("[G1] 把 rejects 聚合成一行人话, 按条数降序")
|
||||||
|
def t_g1():
|
||||||
|
rej = [{"ts_code": f"股{i}", "reasons": ["SECTOR_RATIO: 行业[储能]占组合将达 100.0% > 上限 40%"]}
|
||||||
|
for i in range(5)] + \
|
||||||
|
[{"ts_code": "股X", "reasons": ["STOCK_CAP: 股X 加后 9.0% > 单股上限 8%"]}]
|
||||||
|
s = summarize_rejects(rej)
|
||||||
|
assert s.startswith("SECTOR_RATIO 5 只"), s
|
||||||
|
assert "STOCK_CAP 1 只" in s
|
||||||
|
|
||||||
|
|
||||||
|
@case("[G2] 一只票撞多条判据时每条都计数")
|
||||||
|
def t_g2():
|
||||||
|
s = summarize_rejects([{"ts_code": "股A", "reasons": ["STOCK_CAP: …", "SECTOR_RATIO: …"]}])
|
||||||
|
assert "STOCK_CAP 1 只" in s and "SECTOR_RATIO 1 只" in s
|
||||||
|
|
||||||
|
|
||||||
|
@case("[G3] 没有拒绝时返回空串 (调用方据此区分'拒了'和'本来就没候选')")
|
||||||
|
def t_g3():
|
||||||
|
assert summarize_rejects([]) == "" and summarize_rejects(None) == ""
|
||||||
|
|
||||||
|
|
||||||
|
@case("[G4] 没有判据码的原因 (一手不可行那类) 也归得了类")
|
||||||
|
def t_g4():
|
||||||
|
s = summarize_rejects([{"ts_code": "股A",
|
||||||
|
"reasons": ["目标金额 3000 元按现价 45.00 买不足一手"]}])
|
||||||
|
assert "1 只" in s and "股A" in s
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
import logging
|
import logging
|
||||||
logging.disable(logging.CRITICAL)
|
logging.disable(logging.CRITICAL)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue