diff --git a/README.md b/README.md index 80125f6..d3d3489 100644 --- a/README.md +++ b/README.md @@ -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` 即通 | diff --git a/UPSTREAM_PLAN_API.md b/UPSTREAM_PLAN_API.md index 68a8d39..40743bb 100644 --- a/UPSTREAM_PLAN_API.md +++ b/UPSTREAM_PLAN_API.md @@ -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`。** 那张表还有别的消费方吗? 若没有, +上游可以停写 —— 少一处没有明确写入方的"事实源"。 diff --git a/app/services/param_store.py b/app/services/param_store.py index 20492e7..93c209e 100644 --- a/app/services/param_store.py +++ b/app/services/param_store.py @@ -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", diff --git a/app/services/plan_feed.py b/app/services/plan_feed.py index b0a2bd3..04bc3ce 100644 --- a/app/services/plan_feed.py +++ b/app/services/plan_feed.py @@ -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"]: diff --git a/app/web/static/index.html b/app/web/static/index.html index b5a2003..f4ec0e3 100644 --- a/app/web/static/index.html +++ b/app/web/static/index.html @@ -573,15 +573,19 @@ - - + +
- upside 单位尚未与上游确认 (样例 0.26~2.12), 因此**不参与任何过滤和排序**, 只在此展示。 + 「预期空间」= 券商目标价相对现价的空间 (上游 upside 字段, 2.12 → +212%)。噪音大, + 所以永不参与排序 —— 排序始终按 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。
{{ planResult }}
diff --git a/config/settings.py b/config/settings.py index b03f7a1..001795f 100644 --- a/config/settings.py +++ b/config/settings.py @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index 2fcba39..a5689c9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/scripts/probe_plan_api.py b/scripts/probe_plan_api.py index 1a806ab..bfeba2a 100644 --- a/scripts/probe_plan_api.py +++ b/scripts/probe_plan_api.py @@ -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 '**无价(会被剔除)**'}" diff --git a/scripts/run_tests.py b/scripts/run_tests.py index 616fc11..4fc4fb8 100644 --- a/scripts/run_tests.py +++ b/scripts/run_tests.py @@ -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 diff --git a/scripts/test_batch7_units.py b/scripts/test_batch7_units.py index 36f9938..ae7db0a 100644 --- a/scripts/test_batch7_units.py +++ b/scripts/test_batch7_units.py @@ -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