diff --git a/README.md b/README.md index 526ba0a..5afbf2f 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ scripts/ test_batch6_units.py ws 通道: 测试向量/签名/公钥/水位/DDL/逐笔入账 65 例 test_batch7_units.py 上游选股计划: 解析/新鲜度/候选筛选/取数守卫 32 例 test_batch8_units.py 榜单变化: 名册指纹/三种语义/尾部闸/落库往返 60 例 - test_batch9_units.py 成本价体检 + 连续不一致按日推进 31 例 + test_batch9_units.py 成本价体检 / 对账按日推进 / 行业闸双判据 42 例 test_wiring.py 装配自检: 服务层→核心→落表 全链路 (内存桩) 58 例 init_db.py 建表 (应用 ddl_pms_v1.sql, 幂等, 默认演练; 含 DDL 体检) check_db.py 实机连通性与表结构自检 (需真实 .env) @@ -247,6 +247,22 @@ make t-book # A9 看账本 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` 是联测时最该盯的一张表。** 方案里的票被拦掉时,它是唯一能回答「为什么这只没进去」的地方——单看方案和指令只能看到「少了几只」,看不到是撞了单股上限、一手不可行、行业集中度,还是不追高。 ## 自主提议的分流(设计 §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 例**。 ### 下一步(按可动工顺序) diff --git a/app/core/planner.py b/app/core/planner.py index 16c0bea..7804e0b 100644 --- a/app/core/planner.py +++ b/app/core/planner.py @@ -550,3 +550,38 @@ def _open_batch_items(ts_code, sp, price, reason_prefix="建仓"): f"{reason_prefix}: {nm} 批 {b['qty']} 股 —— {gate_reason.get(nm, '')}", tier=f"批次_{nm}", gated=(nm != "BASE"))) 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) diff --git a/app/core/sizer.py b/app/core/sizer.py index 0cc92b5..b34f8b1 100644 --- a/app/core/sizer.py +++ b/app/core/sizer.py @@ -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"]: v.append(f"SECTOR_NAMES: 行业[{sector}]将达 {ctx['sector_names'] + 1} 只 " 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 - if port_after > 0 and (ctx.get("sector_mv", 0) + add_amount) / port_after > ctx["sector_max_ratio"] + 1e-9: - v.append(f"SECTOR_RATIO: 行业[{sector}]占总仓将达 " - f"{(ctx['sector_mv'] + add_amount) / port_after:.1%} > 上限 {ctx['sector_max_ratio']:.0%}") + sector_after = ctx.get("sector_mv", 0) + add_amount + ratio_max = ctx["sector_max_ratio"] + 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 diff --git a/app/services/command_service.py b/app/services/command_service.py index 14dd1ce..7f6642c 100644 --- a/app/services/command_service.py +++ b/app/services/command_service.py @@ -238,19 +238,42 @@ def plan_command(cmd: dict) -> dict: if spec.get("switch_key") is not None: 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), "planned_amount": result.get("planned_amount", 0.0), "done_amount": 0.0, "gap": result.get("gap", 0.0), "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} - instant = bool(spec.get("instant")) or not rows - status = cs.ST_DONE if instant else cs.ST_EXECUTING - pms_repo.update_command(command_id, status=status, progress=progress, - done_at=datetime.now() if status == cs.ST_DONE else None) + # 零方案的两种情形必须分开 (2026-07-31 修) + # ------------------------------------------------------------------ + # 开关类命令 (HALT_BUY / RESUME_ALL …) 本来就不产出方案, 那是 DONE。 + # 但一条**该**产出方案的任务命令一条都没产出, 它不是"完成", 是"没发生" —— 原来两者 + # 都走 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, "items": items, "errors": []} diff --git a/scripts/run_tests.py b/scripts/run_tests.py index dd89984..093802e 100644 --- a/scripts/run_tests.py +++ b/scripts/run_tests.py @@ -13,9 +13,9 @@ test_batch6_units.py ws 通道: 测试向量/签名/公钥/水位/DDL/逐笔入账 (65 例) test_batch7_units.py 上游选股计划: 解析/新鲜度/候选筛选/取数守卫 (32 例) test_batch8_units.py 榜单变化: 名册指纹/三种语义/尾部闸/落库往返 (60 例) - test_batch9_units.py 成本价体检 + 连续不一致按日推进 (31 例) + test_batch9_units.py 成本价体检 / 对账按日推进 / 行业闸双判据 (42 例) test_wiring.py 装配自检: 服务层→核心→落表 全链路 (内存桩) (58 例) - 共 335 例 + 共 346 例 任一子集失败即整体失败 (退出码 1)。 """ import os diff --git a/scripts/test_batch9_units.py b/scripts/test_batch9_units.py index 717bcff..2f1ea71 100644 --- a/scripts/test_batch9_units.py +++ b/scripts/test_batch9_units.py @@ -301,6 +301,131 @@ def t_e6(): 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(): import logging logging.disable(logging.CRITICAL)