tradingSystem/UPSTREAM_PLAN_API.md

350 lines
20 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` 都要重新看。