买入前资金校验+规模偏差可见; 给QMT侧补1100股来历与持仓情形要求
This commit is contained in:
parent
4f2a545505
commit
0ab611b9e3
|
|
@ -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 条不查明,签名这条
|
||||
安全底座上就一直挂着一个问号。
|
||||
|
|
|
|||
|
|
@ -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 完整通过
|
||||
|
|
|
|||
|
|
@ -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")]
|
||||
|
|
|
|||
|
|
@ -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, 节假日不可辨"})
|
||||
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -70,12 +70,17 @@
|
|||
<el-alert v-if="dm.shadow" class="banner" type="info" effect="dark" show-icon :closable="false"
|
||||
: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="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">{{ 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">账户可用<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="v">{{ ov.names_count }}<span class="muted"> / {{ ov.max_names }}</span></div></div>
|
||||
<div class="m"><div class="k">浮动盈亏</div>
|
||||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue