tradingSystem/UPSTREAM_PLAN_API.md

30 KiB
Raw Blame History

上游选股计划接口 · 接入记录与待确认口径

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:8300curl 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 强刷

五条钉死的口径:

  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.ymlx-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.1192.168.16.155127.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
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 的签名:

@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_TOPTHEME_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, 回的是:

{"status": {"source": "gp_stock_category", "ready": true, "count": 0}, "rows": []}

这个输出什么都没证明。 老的 ready() 只做了一件事: 看参数值在不在枚举里 —— 参数一填 就报 true。而 countrows 统计的是 pms_industry_map (custom_table 的表), 对 gp_stock_category 一个字的证据都没有。再加上 repo 层 fetch_sector_from_category 把异常 except: continue 吞成 None, 而 sizer.check_capssector=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
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 分钟。三种都不命中时明确区分 「表能查但没有这些票」和「表根本查不了」。

验证

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, 已通)

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