tradingSystem/UPSTREAM_PLAN_API.md

665 lines
39 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 上游选股计划接口 · 接入记录与待确认口径
> 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`
与持仓票视图。账本还空着, 所以持仓那段现在必然是空的, 等账本重建后才有内容。