upside口径确认(=相对现价比例)+下限过滤; 容器→宿主机8300走host-gateway+探路模式

This commit is contained in:
zlt 2026-07-30 16:38:24 +08:00
parent dcd05fd949
commit 86956123cb
10 changed files with 230 additions and 52 deletions

View File

@ -60,7 +60,7 @@ scripts/
test_batch4_units.py 动作引擎 四类自主动作触发与数量口径 11 例
test_batch5_units.py 决策系统信号流解析与消化口径 8 例
test_batch6_units.py ws 通道: 测试向量/签名/公钥/水位/DDL/逐笔入账 65 例
test_batch7_units.py 上游选股计划: 解析/新鲜度/候选筛选/取数守卫 23
test_batch7_units.py 上游选股计划: 解析/新鲜度/候选筛选/取数守卫 25
test_wiring.py 装配自检: 服务层→核心→落表 全链路 (内存桩) 51 例
init_db.py 建表 (应用 ddl_pms_v1.sql, 幂等, 默认演练; 含 DDL 体检)
check_db.py 实机连通性与表结构自检 (需真实 .env)
@ -199,7 +199,7 @@ QMT ──trade/order_update──▶ pms-ws ──落 pms_qmt_inbox──▶
## 已实现 / 待开发
**已实现**:建表 DDL 与建表脚本配置与运行参数中心仓位规划器与安全垫账命令系统27 类命令全目录 + 双状态机 + 冲突识别);方案生成器(降仓凑额四档、升仓、建仓分批、清仓/减至、行业清仓与限额、暂停买入撤单);账本回放与对账引擎(成交认领、外部成交并入 BASE 告警、以下游为准修正、除权检测、T+1 可用量、连续不一致升级);规则闸终检;择时执行器实现 B分日配额、分笔、VWAP/回踩/不追高、14:45 兜底、停牌一字板顺延、窗口耗尽收口、挂单有效期);动作引擎四类自主动作 + 研判闸客户端 + 提议分流;决策系统信号消化(两条流独立消费组订阅、置信度分档转清仓指令或提议);管理页面四块 + 运维/日报抽屉;调度器九个调度位;**上游选股计划接口接入**`/plan` 取候选池、交易日龄硬校验、`theme` 灌行业映射表、页面预览抽屉与不可用横幅);**ws 直连通道的连接层**(常驻进程 + 出口队列 + 签名 + seq 水位与累积确认,见下);**单测 228 例**。
**已实现**:建表 DDL 与建表脚本配置与运行参数中心仓位规划器与安全垫账命令系统27 类命令全目录 + 双状态机 + 冲突识别);方案生成器(降仓凑额四档、升仓、建仓分批、清仓/减至、行业清仓与限额、暂停买入撤单);账本回放与对账引擎(成交认领、外部成交并入 BASE 告警、以下游为准修正、除权检测、T+1 可用量、连续不一致升级);规则闸终检;择时执行器实现 B分日配额、分笔、VWAP/回踩/不追高、14:45 兜底、停牌一字板顺延、窗口耗尽收口、挂单有效期);动作引擎四类自主动作 + 研判闸客户端 + 提议分流;决策系统信号消化(两条流独立消费组订阅、置信度分档转清仓指令或提议);管理页面四块 + 运维/日报抽屉;调度器九个调度位;**上游选股计划接口接入**`/plan` 取候选池、交易日龄硬校验、`theme` 灌行业映射表、页面预览抽屉与不可用横幅);**ws 直连通道的连接层**(常驻进程 + 出口队列 + 签名 + seq 水位与累积确认,见下);**单测 230 例**。
### 下一步(按可动工顺序)
@ -209,7 +209,7 @@ QMT ──trade/order_update──▶ pms-ws ──落 pms_qmt_inbox──▶
| 2 | **ws 通道联调**(协议 §9 的 S1/S2/S3 | 密钥已交换,可开工 |
| 2.5 | 部署便利性:`make deploy` 一句话完成 build + up + 各 profile | 待办(每次改动要敲 4 条命令太烦) |
| 2.6 | **行业硬拦截开闸**:跑一次「强刷 + 灌行业映射」把 `theme` 灌进 `pms_industry_map`,再把 `PMS_SECTOR_SOURCE` 改成 `custom_table` | 可做(数据源已就位,只差一次刷新 + 一个参数) |
| 2.7 | 上游计划的 10 个待确认口径 | 阻塞:等上游答复,见 `UPSTREAM_PLAN_API.md` §3。`upside` 口径定了才敢接进排序 |
| 2.7 | 上游计划的剩余待确认口径7 条) | 阻塞:等上游答复,见 `UPSTREAM_PLAN_API.md` §4。`upside`/`gate_covered`/`theme_cap` 已于 07-30 由 `format=md` 输出反推确认 |
| 3 | T0 做T二期 | 可做,设计已有,无外部依赖 |
| 4 | 择时实现 A委托决策系统盘中择时 | 阻塞:等 bionic 侧接口 |
| 5 | 研判闸接通 | 阻塞:等 bionic 侧 `process_intraday_audit` 新增 PMS 请求 direction。客户端已就位接口好了在页面填 `PMS_JUDGE_API_BASE` 即通 |

View File

@ -12,9 +12,13 @@
```
GET http://192.168.16.155:8300/plan
?date=2026-07-29 可选, 缺省=最新一份计划
&format=md 可选, 输出格式 (PMS 只用默认的 JSON)
&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
@ -69,13 +73,14 @@ PMS 内部统一点式 (`600418.SH`), 由 `command_spec.normalize_code` 转。
档位再按 `PMS_PLAN_TIERS` 白名单过滤 (默认只要「强传导」)。
4. **日龄按交易日算并硬校验。** 默认允许 1 个交易日 (上游是收盘后出下一日的计划),
超了直接拒用。防的是上游停更/长假时拿上周的榜当今天用 —— 这种错在盘中完全静默。
5. **`upside` 不参与任何过滤与排序** (单位未确认, 见下 Q1), 只在页面和探活脚本里展示。
5. **`upside` 永不参与排序**, 只做下限过滤 (`PMS_PLAN_MIN_UPSIDE`, 默认不设)。它是券商
目标价相对现价的空间 (2.1203 → +212%), 噪音大到榜首都是 +212%; 排序始终按 score。
参数 (页面「参数设置」可改, 表值优先):
```
PMS_CANDIDATE_SOURCE plan_api(默认) / buy_plan / both
PMS_PLAN_API_BASE http://192.168.16.155:8300 空=停用(候选池恒为空并告警)
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 秒负缓存, 防每分钟调度位叠超时)
@ -84,6 +89,7 @@ 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
```
@ -95,40 +101,81 @@ PMS_PLAN_THEME_SYNC true 刷新时把 theme 灌进 pms_industry_map
---
## 3. 待上游确认 (按重要性排)
## 3. 容器 → 宿主机 :8300 走不通 (2026-07-30 实测)
**Q1 `upside` 的单位和口径是什么?** 样例里 0.2617 ~ 2.1203。若是百分比, 那 rank 1 的
「上涨空间 2.12%」小得不足以建仓; 若是倍数, 则是 212%。两种解读会导出完全相反的用法。
分母是什么 —— 压力位 / 目标价 / 近期高点? PMS 现在**完全不用它**, 口径定了才敢接。
宿主机上两个地址都通, 但 `pms-web` 容器里直连超时:
**Q2 `date` 是「计划生成日」还是「适用交易日」?** 样例 `date=2026-07-29`,
`market_snapshot_days=["2026-07-28"]`, `heat_date=2026-07-27`。若 date 是生成日, 那么
07-30 开盘该用哪一份? PMS 目前按「date 至多比今天旧 1 个交易日」放行, 口径确认后调
`PMS_PLAN_STALE_TDAYS`。**另: 上游出计划的时点是每天几点?** 08:40 的拉取会不会太早。
```
[2] 取数失败: ConnectTimeout: Connection to 192.168.16.155 timed out. (connect timeout=10)
```
**Q3 `main` 会不会分页或截断?** `counts.main=961`, 但一次应答真的会回 961 条吗?
有没有 `limit` / `offset` / `top` 参数? PMS 已把 `counts` (上游全量) 与实际返回条数分开
记录, 两者不一致时会打 warning, 但需要知道正确的取全量方式。
**超时而不是 refused, 说明包被丢了, 不是没人监听。** docker 只往 FORWARD 链加放行规则,
宿主机 INPUT 链对网桥进来的包该不该收, 它不管 —— firewalld/iptables 默认策略一拦, 表现
就是这个。同一个容器能连 153 的 MySQL、208 的 Redis, 唯独连不上「宿主机自己的 LAN IP」,
这个组合基本可以锁定成上面这条。
**Q4 `changes` 字段的结构?** 样例是 `null`。若是「与上一份计划的差异」, 请给一个非空
示例 —— PMS 想用它在页面上标「新进榜 / 掉榜」, 那是判断上游观点变化最直接的信号。
已做的处置: `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)。
**Q5 `counts.gate_covered = 2386` 是什么口径?** 闸门覆盖的股票总数? 与 main+observe
(=1068) 的关系是什么? 目前只做透传展示。
定位用探路模式, 它把所有可能的走法一次试完并给出下一步:
**Q6 `theme` 词表稳定吗? 同一只票的 theme 会日间变动吗?** PMS 把它当行业标签落库,
用来做行业集中度**硬拦截**; 词表漂移会直接影响拦不拦。另: `theme_cap=5` 是上游自己的
同主题限额吗? 若是, PMS 侧的 `PMS_SECTOR_MAX_NAMES` (默认 4) 应否与它对齐。
```bash
docker compose run --rm --no-deps pms-web python scripts/probe_plan_api.py --try-bases
```
**Q7 `tier` 的完整枚举?** 样例只见「强传导」。PMS 用它做白名单过滤, 需要知道全集
(以及档位之间的强弱次序)。
它按序试: 当前配置 → `host.docker.internal` → 容器默认网关 (读 `/proc/net/route`) →
`172.17.0.1``192.168.16.155``127.0.0.1`, 每个 3 秒。读法:
**Q8 没有当日计划时的失败语义?** 返回 404 / 空 `main` / 上一日的计划? PMS 目前把
「两档全空」按变形处理并拒用。**接口需要鉴权或有限流吗?** (PMS 默认缓存 300 秒,
每交易日至少一次盘前拉取 + 页面手动强刷。)
| 结果 | 含义 | 处置 |
|---|---|---|
| 某个通了 | 就用它 | 页面把 `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` |
**Q9 观察档 `upside` 恒为 `null` 是设计如此吗?** 若观察档本就没有空间测算, PMS 就把它
只当备选池 (当前默认 `PMS_PLAN_INCLUDE_OBSERVE=false`)。
最后一行是最干净的走法 —— 少一跳 NAT, 也不用碰防火墙。
**Q10 (给决策系统侧) PMS 已不再读 `trading_buy_plan`。** 那张表还有别的消费方吗?
若没有, 上游可以停写 —— 少一处没有明确写入方的"事实源"。
---
## 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` |
| — | 快照滞后是已知设计 | md 注: 「本日传导用的行情快照 = 2026-07-28与计划日不同——**历史降级日口径**)」 |
### 仍需上游回答
**Q2 `date` 是「计划生成日」还是「适用交易日」? 上游每天几点出计划?** 样例 `date=2026-07-29`
而快照是 07-28。若 date 是生成日, 07-30 开盘该用哪一份? PMS 现在按「至多比今天旧 1 个
交易日」放行, 盘前 08:40 拉 —— 若上游出计划晚于 08:40, 这个调度位要往后挪。
**Q3 `main` 会不会分页或截断?** `counts.main=961`, 一次应答会给全 961 条吗? 有
`limit`/`offset` 参数吗? PMS 已把 `counts` (上游全量) 与实际返回条数分开记, 不一致会打
warning, 但需要知道正确的取全量方式。
**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`。** 那张表还有别的消费方吗? 若没有,
上游可以停写 —— 少一处没有明确写入方的"事实源"。

View File

@ -71,6 +71,7 @@ DESC = {
"PMS_PLAN_INCLUDE_OBSERVE": "观察档是否进候选池",
"PMS_PLAN_MIN_SCORE": "候选 score 下限, 0=不设",
"PMS_PLAN_MIN_SOURCES": "候选 evidence.n_sources 下限, 0=不设",
"PMS_PLAN_MIN_UPSIDE": "候选预期空间下限 (相对现价, 0.5=+50%), 0=不设。只过滤不参与排序; 设了就会把 upside 缺失的行(含整个观察档)一起挡掉",
"PMS_PLAN_STALE_TDAYS": "计划日龄超此交易日数即判过期拒用 (防上游停更时拿旧榜当今天)",
"PMS_PLAN_THEME_SYNC": "刷新计划时把 evidence.theme 灌进 pms_industry_map (行业源 custom_table 的数据来源)",
"PMS_SECTOR_SOURCE": "行业划分数据源: 空=约束停用 / custom_table (推荐, 由上游计划的 theme 灌数) / gp_stock_category",

View File

@ -25,8 +25,10 @@
(默认 30) 进候选, 档位再由 `PMS_PLAN_TIERS` 白名单过滤
4. **日期新鲜度硬校验** 计划日期比今天旧超过 `PMS_PLAN_STALE_TDAYS` 个交易日即
过期 (抛错)防的是节假日/上游停更时拿上周的榜当今天用 这种错在盘中是静默的
5. **upside 单位未确认** (样例 0.26~2.12, 既可能是 % 也可能是倍数), 因此**默认不参与
任何过滤和排序**, 只透传给页面看 UPSTREAM_PLAN_API.md Q1
5. **upside = 相对现价的预期空间比例** (2.1203 +212%; 2026-07-30 与上游
`format=md` 输出的预期空间 +212%对齐确认), 来源是券商目标价噪音大 榜首能
+212% 所以**永不参与排序** (排序始终是 score), 只提供下限过滤
`PMS_PLAN_MIN_UPSIDE`
行业约束的数据源也从这里来: `evidence.theme` 随每次刷新 upsert `pms_industry_map`,
行业源仍是 `custom_table` (`PMS_SECTOR_SOURCE=custom_table`)落库而不是即时查, 是因为
@ -102,7 +104,7 @@ def _rows(raw, bucket: str) -> list:
"rank": rank if rank is not None else i + 1,
"score": _float_or_none(it.get("score")),
"heat": _float_or_none(it.get("heat")),
"upside": _float_or_none(it.get("upside")), # 单位待确认, 不参与判断
"upside": _float_or_none(it.get("upside")), # 相对现价的比例, 2.12=+212%
"tier": _text_or_none(it.get("tier")),
"theme": _text_or_none(ev.get("theme")),
"n_sources": _int_or_none(ev.get("n_sources")),
@ -162,23 +164,27 @@ def assert_fresh(plan: dict, *, max_stale_tdays: int = 1, today=None) -> int:
# ================================================================ 纯逻辑: 筛选
def select_candidates(plan: dict, *, held=(), black=(), top_n: int = 30, tiers=None,
include_observe: bool = False, min_score=None,
min_sources: int = 0) -> dict:
min_sources: int = 0, min_upside=None) -> dict:
"""排序池 → 候选清单。
排序: score 降序, 同分按 rank 升序 (上游 rank 已是它自己的最终次序, 拿来当稳定次序)
tier 白名单**只对带 tier 的行生效** 观察档没有 tier, 它的闸门是 include_observe
min_upside 相反, **对所有行生效**: upside 缺失按 0 算一起挡掉 (观察档 upside 恒为
null, 所以设了下限等于把观察档全挡了)方向选保守那边 宁可少票
"""
held = {normalize_code(c) for c in (held or []) if c}
black = {normalize_code(c) for c in (black or []) if c}
tiers = {str(t).strip() for t in (tiers or []) if str(t).strip()}
min_score = _float_or_none(min_score)
min_upside = _float_or_none(min_upside)
min_sources = int(min_sources or 0)
pool = list(plan.get("main") or [])
if include_observe:
pool += list(plan.get("observe") or [])
dropped = {"held": 0, "black": 0, "tier": 0, "score": 0, "sources": 0, "dup": 0, "capped": 0}
dropped = {"held": 0, "black": 0, "tier": 0, "score": 0, "sources": 0, "upside": 0,
"dup": 0, "capped": 0}
passed, seen = [], set()
for r in sorted(pool, key=lambda x: (-(x.get("score") or 0.0), x.get("rank") or 10 ** 9)):
c = r["ts_code"]
@ -201,6 +207,9 @@ def select_candidates(plan: dict, *, held=(), black=(), top_n: int = 30, tiers=N
if min_sources and (r.get("n_sources") or 0) < min_sources:
dropped["sources"] += 1
continue
if min_upside is not None and (r.get("upside") or 0.0) < min_upside:
dropped["upside"] += 1
continue
passed.append(r)
n = max(0, int(top_n or 0)) or len(passed)
@ -231,6 +240,7 @@ def _params() -> dict:
"include_observe": ps.get_bool("PMS_PLAN_INCLUDE_OBSERVE", False),
"min_score": ps.get_float("PMS_PLAN_MIN_SCORE", 0.0),
"min_sources": ps.get_int("PMS_PLAN_MIN_SOURCES", 0),
"min_upside": ps.get_float("PMS_PLAN_MIN_UPSIDE", 0.0),
"stale_tdays": ps.get_int("PMS_PLAN_STALE_TDAYS", 1),
"theme_sync": ps.get_bool("PMS_PLAN_THEME_SYNC", True),
"source": (ps.get("PMS_CANDIDATE_SOURCE", SRC_PLAN_API) or SRC_PLAN_API).strip(),
@ -342,7 +352,8 @@ def candidates(*, held=(), black=()) -> dict:
out = select_candidates(plan, held=held, black=black, top_n=p["top_n"], tiers=p["tiers"],
include_observe=p["include_observe"],
min_score=(p["min_score"] or None),
min_sources=p["min_sources"])
min_sources=p["min_sources"],
min_upside=(p["min_upside"] or None))
out["age_tdays"] = plan.get("age_tdays")
out["theme_cap"] = plan.get("theme_cap")
return out
@ -352,7 +363,7 @@ def status() -> dict:
"""页面/运维用: 绝不抛错, 失败也要能显示出来。"""
p = _params()
st = {"source": p["source"], "base": p["base"], "path": p["path"],
"top_n": p["top_n"], "tiers": p["tiers"],
"top_n": p["top_n"], "tiers": p["tiers"], "min_upside": p["min_upside"],
"include_observe": p["include_observe"], "stale_tdays": p["stale_tdays"],
"theme_sync": p["theme_sync"], "enabled": bool(p["base"])}
if not p["base"]:

View File

@ -573,15 +573,19 @@
<el-table-column prop="tier" label="传导档" width="82"></el-table-column>
<el-table-column prop="theme" label="主题" width="120"></el-table-column>
<el-table-column prop="heat" label="heat" width="78"></el-table-column>
<el-table-column label="upside" width="90">
<template #default="s"><span class="muted">{{ s.row.upside }}</span></template>
<el-table-column label="预期空间" width="96">
<template #default="s">
<span class="muted">{{ s.row.upside==null ? '-' :
((s.row.upside>0?'+':'') + (s.row.upside*100).toFixed(0) + '%') }}</span>
</template>
</el-table-column>
<el-table-column prop="n_sources" label="源数" width="66"></el-table-column>
</el-table>
<div class="muted" style="margin-top:6px">
upside 单位尚未与上游确认 (样例 0.26~2.12), 因此**不参与任何过滤和排序**, 只在此展示。
「预期空间」= 券商目标价相对现价的空间 (上游 upside 字段, 2.12 → +212%)。噪音大,
所以<b>永不参与排序</b> —— 排序始终按 score; 只在 PMS_PLAN_MIN_UPSIDE 上做下限过滤。
筛选参数在「参数设置」里: PMS_PLAN_TOP_N / PMS_PLAN_TIERS / PMS_PLAN_INCLUDE_OBSERVE /
PMS_PLAN_MIN_SCORE / PMS_PLAN_MIN_SOURCES / PMS_PLAN_STALE_TDAYS。
PMS_PLAN_MIN_SCORE / PMS_PLAN_MIN_SOURCES / PMS_PLAN_MIN_UPSIDE / PMS_PLAN_STALE_TDAYS。
</div>
<pre class="json" v-if="planResult">{{ planResult }}</pre>
</el-drawer>

View File

@ -75,7 +75,10 @@ class Settings(BaseSettings):
# 上游只回答「买什么、排第几」, 不给价格金额 —— 买多少/什么价是 PMS 自己算。
# 接口拿不到一律显式失败 (候选池为空 + ERROR), 绝不静默回退旧表。
PMS_CANDIDATE_SOURCE: str = "plan_api" # plan_api(默认) / buy_plan(旧表) / both(并集)
PMS_PLAN_API_BASE: str = "http://192.168.16.155:8300" # 空=停用, 此时候选池恒为空
# 计划服务与 PMS 同机: 走 host.docker.internal (compose 里配了 host-gateway)。
# 容器直连宿主机物理 IP 192.168.16.155:8300 会被宿主机 INPUT 链丢包 (超时非拒绝);
# 若日后计划服务搬到别的机器, 这里直接填那台的 IP:端口。空=停用, 候选池恒为空。
PMS_PLAN_API_BASE: str = "http://host.docker.internal:8300"
PMS_PLAN_API_PATH: str = "/plan"
PMS_PLAN_TIMEOUT: int = 10 # 单次请求超时 (秒)
PMS_PLAN_CACHE_SEC: int = 300 # 计划缓存秒数 (上游日频产出, 没必要每跳都拉)
@ -84,6 +87,8 @@ class Settings(BaseSettings):
PMS_PLAN_INCLUDE_OBSERVE: bool = False # 观察档是否进候选池 (上游把它定位为备选, 无 upside)
PMS_PLAN_MIN_SCORE: float = 0.0 # score 下限, 0=不设
PMS_PLAN_MIN_SOURCES: int = 0 # evidence.n_sources 下限, 0=不设
PMS_PLAN_MIN_UPSIDE: float = 0.0 # 预期空间下限 (0.5=+50%), 0=不设。券商目标价
# 口径, 噪音大 (榜首能到 +212%), 只做下限过滤, **不参与排序** —— 排序始终是 score
PMS_PLAN_STALE_TDAYS: int = 1 # 计划日龄超此交易日数即判过期并拒用 (防上游停更)
PMS_PLAN_THEME_SYNC: bool = True # 刷新时把 evidence.theme 灌进 pms_industry_map

View File

@ -17,6 +17,15 @@ x-pms-base: &pms-base
- TZ=Asia/Shanghai
volumes:
- ./logs:/app/logs
# 上游选股计划服务 (:8300) 跑在**宿主机本机**上。容器里访问宿主机的物理网卡 IP
# (192.168.16.155) 会 ConnectTimeout —— docker 只给 FORWARD 链放行, 宿主机 INPUT 链
# 对 docker 网桥进来的包默认不放, 表现就是丢包超时 (不是 refused)。
# host-gateway 把宿主机在网桥上的地址 (172.x.0.1) 映射成 host.docker.internal,
# 走这条路一般能通 (firewalld 会把网桥放在 docker zone, 默认 ACCEPT)。
# 还不通就是宿主机 INPUT 拦了 8300, 放行网桥网段即可:
# sudo firewall-cmd --permanent --zone=trusted --add-source=172.16.0.0/12 && sudo firewall-cmd --reload
extra_hosts:
- "host.docker.internal:host-gateway"
restart: unless-stopped
logging:
driver: json-file

View File

@ -28,6 +28,74 @@ def _fmt(v, n=4):
return "-" if v is None else (f"{v:.{n}f}" if isinstance(v, float) else str(v))
def _pct(v):
"""upside 是相对现价的比例: 2.1203 → +212% (与上游 format=md 的「预期空间」同口径)。"""
return "-" if v is None else f"{v * 100:+.0f}%"
def _default_gateway() -> str:
"""容器默认网关 = 宿主机在网桥上的地址。不依赖 ip/route 命令, 直接读 /proc。"""
try:
with open("/proc/net/route", encoding="utf-8") as f:
for line in f.readlines()[1:]:
p = line.split()
if len(p) > 2 and p[1] == "00000000" and p[2] != "00000000":
h = p[2]
return ".".join(str(int(h[i:i + 2], 16)) for i in (6, 4, 2, 0))
except Exception:
pass
return ""
def _try_bases(ps, args) -> int:
"""逐个试候选地址。容器 → 宿主机这条路最容易断, 这里一次把可能的走法全试掉。
ConnectTimeout = 包被丢了 (宿主机 INPUT 链拦了网桥进来的流量), 不是没人监听
ConnectionError = 端口没人听 (地址对了但服务不在这台/没起)
"""
port = "8300"
cfg = (ps.get("PMS_PLAN_API_BASE", "") or "").strip().rstrip("/")
gw = _default_gateway()
cands = []
for b in ([args.base] if args.base else []) + [
cfg, f"http://host.docker.internal:{port}",
(f"http://{gw}:{port}" if gw else ""), f"http://172.17.0.1:{port}",
f"http://192.168.16.155:{port}", f"http://127.0.0.1:{port}"]:
b = (b or "").strip().rstrip("/")
if b and b not in cands:
cands.append(b)
print(f"[探路] 容器默认网关 = {gw or '(读不到)'}; 当前配置 = {cfg or '(空)'}")
print(f"{len(cands)} 个候选, 每个 3 秒超时")
ok = []
for b in cands:
tag = " 当前配置" if b == cfg else ""
try:
plan = pf.fetch(base=b, path=ps.get("PMS_PLAN_API_PATH", "/plan"), timeout=3)
print(f"{b}{tag} → 计划 {plan['date']}, 主榜 {plan['returned']['main']}")
ok.append(b)
except pf.PlanFeedError as e:
msg = str(e).split(": ", 1)[-1]
print(f" 不通 {b}{tag}{msg[:150]}")
print("-" * 62)
if not ok:
print("全都不通。按下面顺序排:")
print(" 1. 宿主机上确认在听: ss -ltnp | grep 8300")
print(" 2. 若上面显示 127.0.0.1:8300 —— 服务只绑了 loopback, 容器永远进不来,")
print(" 要么让它绑 0.0.0.0, 要么把 PMS 容器改 network_mode: host (不推荐)。")
print(" 3. 若显示 0.0.0.0:8300 而这里仍超时 —— 宿主机 INPUT 链拦了网桥流量, 放行:")
print(" sudo firewall-cmd --permanent --zone=trusted --add-source=172.16.0.0/12")
print(" sudo firewall-cmd --reload")
print(" (或 iptables: sudo iptables -I INPUT -s 172.16.0.0/12 -p tcp --dport 8300 -j ACCEPT)")
print(" 4. 若计划服务本身也在 docker 里 —— 最干净的是把它和 pms-* 放同一个 network,")
print(" base 直接填 http://<那个容器名>:8300, 不绕宿主机。")
return 1
print(f"能通的地址: {ok[0]}")
print(f"→ 页面「参数设置」把 PMS_PLAN_API_BASE 改成它, 或先跑:")
print(f" curl -s -X POST http://127.0.0.1:38100/api/params -H 'Content-Type: application/json' \\")
print(f" -d '{{\"key\":\"PMS_PLAN_API_BASE\",\"value\":\"{ok[0]}\"}}'")
return 0
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--date", default=None, help="指定计划日期 YYYY-MM-DD (缺省=上游最新)")
@ -35,11 +103,17 @@ def main():
ap.add_argument("--observe", action="store_true", help="连观察档一起打印")
ap.add_argument("--with-price", action="store_true",
help="逐只查行情价 (需 Redis 通; 用来看候选池会不会因无价掉票)")
ap.add_argument("--base", default=None,
help="临时覆盖 PMS_PLAN_API_BASE (只影响本次, 不落库)")
ap.add_argument("--try-bases", action="store_true",
help="逐个试候选地址, 报告哪个能通 —— 容器连不上宿主机时先跑这个")
ap.add_argument("--json", action="store_true", help="原样打印解析后的结构")
args = ap.parse_args()
from app.services import param_store as ps
base = (ps.get("PMS_PLAN_API_BASE", "") or "").strip()
if args.try_bases:
return _try_bases(ps, args)
base = (args.base or ps.get("PMS_PLAN_API_BASE", "") or "").strip()
print(f"[1] 接口配置 base={base or '(空 —— 候选池将恒为空)'} "
f"path={ps.get('PMS_PLAN_API_PATH', '/plan')} "
f"timeout={ps.get_int('PMS_PLAN_TIMEOUT', 10)}s")
@ -48,7 +122,7 @@ def main():
return 2
try:
plan = pf.fetch(date=args.date)
plan = pf.fetch(date=args.date, base=base)
except pf.PlanFeedError as e:
print(f"[2] 取数失败: {e}")
return 1
@ -67,7 +141,7 @@ def main():
print(f"[4] 主榜前 {args.top} (score 降序即上游 rank 序)")
hdr = f" {'rank':>4} {'代码':<11} {'名称':<8} {'score':>8} {'档位':<6} {'主题':<10} " \
f"{'heat':>7} {'upside':>8}"
f"{'heat':>7} {'预期空间':>9}"
print(hdr + (" " + "行情价" if args.with_price else ""))
rows = plan["main"][:max(1, args.top)]
if args.observe:
@ -83,7 +157,7 @@ def main():
for r in rows:
line = (f" {str(r['rank']):>4} {r['ts_code']:<11} {(r['name'] or '-'):<8} "
f"{_fmt(r['score'], 2):>8} {(r['tier'] or '观察'):<6} "
f"{(r['theme'] or '-'):<10} {_fmt(r['heat']):>7} {_fmt(r['upside']):>8}")
f"{(r['theme'] or '-'):<10} {_fmt(r['heat']):>7} {_pct(r['upside']):>9}")
if args.with_price:
p = prices.get(r["ts_code"])
line += f" {p if p else '**无价(会被剔除)**'}"

View File

@ -11,9 +11,9 @@
test_batch4_units.py 动作引擎 四类自主动作触发与数量口径 (11 )
test_batch5_units.py 决策系统信号流解析与消化口径 (8 )
test_batch6_units.py ws 通道: 测试向量/签名/公钥/水位/DDL/逐笔入账 (65 )
test_batch7_units.py 上游选股计划: 解析/新鲜度/候选筛选/取数守卫 (23 )
test_batch7_units.py 上游选股计划: 解析/新鲜度/候选筛选/取数守卫 (25 )
test_wiring.py 装配自检: 服务层核心落表 全链路 (内存桩) (51 )
228
230
任一子集失败即整体失败 (退出码 1)
"""
import os

View File

@ -253,6 +253,33 @@ def _():
assert r2["dropped"]["sources"] == 5
@case("筛选·upside 下限 (2.12=+212%): 对所有行生效, 观察档因 upside=null 被一并挡掉")
def _():
d = dict(SAMPLE)
d["observe"] = list(SAMPLE["observe"])
p = pf.parse_plan(d)
r = pf.select_candidates(p, min_upside=1.3, top_n=50)
assert [x["ts_code"] for x in r["items"]] == ["600418.SH", "688717.SH", "300952.SZ"], r["items"]
assert r["dropped"]["upside"] == 3 # 1.2926 / 1.2341 / 0.8066 三只在门槛下
# 观察档 upside 恒为 null → 按 0 算, 设了下限就全挡掉 (方向保守: 宁可少票)
r2 = pf.select_candidates(p, min_upside=1.3, include_observe=True, top_n=50)
assert all(x["bucket"] == "main" for x in r2["items"]), r2["items"]
assert r2["dropped"]["upside"] == 6
# 不设下限时观察档照旧能进 (闸门只有 include_observe)
r3 = pf.select_candidates(p, include_observe=True, top_n=50)
assert any(x["bucket"] == "observe" for x in r3["items"])
assert r3["dropped"]["upside"] == 0
@case("筛选·upside 永不参与排序 (券商目标价噪音大, 排序只认 score)")
def _():
d = {"date": "2026-07-29", "main": [
_m(1, "SH600001", "高分低空间", 242.0, "整车", 0.1, 0.30),
_m(2, "SH600002", "低分高空间", 200.0, "整车", 0.1, 5.00)]}
r = pf.select_candidates(pf.parse_plan(d), top_n=2)
assert [x["ts_code"] for x in r["items"]] == ["600001.SH", "600002.SH"], r["items"]
@case("筛选·输出字段: sector 用 theme 灌 (planner 吃这个), score 缺失兜 0.0 不留 None")
def _():
d = {"date": "2026-07-29", "main": [{"rank": 1, "code": "SH600418",
@ -274,7 +301,7 @@ def _():
dr = r["dropped"]
assert r["considered"] == 10, r["considered"]
assert r["considered"] == r["eligible"] + dr["held"] + dr["black"] + dr["tier"] \
+ dr["score"] + dr["sources"] + dr["dup"], (r, dr)
+ dr["score"] + dr["sources"] + dr["upside"] + dr["dup"], (r, dr)
assert len(r["items"]) == r["eligible"] - dr["capped"] == 2