# 上游选股计划接口 · 接入记录与待确认口径 > 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` 都要重新看。