diff --git a/README.md b/README.md index 5afbf2f..649d7b0 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 成本价体检 / 对账按日推进 / 行业闸双判据 42 例 + test_batch9_units.py 成本价体检 / 对账按日推进 / 行业闸 / 取整记账 47 例 test_wiring.py 装配自检: 服务层→核心→落表 全链路 (内存桩) 58 例 init_db.py 建表 (应用 ddl_pms_v1.sql, 幂等, 默认演练; 含 DDL 体检) check_db.py 实机连通性与表结构自检 (需真实 .env) @@ -257,6 +257,19 @@ make t-gate # 随时: 规则闸/研判闸拒了什么、为什么 同一行业的只数上限(`PMS_SECTOR_MAX_NAMES`,默认 4)不受影响,照常生效。 +### 一手取整的记账:planned_amount 必须是取整后的实际值 + +三批各自向下取整到一手,所以实际买到的必然少于目标——**票越贵差得越多**。`plan_increase_exposure` 的「候选池新票」那支原本按**理论目标**累计,而同一个函数里「补既有持仓」那支按取整后的实际金额累计,隔二十行两种写法。 + +2026-07-31 实机撞上:命令进度写着 `planned_amount=600,000` / `gap=0`(读起来是「完全满足」),而 15 条方案的金额合计只有 573,883——**少买 26,117 元、账面上完全看不出来**,其中 136.73 元一股的 `002353.SZ` 一只就差了 10,528。除了报数不实,还有两个连带后果:`ctx` 里的组合市值虚高,后面几只票的上限校验按一个不存在的仓位算;循环也会提前收敛。 + +现在一律按实际金额记账,并把缺口拆成两种——混在一起会得出错误结论: + +- `shortfall`:**没找到足够的票**(候选不够、被闸拒了)。这才是「命令没满足」,`ok` 只看它。 +- `rounding_gap`:**一手取整的零头**。A 股买不了零股,这不是失败。 + +否则任何一次升仓都会因为几千元的取整零头被判成失败,真正的「候选不够」反而淹没在里面。`plan_open_target`(个股建仓)原来把 `gap` 写死 0,同一个毛病的另一面,一并修了。 + ### 零方案的任务命令置 CANCELLED,不是 DONE 一条**该**产出方案的任务命令一条都没产出,那不是「完成」,是「没发生」。原来它和开关类命令(`HALT_BUY` 这种下达即完成的)共用 `DONE`,后果是页面显示已完成、`cancel()` 又因为 `DONE` 不在 `ACTIVE_TASK_STATES` 里而拒绝撤销——用户既看不出没执行成,也退不回来,日报还会把它算进完成的命令。现在这种情况置 `CANCELLED` 并把原因写进 `note`。 @@ -285,7 +298,7 @@ make t-gate # 随时: 规则闸/研判闸拒了什么、为什么 ## 已实现 / 待开发 -**已实现**:建表 DDL 与建表脚本;配置与运行参数中心;仓位规划器与安全垫账;命令系统(27 类命令全目录 + 双状态机 + 冲突识别);方案生成器(降仓凑额四档、升仓、建仓分批、清仓/减至、行业清仓与限额、暂停买入撤单);账本回放与对账引擎(成交认领、外部成交并入 BASE 告警、以下游为准修正、除权检测、T+1 可用量、连续不一致升级);规则闸终检;择时执行器实现 B(分日配额、分笔、VWAP/回踩/不追高、14:45 兜底、停牌一字板顺延、窗口耗尽收口、挂单有效期);动作引擎四类自主动作 + 研判闸客户端 + 提议分流;决策系统信号消化(两条流独立消费组订阅、置信度分档转清仓指令或提议);管理页面四块 + 运维/日报抽屉;调度器九个调度位;**上游选股计划接口接入**(`/plan` 取候选池、交易日龄硬校验、`theme` 灌行业映射表、页面预览抽屉与不可用横幅);**榜单变化提示**(名册快照 + 新进/掉榜/档位升降/覆盖翻转/名次跳变,持仓票单列,榜尾截断噪音闸);**ws 直连通道的连接层**(常驻进程 + 出口队列 + 签名 + seq 水位与累积确认,见下);**单测 346 例**。 +**已实现**:建表 DDL 与建表脚本;配置与运行参数中心;仓位规划器与安全垫账;命令系统(27 类命令全目录 + 双状态机 + 冲突识别);方案生成器(降仓凑额四档、升仓、建仓分批、清仓/减至、行业清仓与限额、暂停买入撤单);账本回放与对账引擎(成交认领、外部成交并入 BASE 告警、以下游为准修正、除权检测、T+1 可用量、连续不一致升级);规则闸终检;择时执行器实现 B(分日配额、分笔、VWAP/回踩/不追高、14:45 兜底、停牌一字板顺延、窗口耗尽收口、挂单有效期);动作引擎四类自主动作 + 研判闸客户端 + 提议分流;决策系统信号消化(两条流独立消费组订阅、置信度分档转清仓指令或提议);管理页面四块 + 运维/日报抽屉;调度器九个调度位;**上游选股计划接口接入**(`/plan` 取候选池、交易日龄硬校验、`theme` 灌行业映射表、页面预览抽屉与不可用横幅);**榜单变化提示**(名册快照 + 新进/掉榜/档位升降/覆盖翻转/名次跳变,持仓票单列,榜尾截断噪音闸);**ws 直连通道的连接层**(常驻进程 + 出口队列 + 签名 + seq 水位与累积确认,见下);**单测 351 例**。 ### 下一步(按可动工顺序) diff --git a/app/core/planner.py b/app/core/planner.py index 7804e0b..ea224b8 100644 --- a/app/core/planner.py +++ b/app/core/planner.py @@ -230,7 +230,8 @@ def plan_increase_exposure(*, add_amount: float, positions: list, candidates=Non ctx = dict(ctx or {}) target = float(add_amount or 0) items, notes, rejects = [], [], [] - acc = 0.0 + acc = 0.0 # 取整后**实际**能买到的金额 + intended = 0.0 # 分配出去的**目标**金额 (acc 与它的差 = 一手取整的零头) scale = float(ctx.get("scale") or 0) default_target = float(params.get("stock_target_default", 0.06)) batch_split = params.get("batch_split") or (0.5, 0.25, 0.25) @@ -266,6 +267,7 @@ def plan_increase_exposure(*, add_amount: float, positions: list, candidates=Non items.append(_item(p["ts_code"], A_ADD, SIDE_BUY, q, amt, P_OPEN_BASE, f"升仓: 垫厚({float(p.get('cushion_pct') or 0):.1%})补至目标 " f"{tgt_pct:.1%}", tier="1_补既有")) + intended += want_amt acc += amt ctx = _ctx_after(ctx, amt, is_new_name=False, sector=p.get("sector")) @@ -282,22 +284,42 @@ def plan_increase_exposure(*, add_amount: float, positions: list, candidates=Non rejects.append({"ts_code": c.get("ts_code"), "reasons": [sp["reason"]]}) continue base = sp["batches"][0] - bad = check_all_caps(ts_code=c.get("ts_code"), add_amount=want_amt, + # **一律按取整后的实际金额记账, 不是按理论目标。** 三批各自向下取整到一手, 实际买到的 + # 比 want_amt 少 —— 票越贵差得越多 (2026-07-31 实机: 136.73 元的 002353 一只就差了 + # 10,528 元)。拿 want_amt 累计有三个后果: ① progress.planned_amount 报高、gap 报低, + # 页面说"完全满足、缺口 0"而实际少买了 4.4% ② ctx 里的组合市值虚高, 后面几只票的 + # 上限校验按一个不存在的仓位算, 会提前把人拦掉 ③ 循环提前收敛。 + # 上面①「补既有持仓」那支本来就是这么写的 (amt = q × price), 这里是漏了。 + actual = sum(b["qty"] * price for b in sp["batches"]) + bad = check_all_caps(ts_code=c.get("ts_code"), add_amount=actual, ctx=_new_name_ctx(ctx, c)) if bad: rejects.append({"ts_code": c.get("ts_code"), "reasons": bad}) continue items.extend(_open_batch_items(c.get("ts_code"), sp, price, reason_prefix="升仓建新仓")) - acc += want_amt - ctx = _ctx_after(ctx, want_amt, is_new_name=True, sector=c.get("sector")) - notes.append(f"{c.get('ts_code')}: 目标 {want_amt:,.0f} 元, 首批 {base['qty']} 股") + intended += want_amt + acc += actual + ctx = _ctx_after(ctx, actual, is_new_name=True, sector=c.get("sector")) + notes.append(f"{c.get('ts_code')}: 目标 {want_amt:,.0f} 元 → 取整后 {actual:,.0f} 元, " + f"首批 {base['qty']} 股") + # 缺口分两种, 混在一起会得出错误结论: + # shortfall 没找到足够的票 (候选不够 / 被闸拒了) —— 这才是"命令没满足" + # rounding 一手取整的零头 —— 票越贵越大, 但它不是失败, 是 A 股买不了零股 + # `ok` 只看 shortfall: 否则任何一次升仓都会因为几千元的取整零头被判成失败, + # 而真正的"候选不够"就淹没在里面了。 gap = max(0.0, target - acc) - if gap > 0: - notes.append(f"候选与补仓空间不足, 缺口 {gap:,.0f} 元") - return {"ok": gap <= 0, "target_amount": round(target, 2), "planned_amount": round(acc, 2), - "gap": round(gap, 2), "items": items, "notes": notes, "rejects": rejects} + shortfall = max(0.0, target - intended) + rounding = max(0.0, intended - acc) + if shortfall > 0: + notes.append(f"候选与补仓空间不足, 缺口 {shortfall:,.0f} 元") + if rounding > 0: + notes.append(f"一手取整少买 {rounding:,.0f} 元 (不足一手的部分买不进去; 票价越高越明显)") + return {"ok": shortfall <= 0, "target_amount": round(target, 2), + "planned_amount": round(acc, 2), "gap": round(gap, 2), + "shortfall": round(shortfall, 2), "rounding_gap": round(rounding, 2), + "items": items, "notes": notes, "rejects": rejects} # ================================================================ @@ -341,8 +363,15 @@ def plan_open_target(*, ts_code: str, target_pct: float, price: float, ctx: dict notes = [] if sp["scheme"] != tuple(batch_split): notes.append(f"一手检查: 批次自动合并为 {sp['scheme']} (原 {tuple(batch_split)})") + # gap 原来写死 0 —— 但三批各自向下取整到一手, 实际买到的必然少于目标, 票越贵差越多。 + # 报 0 等于说"完全建到目标了", 而页面上的占规模会比命令要求的低一截, 对不上。 + gap = max(0.0, add_amount - planned) + if gap > 0: + notes.append(f"一手取整: 目标 {add_amount:,.0f} 元 → 实际 {planned:,.0f} 元, " + f"差 {gap:,.0f} 元 (不足一手的部分买不进去)") return {"ok": True, "items": items, "target_amount": round(add_amount, 2), - "planned_amount": round(planned, 2), "gap": 0.0, "notes": notes, "rejects": []} + "planned_amount": round(planned, 2), "gap": round(gap, 2), + "notes": notes, "rejects": []} def plan_exit_stock(*, ts_code: str, position: dict, reason: str = "清仓命令") -> dict: diff --git a/scripts/run_tests.py b/scripts/run_tests.py index 093802e..807dcec 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 成本价体检 / 对账按日推进 / 行业闸双判据 (42 例) + test_batch9_units.py 成本价体检 / 对账按日推进 / 行业闸 / 取整记账 (47 例) test_wiring.py 装配自检: 服务层→核心→落表 全链路 (内存桩) (58 例) - 共 346 例 + 共 351 例 任一子集失败即整体失败 (退出码 1)。 """ import os diff --git a/scripts/test_batch2_units.py b/scripts/test_batch2_units.py index 895b304..e70663e 100644 --- a/scripts/test_batch2_units.py +++ b/scripts/test_batch2_units.py @@ -305,7 +305,15 @@ def _(): assert len(adds) == 1 and adds[0]["ts_code"] == "600000.SH" and adds[0]["qty"] == 4000, adds new_codes = {i["ts_code"] for i in r["items"] if i["tier"].startswith("批次_")} assert new_codes == {"688981.SH", "000651.SZ"}, new_codes - assert r["ok"] and abs(r["planned_amount"] - 200_000) < 1e-6, r + # 两种缺口要分开 (2026-07-31 修): 目标 20 万全分配出去了 → shortfall=0 → ok; + # 但 000651.SZ 目标 4 万、15 元一股, 三批各自向下取整到一手只买到 37,500 —— 那 2,500 + # 是一手取整的零头, 不是"没找到票"。**planned_amount 必须是取整后的实际值**, 否则 + # 页面会说"完全满足、缺口 0"而实际少买 (07-31 实机就是这么报的)。 + assert r["ok"] and r["shortfall"] == 0, r + assert abs(r["planned_amount"] - 197_500) < 1e-6, r["planned_amount"] + assert abs(r["rounding_gap"] - 2_500) < 1e-6, r["rounding_gap"] + assert abs(sum(i["amount"] for i in r["items"]) - r["planned_amount"]) < 1e-6, ( + "planned_amount 必须等于各条方案金额之和") @case("升仓命令·冻结票与非厚垫票不补仓") diff --git a/scripts/test_batch9_units.py b/scripts/test_batch9_units.py index 2f1ea71..ebc8728 100644 --- a/scripts/test_batch9_units.py +++ b/scripts/test_batch9_units.py @@ -426,6 +426,86 @@ def t_g4(): assert "1 只" in s and "股A" in s +# ================================================================ [H] 一手取整的记账口径 +# 2026-07-31 实机: 命令进度写着 planned_amount=600,000 / gap=0 ("完全满足"), 而 15 条方案 +# 的金额合计只有 573,883 —— 少买 26,117 元 (4.4%) 且账面上完全看不出来。 +# 原因是 plan_increase_exposure 的②分支按**理论目标**累计, 而①分支按取整后的实际金额累计, +# 同一个函数隔二十行两种写法。 +from app.core import planner as pl # noqa: E402 + + +def _cx(**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=None, sector_names=0, sector_mv=0.0, + sector_max_names=4, sector_max_ratio=0.40) + d.update(kw) + return d + + +@case("[H1] planned_amount 必须等于各条方案金额之和") +def t_h1(): + r = pl.plan_increase_exposure( + add_amount=0.30 * SCALE, positions=[], + candidates=[{"ts_code": f"股{i}.SH", "price": p, "score": 1.0 - i * 0.01} + for i, p in enumerate([24.83, 25.22, 35.21, 136.73, 35.04])], + ctx=_cx(), params={"stock_target_default": 0.06}) + assert abs(sum(i["amount"] for i in r["items"]) - r["planned_amount"]) < 1e-6, ( + f"账面 {r['planned_amount']:,.0f} vs 方案合计 " + f"{sum(i['amount'] for i in r['items']):,.0f}") + + +@case("[H2] 高价股的取整损失要记进 rounding_gap, 不能被吞掉") +def t_h2(): + # 136.73 元一股, 一手就要 13,673 元 —— 目标 12 万只能买到 800 股 = 109,384 + r = pl.plan_increase_exposure( + add_amount=0.06 * SCALE, positions=[], + candidates=[{"ts_code": "002353.SZ", "price": 136.73, "score": 1.0}], + ctx=_cx(), params={"stock_target_default": 0.06}) + assert abs(r["planned_amount"] - 109_384) < 1.0, r["planned_amount"] + assert abs(r["rounding_gap"] - (120_000 - 109_384)) < 1.0, r["rounding_gap"] + assert any("一手取整" in n for n in r["notes"]), r["notes"] + + +@case("[H3] 取整零头不算命令失败, 候选不够才算") +def t_h3(): + # 候选够: 目标全分配出去了, 只是取整少买一点 → ok + ok_r = pl.plan_increase_exposure( + add_amount=0.06 * SCALE, positions=[], + candidates=[{"ts_code": "002353.SZ", "price": 136.73, "score": 1.0}], + ctx=_cx(), params={"stock_target_default": 0.06}) + assert ok_r["ok"] is True and ok_r["shortfall"] == 0 and ok_r["rounding_gap"] > 0 + # 候选不够: 要 30% 却只有一只票能给 6% → 不 ok + bad_r = pl.plan_increase_exposure( + add_amount=0.30 * SCALE, positions=[], + candidates=[{"ts_code": "002353.SZ", "price": 136.73, "score": 1.0}], + ctx=_cx(), params={"stock_target_default": 0.06}) + assert bad_r["ok"] is False and bad_r["shortfall"] > 0 + assert any("候选与补仓空间不足" in n for n in bad_r["notes"]) + + +@case("[H4] 组合市值按实际金额推进, 不按理论目标 —— 否则后面几只被虚高的仓位拦掉") +def t_h4(): + r = pl.plan_increase_exposure( + add_amount=0.60 * SCALE, positions=[], + candidates=[{"ts_code": f"股{i}.SH", "price": 136.73, "score": 1.0 - i * 0.01} + for i in range(10)], + ctx=_cx(), params={"stock_target_default": 0.06}) + names = {i["ts_code"] for i in r["items"]} + assert len(names) == 10, ( + f"十只高价股每只 6%, 按实际金额推进才装得下 10 只; 按理论目标会提前收敛, " + f"实际装了 {len(names)} 只") + + +@case("[H5] 个股建仓命令的 gap 也要如实反映取整损失") +def t_h5(): + r = pl.plan_open_target(ts_code="002353.SZ", price=136.73, target_pct=0.06, + ctx=_cx(), params={}) + assert r["ok"] and r["gap"] > 0, f"原来写死 gap=0, 等于说'完全建到目标了': {r}" + assert abs(r["planned_amount"] - sum(i["amount"] for i in r["items"])) < 1e-6 + + + def main(): import logging logging.disable(logging.CRITICAL)