upside口径确认(=相对现价比例)+下限过滤; 容器→宿主机8300走host-gateway+探路模式
This commit is contained in:
parent
dcd05fd949
commit
86956123cb
|
|
@ -60,7 +60,7 @@ scripts/
|
||||||
test_batch4_units.py 动作引擎 四类自主动作触发与数量口径 11 例
|
test_batch4_units.py 动作引擎 四类自主动作触发与数量口径 11 例
|
||||||
test_batch5_units.py 决策系统信号流解析与消化口径 8 例
|
test_batch5_units.py 决策系统信号流解析与消化口径 8 例
|
||||||
test_batch6_units.py ws 通道: 测试向量/签名/公钥/水位/DDL/逐笔入账 65 例
|
test_batch6_units.py ws 通道: 测试向量/签名/公钥/水位/DDL/逐笔入账 65 例
|
||||||
test_batch7_units.py 上游选股计划: 解析/新鲜度/候选筛选/取数守卫 23 例
|
test_batch7_units.py 上游选股计划: 解析/新鲜度/候选筛选/取数守卫 25 例
|
||||||
test_wiring.py 装配自检: 服务层→核心→落表 全链路 (内存桩) 51 例
|
test_wiring.py 装配自检: 服务层→核心→落表 全链路 (内存桩) 51 例
|
||||||
init_db.py 建表 (应用 ddl_pms_v1.sql, 幂等, 默认演练; 含 DDL 体检)
|
init_db.py 建表 (应用 ddl_pms_v1.sql, 幂等, 默认演练; 含 DDL 体检)
|
||||||
check_db.py 实机连通性与表结构自检 (需真实 .env)
|
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 | **ws 通道联调**(协议 §9 的 S1/S2/S3) | 密钥已交换,可开工 |
|
||||||
| 2.5 | 部署便利性:`make deploy` 一句话完成 build + up + 各 profile | 待办(每次改动要敲 4 条命令太烦) |
|
| 2.5 | 部署便利性:`make deploy` 一句话完成 build + up + 各 profile | 待办(每次改动要敲 4 条命令太烦) |
|
||||||
| 2.6 | **行业硬拦截开闸**:跑一次「强刷 + 灌行业映射」把 `theme` 灌进 `pms_industry_map`,再把 `PMS_SECTOR_SOURCE` 改成 `custom_table` | 可做(数据源已就位,只差一次刷新 + 一个参数) |
|
| 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(二期) | 可做,设计已有,无外部依赖 |
|
| 3 | T0 做T(二期) | 可做,设计已有,无外部依赖 |
|
||||||
| 4 | 择时实现 A(委托决策系统盘中择时) | 阻塞:等 bionic 侧接口 |
|
| 4 | 择时实现 A(委托决策系统盘中择时) | 阻塞:等 bionic 侧接口 |
|
||||||
| 5 | 研判闸接通 | 阻塞:等 bionic 侧 `process_intraday_audit` 新增 PMS 请求 direction。客户端已就位,接口好了在页面填 `PMS_JUDGE_API_BASE` 即通 |
|
| 5 | 研判闸接通 | 阻塞:等 bionic 侧 `process_intraday_audit` 新增 PMS 请求 direction。客户端已就位,接口好了在页面填 `PMS_JUDGE_API_BASE` 即通 |
|
||||||
|
|
|
||||||
|
|
@ -12,9 +12,13 @@
|
||||||
```
|
```
|
||||||
GET http://192.168.16.155:8300/plan
|
GET http://192.168.16.155:8300/plan
|
||||||
?date=2026-07-29 可选, 缺省=最新一份计划
|
?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
|
```json
|
||||||
|
|
@ -69,13 +73,14 @@ PMS 内部统一点式 (`600418.SH`), 由 `command_spec.normalize_code` 转。
|
||||||
档位再按 `PMS_PLAN_TIERS` 白名单过滤 (默认只要「强传导」)。
|
档位再按 `PMS_PLAN_TIERS` 白名单过滤 (默认只要「强传导」)。
|
||||||
4. **日龄按交易日算并硬校验。** 默认允许 1 个交易日 (上游是收盘后出下一日的计划),
|
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_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_API_PATH /plan
|
||||||
PMS_PLAN_TIMEOUT 10 秒
|
PMS_PLAN_TIMEOUT 10 秒
|
||||||
PMS_PLAN_CACHE_SEC 300 秒 (失败另有 60 秒负缓存, 防每分钟调度位叠超时)
|
PMS_PLAN_CACHE_SEC 300 秒 (失败另有 60 秒负缓存, 防每分钟调度位叠超时)
|
||||||
|
|
@ -84,6 +89,7 @@ PMS_PLAN_TIERS 强传导 传导档白名单, 空=不按档过滤
|
||||||
PMS_PLAN_INCLUDE_OBSERVE false 观察档是否进候选池
|
PMS_PLAN_INCLUDE_OBSERVE false 观察档是否进候选池
|
||||||
PMS_PLAN_MIN_SCORE 0 score 下限, 0=不设
|
PMS_PLAN_MIN_SCORE 0 score 下限, 0=不设
|
||||||
PMS_PLAN_MIN_SOURCES 0 evidence.n_sources 下限, 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_STALE_TDAYS 1 日龄上限 (交易日)
|
||||||
PMS_PLAN_THEME_SYNC true 刷新时把 theme 灌进 pms_industry_map
|
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 的
|
宿主机上两个地址都通, 但 `pms-web` 容器里直连超时:
|
||||||
「上涨空间 2.12%」小得不足以建仓; 若是倍数, 则是 212%。两种解读会导出完全相反的用法。
|
|
||||||
分母是什么 —— 压力位 / 目标价 / 近期高点? PMS 现在**完全不用它**, 口径定了才敢接。
|
|
||||||
|
|
||||||
**Q2 `date` 是「计划生成日」还是「适用交易日」?** 样例 `date=2026-07-29`,
|
```
|
||||||
`market_snapshot_days=["2026-07-28"]`, `heat_date=2026-07-27`。若 date 是生成日, 那么
|
[2] 取数失败: ConnectTimeout: Connection to 192.168.16.155 timed out. (connect timeout=10)
|
||||||
07-30 开盘该用哪一份? PMS 目前按「date 至多比今天旧 1 个交易日」放行, 口径确认后调
|
```
|
||||||
`PMS_PLAN_STALE_TDAYS`。**另: 上游出计划的时点是每天几点?** 08:40 的拉取会不会太早。
|
|
||||||
|
|
||||||
**Q3 `main` 会不会分页或截断?** `counts.main=961`, 但一次应答真的会回 961 条吗?
|
**超时而不是 refused, 说明包被丢了, 不是没人监听。** docker 只往 FORWARD 链加放行规则,
|
||||||
有没有 `limit` / `offset` / `top` 参数? PMS 已把 `counts` (上游全量) 与实际返回条数分开
|
宿主机 INPUT 链对网桥进来的包该不该收, 它不管 —— firewalld/iptables 默认策略一拦, 表现
|
||||||
记录, 两者不一致时会打 warning, 但需要知道正确的取全量方式。
|
就是这个。同一个容器能连 153 的 MySQL、208 的 Redis, 唯独连不上「宿主机自己的 LAN IP」,
|
||||||
|
这个组合基本可以锁定成上面这条。
|
||||||
|
|
||||||
**Q4 `changes` 字段的结构?** 样例是 `null`。若是「与上一份计划的差异」, 请给一个非空
|
已做的处置: `docker-compose.yml` 的 `x-pms-base` 加了
|
||||||
示例 —— PMS 想用它在页面上标「新进榜 / 掉榜」, 那是判断上游观点变化最直接的信号。
|
`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 把它当行业标签落库,
|
```bash
|
||||||
用来做行业集中度**硬拦截**; 词表漂移会直接影响拦不拦。另: `theme_cap=5` 是上游自己的
|
docker compose run --rm --no-deps pms-web python scripts/probe_plan_api.py --try-bases
|
||||||
同主题限额吗? 若是, PMS 侧的 `PMS_SECTOR_MAX_NAMES` (默认 4) 应否与它对齐。
|
```
|
||||||
|
|
||||||
**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 就把它
|
最后一行是最干净的走法 —— 少一跳 NAT, 也不用碰防火墙。
|
||||||
只当备选池 (当前默认 `PMS_PLAN_INCLUDE_OBSERVE=false`)。
|
|
||||||
|
|
||||||
**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`。** 那张表还有别的消费方吗? 若没有,
|
||||||
|
上游可以停写 —— 少一处没有明确写入方的"事实源"。
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,7 @@ DESC = {
|
||||||
"PMS_PLAN_INCLUDE_OBSERVE": "观察档是否进候选池",
|
"PMS_PLAN_INCLUDE_OBSERVE": "观察档是否进候选池",
|
||||||
"PMS_PLAN_MIN_SCORE": "候选 score 下限, 0=不设",
|
"PMS_PLAN_MIN_SCORE": "候选 score 下限, 0=不设",
|
||||||
"PMS_PLAN_MIN_SOURCES": "候选 evidence.n_sources 下限, 0=不设",
|
"PMS_PLAN_MIN_SOURCES": "候选 evidence.n_sources 下限, 0=不设",
|
||||||
|
"PMS_PLAN_MIN_UPSIDE": "候选预期空间下限 (相对现价, 0.5=+50%), 0=不设。只过滤不参与排序; 设了就会把 upside 缺失的行(含整个观察档)一起挡掉",
|
||||||
"PMS_PLAN_STALE_TDAYS": "计划日龄超此交易日数即判过期拒用 (防上游停更时拿旧榜当今天)",
|
"PMS_PLAN_STALE_TDAYS": "计划日龄超此交易日数即判过期拒用 (防上游停更时拿旧榜当今天)",
|
||||||
"PMS_PLAN_THEME_SYNC": "刷新计划时把 evidence.theme 灌进 pms_industry_map (行业源 custom_table 的数据来源)",
|
"PMS_PLAN_THEME_SYNC": "刷新计划时把 evidence.theme 灌进 pms_industry_map (行业源 custom_table 的数据来源)",
|
||||||
"PMS_SECTOR_SOURCE": "行业划分数据源: 空=约束停用 / custom_table (推荐, 由上游计划的 theme 灌数) / gp_stock_category",
|
"PMS_SECTOR_SOURCE": "行业划分数据源: 空=约束停用 / custom_table (推荐, 由上游计划的 theme 灌数) / gp_stock_category",
|
||||||
|
|
|
||||||
|
|
@ -25,8 +25,10 @@
|
||||||
(默认 30) 进候选, 档位再由 `PMS_PLAN_TIERS` 白名单过滤。
|
(默认 30) 进候选, 档位再由 `PMS_PLAN_TIERS` 白名单过滤。
|
||||||
4. **日期新鲜度硬校验。** 计划日期比今天旧超过 `PMS_PLAN_STALE_TDAYS` 个交易日即
|
4. **日期新鲜度硬校验。** 计划日期比今天旧超过 `PMS_PLAN_STALE_TDAYS` 个交易日即
|
||||||
过期 (抛错)。防的是节假日/上游停更时拿上周的榜当今天用 —— 这种错在盘中是静默的。
|
过期 (抛错)。防的是节假日/上游停更时拿上周的榜当今天用 —— 这种错在盘中是静默的。
|
||||||
5. **upside 单位未确认** (样例 0.26~2.12, 既可能是 % 也可能是倍数), 因此**默认不参与
|
5. **upside = 相对现价的预期空间比例** (2.1203 → +212%; 2026-07-30 与上游
|
||||||
任何过滤和排序**, 只透传给页面看。见 UPSTREAM_PLAN_API.md Q1。
|
`format=md` 输出的「预期空间 +212%」对齐确认), 来源是券商目标价。噪音大 —— 榜首能
|
||||||
|
到 +212% —— 所以**永不参与排序** (排序始终是 score), 只提供下限过滤
|
||||||
|
`PMS_PLAN_MIN_UPSIDE`。
|
||||||
|
|
||||||
行业约束的数据源也从这里来: `evidence.theme` 随每次刷新 upsert 进 `pms_industry_map`,
|
行业约束的数据源也从这里来: `evidence.theme` 随每次刷新 upsert 进 `pms_industry_map`,
|
||||||
行业源仍是 `custom_table` (`PMS_SECTOR_SOURCE=custom_table`)。落库而不是即时查, 是因为
|
行业源仍是 `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,
|
"rank": rank if rank is not None else i + 1,
|
||||||
"score": _float_or_none(it.get("score")),
|
"score": _float_or_none(it.get("score")),
|
||||||
"heat": _float_or_none(it.get("heat")),
|
"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")),
|
"tier": _text_or_none(it.get("tier")),
|
||||||
"theme": _text_or_none(ev.get("theme")),
|
"theme": _text_or_none(ev.get("theme")),
|
||||||
"n_sources": _int_or_none(ev.get("n_sources")),
|
"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,
|
def select_candidates(plan: dict, *, held=(), black=(), top_n: int = 30, tiers=None,
|
||||||
include_observe: bool = False, min_score=None,
|
include_observe: bool = False, min_score=None,
|
||||||
min_sources: int = 0) -> dict:
|
min_sources: int = 0, min_upside=None) -> dict:
|
||||||
"""排序池 → 候选清单。
|
"""排序池 → 候选清单。
|
||||||
|
|
||||||
排序: score 降序, 同分按 rank 升序 (上游 rank 已是它自己的最终次序, 拿来当稳定次序)。
|
排序: score 降序, 同分按 rank 升序 (上游 rank 已是它自己的最终次序, 拿来当稳定次序)。
|
||||||
tier 白名单**只对带 tier 的行生效** —— 观察档没有 tier, 它的闸门是 include_observe。
|
tier 白名单**只对带 tier 的行生效** —— 观察档没有 tier, 它的闸门是 include_observe。
|
||||||
|
min_upside 相反, **对所有行生效**: upside 缺失按 0 算一起挡掉 (观察档 upside 恒为
|
||||||
|
null, 所以设了下限等于把观察档全挡了)。方向选保守那边 —— 宁可少票。
|
||||||
"""
|
"""
|
||||||
held = {normalize_code(c) for c in (held or []) if c}
|
held = {normalize_code(c) for c in (held or []) if c}
|
||||||
black = {normalize_code(c) for c in (black 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()}
|
tiers = {str(t).strip() for t in (tiers or []) if str(t).strip()}
|
||||||
min_score = _float_or_none(min_score)
|
min_score = _float_or_none(min_score)
|
||||||
|
min_upside = _float_or_none(min_upside)
|
||||||
min_sources = int(min_sources or 0)
|
min_sources = int(min_sources or 0)
|
||||||
|
|
||||||
pool = list(plan.get("main") or [])
|
pool = list(plan.get("main") or [])
|
||||||
if include_observe:
|
if include_observe:
|
||||||
pool += list(plan.get("observe") or [])
|
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()
|
passed, seen = [], set()
|
||||||
for r in sorted(pool, key=lambda x: (-(x.get("score") or 0.0), x.get("rank") or 10 ** 9)):
|
for r in sorted(pool, key=lambda x: (-(x.get("score") or 0.0), x.get("rank") or 10 ** 9)):
|
||||||
c = r["ts_code"]
|
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:
|
if min_sources and (r.get("n_sources") or 0) < min_sources:
|
||||||
dropped["sources"] += 1
|
dropped["sources"] += 1
|
||||||
continue
|
continue
|
||||||
|
if min_upside is not None and (r.get("upside") or 0.0) < min_upside:
|
||||||
|
dropped["upside"] += 1
|
||||||
|
continue
|
||||||
passed.append(r)
|
passed.append(r)
|
||||||
|
|
||||||
n = max(0, int(top_n or 0)) or len(passed)
|
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),
|
"include_observe": ps.get_bool("PMS_PLAN_INCLUDE_OBSERVE", False),
|
||||||
"min_score": ps.get_float("PMS_PLAN_MIN_SCORE", 0.0),
|
"min_score": ps.get_float("PMS_PLAN_MIN_SCORE", 0.0),
|
||||||
"min_sources": ps.get_int("PMS_PLAN_MIN_SOURCES", 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),
|
"stale_tdays": ps.get_int("PMS_PLAN_STALE_TDAYS", 1),
|
||||||
"theme_sync": ps.get_bool("PMS_PLAN_THEME_SYNC", True),
|
"theme_sync": ps.get_bool("PMS_PLAN_THEME_SYNC", True),
|
||||||
"source": (ps.get("PMS_CANDIDATE_SOURCE", SRC_PLAN_API) or SRC_PLAN_API).strip(),
|
"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"],
|
out = select_candidates(plan, held=held, black=black, top_n=p["top_n"], tiers=p["tiers"],
|
||||||
include_observe=p["include_observe"],
|
include_observe=p["include_observe"],
|
||||||
min_score=(p["min_score"] or None),
|
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["age_tdays"] = plan.get("age_tdays")
|
||||||
out["theme_cap"] = plan.get("theme_cap")
|
out["theme_cap"] = plan.get("theme_cap")
|
||||||
return out
|
return out
|
||||||
|
|
@ -352,7 +363,7 @@ def status() -> dict:
|
||||||
"""页面/运维用: 绝不抛错, 失败也要能显示出来。"""
|
"""页面/运维用: 绝不抛错, 失败也要能显示出来。"""
|
||||||
p = _params()
|
p = _params()
|
||||||
st = {"source": p["source"], "base": p["base"], "path": p["path"],
|
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"],
|
"include_observe": p["include_observe"], "stale_tdays": p["stale_tdays"],
|
||||||
"theme_sync": p["theme_sync"], "enabled": bool(p["base"])}
|
"theme_sync": p["theme_sync"], "enabled": bool(p["base"])}
|
||||||
if not p["base"]:
|
if not p["base"]:
|
||||||
|
|
|
||||||
|
|
@ -573,15 +573,19 @@
|
||||||
<el-table-column prop="tier" label="传导档" width="82"></el-table-column>
|
<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="theme" label="主题" width="120"></el-table-column>
|
||||||
<el-table-column prop="heat" label="heat" width="78"></el-table-column>
|
<el-table-column prop="heat" label="heat" width="78"></el-table-column>
|
||||||
<el-table-column label="upside" width="90">
|
<el-table-column label="预期空间" width="96">
|
||||||
<template #default="s"><span class="muted">{{ s.row.upside }}</span></template>
|
<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>
|
||||||
<el-table-column prop="n_sources" label="源数" width="66"></el-table-column>
|
<el-table-column prop="n_sources" label="源数" width="66"></el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
<div class="muted" style="margin-top:6px">
|
<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_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>
|
</div>
|
||||||
<pre class="json" v-if="planResult">{{ planResult }}</pre>
|
<pre class="json" v-if="planResult">{{ planResult }}</pre>
|
||||||
</el-drawer>
|
</el-drawer>
|
||||||
|
|
|
||||||
|
|
@ -75,7 +75,10 @@ class Settings(BaseSettings):
|
||||||
# 上游只回答「买什么、排第几」, 不给价格金额 —— 买多少/什么价是 PMS 自己算。
|
# 上游只回答「买什么、排第几」, 不给价格金额 —— 买多少/什么价是 PMS 自己算。
|
||||||
# 接口拿不到一律显式失败 (候选池为空 + ERROR), 绝不静默回退旧表。
|
# 接口拿不到一律显式失败 (候选池为空 + ERROR), 绝不静默回退旧表。
|
||||||
PMS_CANDIDATE_SOURCE: str = "plan_api" # plan_api(默认) / buy_plan(旧表) / both(并集)
|
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_API_PATH: str = "/plan"
|
||||||
PMS_PLAN_TIMEOUT: int = 10 # 单次请求超时 (秒)
|
PMS_PLAN_TIMEOUT: int = 10 # 单次请求超时 (秒)
|
||||||
PMS_PLAN_CACHE_SEC: int = 300 # 计划缓存秒数 (上游日频产出, 没必要每跳都拉)
|
PMS_PLAN_CACHE_SEC: int = 300 # 计划缓存秒数 (上游日频产出, 没必要每跳都拉)
|
||||||
|
|
@ -84,6 +87,8 @@ class Settings(BaseSettings):
|
||||||
PMS_PLAN_INCLUDE_OBSERVE: bool = False # 观察档是否进候选池 (上游把它定位为备选, 无 upside)
|
PMS_PLAN_INCLUDE_OBSERVE: bool = False # 观察档是否进候选池 (上游把它定位为备选, 无 upside)
|
||||||
PMS_PLAN_MIN_SCORE: float = 0.0 # score 下限, 0=不设
|
PMS_PLAN_MIN_SCORE: float = 0.0 # score 下限, 0=不设
|
||||||
PMS_PLAN_MIN_SOURCES: int = 0 # evidence.n_sources 下限, 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_STALE_TDAYS: int = 1 # 计划日龄超此交易日数即判过期并拒用 (防上游停更)
|
||||||
PMS_PLAN_THEME_SYNC: bool = True # 刷新时把 evidence.theme 灌进 pms_industry_map
|
PMS_PLAN_THEME_SYNC: bool = True # 刷新时把 evidence.theme 灌进 pms_industry_map
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,15 @@ x-pms-base: &pms-base
|
||||||
- TZ=Asia/Shanghai
|
- TZ=Asia/Shanghai
|
||||||
volumes:
|
volumes:
|
||||||
- ./logs:/app/logs
|
- ./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
|
restart: unless-stopped
|
||||||
logging:
|
logging:
|
||||||
driver: json-file
|
driver: json-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))
|
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():
|
def main():
|
||||||
ap = argparse.ArgumentParser()
|
ap = argparse.ArgumentParser()
|
||||||
ap.add_argument("--date", default=None, help="指定计划日期 YYYY-MM-DD (缺省=上游最新)")
|
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("--observe", action="store_true", help="连观察档一起打印")
|
||||||
ap.add_argument("--with-price", action="store_true",
|
ap.add_argument("--with-price", action="store_true",
|
||||||
help="逐只查行情价 (需 Redis 通; 用来看候选池会不会因无价掉票)")
|
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="原样打印解析后的结构")
|
ap.add_argument("--json", action="store_true", help="原样打印解析后的结构")
|
||||||
args = ap.parse_args()
|
args = ap.parse_args()
|
||||||
|
|
||||||
from app.services import param_store as ps
|
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 '(空 —— 候选池将恒为空)'} "
|
print(f"[1] 接口配置 base={base or '(空 —— 候选池将恒为空)'} "
|
||||||
f"path={ps.get('PMS_PLAN_API_PATH', '/plan')} "
|
f"path={ps.get('PMS_PLAN_API_PATH', '/plan')} "
|
||||||
f"timeout={ps.get_int('PMS_PLAN_TIMEOUT', 10)}s")
|
f"timeout={ps.get_int('PMS_PLAN_TIMEOUT', 10)}s")
|
||||||
|
|
@ -48,7 +122,7 @@ def main():
|
||||||
return 2
|
return 2
|
||||||
|
|
||||||
try:
|
try:
|
||||||
plan = pf.fetch(date=args.date)
|
plan = pf.fetch(date=args.date, base=base)
|
||||||
except pf.PlanFeedError as e:
|
except pf.PlanFeedError as e:
|
||||||
print(f"[2] 取数失败: {e}")
|
print(f"[2] 取数失败: {e}")
|
||||||
return 1
|
return 1
|
||||||
|
|
@ -67,7 +141,7 @@ def main():
|
||||||
|
|
||||||
print(f"[4] 主榜前 {args.top} (score 降序即上游 rank 序)")
|
print(f"[4] 主榜前 {args.top} (score 降序即上游 rank 序)")
|
||||||
hdr = f" {'rank':>4} {'代码':<11} {'名称':<8} {'score':>8} {'档位':<6} {'主题':<10} " \
|
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 ""))
|
print(hdr + (" " + "行情价" if args.with_price else ""))
|
||||||
rows = plan["main"][:max(1, args.top)]
|
rows = plan["main"][:max(1, args.top)]
|
||||||
if args.observe:
|
if args.observe:
|
||||||
|
|
@ -83,7 +157,7 @@ def main():
|
||||||
for r in rows:
|
for r in rows:
|
||||||
line = (f" {str(r['rank']):>4} {r['ts_code']:<11} {(r['name'] or '-'):<8} "
|
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"{_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:
|
if args.with_price:
|
||||||
p = prices.get(r["ts_code"])
|
p = prices.get(r["ts_code"])
|
||||||
line += f" {p if p else '**无价(会被剔除)**'}"
|
line += f" {p if p else '**无价(会被剔除)**'}"
|
||||||
|
|
|
||||||
|
|
@ -11,9 +11,9 @@
|
||||||
test_batch4_units.py 动作引擎 四类自主动作触发与数量口径 (11 例)
|
test_batch4_units.py 动作引擎 四类自主动作触发与数量口径 (11 例)
|
||||||
test_batch5_units.py 决策系统信号流解析与消化口径 (8 例)
|
test_batch5_units.py 决策系统信号流解析与消化口径 (8 例)
|
||||||
test_batch6_units.py ws 通道: 测试向量/签名/公钥/水位/DDL/逐笔入账 (65 例)
|
test_batch6_units.py ws 通道: 测试向量/签名/公钥/水位/DDL/逐笔入账 (65 例)
|
||||||
test_batch7_units.py 上游选股计划: 解析/新鲜度/候选筛选/取数守卫 (23 例)
|
test_batch7_units.py 上游选股计划: 解析/新鲜度/候选筛选/取数守卫 (25 例)
|
||||||
test_wiring.py 装配自检: 服务层→核心→落表 全链路 (内存桩) (51 例)
|
test_wiring.py 装配自检: 服务层→核心→落表 全链路 (内存桩) (51 例)
|
||||||
共 228 例
|
共 230 例
|
||||||
任一子集失败即整体失败 (退出码 1)。
|
任一子集失败即整体失败 (退出码 1)。
|
||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
|
|
|
||||||
|
|
@ -253,6 +253,33 @@ def _():
|
||||||
assert r2["dropped"]["sources"] == 5
|
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")
|
@case("筛选·输出字段: sector 用 theme 灌 (planner 吃这个), score 缺失兜 0.0 不留 None")
|
||||||
def _():
|
def _():
|
||||||
d = {"date": "2026-07-29", "main": [{"rank": 1, "code": "SH600418",
|
d = {"date": "2026-07-29", "main": [{"rank": 1, "code": "SH600418",
|
||||||
|
|
@ -274,7 +301,7 @@ def _():
|
||||||
dr = r["dropped"]
|
dr = r["dropped"]
|
||||||
assert r["considered"] == 10, r["considered"]
|
assert r["considered"] == 10, r["considered"]
|
||||||
assert r["considered"] == r["eligible"] + dr["held"] + dr["black"] + dr["tier"] \
|
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
|
assert len(r["items"]) == r["eligible"] - dr["capped"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue