diff --git a/QMT_SIDE_S3_CLOSEOUT.md b/QMT_SIDE_S3_CLOSEOUT.md index e79557e..cbfac15 100644 --- a/QMT_SIDE_S3_CLOSEOUT.md +++ b/QMT_SIDE_S3_CLOSEOUT.md @@ -150,6 +150,40 @@ sender = asyncio.create_task(self._sender_loop(websocket, queue)) 2026-07-29 首次接管 22 只持仓时踩过一次。我方取值优先级已有单测守着,**但数据本身对不对 只能靠贵方**。`available_quantity`(T+1 可卖)同理,请填合理值。 +**5.1 先确认一件事:模拟仓现在那 1100 股是哪来的?** + +07-30 12:00 我方收到的 `query_positions` 回应(字段完全按 §5.7,这点很干净): + +```json +{"kind": "positions", "as_of": 1785384340542, "items": [ + {"ts_code": "600000.SH", "total_qty": 1100, "avail_qty": 1100, "frozen_qty": 0, + "cost_price": 9.2732, "market_price": 9.27}]} +``` + +`1100 股 @ 9.2732` 这两个数我方认得——**清账前 PMS 账本里那笔来历不明的持仓,成本正好是 9.273**, +而它是 07-29 联调期那批测试买单成交出来的(我方当时误把停机遗留的联调成交当外部成交入了账)。 +所以想确认:**模拟仓这 1100 股是不是同一批联调单的产物?** + +- **是** → 请连它一起清掉,我方这边不认领;测试数据进了账本,后面每一步都建在错的基础上 +- **不是** → 请说明 `cost_price 9.2732` 的来历,我方按真实持仓认领 + +在得到答复前,我方已停掉日终结算调度,不会自动认领。 + +**5.2 装模拟持仓时,希望能覆盖这几种情形** + +只有一只、且成本≈现价(浮动盈亏 ≈ 0)的话,我方好几条纪律根本验不到——安全垫是盈利加仓 +(≥3%)、保垫减仓(峰值 ≥6%)、补仓评估档(−8% / −15%)**共同的判断依据**,全 0 就等于这些 +逻辑一条都跑不到。希望能有: + +| 情形 | 目的 | +|---|---| +| 一只明显浮盈(现价高于成本 5% 以上) | 验盈利加仓与保垫减仓 | +| 一只明显浮亏(现价低于成本 10% 以上) | 验补仓评估档 | +| 一只当日买入(`avail_qty < total_qty`) | 验 T+1 可卖量口径 | +| 3~5 只、分散在不同板块 | 验组合上限与行业集中度 | + +数量不用大,每只 100~1000 股即可——我方验的是逻辑分支,不是金额。 + **另外两件请提前通知我方的事:** 1. **重建模拟环境会不会重置 seq / 清空存上行消息的 Redis?** 目前我方水位 8052,与贵方 @@ -176,7 +210,7 @@ C2 只覆盖了 `trading_buy_plan` 这一条路,「直接执行决策系统卖 ## 优先级建议 -**1(阻断切换) > 5(数据正确性,做在装持仓之前) > 3(安全底座) > 2 > 4** +**5.1(一句话就能答,卡着我方认领)> 1(阻断切换)> 5 / 5.2(数据正确性,做在装持仓之前)> 3(安全底座)> 2 > 4** 第 1 条改完 + 第 5 条按口径装好持仓,我方即可进入「小仓位实盘」;第 3 条不查明,签名这条 安全底座上就一直挂着一个问号。 diff --git a/WS_INTEGRATION_STATUS.md b/WS_INTEGRATION_STATUS.md index 4e6081b..8d16444 100644 --- a/WS_INTEGRATION_STATUS.md +++ b/WS_INTEGRATION_STATUS.md @@ -63,6 +63,9 @@ | 回放游标 | 重新 seed 到 `SELL_688819.SH_1774588308`(字典序锚点,此后不追认历史) | | `PMS_RECON_STREAK` | 归零 | | `PMS_SIGNAL_ENABLED` | **False**(清账期间关的)。**账本重建完必须打开**,理由见 §7 | +| `pms-beat` | **停着**(07-30 12:xx 主动停的)。原因:账本空时爆炸半径闸放行,15:10 的 `daily_settle` 会**无条件**认领 `trading_position` / ws 快照里的东西,而那 1100 股来历待对端确认(§5.1)。`pms-web` / `pms-ws` 照常跑——页面能看,ws 继续拉快照 | +| `PMS_TOTAL_SCALE` | **98 万**(07-30 由 200 万改,与模拟账户 `total_asset 981448.56` 对齐)。实盘前按真实投入资金重设 | +| ws 持仓/资金快照 | ✅ 已通。`query_positions` / `query_funds` 每 300 秒一轮,对端字段完全按 §5.7。持仓 1 条:`600000.SH 1100 @ 9.2732`;资金:`total_asset 981448.56 / available_cash 971251.56 / sell_return_today 0` | | 下游 `trading_buy_plan` 轮询 | **已于 2026-07-30 10:55 停止**——`QMT_INTERFACE_REQUIREMENTS.md` C2 那个「当前最大未决项」至此闭环 | 调度恢复后一轮四个调度位全部空转正常:`signal_digest` 报「已关闭」、`command_poll` 空、 @@ -122,6 +125,31 @@ trade 引用了 `4ec5ce4c` / `f0efcf41` / `43e47c39` 三个**出口表里根本 | 反查报错时一笔都不判(07-30) | 同上 | 原先把「库抖了」当「查不到」→ 当真单入账,一次超时就能造出一笔外部成交 | | 挂起数字露出通道状态(07-30) | `dispatcher.channel_status().orphan_held` + `ws_smoke status` | 挂起的行不再进 `inbox_pending`,没人报数就是一笔无人知晓的漏账 | +**07-30 午间两笔(都是「协议定了但代码没实现」,不是 bug)** + +| 补的东西 | 位置 | 为什么之前看不见 | +|---|---|---| +| **ws 快照对账通路**(协议 §6.2) | `runner._query_loop`(第五个协程)+ `ledger_service.positions_source` + `ws_codec.parse_positions_snapshot` | 协议 §6.2 写着「这是 PMS 侧既有的对账引擎」,实际一行都没有:不发 `query_*`、`snapshot`/`position_update` 落库即 `processed=2` 无人读、`reconcile` 只认 `trading_position` 表。而那张表在新架构下没有写入方——07-30 实测 QMT 已切模拟仓且功能正常,表却是空的(`fetch_positions` 的 `columns` 三个 `None` 就是铁证) | +| **买入前资金校验**(A3 的原意) | `portfolio.cash_view` + `rule_gate._check_cash` | 全系统只有一个 `cash_est = scale − 市值`,那是**从参数算出来的虚数**。scale=200 万而账户真有 98 万,规划器排出的方案规则闸一路放行,要等 QMT 回 `INSUFFICIENT_CASH` 才被拒;而 reject 不自动重发,择时下一跳又算又发又拒——页面看着正常,实际一单也下不去 | +| **偏差可见** | 页面「账户可用」指标 + 偏差 >10% 的 banner(`scaleGap`);日报关注区 `规模与账户不符` / `资金快照未接通`(阈值 `PMS_SCALE_GAP_ALARM`,默认 0.10) | 上面那条洞之所以到 07-30 才发现,就是因为**页面上完全看不出异常**:只显示 `cash_est`,而它永远等于 `scale − 市值`,看起来总是很充裕。改 `index.html` 前按 07-29 白屏那个坑做了全文体检(无自闭合自定义标签、`el-alert` 8/8、`div` 90/90) | + +**对账事实源的三条仲裁**(`positions_source`,ws 为主 / 表为兜底):① ws 快照新鲜 → 用 ws; +表也非空且对不上 → 照样用 ws 但记 `SOURCE_DISAGREE`(说明表的写入方与 QMT 不同步)② ws +缺失/过期/字段不认 → 退回表并说明**是哪种原因**(三种处理方式完全不同)③ 两个源都**没有应答** +→ `source=none`,本端有持仓则拒绝对账、**force 也不放行**(没有读数可供人工确认,要清账走 +`reset_ledger.py`);本端也空则只留 note 不报 ERROR。 +**「应答了空集」≠「没应答」**:表查询成功返回 0 行是有效数据,归 `table` 交给爆炸半径闸处理, +force 能放行——一开始把两者混为一谈,直接把 07-29 那个「下游读空绝不清账」用例的 force 分支挂了。 + +**资金校验的两条口径**:`scale` 管仓位纪律(该不该买这么多)、`available_cash` 管买不买得起, +两个不同的约束不合并;`cash_avail` 必须含 `sell_return_today`(当日回笼 T+0 可用),否则「卖一只 +买另一只」这条最常见的换仓路径会被判成资金不足。拿不到 ws 资金快照时**降级不拦但留痕** +(`CASH_ESTIMATED`)——与研判闸同一口径,一律拒等于把通道故障升级成业务停摆。 + +**代码格式归一**:ws 快照的 `ts_code` 强制转点式。不转的话 diff 会拿 `SH600000` 比账本里的 +`600000.SH`,**每一只都对不上**——账本那只判「下游没了」要核销、ws 那只判「新持仓」要补, +一次格式不一致就能造出一轮双向全量重写,比读空还狠。有单测锁着。 + **孤儿成交这条的判断依据**(值得记住的口径):ws 这条路上的 trade 必带 `instruction_id`(§5.5), 而那个 id 是 PMS 自己生成、自己写进出口表的,所以**反查不到只可能是数据不一致**,不可能是 「有人在 QMT 手工下了单」——手工成交走 `trading_order` 那条路,根本不进 inbox。两种错的代价 @@ -192,9 +220,10 @@ code: SIG_INVALID, reason: "signature verification failed"}`——**是对端验 1. ~~确认对端实现了 R1–R4 的哪几条~~ ✅ 07-30 上午完成,结果见 §2 2. ~~清账重来~~ ✅ 07-30 11:14 完成,基线见 §3.0 3. **发函 QMT 侧**(§5.1~5.5 五条,见 `QMT_SIDE_S3_CLOSEOUT.md`) -4. **等对端装持仓 → 认领账本**。对端装好之前,若不想让 15:10 的 `daily_settle` 拿半成品建账, - 就先 `docker compose stop pms-beat`;装好确认 `cost_price` 无误后再 `--profile sched up -d pms-beat`。 - 探下游用 `curl -X POST '.../api/ops/reconcile?apply_fix=false'`(只看不改) +4. **等对端答复 §5.1(那 1100 股的来历)再认领账本**。`pms-beat` 现在停着,所以 15:10 不会 + 自动建账。答复回来后:是联调残留 → 请对端清掉、我方不认领;是真持仓 → 跑 + `curl -X POST '.../api/ops/reconcile?apply_fix=true'` 认领,然后 `--profile sched up -d pms-beat`。 + 探数据用 `apply_fix=false`(只看不改)或 `ws_smoke.py inbox --type snapshot --width 600` 5. **账本重建完立刻把 `PMS_SIGNAL_ENABLED` 打开**(见 §7 最后一条) 6. **盘中**跑 `scan-proposals?dry_run=true` + `exec-tick?dry_run=true`,看真实候选清单 7. 日终对账零差异 → S3 完整通过 diff --git a/app/core/rule_gate.py b/app/core/rule_gate.py index 07a92c5..9f42782 100644 --- a/app/core/rule_gate.py +++ b/app/core/rule_gate.py @@ -132,10 +132,39 @@ def _check_buy(failed, warns, qty, price, ctx, pos, day, prm, flg, is_cmd, hard) failed.extend(bad) if not caps.get("sector_source_ready", True): warns.append("SECTOR_OFF: 行业数据源未配置, 行业集中度未校验 (页面已提示)") + _check_cash(failed, warns, qty, price, caps, hard) else: failed.append("CAPS_MISSING: 缺组合上下文, 无法校验上限 (宁可不动)") +def _check_cash(failed, warns, qty, price, caps, hard): + """买入前的资金校验 (QMT_INTERFACE_REQUIREMENTS A3 的原意)。 + + 与组合上限是**两个不同的约束**, 所以单列一条而不是塞进 check_all_caps: + 上限管「该不该买这么多」—— 仓位纪律, 基准是 PMS_TOTAL_SCALE 这个用户命令参数 + 资金管「买不买得起」 —— 账户事实, 基准是 ws 资金快照的 available_cash + 当日回笼 + 2026-07-30 之前只有前者: scale=200 万而模拟账户实际只有 98 万, 于是排出的方案规则闸一路 + 放行, 要等 QMT 回 `INSUFFICIENT_CASH` 才被拒; 而 reject 不自动重发, 择时下一跳又算又发 + 又拒 —— 页面看着一切正常, 实际一单也下不去。 + + **拿不到真实资金时降级不拦, 但必须留痕。** 与研判闸「拿不到不算通过、也不当拒绝, 降级 + 并记录」同一口径 —— 一律拒会让资金快照一断就把所有买入停掉, 那是把通道故障升级成业务 + 停摆。降级期间 QMT 那层的 `INSUFFICIENT_CASH` 仍是最后一道防线, 只是会吵一点。 + """ + src = str(caps.get("cash_source") or "estimate") + avail = caps.get("cash_avail") + need = round(qty * price, 2) + hard.update({"cash_need": need, "cash_source": src, + "cash_avail": None if avail is None else round(_num(avail), 2)}) + if src != "ws" or avail is None: + warns.append("CASH_ESTIMATED: 拿不到 ws 资金快照, 本单未按真实可用资金校验 " + "(scale−市值 的估算值不能当真钱用)") + return + if need > _num(avail): + failed.append(f"INSUFFICIENT_CASH: 需 {need:.0f} 元 > 可用 {_num(avail):.0f} 元 " + f"(含当日卖出回笼)") + + def summarize(results: list) -> dict: """一批指令的闸门统计 (日报关注区用)。""" passed = [r for r in results if r.get("passed")] diff --git a/app/services/ledger_service.py b/app/services/ledger_service.py index 6157511..e945e13 100644 --- a/app/services/ledger_service.py +++ b/app/services/ledger_service.py @@ -865,6 +865,22 @@ def build_daily_report(ymd: int = None) -> dict: attention.append({"type": "行情缺失", "codes": v["price_missing"]}) if not v["sector_ready"]: attention.append({"type": "行业约束停用", "hint": "PMS_SECTOR_SOURCE 未配置"}) + # 总规模与账户实际总资产的偏差。**这条必须每天摆出来**: 三道仓位闸 (总仓/单股/只数) 全部 + # 以 PMS_TOTAL_SCALE 为基准算, 而它是用户命令参数, 不是账户余额。两者一旦差得远, 规划器 + # 会排出账户根本执行不了的方案, 却一路过闸 —— 到 QMT 那儿才被 INSUFFICIENT_CASH 拒。 + # 07-30 就是这个情形: scale 200 万 vs 模拟账户 98 万, 差一倍而页面上看不出任何异常。 + ta, scale_v = t.get("total_asset"), float(t.get("scale") or 0) + if ta and scale_v > 0: + gap = float(ta) / scale_v - 1 + if abs(gap) > param_store.get_float("PMS_SCALE_GAP_ALARM", 0.10): + attention.append({"type": "规模与账户不符", "scale": scale_v, + "total_asset": round(float(ta), 2), "gap": round(gap, 4), + "hint": "仓位上限按 scale 算, 资金校验按账户算 —— 差得远时方案会" + "过闸但下不出去。改 PMS_TOTAL_SCALE 或核对账户"}) + if t.get("cash_source") != "ws": + attention.append({"type": "资金快照未接通", "hint": t.get("cash_why") or "", + "note": "买入前的资金校验已降级 (只按 scale−市值 估算), 见规则闸的 " + "CASH_ESTIMATED 告警"}) if td.calendar_degraded(): attention.append({"type": "交易日历降级", "hint": "未安装 chinesecalendar, 节假日不可辨"}) diff --git a/app/services/portfolio.py b/app/services/portfolio.py index 9a32d83..73c36ee 100644 --- a/app/services/portfolio.py +++ b/app/services/portfolio.py @@ -14,12 +14,57 @@ import json import logging from app.core import cushion as cu -from app.repo import pms_repo +from app.core import ws_codec as wsc +from app.repo import pms_repo, qmt_repo from app.services import industry, market, param_store logger = logging.getLogger("pms.portfolio") NEG_STREAK_KEY = "PMS_CUSHION_NEG_STREAK" +CASH_WS, CASH_EST = "ws", "estimate" + + +def cash_view() -> dict: + """真实可用资金 (ws 资金快照 §5.7 kind=funds)。取不到则回落估算并**标明是估的**。 + + 为什么必须有这一层 + ------------------ + 原来全系统只有一个 `cash_est = PMS_TOTAL_SCALE − 持仓市值` —— 那是**从参数算出来的虚数**, + 与账户里真有多少钱毫无关系。`PMS_TOTAL_SCALE` 是用户命令参数 (「我打算投多少」), + `available_cash` 是账户事实 (「现在有多少钱」), 两者是不同的约束, 不该混: + * scale 管**仓位纪律** —— 单股不超几成、总仓不超几成、最多几只 + * 可用资金管**买不买得起** —— 这一单的钱够不够 + 07-30 实测: scale=200 万, 而模拟账户 total_asset 只有 98.1 万、available_cash 97.1 万。 + 少了第二个约束, 规划器会按 200 万排出账户根本执行不了的方案, 规则闸一路放行, 直到 QMT + 回 `INSUFFICIENT_CASH` 才被拒 —— 而 PMS 收到 reject 不自动重发, 择时下一跳又算又发又拒, + 页面看着一切正常, 实际一单也下不去。 + + **`sell_return_today` 必须加进来**: 当日卖出回笼资金 T+0 可用 (协议 §5.7, A3 点名要的 + 字段)。不加会把「刚卖掉一只、拿回笼的钱买另一只」这条最常见的换仓路径判成资金不足。 + """ + max_age = param_store.get_int("PMS_RECON_WS_SNAPSHOT_MAX_AGE_SEC", 900) + out = {"source": CASH_EST, "cash_avail": None, "total_asset": None, + "age_sec": None, "why": ""} + try: + snap = qmt_repo.latest_snapshot("funds") + except Exception as e: + out["why"] = f"读 ws 资金快照失败: {type(e).__name__}: {e}" + return out + if not snap: + out["why"] = "ws 从未回过 funds 快照 (对端未实现 query_funds, 或 pms-ws 没在跑)" + return out + age = snap.get("age_sec") + if age is not None and age > max_age: + out.update({"why": f"ws 资金快照已过期 ({age:.0f}s > {max_age}s)", "age_sec": age}) + return out + f = wsc.parse_funds_snapshot(snap["payload"]) + avail = f.get("available_cash") + if avail is None: + out["why"] = "ws 资金快照里认不出可用资金字段 (补进 ws_codec.parse_funds_snapshot)" + return out + out.update({"source": CASH_WS, "age_sec": age, "total_asset": f.get("total_asset"), + "cash_avail": round(float(avail) + float(f.get("sell_return_today") or 0), 2)}) + return out def neg_streak_map() -> dict: @@ -91,10 +136,17 @@ def positions_view(*, with_price: bool = True) -> dict: sector_names[s] = sector_names.get(s, 0) + 1 sector_mv[s] = sector_mv.get(s, 0.0) + x["market_value"] cost_sum = sum((x["avg_cost"] or 0) * x["total_qty"] for x in held) + cash = cash_view() totals = { "scale": scale, "portfolio_mv": round(port_mv, 2), "portfolio_pct": round(port_mv / scale, 4) if scale > 0 else None, + # cash_est 是 scale−市值 的**估算**, 与账户里有多少钱无关; + # cash_avail 才是账户事实 (ws 资金快照)。两个都留着并标明来源, 免得后来者 + # 又把估算值当真钱用 —— 见 cash_view 的说明。 "names_count": len(held), "cash_est": round(scale - port_mv, 2) if scale > 0 else None, + "cash_avail": cash["cash_avail"], "cash_source": cash["source"], + "cash_age_sec": cash["age_sec"], "cash_why": cash["why"], + "total_asset": cash["total_asset"], "float_pnl": round(port_mv - cost_sum, 2) if cost_sum else 0.0, "float_pnl_pct": round(port_mv / cost_sum - 1, 4) if cost_sum > 0 else None, "sector_names": sector_names, "sector_mv": sector_mv, @@ -130,6 +182,9 @@ def caps_ctx(view: dict, *, ts_code=None, is_new_name=False, sector=None) -> dic "sector_mv_map": t["sector_mv"] if ready else {}, "sector_max_names": p["sector_max_names"], "sector_max_ratio": p["sector_max_ratio"], "cash_reserve": p["cash_reserve"], "sector_source_ready": ready, + # 真实可用资金随 caps 一起进规则闸 (executor 已经在传 caps, 不必再改它的取数)。 + # cash_source 一定要跟着传: 规则闸靠它区分「校验过了」和「拿不到数据只能降级」。 + "cash_avail": t.get("cash_avail"), "cash_source": t.get("cash_source", "estimate"), } @@ -145,7 +200,12 @@ def overview() -> dict: "ok": True, "scale": t["scale"], "portfolio_mv": t["portfolio_mv"], "portfolio_pct": t["portfolio_pct"], "portfolio_cap": p["portfolio_cap"], "names_count": t["names_count"], - "max_names": p["max_names"], "cash_est": t["cash_est"], "float_pnl": t["float_pnl"], + "max_names": p["max_names"], "cash_est": t["cash_est"], + # 真钱与来源一起给页面 —— 只显示 cash_est 的话, 那个数在 scale 与账户不一致时 + # 会让人以为钱多得是 (07-30: cash_est 199 万 vs 真实可用 97 万) + "cash_avail": t["cash_avail"], "cash_source": t["cash_source"], + "total_asset": t["total_asset"], "cash_why": t["cash_why"], + "float_pnl": t["float_pnl"], "float_pnl_pct": t["float_pnl_pct"], "solid_names": t["solid_names"], "neg_names": t["neg_names"], "autonomy": p["autonomy"], "buy_halt": p["buy_halt"], "exec_halt": p["exec_halt"], diff --git a/app/web/static/index.html b/app/web/static/index.html index db4dbe8..c02689e 100644 --- a/app/web/static/index.html +++ b/app/web/static/index.html @@ -70,12 +70,17 @@ + +
总规模
{{ money(ov.scale) }}
组合市值
{{ money(ov.portfolio_mv) }}
总仓位
{{ pct(ov.portfolio_pct) }} / {{ pct(ov.portfolio_cap) }}
可用额度
{{ money(ov.cap_room) }}
+
账户可用 (估算)
+
{{ money(ov.cash_source==='ws' ? ov.cash_avail : ov.cash_est) }}
持仓数
{{ ov.names_count }} / {{ ov.max_names }}
浮动盈亏
@@ -563,6 +568,15 @@ createApp({ // ---- ws 通道监控 (只读) const wsRaw = ref({}), wsAuto = ref(true), wsHidePong = ref(true); let wsTimer = null; + // 总规模(用户命令参数) 与 账户实际总资产(ws 资金快照) 的偏差。三道仓位闸都按 scale 算, + // 资金校验按账户算 —— 差得远时方案会过闸但下不出去, 而页面上原来完全看不出来。 + const scaleGap = computed(() => { + const ta = Number(ov.value.total_asset || 0), s = Number(ov.value.scale || 0); + if (!ta || !s) return 0; + const g = ta / s - 1; + return Math.abs(g) > 0.10 ? g : 0; + }); + const wsc = computed(() => wsRaw.value.channel || {}); const wst = computed(() => wsc.value.stat || {}); const wsMode = computed(() => wsRaw.value.mode || ''); @@ -810,6 +824,7 @@ createApp({ positions, lots, lotsOf, instructions, ledger, proposals, report, reportDrawer, opsDrawer, opsResult, opsLoading, issuing, form, curSpec, dirtyCount, dm, money, pct, groupLabel, fieldLabel, stTag, cuTag, canCancel, progPct, insPct, + scaleGap, wsRaw, wsAuto, wsHidePong, wsc, wst, wsMode, wsOrders, wsInbox, wsWarnings, loadWs, loadAll, loadParams, saveParams, loadPlans, loadLots, onCmdChange, issue, cancelCmd, replan, decide, ops, loadSchema, openReport, cancelIns }; diff --git a/scripts/test_batch3_units.py b/scripts/test_batch3_units.py index 1579842..4764dbc 100644 --- a/scripts/test_batch3_units.py +++ b/scripts/test_batch3_units.py @@ -243,6 +243,32 @@ def _(): assert any(x.startswith("CAPS_MISSING") for x in r["failed"]), r +@case("规则闸·资金不足拒买 (真钱基准, 与 scale 上限是两个不同的约束)") +def _(): + # scale 200 万、单股上限 8%=16 万 —— 上限过得了, 但账户真实可用只有 5 万 + cash = {"cash_avail": 50_000.0, "cash_source": "ws"} + r = rg.check(side="buy", action="OPEN", qty=1000, price=10.0, ctx=ctx(caps=cash)) + assert r["passed"], f"1 万 < 可用 5 万, 该放行: {r}" + assert r["hard_numbers"]["cash_need"] == 10_000.0 + assert r["hard_numbers"]["cash_source"] == "ws" + r = rg.check(side="buy", action="OPEN", qty=8000, price=10.0, ctx=ctx(caps=cash)) + assert any(x.startswith("INSUFFICIENT_CASH") for x in r["failed"]), r + # 卖出不受资金约束 (减持方向不设门槛) + r = rg.check(side="sell", action="TRIM", qty=2000, price=10.0, ctx=ctx(caps=cash)) + assert r["passed"] and "cash_need" not in r["hard_numbers"], r + + +@case("规则闸·拿不到 ws 资金快照 → 降级不拦但留痕 (不把通道故障升级成业务停摆)") +def _(): + for caps in ({"cash_source": "estimate", "cash_avail": None}, + {"cash_source": "estimate", "cash_avail": 1_990_000.0}, # 估算值也不认 + {}): # 字段压根没传 + r = rg.check(side="buy", action="OPEN", qty=8000, price=10.0, ctx=ctx(caps=caps)) + assert r["passed"], f"降级不该拦: {caps} → {r}" + assert any(w.startswith("CASH_ESTIMATED") for w in r["warnings"]), r + assert not any(x.startswith("INSUFFICIENT_CASH") for x in r["failed"]), r + + @case("规则闸·必要输入缺失与全局暂停执行一律拒绝 (宁可不动)") def _(): r = rg.check(side="sell", action="EXIT", qty=1000, price=0, ctx=ctx()) diff --git a/scripts/test_wiring.py b/scripts/test_wiring.py index 1f4fd25..5c0aca0 100644 --- a/scripts/test_wiring.py +++ b/scripts/test_wiring.py @@ -1130,6 +1130,30 @@ def _(): assert fake.instructions["INS_A2"]["status"] == "EXPIRED" +@case("组合快照·真钱取 ws 资金快照(含当日回笼); 取不到则标明是估的") +def _(): + from app.services import portfolio + fake = install_fakes(prices={"600000.SH": 10.0}, params={"PMS_TOTAL_SCALE": "2000000"}) + t = portfolio.positions_view()["totals"] + assert t["cash_source"] == "estimate" and t["cash_avail"] is None, t + assert t["cash_est"] == 2_000_000.0, t # scale − 市值, 是个估算值 + + fake.qmt.put_snapshot(kind="funds", data={"total_asset": 981448.56, + "available_cash": 971251.56, + "sell_return_today": 48900.0}) + v = portfolio.positions_view() + t = v["totals"] + assert t["cash_source"] == "ws", t + # 当日卖出回笼 T+0 可用, 必须算进去 —— 不算就把"卖一只买另一只"判成资金不足 + assert t["cash_avail"] == round(971251.56 + 48900.0, 2), t + assert t["total_asset"] == 981448.56, t + # 这一行是整件事的由来: 按 scale 估出来 200 万, 账户真钱只有 102 万, 差着一倍 + assert t["cash_est"] > t["cash_avail"], (t["cash_est"], t["cash_avail"]) + # caps_ctx 要把真钱与来源一起带给规则闸, 否则闸门只能降级 + caps = portfolio.caps_ctx(v, ts_code="600000.SH") + assert caps["cash_source"] == "ws" and caps["cash_avail"] == t["cash_avail"], caps + + @case("对账事实源·ws 快照新鲜 → 以 ws 为准建账 (表空集不再当事实)") def _(): from app.services import ledger_service as ls