tradingSystem/UPSTREAM_PLAN_API.md

531 lines
30 KiB
Markdown
Raw 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% 是当初按"二级或更粗"的粒度定的, 三级粒度下
> 偏松 —— 等账本重建、有真实持仓分布之后再回头看这个数, 现在不动。