665 lines
39 KiB
Markdown
665 lines
39 KiB
Markdown
# 上游选股计划接口 · 接入记录与待确认口径
|
||
|
||
> 2026-07-30 建。上游「选股系统」提供 HTTP 计划接口, PMS 的升仓/建仓候选池自此改吃它,
|
||
> 不再读 `trading_buy_plan`。本文是接口现状 + PMS 侧接法 + **需要上游回答的问题**。
|
||
> 代码入口: `app/services/plan_feed.py`; 单测: `scripts/test_batch7_units.py` (23 例);
|
||
> 实机探活: `scripts/probe_plan_api.py`。
|
||
|
||
---
|
||
|
||
## 1. 接口现状 (上游给的)
|
||
|
||
```
|
||
GET http://192.168.16.155:8300/plan
|
||
?date=2026-07-29 可选, 缺省=最新一份计划
|
||
&format=md 可选, 输出格式 (PMS 只用默认的 JSON; md 那版是人看的)
|
||
```
|
||
|
||
**服务与 PMS 同机** (`factorevaluation`, 即 192.168.16.155 本机)。宿主机上
|
||
`curl localhost:8300` 与 `curl 192.168.16.155:8300` 都通, 但**容器里直连
|
||
192.168.16.155:8300 会 ConnectTimeout** —— 见下 §3。
|
||
|
||
应答骨架:
|
||
|
||
```json
|
||
{
|
||
"date": "2026-07-29",
|
||
"counts": { "main": 961, "observe": 107, "gate_covered": 2386 },
|
||
"market_snapshot_days": ["2026-07-28"],
|
||
"heat_date": "2026-07-27",
|
||
"theme_cap": 5,
|
||
"main": [
|
||
{ "rank": 1, "code": "SH600418", "name": "江淮汽车", "score": 242.24,
|
||
"evidence": { "theme": "整车", "n_sources": 7, "moved_ratio": 0.0 },
|
||
"heat": 0.2249, "upside": 2.1203202525935945, "tier": "强传导" }
|
||
],
|
||
"observe": [
|
||
{ "rank": 1, "code": "SH600877", "name": "电科芯片", "score": 101.18,
|
||
"evidence": { "theme": "集成电路设计", "n_sources": 9, "moved_ratio": 0.0 },
|
||
"heat": 0.4743, "upside": null }
|
||
],
|
||
"changes": null,
|
||
"encoding": "主榜分=200+传导档位×20+组内分(还没热、还便宜);观察档分=100+0.6z(传导)+0.4z(−热度)"
|
||
}
|
||
```
|
||
|
||
字段差异: 观察档没有 `tier`, `upside` 恒为 `null`。代码是前缀式 (`SH600418`),
|
||
PMS 内部统一点式 (`600418.SH`), 由 `command_spec.normalize_code` 转。
|
||
|
||
---
|
||
|
||
## 2. PMS 侧接法 (已实现)
|
||
|
||
| 环节 | 落点 |
|
||
|---|---|
|
||
| 取数与解析 | `plan_feed.fetch()` / `parse_plan()` —— 变形/超时/过期一律抛 `PlanFeedError` |
|
||
| 新鲜度 | `assert_fresh()` —— 日龄按**交易日**算, 超 `PMS_PLAN_STALE_TDAYS` 拒用 |
|
||
| 筛选 | `select_candidates()` —— score 降序、档位白名单、top-N、观察档闸门 |
|
||
| 候选池 | `command_service._candidates()` —— 按 `PMS_CANDIDATE_SOURCE` 取源 |
|
||
| 行业源 | 刷新时把 `evidence.theme` upsert 进 `pms_industry_map`, 行业源仍走 `custom_table` |
|
||
| 调度 | `pms.plan_pull` 交易日 08:40 (在 premarket 08:50 之前, 盘前算集中度要用映射) |
|
||
| 页面 | 头部「上游计划」抽屉 + 不可用时顶部红色横幅; 运维按钮「强刷 + 灌行业映射」 |
|
||
| 接口 | `GET /api/upstream/plan` 预览 · `POST /api/ops/plan-refresh` 强刷 |
|
||
|
||
五条钉死的口径:
|
||
|
||
1. **计划里没有价格、没有金额。** 上游只回答「买什么、排第几」; 买多少、什么价位是 PMS
|
||
自己的活 (sizer/planner)。价格一律现取 `market.get_price`, **取不到就不进池**并记 warning
|
||
—— 不进池比拿一个假价格进去安全。
|
||
2. **拿不到 ≠ 今天没票可买。** 接口失败时候选池为空 + ERROR 告警, **绝不静默回退**到
|
||
`trading_buy_plan`。那张表在目标架构下没有明确写入方, 拿它当事实源比没有候选更危险。
|
||
页面顶部会挂红色横幅, 不会安静地什么都不买。
|
||
3. **961 只主榜不是买入清单, 是排序池。** 默认按 score 降序取前 30 (`PMS_PLAN_TOP_N`),
|
||
档位再按 `PMS_PLAN_TIERS` 白名单过滤 (默认只要「强传导」)。
|
||
4. **日龄按交易日算并硬校验。** 默认允许 1 个交易日 (上游是收盘后出下一日的计划),
|
||
超了直接拒用。防的是上游停更/长假时拿上周的榜当今天用 —— 这种错在盘中完全静默。
|
||
5. **`upside` 永不参与排序**, 只做下限过滤 (`PMS_PLAN_MIN_UPSIDE`, 默认不设)。它是券商
|
||
目标价相对现价的空间 (2.1203 → +212%), 噪音大到榜首都是 +212%; 排序始终按 score。
|
||
|
||
参数 (页面「参数设置」可改, 表值优先):
|
||
|
||
```
|
||
PMS_CANDIDATE_SOURCE plan_api(默认) / buy_plan / both
|
||
PMS_PLAN_API_BASE http://host.docker.internal:8300 (见 §3) 空=停用(候选池恒为空并告警)
|
||
PMS_PLAN_API_PATH /plan
|
||
PMS_PLAN_TIMEOUT 10 秒
|
||
PMS_PLAN_CACHE_SEC 300 秒 (失败另有 60 秒负缓存, 防每分钟调度位叠超时)
|
||
PMS_PLAN_TOP_N 30 主榜取前 N 进候选池
|
||
PMS_PLAN_TIERS 强传导 传导档白名单, 空=不按档过滤
|
||
PMS_PLAN_INCLUDE_OBSERVE false 观察档是否进候选池
|
||
PMS_PLAN_MIN_SCORE 0 score 下限, 0=不设
|
||
PMS_PLAN_MIN_SOURCES 0 evidence.n_sources 下限, 0=不设
|
||
PMS_PLAN_MIN_UPSIDE 0 预期空间下限 (0.5=+50%), 0=不设; 只过滤不排序
|
||
PMS_PLAN_STALE_TDAYS 1 日龄上限 (交易日)
|
||
PMS_PLAN_THEME_SYNC true 刷新时把 theme 灌进 pms_industry_map
|
||
PMS_PLAN_QUERY_EXTRA (空) 附加查询串逃生口 (上游加新参数时免改码); 同名键输给显式参数
|
||
|
||
发给上游的三个 (对方 api.py 的签名, 0=不传用上游默认):
|
||
PMS_PLAN_TOP 300 上游 top (上游默认 20)
|
||
PMS_PLAN_OBS_TOP 100 上游 obs_top (上游默认 10)
|
||
PMS_PLAN_THEME_CAP 999 上游 theme_cap (上游默认 5; 设大=让上游别裁)
|
||
PMS_PLAN_THEME_CAP_LOCAL 5 PMS 侧同主题限额, 在 TOP_N 截断**之前**生效, 0=不限
|
||
```
|
||
|
||
行业硬拦截的开法: 先跑一次「强刷 + 灌行业映射」(或等 08:40 调度位), 再把
|
||
`PMS_SECTOR_SOURCE` 改成 `custom_table`。映射表页面可见可手改, 覆盖面随每天刷新累积
|
||
—— 这就是不用「即时查当日计划」的原因: 今天没上榜的持仓票也得有行业标签, 否则它的
|
||
行业约束会悄悄失效。
|
||
|
||
---
|
||
|
||
## 3. 容器 → 宿主机 :8300 (2026-07-30 已打通)
|
||
|
||
宿主机上两个地址都通, 但 `pms-web` 容器里直连超时:
|
||
|
||
```
|
||
[2] 取数失败: ConnectTimeout: Connection to 192.168.16.155 timed out. (connect timeout=10)
|
||
```
|
||
|
||
**超时而不是 refused, 说明包被丢了, 不是没人监听。** docker 只往 FORWARD 链加放行规则,
|
||
宿主机 INPUT 链对网桥进来的包该不该收, 它不管 —— firewalld/iptables 默认策略一拦, 表现
|
||
就是这个。同一个容器能连 153 的 MySQL、208 的 Redis, 唯独连不上「宿主机自己的 LAN IP」,
|
||
这个组合基本可以锁定成上面这条。
|
||
|
||
已做的处置: `docker-compose.yml` 的 `x-pms-base` 加了
|
||
`extra_hosts: ["host.docker.internal:host-gateway"]`, `PMS_PLAN_API_BASE` 默认值改成
|
||
`http://host.docker.internal:8300`。走网桥网关这条路通常能通 (firewalld 会把网桥放进
|
||
docker zone, 默认 ACCEPT)。
|
||
|
||
定位用探路模式, 它把所有可能的走法一次试完并给出下一步:
|
||
|
||
```bash
|
||
docker compose run --rm --no-deps pms-web python scripts/probe_plan_api.py --try-bases
|
||
```
|
||
|
||
它按序试: 当前配置 → `host.docker.internal` → 容器默认网关 (读 `/proc/net/route`) →
|
||
`172.17.0.1` → `192.168.16.155` → `127.0.0.1`, 每个 3 秒。读法:
|
||
|
||
| 结果 | 含义 | 处置 |
|
||
|---|---|---|
|
||
| 某个通了 | 就用它 | 页面把 `PMS_PLAN_API_BASE` 改成它 (脚本会把 curl 命令打出来) |
|
||
| 全部 ConnectTimeout | 宿主机 INPUT 拦了网桥 | `sudo firewall-cmd --permanent --zone=trusted --add-source=172.16.0.0/12 && sudo firewall-cmd --reload` |
|
||
| 全部 ConnectionError | 服务只绑了 127.0.0.1 | 宿主机 `ss -ltnp \| grep 8300` 确认; 让它绑 0.0.0.0 |
|
||
| 计划服务本身也在 docker 里 | 别绕宿主机 | 把它和 `pms-*` 放同一个 docker network, base 填 `http://<容器名>:8300` |
|
||
|
||
最后一行是最干净的走法 —— 少一跳 NAT, 也不用碰防火墙。
|
||
|
||
### 实际结果 (2026-07-30)
|
||
|
||
宿主机上放行网桥网段后四条路全通:
|
||
|
||
```bash
|
||
sudo iptables -I INPUT -s 172.16.0.0/12 -p tcp --dport 8300 -j ACCEPT
|
||
```
|
||
|
||
```
|
||
通 http://host.docker.internal:8300 当前配置 → 计划 2026-07-29, 主榜 20 条
|
||
通 http://172.20.0.1:8300 (compose 网络网关)
|
||
通 http://172.17.0.1:8300 (docker0)
|
||
通 http://192.168.16.155:8300 (宿主机 LAN IP)
|
||
不通 http://127.0.0.1:8300 ← 正确, 容器自己的 loopback 里当然没有服务
|
||
```
|
||
|
||
判断得到证实: 服务一直监听在 `0.0.0.0`, 挡住的是宿主机 INPUT 链。配置保持
|
||
`host.docker.internal` —— 它不依赖具体网段, 换 compose 网络也不用改。
|
||
|
||
> ⚠️ **`iptables -I` 重启就没了。** 要持久化, 二选一:
|
||
> ```bash
|
||
> sudo apt install iptables-persistent && sudo netfilter-persistent save # Debian/Ubuntu
|
||
> # 或 firewalld:
|
||
> sudo firewall-cmd --permanent --zone=trusted --add-source=172.16.0.0/12 && sudo firewall-cmd --reload
|
||
> ```
|
||
> 不持久化的话, 服务器重启后候选池会静默变空 —— 页面顶部会挂红横幅, 但没人看页面就发现不了。
|
||
|
||
---
|
||
|
||
## 4. 口径确认进度
|
||
|
||
### 已确认 (2026-07-30, 依据上游 `format=md` 的人读版输出)
|
||
|
||
| # | 口径 | 结论 |
|
||
|---|---|---|
|
||
| Q1 | `upside` 的单位 | **相对现价的比例**: `2.1203` ↔ md 里的「预期空间 **+212%**」。来源是券商目标价。已按此接入 (只过滤不排序) |
|
||
| Q5 | `counts.gate_covered` | md 里写作「全池**档位覆盖** 2386 只」——即被档位模型覆盖到的全池股票数, 与 main+observe(1068) 是包含关系。PMS 只透传展示 |
|
||
| Q6a | `theme_cap=5` 的含义 | md 标题写明「主榜 Top 20(有券商预期、目标价不低于现价, **每主题限额 5**)」——上游**已自行执行**同主题限额。PMS 侧 `PMS_SECTOR_MAX_NAMES=4` 更严, 不冲突 |
|
||
| — | 主榜的隐含前置 | 同一行标题揭示主榜已过两道筛: **有券商预期** + **目标价不低于现价**。所以主榜里不会出现 `upside < 0` |
|
||
| Q3 | 取全量的方式 | `top` / `obs_top` / `theme_cap` **全是请求参数** (上游 `api.py` 签名: `get_plan(date, format, top=20, obs_top=10, theme_cap=5)`)。20/10/5 是默认值不是政策 |
|
||
| Q6b | `theme_cap` 归谁管 | **归调用方**。既然是参数, 主题分散就该由 PMS 决定 —— 见 §5 |
|
||
| — | 快照滞后是已知设计 | md 注: 「本日传导用的行情快照 = 2026-07-28(与计划日不同——**历史降级日口径**)」 |
|
||
|
||
### 仍需上游回答
|
||
|
||
**Q2 `date` 是「计划生成日」还是「适用交易日」? 上游每天几点出计划?** 样例 `date=2026-07-29`
|
||
而快照是 07-28。若 date 是生成日, 07-30 开盘该用哪一份? PMS 现在按「至多比今天旧 1 个
|
||
交易日」放行, 盘前 08:40 拉 —— 若上游出计划晚于 08:40, 这个调度位要往后挪。
|
||
|
||
**~~Q3 怎么取主榜全量?~~ 已解决 (2026-07-30 拿到上游 `api.py`)。** 见下「§5 三个请求参数」。
|
||
|
||
**Q4 `changes` 的结构?** 样例是 `null`。若是「与上一份计划的差异」, 给个非空示例 ——
|
||
PMS 想在页面上标「新进榜 / 掉榜」, 那是上游观点变化最直接的信号。
|
||
|
||
**Q6b `theme` 词表稳定吗? 同一只票的 theme 会日间变动吗?** PMS 把它落库当行业标签, 用来
|
||
做行业集中度**硬拦截**; 词表漂移会直接影响拦不拦。
|
||
|
||
**Q7 `tier` 的完整枚举与强弱次序?** 样例只见「强传导」。PMS 用它做白名单过滤
|
||
(`PMS_PLAN_TIERS`, 默认只要「强传导」), 需要知道全集, 否则新档位会被静默挡掉。
|
||
|
||
**Q8 没有当日计划时的失败语义?** 404 / 空 `main` / 回上一日? PMS 目前把「两档全空」按变形
|
||
处理并拒用。**需要鉴权或有限流吗?** (PMS 缓存 300 秒, 每交易日盘前一次 + 页面手动强刷。)
|
||
|
||
**Q9 观察档 `upside` 恒为 `null` 是设计如此吗?** 若观察档本就没有空间测算, PMS 就只把它
|
||
当备选池 (当前默认 `PMS_PLAN_INCLUDE_OBSERVE=false`)。注意: 一旦设了
|
||
`PMS_PLAN_MIN_UPSIDE`, 观察档会因 `upside=null` 被整档挡掉 —— 这是故意选的保守方向。
|
||
|
||
**Q10 (给决策系统侧) PMS 已不再读 `trading_buy_plan`。** 那张表还有别的消费方吗? 若没有,
|
||
上游可以停写 —— 少一处没有明确写入方的"事实源"。
|
||
|
||
|
||
---
|
||
|
||
## 5. 三个请求参数与「谁来做主题分散」(2026-07-30)
|
||
|
||
上游 `api.py` 的签名:
|
||
|
||
```python
|
||
@app.get("/plan")
|
||
def get_plan(date: str | None = None, format: str = "json",
|
||
top: int = 20, obs_top: int = 10, theme_cap: int = 5):
|
||
data = plan.collect(date, top, obs_top, theme_cap)
|
||
```
|
||
|
||
**20 / 10 / 5 是默认值, 不是上游的既定政策。** 之前以为的「上游按每主题限额 5 裁过」,
|
||
其实是我们没传参数、吃了它的默认值。这一点改变了分工。
|
||
|
||
### 55 是怎么来的
|
||
|
||
`top=1000` 只回 55 条 —— 不是 top 卡的, 是 `theme_cap=5` 卡的: 过完「有券商预期 +
|
||
目标价不低于现价」之后大约 11 个主题, 每主题限 5 只 = 55。所以:
|
||
|
||
```
|
||
打分池 961 →(券商预期 + 目标价≥现价)→ ~11 个主题的若干只 →(theme_cap=5)→ 55 →(top)→ 实收
|
||
```
|
||
|
||
`counts.main=961` 与实收条数**衡量的不是一回事**, 相减没有意义。判「还有没有更多」只能看
|
||
**返回条数有没有吃满我们要的 top** —— 拿 961 去比会天天报假警。代码里 `_capped()` 就是这条。
|
||
|
||
### 分工: 上游别裁, PMS 自己裁
|
||
|
||
主题分散这件事放在哪一层做, 结果差很多:
|
||
|
||
| 做法 | 后果 |
|
||
|---|---|
|
||
| 上游裁 (`theme_cap=5`) | 池子只有 55 只。PMS 看不到「上游主题限额之外但更合适」的票, 而且这个限额调不动 |
|
||
| 都不裁 | 池子宽了, 但 `PMS_PLAN_TOP_N=30` 是**按纯 score 切**的 —— 前 30 名可能全是储能, 切完交给规则闸, 规则闸按 `PMS_SECTOR_MAX_NAMES=4` 一拦就剩 4 只, **白瞎 26 个名额且日志上看不出来** |
|
||
| **上游别裁 + PMS 在候选阶段裁** ← 现在的做法 | 要个宽池子回来 (`theme_cap=999`), `select_candidates` 按 score 序走的时候就按 `PMS_PLAN_THEME_CAP_LOCAL` 摊开, **再**截 top_n。切出来的 30 只本身就是分散的, 不会被规则闸大批拦掉 |
|
||
|
||
第三种在数学上不劣于第一种: 只要上游的每主题挑选也是 score 降序 (几乎必然), PMS 侧
|
||
`THEME_CAP_LOCAL=5` 就能复现上游 `theme_cap=5` 的结果 —— 区别是这个数现在归我们调。
|
||
|
||
默认值:
|
||
|
||
```
|
||
PMS_PLAN_TOP=300 PMS_PLAN_OBS_TOP=100 PMS_PLAN_THEME_CAP=999 # 发给上游: 要宽池子
|
||
PMS_PLAN_THEME_CAP_LOCAL=5 # PMS 侧摊开, 与原行为等价
|
||
PMS_PLAN_TOP_N=30 # 摊开之后再截断
|
||
```
|
||
|
||
`dropped.theme` 会记下被主题限额挡掉几只, 页面抽屉和探活脚本都会打「入池主题分布」——
|
||
限额有没有真在起作用, 一眼能看出来。
|
||
|
||
> 注意: 服务器上如果还留着 `PMS_PLAN_QUERY_EXTRA=top=30`, 清掉它。同名键以显式参数为准,
|
||
> 留着不影响功能, 但两个地方写同一件事迟早看走眼。
|
||
|
||
|
||
---
|
||
|
||
## 6. 拿到上游《选股说明·下游对接》之后的修正 (2026-07-31)
|
||
|
||
对方给了系统说明 + `api.py`, 加上当天盘前的实机跑批, 推翻了两条我们先前的判断, 也补上了
|
||
几条只有实盘才看得见的坑。
|
||
|
||
### 6.1 `theme` 不能当行业标签 (推翻 07-30 的决定)
|
||
|
||
07-30 定的是「`theme` 灌进 `pms_industry_map`, 行业源走 `custom_table`」。当天的样例里每条
|
||
都有 theme, 看着挺好。07-31 拉 300 条真实数据, 结论反了:
|
||
|
||
```
|
||
[5] 主榜主题分布 (共 10 个主题): (无)×205, 整机制造×51, 储能×23, 整机×9, 数据中心×4,
|
||
整车×3, 锂电×2, 消费类锂电池×1, 动力电池×1, 储能电池×1
|
||
```
|
||
|
||
三条硬伤:
|
||
|
||
1. **覆盖率约三成。** 300 条里 205 条没有 theme —— 上游文档写得很清楚:「无板块传导的日子
|
||
主榜靠"冷+便宜"排序」, 没有传导就没有主题。持仓票不在传导链上就永远没有标签,
|
||
它的行业硬拦截会**悄悄失效**。
|
||
2. **词表不规范。** `整机制造×51` 与 `整机×9` 并存; `锂电 / 动力电池 / 储能电池 /
|
||
消费类锂电池` 分成四个。当行业名做集中度累计, 同一门生意会被算成好几个行业。
|
||
3. **语义就不是行业。** 它是**传导主题** —— 事件驱动, 昨天哪个板块异动就叫什么。同一只票
|
||
今天算「储能」明天算「整机制造」, 集中度约束跟着一起漂。
|
||
|
||
**改法**: `PMS_PLAN_THEME_SYNC` 默认关; 行业源用 `PMS_SECTOR_SOURCE=gp_stock_category`
|
||
(决策系统生态已有的行业表, 语义正确、覆盖全市场、不随事件漂)。theme 保留在候选阶段的
|
||
`PMS_PLAN_THEME_CAP_LOCAL` 上 —— 防单一传导主题刷屏, 那才是它该干的活。两个维度各干各的。
|
||
|
||
### 6.2 盘前没有实时价 (阻断性, 已修)
|
||
|
||
07-31 盘前跑批, 主榜前 30 只**全部**「无价」。原因: `market.get_price` 读 Redis db13 的
|
||
**当日**分钟线, 盘前那张 key 根本不存在。而候选池的规矩是「取不到价就不进池」——
|
||
盘前下的升仓命令会得到一个空候选池, 而且日志上只是一行 warning。
|
||
|
||
已加 `market.plan_price()`: 实时价优先, 取不到回落**昨收** (日线 `close_qfq`, 按日缓存),
|
||
两者都没有才剔除。候选带 `price_source` 字段, 日志把「用昨收定量」和「彻底无价剔除」分开报。
|
||
规划价本来就只用来算批次数量, 真正的委托价由择时环节按实时行情现定, 用昨收完全够用。
|
||
|
||
### 6.3 ST 要 PMS 自己剔 (新增)
|
||
|
||
上游文档: 「名单当前**不剔除 ST / \*ST**; 需要过滤请在下游做」。已加
|
||
`PMS_PLAN_EXCLUDE_ST` (默认开), 判据覆盖 `ST` / `*ST` / `SST` / `S*ST` 前缀、`退市` 前缀、
|
||
`退` 后缀。名称缺失时**不误杀也不静默**: 保留该行, 但计进 `st_unknown` 由页面和探活脚本报出来。
|
||
|
||
### 6.4 「吃满 top」不等于漏了票 (假警报已消)
|
||
|
||
主榜分 = 200 + 传导档位×20 + 组内分, **档位是分层的**。我们只要「强传导」, 而强传导的分
|
||
最高、全在榜首。所以吃满 `top=300` 并不意味着漏了强传导。
|
||
|
||
判据换成 `tier_complete`: 返回的池子里只要出现过一条白名单之外的档位 (比如「弱传导」),
|
||
就说明白名单那几档已经全拿到了。实测 07-31: 300 条里 226 条被 tier 白名单挡掉 → 已经看见
|
||
大量弱传导 → 强传导取全。页面横幅和探活脚本都改看这个, 不再拿「吃满 top」报警。
|
||
|
||
### 6.5 文档回答掉的口径 (Q2/Q4/Q7/Q8/Q9 结案)
|
||
|
||
| 问题 | 上游答复 |
|
||
|---|---|
|
||
| Q2 `date` 语义与出单时点 | **date = 数据日 (上一交易日)**; 每交易日早晨约 **07:15 前**就绪; 周二至周六晨跑, 周一盘前读周六那期。→ PMS 的 08:40 调度位与 `STALE_TDAYS=1` 都对得上 |
|
||
| Q4 `changes` | 升降档: 覆盖新增、估值闸翻转、传导链进出, 每日几十只。「首次覆盖 / 新进传导链」本身有信息量 —— 可以做成页面提示, 列进待办 |
|
||
| Q7 `tier` 枚举 | 强传导 > 弱传导 > 无传导 |
|
||
| Q8 查不到 | 返回 **404**; 局域网内部服务, v1 无鉴权 |
|
||
| Q9 观察档无 upside | 观察档=无券商覆盖, 没有估值锚, 所以 `upside` 恒 null。置信度本就低 |
|
||
| upside 用法 | 「一致预期取 90 天内全部研报的简单平均, 且研报报喜不报忧, **绝对值偏高, 请当相对排序用**」→ 印证 PMS 的做法: 永不参与排序 (上游已把"还便宜"折进 score 的组内分, 再排一次是重复计分), 只留 `PMS_PLAN_MIN_UPSIDE` 做下限 |
|
||
|
||
### 6.6 还要盯着的两件事
|
||
|
||
1. **强传导规模随市场结构波动**: 「典型 1~5 个主题、合计几只到几十只」, 无板块传导的日子
|
||
可能一个都没有。所以候选池空**不一定是故障**。实测 07-31 合格只有 10 只
|
||
(强传导 74 条 ∩ PMS 侧每主题 5 只 × 储能/整机制造 2 个主题)。要不要在没有强传导的日子
|
||
放宽到「弱传导」, 是个策略选择, 目前保持不放宽。
|
||
2. **赛道硬门槛启用后主榜会收窄到约三分之一** (十五五前沿产业链: 核聚变、商业航天、通信、
|
||
算力、人工智能、低空经济)。上游说「已建成、暂未启用, 启用后另行通知」。到时候候选池
|
||
规模会跳变一次, `PMS_PLAN_TOP` 与 `THEME_CAP_LOCAL` 都要重新看。
|
||
|
||
|
||
---
|
||
|
||
## 7. 阶段收尾 (2026-07-31)
|
||
|
||
### 7.1 生效中的配置基线
|
||
|
||
```
|
||
PMS_CANDIDATE_SOURCE=plan_api 候选池独占来源, 拿不到不回退旧表
|
||
PMS_PLAN_API_BASE=http://host.docker.internal:8300
|
||
PMS_PLAN_TOP=300 OBS_TOP=100 THEME_CAP=999 发给上游: 要宽池子
|
||
PMS_PLAN_TIERS=强传导 只吃强传导 (见下"已定纪律")
|
||
PMS_PLAN_THEME_CAP_LOCAL=5 PMS 侧同主题限额, 在 TOP_N 之前生效
|
||
PMS_PLAN_TOP_N=30 EXCLUDE_ST=true THEME_SYNC=false STALE_TDAYS=1
|
||
PMS_SECTOR_SOURCE=gp_stock_category 行业硬拦截的数据源 (07-31 开启)
|
||
```
|
||
|
||
**已定纪律 (用户, 2026-07-31)**: **候选宁缺毋滥。** 不因当天强传导少就放宽到弱传导 ——
|
||
上游文档写明「强传导规模随市场结构波动, 无板块传导的日子可能一个都没有」, 那种日子候选池
|
||
就该是空的。只有**热度启动信息经确认**时才考虑放宽, 且必须显式改 `PMS_PLAN_TIERS`。
|
||
|
||
### 7.2 07-31 两次跑批的实测对照
|
||
|
||
| | 08:44 那次 | 收尾那次 | 说明 |
|
||
|---|---|---|---|
|
||
| `date` | 2026-07-30 | 2026-07-30 | **同一天** |
|
||
| 打分池 主榜/观察 | 972 / 81 | **423 / 502** | 见下 7.3 |
|
||
| 强传导条数 (300 中) | 74 | 47 | 随市场结构波动, 符合文档 |
|
||
| 榜首 | 688717 艾罗能源 242.28 | 605598 上海港湾 241.55 | 艾罗能源整个掉出主榜 |
|
||
| 无 theme 占比 | 205/300 | 238/300 | 印证 theme 不能当行业标签 |
|
||
| 合格候选 | 10 | 10 | 储能×5 + 整机制造×5 |
|
||
| ST 剔除 | — | 0 | 今天前 300 的强传导段没有 ST |
|
||
|
||
除 7.3 那一条外全部符合预期: 价格全部取到 (盘中实时价, 无 `(昨收)` 标记),
|
||
`白名单档位取全: 是`, 主题限额把 37 只挤出去, 候选池 10 只 —— 按 `STOCK_TARGET_DEFAULT=6%`
|
||
算正好 60%, 与 `PORTFOLIO_CAP=60%` 对得上, 不缺票。
|
||
|
||
### 7.3 同一 `date` 的计划会变 (已知行为, 非故障)
|
||
|
||
`/plan` 不是读静态文件, 是 `plan.collect()` **实时从库里算**的。同一个 `date=2026-07-30`,
|
||
相隔约一小时的两次请求给出了不同的结果:
|
||
|
||
- **打分池 972/81 → 423/502。** 文档写的典型规模是「主榜 950~1000、观察档 80~110」,
|
||
第二次是 **423/502 —— 主榜腰斩、观察档翻六倍, 两档几乎对调**。观察档的定义是「无券商
|
||
覆盖」, 所以这个变化意味着**大批股票从"有券商一致预期"掉成了"无覆盖"**。像是券商预期
|
||
数据在中途重载, 请求正好落在半截快照上。
|
||
- **榜首换人**, 艾罗能源整个掉出主榜。
|
||
|
||
**已与上游对齐 (2026-07-31)**: 上游当天刚做过调整, 这个前后不一致是**预期内的**, 不是
|
||
半截快照。
|
||
|
||
仍值得提一句的改进 (非阻塞): 给应答加个 `generated_at` / 版本戳。`/plan` 是实时算的,
|
||
同一 `date` 可以对应多个版本, 下游现在没有任何办法判断"手上这份是哪一版"—— 出问题复盘时
|
||
只能靠猜。PMS 侧已经把 `date` / `funnel` / `requested` 全记进日志与页面, 但那记的是
|
||
"我拿到了什么", 不是"上游发的是哪一版"。
|
||
|
||
对 PMS 的影响目前可控: 候选池只在**命令规划期**取一次, 规划完即落 `pms_plan`, 之后不再
|
||
重取 —— 不会出现"方案在途中候选池换了"。但盘前拉与盘中下命令若跨了重载窗口, 两次看到的
|
||
榜可能不一样, 心里要有数。
|
||
|
||
|
||
### 7.4 行业源实探 (07-31 收尾时补的一个洞)
|
||
|
||
切完 `PMS_SECTOR_SOURCE=gp_stock_category` 之后查 `/api/industry`, 回的是:
|
||
|
||
```json
|
||
{"status": {"source": "gp_stock_category", "ready": true, "count": 0}, "rows": []}
|
||
```
|
||
|
||
**这个输出什么都没证明。** 老的 `ready()` 只做了一件事: 看参数值在不在枚举里 —— 参数一填
|
||
就报 `true`。而 `count` 与 `rows` 统计的是 `pms_industry_map` (custom_table 的表), 对
|
||
gp_stock_category 一个字的证据都没有。再加上 repo 层 `fetch_sector_from_category` 把异常
|
||
`except: continue` 吞成 None, 而 `sizer.check_caps` 里 `sector=None` 就是**跳过行业约束**——
|
||
三件事叠起来的效果是: 表可能根本查不了, 页面上一片绿, 行业硬拦截静默失效。
|
||
|
||
这跟本仓库到处在讲的「拿不到 ≠ 通过」是同一件事, 已修:
|
||
|
||
- `downstream_repo.probe_category()` —— 把查询过程带出来 (`value` / `code_col` /
|
||
`industry_col` / `columns` / `error`), 区分"这只票不在表里"和"这张表查不了"。
|
||
`fetch_sector_from_category()` 退化成取它的 `value`。
|
||
- `industry.ready()` —— **要有证据**。`gp_stock_category` 拿持仓票 (不足补三只大盘股)
|
||
做小样本实探, 命中 0 就是 `False`; `custom_table` 空映射表同样是 `False`。
|
||
缓存 300 秒, 不进热路径。
|
||
- `industry.status()` —— 命中数、命中的列名、错误原文全带上, 一只没查到时 hint 直接写
|
||
「**一只都没查到** —— 行业约束等同于未配置且会静默失效」。
|
||
|
||
`ready=False` 的连锁反应是设计里本来就有的: 行业类命令在页面置灰、`sector_source_ready`
|
||
让 planner 把 sector 置空、页面顶部挂黄色横幅。**该停的时候明着停**, 比绿着失效强。
|
||
|
||
三条单测锁住: 查得到才 ready / 查不到 ready=False 且错误原文冒到 hint / 空映射表不算 ready。
|
||
|
||
#### 实探结果 (2026-07-31)
|
||
|
||
```
|
||
ready: false
|
||
hint: gp_stock_category **一只都没查到** ...
|
||
errors: Unknown column 'stock_code' in 'where clause'
|
||
```
|
||
|
||
**这张表没有 `stock_code` 列。** 老代码撞的两个候选列名 (`stock_code` / `ts_code`) 里,
|
||
第一个不存在, 第二个查得动但三只样本一只都不在表里 —— 而两种情况在老实现里都变成 `None`,
|
||
分不出来。行业硬拦截当时就是**开着但全程失效**的状态, 只不过现在它明着说了。
|
||
|
||
据此又改了一版: **先 `SHOW COLUMNS` 认列, 再查**。
|
||
|
||
- `downstream_repo.category_columns()` —— 认代码列 (8 个候选) 与行业列 (10 个候选, 加了
|
||
`sw_l1` / `board_name` / `plate_name` 这类), 缓存 10 分钟。认不出来时报错直接带上**实际列名**,
|
||
是一条可执行的信息而不是一句"查不到"。
|
||
- `probe_category()` —— 拿认出来的那一列, 三种代码写法 (点式 / 前缀式 / 纯数字) 逐个试。
|
||
- `gp_stock_category` 加进 `describe()` 白名单与「运维 → 导出下游表结构」, 多出一个
|
||
`_category_probe` 段。**下个窗口的第一条命令就是它** —— 看清列名才能决定是改列名映射
|
||
还是干脆放弃这张表、去灌 `pms_industry_map`。
|
||
|
||
```bash
|
||
curl -s -X GET http://127.0.0.1:38100/api/ops/downstream-schema | python3 -m json.tool
|
||
```
|
||
|
||
|
||
---
|
||
|
||
## 8. 行业源换成 gp_hybk (2026-07-31 定案)
|
||
|
||
`gp_stock_category` 这条路作废 (§7.4: 没有 `stock_code` 列, `ts_code` 也查不到样本票)。
|
||
参考另一项目的持仓行业分析实现, 换成 **`gp_hybk`**:
|
||
|
||
| | |
|
||
|---|---|
|
||
| 库 | `DB_MYSQL_URL` (192.168.18.199) —— PMS 原本只用它读 `zs_day_data`, `fetch_all(..., source="index")` 直接可用, **不新增连接** |
|
||
| 表 / 列 | `gp_hybk`: `gp_code` / `bk_code` (数值型) / `bk_name` |
|
||
| 分级 | `bk_code` 前缀 `881`=二级, `884`=三级 |
|
||
| PMS 取哪级 | **三级 884** (`PMS_SECTOR_HYBK_LEVEL=l3`) —— 二级太粗, 4 只堆一个二级行业拦不住 |
|
||
| 一票多行业 | **只认一个主行业**, `ctx["sector"]` 保持单值, sizer/planner 一行不改 |
|
||
|
||
主行业的选法定为 **该级板块里 `bk_code` 升序第一个**。要点是**稳定**: 同一只票每次都得到
|
||
同一个行业, 否则今天算「工程机械」明天算「专用设备」, `sector_names_map` / `sector_mv_map`
|
||
的累计会自己跳。按 bk_code 数值排, 不依赖查询返回次序, 也不依赖表里有没有主次标记。
|
||
|
||
> 与参考实现的差异是故意的: 那边把金额**均分**到多个行业, 因为它要的是统计准确;
|
||
> 这边是**拦不拦**, 只认一个主行业——代价是一票同属多个热门三级行业时集中度会算漏,
|
||
> 这是明确接受的取舍 (用户 2026-07-31 定)。
|
||
|
||
### 两个没确认、改成自适应的点
|
||
|
||
1. **`gp_hybk` 在 199 的哪个 database?** 按 `DB_MYSQL_URL` (默认 `db_gp_cj`) 实现。
|
||
若不在那儿, `industry.status()` 的 hint 会直接写「gp_hybk 在 DB_MYSQL_URL 指向的库,
|
||
若不在那儿要改 .env」并带上原始报错 —— 不会静默失效。
|
||
2. **`gp_code` 的代码写法?** 点式 `600000.SH` / 前缀式 `SH600000` / 纯数字 `600000`
|
||
三种各试一遍 (样本: 浦发、平安、茅台), 谁命中用谁并缓存 10 分钟。三种都不命中时明确区分
|
||
「表能查但没有这些票」和「表根本查不了」。
|
||
|
||
### 验证
|
||
|
||
```bash
|
||
curl -s http://127.0.0.1:38100/api/params -X POST -H 'Content-Type: application/json' \
|
||
-d '{"key":"PMS_SECTOR_SOURCE","value":"gp_hybk"}'
|
||
curl -s http://127.0.0.1:38100/api/industry | python3 -m json.tool
|
||
```
|
||
|
||
看 `status.probe.form` (认出来的代码写法) 与 `status.ready`。ready=true 之后再看
|
||
`/api/positions` 里持仓票的 `sector` 有没有真填上。
|
||
|
||
### 实测结果 (2026-07-31, 已通)
|
||
|
||
```json
|
||
{"source": "gp_hybk", "ready": true, "level": "l3",
|
||
"probe": {"form": "prefix", "error": null,
|
||
"columns": ["bk_code", "bk_name", "gp_code"]}}
|
||
```
|
||
|
||
- **代码写法 = `prefix`** (`SH600000`)。表里就是前缀式, 三种写法的自适应第二轮命中。
|
||
- 列与参考实现完全一致; `gp_hybk` 确实在 `DB_MYSQL_URL` (199/db_gp_cj) 那个库里, `.env` 不用改。
|
||
- **行业集中度硬拦截自此真正生效** —— 这是 PMS 第一次有可用的行业源。
|
||
|
||
`status.count` (= `cached_today`) 是**按需查询的日缓存计数**, 不是"表里有多少条"。账本空、
|
||
还没下过命令时它就是 0。这跟 `gp_stock_category` 时代那个恒为 0 的假 count 是两回事
|
||
(那个统计的是另一张表, 跟数据源通不通毫无关系)。hint 里已经把这句写死, 免得再被误读。
|
||
|
||
> 提醒: 行业源通了以后 `PMS_SECTOR_MAX_NAMES=4` / `PMS_SECTOR_MAX_RATIO=40%` 才真正开始
|
||
> 拦人。参考项目在**三级**上用的阈值是 20%。40% 是当初按"二级或更粗"的粒度定的, 三级粒度下
|
||
> 偏松 —— 等账本重建、有真实持仓分布之后再回头看这个数, 现在不动。
|
||
|
||
|
||
---
|
||
|
||
## 9. 榜单变化改成 PMS 自算 (2026-07-31)
|
||
|
||
上游应答里的 `changes` 字段, **至今回的都是 `null`** (§4 Q4 已问, 只拿到语义答复
|
||
「升降档: 覆盖新增、估值闸翻转、传导链进出, 每日几十只」, 没给过非空示例)。而
|
||
「谁新进榜、谁掉榜、谁降了档」是上游观点变化最直接的信号 —— 尤其**持仓票被摘出榜或降了
|
||
档**, 那是"还该不该继续拿着"的直接依据。
|
||
|
||
等对方补, 就是把一件自己能算的事挂在外部依赖上。所以改成: **PMS 每次拉计划落一份名册
|
||
快照, 差异自己算。**
|
||
|
||
### 9.1 一石二鸟: 顺带补上 §7.3 那个洞
|
||
|
||
§7.3 记的是「同一 `date` 的计划会变」: `/plan` 不是读静态文件, 是 `plan.collect()` 实时
|
||
从库里算的, 07-31 实测相隔一小时的两次请求给出了不同结果 (打分池 972/81 → 423/502、
|
||
榜首换人)。当时提的改进是「请上游加 `generated_at` / 版本戳」, 因为**下游没有任何办法
|
||
判断"手上这份是哪一版"**。
|
||
|
||
落了快照, 这个问题就地解决, 一个字都不用上游改: 同一 `plan_date` 底下有几行快照, 就是
|
||
上游那天重算过几版; 每一行都带指纹与拉到的时刻。复盘时能确切说出"我当时用的是这一版",
|
||
而不是靠猜。
|
||
|
||
### 9.2 落点
|
||
|
||
| 环节 | 落点 |
|
||
|---|---|
|
||
| 表 | `pms_plan_snapshot` (**第 15 张**, DDL 尾部)。名册存 JSON, 一份 300 只约 20KB |
|
||
| 纯逻辑 | `app/core/plan_diff.py` —— 名册归一 / 指纹 / 比对。零外部依赖 |
|
||
| 服务层 | `plan_feed.snapshot()` 落库 · `changes()` 页面用 · `preview_changes()` 只读 |
|
||
| 接口 | `GET /api/upstream/plan-changes` |
|
||
| 页面 | 「上游计划」抽屉里的「榜单变化」段: 持仓票 / 掉榜与降档 / 新进榜 / 升档 / 名次跳变 / 快照台账 |
|
||
| 探活 | `probe_plan_api.py` 的 `[7]` 段 (**默认只读**, `--snapshot` 才写) |
|
||
| 单测 | `scripts/test_batch8_units.py` 60 例 |
|
||
|
||
参数 (页面可改):
|
||
|
||
```
|
||
PMS_PLAN_SNAPSHOT true 每次拉计划落一份名册快照
|
||
PMS_PLAN_SNAPSHOT_KEEP 200 保留份数
|
||
PMS_PLAN_DIFF_RANK_JUMP 50 名次跳变多少名才报
|
||
PMS_PLAN_DIFF_TAIL_GUARD 0.5 榜尾进出的噪音闸, 见 9.4
|
||
```
|
||
|
||
### 9.3 三种比对语义要分开
|
||
|
||
混在一起看会得出错误结论:
|
||
|
||
| kind | 含义 | 页面怎么显示 |
|
||
|---|---|---|
|
||
| `first` | 没有上一份快照 | **一条变化都不报** —— 否则首次是整整一榜 300 条"新进榜" |
|
||
| `cross_day` | 计划日不同 | Q4 说的「每日几十只」的正常升降档 |
|
||
| `same_date_revision` | **同一计划日的不同版本** | 黄色横幅。这不是市场变化, 是上游重算 —— 它意味着此前拿到的候选池与现在不是一回事 |
|
||
|
||
### 9.4 掉榜不能直接算「上一版有、这一版没有」
|
||
|
||
我们向上游要 `top=300`。一只票从 rank 250 掉到 rank 310, 会从名册里"消失"——
|
||
但那不是上游摘了它, 只是排到了我们要的条数之外。**吃满 top 的那一版, 尾部的进出全是
|
||
截断噪音。** 不作区分的话每天多出几十上百条假掉榜, 提示就没人看了 —— 跟 §5 里
|
||
`_capped` 治的那个「拿 counts.main=961 去比返回条数」是同一类错误的两个面。
|
||
|
||
新进榜是**对称**的同一个问题。所以两道闸各看各的那一版:
|
||
|
||
```
|
||
掉榜 —— 看 这一版 吃没吃满 (它截断了, 掉出去的可能只是被截掉)
|
||
新进 —— 看 上一版 吃没吃满 (它截断了, 新面孔可能上一版就在, 只是没露面)
|
||
```
|
||
|
||
闸位是 `PMS_PLAN_DIFF_TAIL_GUARD` (默认 0.5 = 排名进入榜单前一半才算数), 被挡下的计进
|
||
`tail_churn` 只报个数、不列名。**没吃满 top 的那一版不设闸** —— 那种情况下的进出是真的。
|
||
另有一条: 某一档在这一版一条都没有时也不设闸, 否则"整档消失"这种大事会被闸吞掉。
|
||
|
||
这道闸有三条边界, 每一条都是"别把真信号当噪音吞了":
|
||
|
||
1. **持仓票完全豁免。** 闸是拿"少报几条噪音"换"提示还有人看", 这笔账在持仓票上不成立:
|
||
非持仓票误报一条只是白看一眼, **持仓票漏报一条就是一个该减没减的仓位**。按默认
|
||
`top=300` + `tail_guard=0.5`, 不豁免的话主榜后一半是个永久盲区。豁免出来的条数记在
|
||
`tail_churn.held_exempt`, 页面明写。
|
||
2. **两档各判各的。** `capped` 按档给 —— `top` 与 `obs_top` 是两个独立的请求参数, 主榜吃满
|
||
完全不代表观察档也吃满。混用一个标志的后果是: 观察档一次真实的摘牌被记成"截断噪音"。
|
||
3. **`rank` 判不出来时照报。** 本模块别处的口径是"判不了就不判"= 不报变化; 但这个判据的
|
||
"是"代表**吞掉一条变化**, 所以方向要反过来 —— 拿不准就放行。
|
||
|
||
### 9.5 几个刻意的取舍
|
||
|
||
- **指纹不含 `score`。** `/plan` 实时算, score 末位天天抖; 算进指纹, 每次缓存过期重拉都会
|
||
多落一行快照, 表白涨而信息量为零。含 `rank` 就够 —— 分数抖到改变了次序才算真的变了,
|
||
没改变次序的抖动对候选池毫无影响 (候选就是按 score 降序切前 N)。
|
||
- **未知档位不参与升降判定。** 档位强弱按 Q7 的「强传导 > 弱传导 > 无传导」; 上游哪天加了
|
||
新档位, 宁可不报, 不能报反。
|
||
- **主榜 ↔ 观察档 互换单列一段**, 不算掉榜。观察档的定义是"无券商覆盖", 所以这个变化的
|
||
含义是**券商覆盖翻转**(Q4 说的「估值闸翻转」), 票还在, 只是没有估值锚了。§7.3 那次
|
||
423/502 的大对调就是这一类, 混进"掉榜"里会完全看不出发生了什么。
|
||
但**持仓票掉进观察档要算进 `n_watch`**(页面红条的判据): 观察档的行没有 `tier`, 所以它
|
||
既不算掉榜也不算降档, 不显式算进来的话, 一只持仓票丢了估值锚而页面一片安静。
|
||
- **`digest` 上不设唯一键, 去重只跟"最新那行"比。** 当初想拿 `(plan_date, digest)` 唯一键
|
||
当去重手段, 有两个坑: ① SQLAlchemy 的 MySQL 方言默认开 `CLIENT_FOUND_ROWS`,
|
||
`ON DUPLICATE KEY UPDATE` 命中重复照样回 1 影响行, 拿 rowcount 判"是不是新插的"必错
|
||
(`inbox_put` 那里已经栽过一次); ② 更要命的是同一天上游 **A→B→A 改回去**时, 第三次的 A
|
||
因为"曾经存过"而落不下去, 库里最新仍是 B —— 页面会把 B→A 这次**真实回退**显示成 A→B,
|
||
方向整个反了, 还顺带诬告一句"手上这份没落进快照 (落库失败?)"。现在改成先查最新一行、
|
||
变了才插。代价是并发下可能多落一行相同的快照 (比对结果为"无变化", 不产生假信号)。
|
||
- **上一版"存在但读不出来"要说成故障。** `_loads` 解析失败返回 `[]`, 长得跟"没有上一版"
|
||
一模一样 —— 于是一条真实变化都不报, 页面还显示得很正常。现在 `prev_broken` 单独标出来,
|
||
页面挂红条明说"这次比不了, 空白不代表没有变化"。
|
||
- **换了档就不算名次跳变** —— 两档的 rank 各排各的, 不可比。
|
||
- **只提示, 不产生任何指令。** 持仓票掉榜是"要不要继续拿着"的信号, 但减仓/清仓仍走命令台
|
||
或提议确认。这条通道从头到尾不碰账本、不进规则闸。
|
||
- **落库失败不阻断候选池**, 但要把错带出来 (`_snapshot_quiet` 的口径, 同 theme 灌库)。
|
||
另有 `in_sync`: 手上这份计划的指纹与库里最新快照对不上时, 页面直接说「下面比的是两个旧
|
||
版本」—— 宁可摆出来, 也不显示一个看起来正常、实际过时的差异。
|
||
- **`reset_ledger` 不清这张表。** 它记的是**上游给过我们什么**, 与本方账本无关; 清账是
|
||
"我方重来一次", 不该把上游的历史一起抹掉。
|
||
|
||
### 9.6 实机验收
|
||
|
||
```bash
|
||
# 【服务器 factorevaluation · ~/project/tradingSystem】
|
||
make test # 应输出 ALL SUITES PASS (327 例)
|
||
make initdb # 幂等, 建第 15 张表
|
||
make check # [2] 段应能看到 pms_plan_snapshot
|
||
make probe SNAP=1 # 落第一份快照; [7] 段此时应报"库里还没有快照"
|
||
make probe SNAP=1 # 再跑一次: 应报"与库里最新那份完全相同, 未新增"
|
||
make changes # 页面同款结论
|
||
```
|
||
|
||
第一份快照落下之前, 变化提示一条都不会报 —— 这是设计如此 (`first`), 不是坏了。
|
||
`make probe` (不加 `SNAP=1`) 是纯只读的, 随时可跑; 它**先比后写**, 所以加了 `SNAP=1` 也
|
||
不会拿这份计划跟刚写进去的它自己比。
|
||
|
||
隔一天再跑一次 `make changes`, 就能看到第一份真正的跨日比对 —— 那时候才验得到 `cross_day`
|
||
与持仓票视图。账本还空着, 所以持仓那段现在必然是空的, 等账本重建后才有内容。
|