13 KiB
上游选股计划接口 · 接入记录与待确认口径
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。
应答骨架:
{
"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 强刷 |
五条钉死的口径:
- 计划里没有价格、没有金额。 上游只回答「买什么、排第几」; 买多少、什么价位是 PMS
自己的活 (sizer/planner)。价格一律现取
market.get_price, 取不到就不进池并记 warning —— 不进池比拿一个假价格进去安全。 - 拿不到 ≠ 今天没票可买。 接口失败时候选池为空 + ERROR 告警, 绝不静默回退到
trading_buy_plan。那张表在目标架构下没有明确写入方, 拿它当事实源比没有候选更危险。 页面顶部会挂红色横幅, 不会安静地什么都不买。 - 961 只主榜不是买入清单, 是排序池。 默认按 score 降序取前 30 (
PMS_PLAN_TOP_N), 档位再按PMS_PLAN_TIERS白名单过滤 (默认只要「强传导」)。 - 日龄按交易日算并硬校验。 默认允许 1 个交易日 (上游是收盘后出下一日的计划), 超了直接拒用。防的是上游停更/长假时拿上周的榜当今天用 —— 这种错在盘中完全静默。
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 (空) 附加查询串, 如 limit=1000 (取全量用, 见 §4 Q3)
行业硬拦截的开法: 先跑一次「强刷 + 灌行业映射」(或等 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)。
定位用探路模式, 它把所有可能的走法一次试完并给出下一步:
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)
宿主机上放行网桥网段后四条路全通:
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重启就没了。 要持久化, 二选一: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 |
| Q3a | 默认返回条数 | 主榜 20 / 观察档 10 (实测), 与 md 版「主榜 Top 20」一致。取全量的方式仍未知 —— 见下 Q3 |
| — | 快照滞后是已知设计 | md 注: 「本日传导用的行情快照 = 2026-07-28(与计划日不同——历史降级日口径)」 |
仍需上游回答
Q2 date 是「计划生成日」还是「适用交易日」? 上游每天几点出计划? 样例 date=2026-07-29
而快照是 07-28。若 date 是生成日, 07-30 开盘该用哪一份? PMS 现在按「至多比今天旧 1 个
交易日」放行, 盘前 08:40 拉 —— 若上游出计划晚于 08:40, 这个调度位要往后挪。
Q3 (最重要) 怎么取主榜全量? 实测默认只回 20 条。
counts(上游全量)={'main': 961, 'observe': 107} returned(本次应答)={'main': 20, 'observe': 10}
format=md 那版的标题也印证了: 「主榜 Top 20(有券商预期、目标价不低于现价, 每主题限额 5)」。
两个后果得说清:
- 候选池实际只在这 20 条里选,
PMS_PLAN_TOP_N=30根本吃不满 —— 排序池比以为的小 48 倍。 - 这 20 条已被上游按「每主题限额 5」裁过 (实测主题分布: 储能×5 / 传感器×5 / 整机×5 / 整车×3 / 集成电路设计×2)。PMS 侧的行业集中度约束因此是在一个已经被裁过的池子上再裁 一次 —— 约束还成立, 但它看不到全貌, 也就选不出"上游主题限额之外但更合适"的票。
请给取全量 (或分页) 的方式: 参数名是什么? 有上限吗? 还是另有端点?
在此之前 PMS 侧已备好: 探参数名 probe_plan_api.py --try-limit (自动试 limit/top/size/
page_size/… 十来个常见名), 探到就填 PMS_PLAN_QUERY_EXTRA=limit=1000, 不用改代码。
parse_plan 也已经把 truncated 标记算出来, 日志 warning + 页面抽屉橙色横幅都会显式提示,
不会静默拿 20 条当全量用。
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。 那张表还有别的消费方吗? 若没有,
上游可以停写 —— 少一处没有明确写入方的"事实源"。