买入前资金校验+规模偏差可见; 给QMT侧补1100股来历与持仓情形要求

This commit is contained in:
zlt 2026-07-30 13:02:51 +08:00
parent 4f2a545505
commit 0ab611b9e3
8 changed files with 239 additions and 6 deletions

View File

@ -150,6 +150,40 @@ sender = asyncio.create_task(self._sender_loop(websocket, queue))
2026-07-29 首次接管 22 只持仓时踩过一次。我方取值优先级已有单测守着,**但数据本身对不对 2026-07-29 首次接管 22 只持仓时踩过一次。我方取值优先级已有单测守着,**但数据本身对不对
只能靠贵方**。`available_quantity`T+1 可卖)同理,请填合理值。 只能靠贵方**。`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与贵方 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 条不查明,签名这条 第 1 条改完 + 第 5 条按口径装好持仓,我方即可进入「小仓位实盘」;第 3 条不查明,签名这条
安全底座上就一直挂着一个问号。 安全底座上就一直挂着一个问号。

View File

@ -63,6 +63,9 @@
| 回放游标 | 重新 seed 到 `SELL_688819.SH_1774588308`(字典序锚点,此后不追认历史) | | 回放游标 | 重新 seed 到 `SELL_688819.SH_1774588308`(字典序锚点,此后不追认历史) |
| `PMS_RECON_STREAK` | 归零 | | `PMS_RECON_STREAK` | 归零 |
| `PMS_SIGNAL_ENABLED` | **False**(清账期间关的)。**账本重建完必须打开**,理由见 §7 | | `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 那个「当前最大未决项」至此闭环 | | 下游 `trading_buy_plan` 轮询 | **已于 2026-07-30 10:55 停止**——`QMT_INTERFACE_REQUIREMENTS.md` C2 那个「当前最大未决项」至此闭环 |
调度恢复后一轮四个调度位全部空转正常:`signal_digest` 报「已关闭」、`command_poll` 空、 调度恢复后一轮四个调度位全部空转正常:`signal_digest` 报「已关闭」、`command_poll` 空、
@ -122,6 +125,31 @@ trade 引用了 `4ec5ce4c` / `f0efcf41` / `43e47c39` 三个**出口表里根本
| 反查报错时一笔都不判07-30 | 同上 | 原先把「库抖了」当「查不到」→ 当真单入账,一次超时就能造出一笔外部成交 | | 反查报错时一笔都不判07-30 | 同上 | 原先把「库抖了」当「查不到」→ 当真单入账,一次超时就能造出一笔外部成交 |
| 挂起数字露出通道状态07-30 | `dispatcher.channel_status().orphan_held` + `ws_smoke status` | 挂起的行不再进 `inbox_pending`,没人报数就是一笔无人知晓的漏账 | | 挂起数字露出通道状态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 **孤儿成交这条的判断依据**值得记住的口径ws 这条路上的 trade 必带 `instruction_id`§5.5
而那个 id 是 PMS 自己生成、自己写进出口表的,所以**反查不到只可能是数据不一致**,不可能是 而那个 id 是 PMS 自己生成、自己写进出口表的,所以**反查不到只可能是数据不一致**,不可能是
「有人在 QMT 手工下了单」——手工成交走 `trading_order` 那条路,根本不进 inbox。两种错的代价 「有人在 QMT 手工下了单」——手工成交走 `trading_order` 那条路,根本不进 inbox。两种错的代价
@ -192,9 +220,10 @@ code: SIG_INVALID, reason: "signature verification failed"}`——**是对端验
1. ~~确认对端实现了 R1R4 的哪几条~~ ✅ 07-30 上午完成,结果见 §2 1. ~~确认对端实现了 R1R4 的哪几条~~ ✅ 07-30 上午完成,结果见 §2
2. ~~清账重来~~ ✅ 07-30 11:14 完成,基线见 §3.0 2. ~~清账重来~~ ✅ 07-30 11:14 完成,基线见 §3.0
3. **发函 QMT 侧**§5.1~5.5 五条,见 `QMT_SIDE_S3_CLOSEOUT.md` 3. **发函 QMT 侧**§5.1~5.5 五条,见 `QMT_SIDE_S3_CLOSEOUT.md`
4. **等对端装持仓 → 认领账本**。对端装好之前,若不想让 15:10 的 `daily_settle` 拿半成品建账, 4. **等对端答复 §5.1(那 1100 股的来历)再认领账本**。`pms-beat` 现在停着,所以 15:10 不会
就先 `docker compose stop pms-beat`;装好确认 `cost_price` 无误后再 `--profile sched up -d pms-beat` 自动建账。答复回来后:是联调残留 → 请对端清掉、我方不认领;是真持仓 → 跑
探下游用 `curl -X POST '.../api/ops/reconcile?apply_fix=false'`(只看不改) `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 最后一条) 5. **账本重建完立刻把 `PMS_SIGNAL_ENABLED` 打开**(见 §7 最后一条)
6. **盘中**跑 `scan-proposals?dry_run=true` + `exec-tick?dry_run=true`,看真实候选清单 6. **盘中**跑 `scan-proposals?dry_run=true` + `exec-tick?dry_run=true`,看真实候选清单
7. 日终对账零差异 → S3 完整通过 7. 日终对账零差异 → S3 完整通过

View File

@ -132,10 +132,39 @@ def _check_buy(failed, warns, qty, price, ctx, pos, day, prm, flg, is_cmd, hard)
failed.extend(bad) failed.extend(bad)
if not caps.get("sector_source_ready", True): if not caps.get("sector_source_ready", True):
warns.append("SECTOR_OFF: 行业数据源未配置, 行业集中度未校验 (页面已提示)") warns.append("SECTOR_OFF: 行业数据源未配置, 行业集中度未校验 (页面已提示)")
_check_cash(failed, warns, qty, price, caps, hard)
else: else:
failed.append("CAPS_MISSING: 缺组合上下文, 无法校验上限 (宁可不动)") 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: def summarize(results: list) -> dict:
"""一批指令的闸门统计 (日报关注区用)。""" """一批指令的闸门统计 (日报关注区用)。"""
passed = [r for r in results if r.get("passed")] passed = [r for r in results if r.get("passed")]

View File

@ -865,6 +865,22 @@ def build_daily_report(ymd: int = None) -> dict:
attention.append({"type": "行情缺失", "codes": v["price_missing"]}) attention.append({"type": "行情缺失", "codes": v["price_missing"]})
if not v["sector_ready"]: if not v["sector_ready"]:
attention.append({"type": "行业约束停用", "hint": "PMS_SECTOR_SOURCE 未配置"}) 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(): if td.calendar_degraded():
attention.append({"type": "交易日历降级", "hint": "未安装 chinesecalendar, 节假日不可辨"}) attention.append({"type": "交易日历降级", "hint": "未安装 chinesecalendar, 节假日不可辨"})

View File

@ -14,12 +14,57 @@ import json
import logging import logging
from app.core import cushion as cu 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 from app.services import industry, market, param_store
logger = logging.getLogger("pms.portfolio") logger = logging.getLogger("pms.portfolio")
NEG_STREAK_KEY = "PMS_CUSHION_NEG_STREAK" 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: 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_names[s] = sector_names.get(s, 0) + 1
sector_mv[s] = sector_mv.get(s, 0.0) + x["market_value"] 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) cost_sum = sum((x["avg_cost"] or 0) * x["total_qty"] for x in held)
cash = cash_view()
totals = { totals = {
"scale": scale, "portfolio_mv": round(port_mv, 2), "scale": scale, "portfolio_mv": round(port_mv, 2),
"portfolio_pct": round(port_mv / scale, 4) if scale > 0 else None, "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, "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": 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, "float_pnl_pct": round(port_mv / cost_sum - 1, 4) if cost_sum > 0 else None,
"sector_names": sector_names, "sector_mv": sector_mv, "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_mv_map": t["sector_mv"] if ready else {},
"sector_max_names": p["sector_max_names"], "sector_max_ratio": p["sector_max_ratio"], "sector_max_names": p["sector_max_names"], "sector_max_ratio": p["sector_max_ratio"],
"cash_reserve": p["cash_reserve"], "sector_source_ready": ready, "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, "ok": True,
"scale": t["scale"], "portfolio_mv": t["portfolio_mv"], "portfolio_pct": t["portfolio_pct"], "scale": t["scale"], "portfolio_mv": t["portfolio_mv"], "portfolio_pct": t["portfolio_pct"],
"portfolio_cap": p["portfolio_cap"], "names_count": t["names_count"], "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"], "float_pnl_pct": t["float_pnl_pct"], "solid_names": t["solid_names"],
"neg_names": t["neg_names"], "autonomy": p["autonomy"], "neg_names": t["neg_names"], "autonomy": p["autonomy"],
"buy_halt": p["buy_halt"], "exec_halt": p["exec_halt"], "buy_halt": p["buy_halt"], "exec_halt": p["exec_halt"],

View File

@ -70,12 +70,17 @@
<el-alert v-if="dm.shadow" class="banner" type="info" effect="dark" show-icon :closable="false" <el-alert v-if="dm.shadow" class="banner" type="info" effect="dark" show-icon :closable="false"
:title="'影子运行中 —— ' + (dm.hint || '')"></el-alert> :title="'影子运行中 —— ' + (dm.hint || '')"></el-alert>
<el-alert v-if="scaleGap" class="banner" type="warning" effect="dark" show-icon :closable="false"
:title="'总规模与账户不符 —— 参数 ' + money(ov.scale) + ' / 账户总资产 ' + money(ov.total_asset) + ' (' + pct(scaleGap) + ')。仓位上限按参数算、资金校验按账户算, 差得远时方案会过闸却下不出去'"></el-alert>
<div class="metrics"> <div class="metrics">
<div class="m"><div class="k">总规模</div><div class="v">{{ money(ov.scale) }}</div></div> <div class="m"><div class="k">总规模</div><div class="v">{{ money(ov.scale) }}</div></div>
<div class="m"><div class="k">组合市值</div><div class="v">{{ money(ov.portfolio_mv) }}</div></div> <div class="m"><div class="k">组合市值</div><div class="v">{{ money(ov.portfolio_mv) }}</div></div>
<div class="m"><div class="k">总仓位</div> <div class="m"><div class="k">总仓位</div>
<div class="v">{{ pct(ov.portfolio_pct) }}<span class="muted"> / {{ pct(ov.portfolio_cap) }}</span></div></div> <div class="v">{{ pct(ov.portfolio_pct) }}<span class="muted"> / {{ pct(ov.portfolio_cap) }}</span></div></div>
<div class="m"><div class="k">可用额度</div><div class="v">{{ money(ov.cap_room) }}</div></div> <div class="m"><div class="k">可用额度</div><div class="v">{{ money(ov.cap_room) }}</div></div>
<div class="m"><div class="k">账户可用<span class="muted" v-if="ov.cash_source!=='ws'"> (估算)</span></div>
<div class="v" :class="ov.cash_source==='ws'?'':'muted'">{{ money(ov.cash_source==='ws' ? ov.cash_avail : ov.cash_est) }}</div></div>
<div class="m"><div class="k">持仓数</div> <div class="m"><div class="k">持仓数</div>
<div class="v">{{ ov.names_count }}<span class="muted"> / {{ ov.max_names }}</span></div></div> <div class="v">{{ ov.names_count }}<span class="muted"> / {{ ov.max_names }}</span></div></div>
<div class="m"><div class="k">浮动盈亏</div> <div class="m"><div class="k">浮动盈亏</div>
@ -563,6 +568,15 @@ createApp({
// ---- ws 通道监控 (只读) // ---- ws 通道监控 (只读)
const wsRaw = ref({}), wsAuto = ref(true), wsHidePong = ref(true); const wsRaw = ref({}), wsAuto = ref(true), wsHidePong = ref(true);
let wsTimer = null; 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 wsc = computed(() => wsRaw.value.channel || {});
const wst = computed(() => wsc.value.stat || {}); const wst = computed(() => wsc.value.stat || {});
const wsMode = computed(() => wsRaw.value.mode || ''); const wsMode = computed(() => wsRaw.value.mode || '');
@ -810,6 +824,7 @@ createApp({
positions, lots, lotsOf, instructions, ledger, proposals, report, reportDrawer, positions, lots, lotsOf, instructions, ledger, proposals, report, reportDrawer,
opsDrawer, opsResult, opsLoading, issuing, form, curSpec, dirtyCount, dm, opsDrawer, opsResult, opsLoading, issuing, form, curSpec, dirtyCount, dm,
money, pct, groupLabel, fieldLabel, stTag, cuTag, canCancel, progPct, insPct, money, pct, groupLabel, fieldLabel, stTag, cuTag, canCancel, progPct, insPct,
scaleGap,
wsRaw, wsAuto, wsHidePong, wsc, wst, wsMode, wsOrders, wsInbox, wsWarnings, loadWs, wsRaw, wsAuto, wsHidePong, wsc, wst, wsMode, wsOrders, wsInbox, wsWarnings, loadWs,
loadAll, loadParams, saveParams, loadPlans, loadLots, onCmdChange, issue, loadAll, loadParams, saveParams, loadPlans, loadLots, onCmdChange, issue,
cancelCmd, replan, decide, ops, loadSchema, openReport, cancelIns }; cancelCmd, replan, decide, ops, loadSchema, openReport, cancelIns };

View File

@ -243,6 +243,32 @@ def _():
assert any(x.startswith("CAPS_MISSING") for x in r["failed"]), r 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("规则闸·必要输入缺失与全局暂停执行一律拒绝 (宁可不动)") @case("规则闸·必要输入缺失与全局暂停执行一律拒绝 (宁可不动)")
def _(): def _():
r = rg.check(side="sell", action="EXIT", qty=1000, price=0, ctx=ctx()) r = rg.check(side="sell", action="EXIT", qty=1000, price=0, ctx=ctx())

View File

@ -1130,6 +1130,30 @@ def _():
assert fake.instructions["INS_A2"]["status"] == "EXPIRED" 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 为准建账 (表空集不再当事实)") @case("对账事实源·ws 快照新鲜 → 以 ws 为准建账 (表空集不再当事实)")
def _(): def _():
from app.services import ledger_service as ls from app.services import ledger_service as ls