diff --git a/README.md b/README.md
index c41be86..80125f6 100644
--- a/README.md
+++ b/README.md
@@ -13,6 +13,7 @@
| `POSITION_MGMT_DESIGN.md` | 总体设计 **V0.4(定稿,开发启动)**:命令系统与管理页面/账本/仓位框架/动作引擎/两道关口/择时执行/下游通道。功能一次性开发,上线按依赖分三步切换 |
| `QMT_WS_PROTOCOL.md` | **PMS ↔ QMT WebSocket 指令与回报协议 V1.0(定稿)**:传输与重连、Ed25519 签名与幂等、消息集、状态机、断线补发与对账兜底、部署前检查清单。**这是下发通道的唯一实现依据** |
| `QMT_INTERFACE_REQUIREMENTS.md` | 与 QMT 侧的数据与接口需求清单 **V2.0**:A 部分(只读数据)与 C 部分(切换约定)有效;**B 部分的表通道已废止**,改由上面的 ws 协议承担 |
+| `UPSTREAM_PLAN_API.md` | **上游选股计划接口 (`/plan`) 的接入记录**:应答结构、PMS 侧五条口径、参数表,以及**待上游确认的 10 个口径问题**(`upside` 单位、`date` 语义、分页、`changes` 结构、`theme` 稳定性…)|
| `ddl_pms_v1.sql` | PMS 全部自有表建表语句(153 代理侧,**14 张**:设计 §11 的 10 张 + ws 通道 3 张 + 现金流水 1 张) |
| `config/settings.py` | 配置(基础设施键名对齐 bionic;业务参数为初值,页面调参持久化到 `pms_runtime_param` 后优先) |
@@ -46,23 +47,26 @@ app/
signal_service.py 盘中信号订阅 (db2 广播 + db3 风控卖出) → 卖出指令或提议
ledger_service.py 成交回放 / 对账 / 除权 / 盘前 / 日终结算 / 运营日报
market.py 行情 (Redis db13) 与参考位 (决策系统主口径 + 兜底自算)
+ plan_feed.py **上游选股计划 /plan 接入**: 解析 / 交易日龄校验 / 候选筛选 / theme 灌行业映射
industry.py 行业划分可插拔适配器 (custom_table / gp_stock_category / 停用)
ws/runner.py **常驻连接进程 (pms-ws)**: 握手/心跳/重连/补发 + 出口出栈 + 上行落库确认
web/ FastAPI + 单页 (Vue3 + ElementPlus),页面四块 + 运维/日报抽屉
- scheduler.py Celery beat 调度总表 (设计 §10 八个调度位 + 三条守卫)
+ scheduler.py Celery beat 调度总表 (九个调度位 + 三条守卫)
scripts/
run_tests.py 一次跑完全部单测 (见下方「Docker 部署」)
test_core_units.py 仓位与安全垫核心逻辑 14 例
test_batch2_units.py 命令 / 方案 / 回放对账 纯逻辑 35 例
- test_batch3_units.py 规则闸 / 择时执行器实现B 纯逻辑 19 例
+ test_batch3_units.py 规则闸 / 择时执行器实现B 纯逻辑 21 例
test_batch4_units.py 动作引擎 四类自主动作触发与数量口径 11 例
test_batch5_units.py 决策系统信号流解析与消化口径 8 例
- test_batch6_units.py ws 通道: 测试向量/签名/公钥/水位/DDL/逐笔入账 58 例
- test_wiring.py 装配自检: 服务层→核心→落表 全链路 (内存桩) 35 例
+ test_batch6_units.py ws 通道: 测试向量/签名/公钥/水位/DDL/逐笔入账 65 例
+ test_batch7_units.py 上游选股计划: 解析/新鲜度/候选筛选/取数守卫 23 例
+ test_wiring.py 装配自检: 服务层→核心→落表 全链路 (内存桩) 51 例
init_db.py 建表 (应用 ddl_pms_v1.sql, 幂等, 默认演练; 含 DDL 体检)
check_db.py 实机连通性与表结构自检 (需真实 .env)
gen_keys.py ws 通道密钥: 生成 / 只取公钥(--pubkey) / PEM(--pem) / 自检(--check)
reset_ledger.py 清空账本并把回放游标对齐到当前 (影子运行期重来一次; 不碰下游表)
+ probe_plan_api.py 上游计划实机探活: 通不通 / 字段口径 / 候选筛选结果 / 有没有价
ws_smoke.py ws 联调工具: status/watch/place/cancel/inbox (绕开 dispatch_mode)
```
@@ -116,9 +120,10 @@ git pull && docker compose build && docker compose up -d
| 调度 | 时间 | 任务 | 本批状态 |
|---|---|---|---|
+| 拉上游计划 | 交易日 08:40 | `/plan` 拉当日选股计划 → 刷候选池缓存 + `theme` 灌 `pms_industry_map` | ✅ |
| 盘前准备 | 交易日 08:50 | T+1 可卖重置 / 参考位取数 / 刹车结算 | ✅ |
| 命令轮询 | 每 1 分钟(全天) | 新命令解析 → 方案生成 → 状态机推进 | ✅ |
-| 成交回放 | 交易时段每 5 分钟 | `trading_order` 增量回放 + 盘中轻对账 | ✅ |
+| 成交回放 | 交易时段每 1 分钟 | ws 逐笔入账 + `trading_order` 增量回放 + 盘中轻对账 | ✅ |
| 盘中执行 | 交易时段每 1 分钟 | 方案转指令 → 自主提议扫描 → 择时出手(规则闸终检 → 下发 → 记子单) | ✅ |
| 信号消化 | 交易时段每 1 分钟 | 订阅 db2 盘中广播 + db3 风控卖出 → 卖出指令或提议 | ✅ |
| T 仓平回 | 14:50 | 做T强制平回 | 🔜 二期(现只自证 T 仓为 0) |
@@ -194,7 +199,7 @@ QMT ──trade/order_update──▶ pms-ws ──落 pms_qmt_inbox──▶
## 已实现 / 待开发
-**已实现**:建表 DDL 与建表脚本;配置与运行参数中心;仓位规划器与安全垫账;命令系统(27 类命令全目录 + 双状态机 + 冲突识别);方案生成器(降仓凑额四档、升仓、建仓分批、清仓/减至、行业清仓与限额、暂停买入撤单);账本回放与对账引擎(成交认领、外部成交并入 BASE 告警、以下游为准修正、除权检测、T+1 可用量、连续不一致升级);规则闸终检;择时执行器实现 B(分日配额、分笔、VWAP/回踩/不追高、14:45 兜底、停牌一字板顺延、窗口耗尽收口、挂单有效期);动作引擎四类自主动作 + 研判闸客户端 + 提议分流;决策系统信号消化(两条流独立消费组订阅、置信度分档转清仓指令或提议);管理页面四块 + 运维/日报抽屉;调度器八个调度位;**ws 直连通道的连接层**(常驻进程 + 出口队列 + 签名 + seq 水位与累积确认,见下);**单测 163 例**。
+**已实现**:建表 DDL 与建表脚本;配置与运行参数中心;仓位规划器与安全垫账;命令系统(27 类命令全目录 + 双状态机 + 冲突识别);方案生成器(降仓凑额四档、升仓、建仓分批、清仓/减至、行业清仓与限额、暂停买入撤单);账本回放与对账引擎(成交认领、外部成交并入 BASE 告警、以下游为准修正、除权检测、T+1 可用量、连续不一致升级);规则闸终检;择时执行器实现 B(分日配额、分笔、VWAP/回踩/不追高、14:45 兜底、停牌一字板顺延、窗口耗尽收口、挂单有效期);动作引擎四类自主动作 + 研判闸客户端 + 提议分流;决策系统信号消化(两条流独立消费组订阅、置信度分档转清仓指令或提议);管理页面四块 + 运维/日报抽屉;调度器九个调度位;**上游选股计划接口接入**(`/plan` 取候选池、交易日龄硬校验、`theme` 灌行业映射表、页面预览抽屉与不可用横幅);**ws 直连通道的连接层**(常驻进程 + 出口队列 + 签名 + seq 水位与累积确认,见下);**单测 228 例**。
### 下一步(按可动工顺序)
@@ -203,10 +208,13 @@ QMT ──trade/order_update──▶ pms-ws ──落 pms_qmt_inbox──▶
| 1 | ~~ws 通道的账本侧改造(清单 4~6)~~ | ✅ 2026-07-29 完成 |
| 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` 口径定了才敢接进排序 |
| 3 | T0 做T(二期) | 可做,设计已有,无外部依赖 |
| 4 | 择时实现 A(委托决策系统盘中择时) | 阻塞:等 bionic 侧接口 |
| 5 | 研判闸接通 | 阻塞:等 bionic 侧 `process_intraday_audit` 新增 PMS 请求 direction。客户端已就位,接口好了在页面填 `PMS_JUDGE_API_BASE` 即通 |
| 6 | bionic 侧配套改造(出口改道 + PMS direction) | 另一仓库 |
+| 7 | `trading_buy_plan` 退场:PMS 已不读它(`PMS_CANDIDATE_SOURCE=plan_api`),确认无其他消费方后上游可停写 | 待上游确认(`UPSTREAM_PLAN_API.md` Q10) |
### ws 通道实现清单
diff --git a/UPSTREAM_PLAN_API.md b/UPSTREAM_PLAN_API.md
new file mode 100644
index 0000000..68a8d39
--- /dev/null
+++ b/UPSTREAM_PLAN_API.md
@@ -0,0 +1,134 @@
+# 上游选股计划接口 · 接入记录与待确认口径
+
+> 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)
+```
+
+应答骨架:
+
+```json
+{
+ "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` 不参与任何过滤与排序** (单位未确认, 见下 Q1), 只在页面和探活脚本里展示。
+
+参数 (页面「参数设置」可改, 表值优先):
+
+```
+PMS_CANDIDATE_SOURCE plan_api(默认) / buy_plan / both
+PMS_PLAN_API_BASE http://192.168.16.155:8300 空=停用(候选池恒为空并告警)
+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_STALE_TDAYS 1 日龄上限 (交易日)
+PMS_PLAN_THEME_SYNC true 刷新时把 theme 灌进 pms_industry_map
+```
+
+行业硬拦截的开法: 先跑一次「强刷 + 灌行业映射」(或等 08:40 调度位), 再把
+`PMS_SECTOR_SOURCE` 改成 `custom_table`。映射表页面可见可手改, 覆盖面随每天刷新累积
+—— 这就是不用「即时查当日计划」的原因: 今天没上榜的持仓票也得有行业标签, 否则它的
+行业约束会悄悄失效。
+
+---
+
+## 3. 待上游确认 (按重要性排)
+
+**Q1 `upside` 的单位和口径是什么?** 样例里 0.2617 ~ 2.1203。若是百分比, 那 rank 1 的
+「上涨空间 2.12%」小得不足以建仓; 若是倍数, 则是 212%。两种解读会导出完全相反的用法。
+分母是什么 —— 压力位 / 目标价 / 近期高点? PMS 现在**完全不用它**, 口径定了才敢接。
+
+**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 的拉取会不会太早。
+
+**Q3 `main` 会不会分页或截断?** `counts.main=961`, 但一次应答真的会回 961 条吗?
+有没有 `limit` / `offset` / `top` 参数? PMS 已把 `counts` (上游全量) 与实际返回条数分开
+记录, 两者不一致时会打 warning, 但需要知道正确的取全量方式。
+
+**Q4 `changes` 字段的结构?** 样例是 `null`。若是「与上一份计划的差异」, 请给一个非空
+示例 —— PMS 想用它在页面上标「新进榜 / 掉榜」, 那是判断上游观点变化最直接的信号。
+
+**Q5 `counts.gate_covered = 2386` 是什么口径?** 闸门覆盖的股票总数? 与 main+observe
+(=1068) 的关系是什么? 目前只做透传展示。
+
+**Q6 `theme` 词表稳定吗? 同一只票的 theme 会日间变动吗?** PMS 把它当行业标签落库,
+用来做行业集中度**硬拦截**; 词表漂移会直接影响拦不拦。另: `theme_cap=5` 是上游自己的
+同主题限额吗? 若是, PMS 侧的 `PMS_SECTOR_MAX_NAMES` (默认 4) 应否与它对齐。
+
+**Q7 `tier` 的完整枚举?** 样例只见「强传导」。PMS 用它做白名单过滤, 需要知道全集
+(以及档位之间的强弱次序)。
+
+**Q8 没有当日计划时的失败语义?** 返回 404 / 空 `main` / 上一日的计划? PMS 目前把
+「两档全空」按变形处理并拒用。**接口需要鉴权或有限流吗?** (PMS 默认缓存 300 秒,
+每交易日至少一次盘前拉取 + 页面手动强刷。)
+
+**Q9 观察档 `upside` 恒为 `null` 是设计如此吗?** 若观察档本就没有空间测算, PMS 就把它
+只当备选池 (当前默认 `PMS_PLAN_INCLUDE_OBSERVE=false`)。
+
+**Q10 (给决策系统侧) PMS 已不再读 `trading_buy_plan`。** 那张表还有别的消费方吗?
+若没有, 上游可以停写 —— 少一处没有明确写入方的"事实源"。
diff --git a/app/scheduler.py b/app/scheduler.py
index 8f562f0..d5b39f9 100644
--- a/app/scheduler.py
+++ b/app/scheduler.py
@@ -4,6 +4,7 @@
====================================================
| 调度 | 时间 | 任务 |
|-----------|----------------------|---------------------------------------------|
+| 拉上游计划| 交易日 08:40 | /plan 拉当日选股计划 + theme 灌行业映射表 |
| 盘前准备 | 交易日 08:50 | T+1 可卖重置 / 参考位取数 / 刹车结算 |
| 命令轮询 | 每 1 分钟 (全天) | 新命令解析 → 方案生成 → 任务状态机推进 |
| 盘中执行 | 交易时段每 1 分钟 | 择时出手 + 自主提议扫描 (执行器下一批交付) |
@@ -92,7 +93,7 @@ def _brief(r):
return str(r)[:200]
keep = {k: v for k, v in r.items()
if k in ("ok", "fills", "actions", "planned", "failed", "diffs", "errors",
- "skipped", "avail_reset", "refs", "cursor")}
+ "skipped", "avail_reset", "refs", "cursor", "date", "returned")}
if isinstance(keep.get("diffs"), list):
keep["diffs"] = len(keep["diffs"])
if isinstance(keep.get("errors"), list):
@@ -101,6 +102,22 @@ def _brief(r):
# ================================================================ 任务
+@celery_app.task(name="pms.plan_pull")
+@guard(trade_day=True, respect_exec_halt=False) # 取数动作, 休假模式照跑 (只更新映射不产生指令)
+def plan_pull():
+ """盘前拉上游选股计划: 刷候选池缓存 + evidence.theme 灌进 pms_industry_map。
+
+ 放在 premarket (08:50) 之前, 是因为盘前那一跳要用行业映射算集中度。
+ 拉失败照旧只记 ERROR —— 候选池当天就是空的, 升仓类命令会明确报"无票可选",
+ 比拿昨天的榜静默买进去好。
+ """
+ from app.services import plan_feed
+ plan_feed.invalidate()
+ plan = plan_feed.get_plan(force=True)
+ return {"ok": True, "date": plan["date"], "age_tdays": plan.get("age_tdays"),
+ "returned": plan["returned"], "theme_sync": plan.get("theme_sync")}
+
+
@celery_app.task(name="pms.premarket")
@guard(trade_day=True)
def premarket():
@@ -190,6 +207,7 @@ def daily_report():
# ================================================================ beat 调度表
celery_app.conf.beat_schedule = {
+ "plan_pull": {"task": "pms.plan_pull", "schedule": crontab(hour=8, minute=40)},
"premarket": {"task": "pms.premarket", "schedule": crontab(hour=8, minute=50)},
"command_poll": {"task": "pms.command_poll", "schedule": crontab(minute="*")},
# ws 通道的成交是推过来的, 落 inbox 后没必要再等 5 分钟才入账 —— 改成每分钟。
diff --git a/app/services/command_service.py b/app/services/command_service.py
index baf4e2d..689b459 100644
--- a/app/services/command_service.py
+++ b/app/services/command_service.py
@@ -453,34 +453,68 @@ def _price_of(view: dict, ts_code: str):
def _candidates(view: dict) -> list:
- """升仓候选池 = 上游买入计划 ∪ 白名单, 剔除黑名单/已持有/冻结。"""
- from app.repo import downstream_repo
+ """升仓候选池 = 上游选股计划 (∪ 旧买入计划表) ∪ 白名单, 剔除黑名单/已持有。
+
+ 来源由 `PMS_CANDIDATE_SOURCE` 决定: plan_api (默认, 上游 /plan 接口) / buy_plan
+ (旧 trading_buy_plan 表) / both (并集)。三条口径:
+
+ 1. **计划不带价格** —— /plan 只回答「买什么、排第几」, 价格一律 market.get_price
+ 现取, 取不到就不进池并记一条 warning。旧表的 target_price 若有则直接用。
+ 2. **上游拿不到不回退旧表** —— 候选池宁可为空。那张表在目标架构下没有明确写入方,
+ 拿它当事实源比没有候选更危险 (沿用「拿不到 ≠ 通过」的纪律)。
+ 3. **白名单必须压过计划票** —— planner 只认 score 一把尺子, 而计划的 score 是 200+
+ 量级 (旧表的 prob_thresh 是 0~1)。白名单是用户点名的票, 故给 max(池内分)+1,
+ 写死 1.0 会让点名票沉到池底。
+ """
+ from app.services import market, plan_feed
held = {x["ts_code"] for x in view["held"]}
sp = effective_stock_params()
black = {c for c, d in sp.items() if d.get("black")}
- out, seen = [], set()
- try:
- plans = downstream_repo.fetch_buy_plans(is_active=7, limit=100)
- except Exception as e:
- logger.warning("读上游买入计划失败: %s", e)
- plans = []
- for p in plans:
- c = p["ts_code"]
+ src = (param_store.get("PMS_CANDIDATE_SOURCE", plan_feed.SRC_PLAN_API)
+ or plan_feed.SRC_PLAN_API).strip()
+ out, seen, noprice = [], set(), []
+
+ def _push(c, score, tag, *, price=None, theme=None):
if not c or c in held or c in black or c in seen:
- continue
+ return
+ px = float(price or 0) or (market.get_price(c) or 0)
+ if px <= 0:
+ noprice.append(c)
+ return
seen.add(c)
- out.append({"ts_code": c, "price": p.get("price") or 0, "score": p.get("score") or 0,
- "sector": industry.get(c), "src": "upstream"})
- from app.services import market
+ # sector 取已配置的行业源 (与持仓侧的 sector_*_map 同一套词表); theme 只作展示,
+ # 不参与约束 —— 行业源若是 gp_stock_category, 混入上游主题会把累计口径搞乱。
+ out.append({"ts_code": c, "price": px, "score": float(score or 0),
+ "sector": industry.get(c), "theme": theme, "src": tag})
+
+ if src in (plan_feed.SRC_PLAN_API, plan_feed.SRC_BOTH):
+ try:
+ sel = plan_feed.candidates(held=held, black=black)
+ for r in sel["items"]:
+ _push(r["ts_code"], r["score"], "plan_api", theme=r.get("theme"))
+ logger.info("[候选池] 上游计划 %s: 排序池 %s → 合格 %s → 取 %d 只 (丢弃 %s)",
+ sel.get("date"), sel.get("considered"), sel.get("eligible"),
+ len(sel["items"]), sel.get("dropped"))
+ except plan_feed.PlanFeedError as e:
+ logger.error("[候选池] 上游计划不可用, 计划票一只不进池 (按纪律不回退旧表): %s", e)
+ except Exception as e:
+ logger.exception("[候选池] 上游计划处理异常: %s", e)
+
+ if src in (plan_feed.SRC_BUY_PLAN, plan_feed.SRC_BOTH):
+ from app.repo import downstream_repo
+ try:
+ for p in downstream_repo.fetch_buy_plans(is_active=7, limit=100):
+ _push(p["ts_code"], p.get("score"), "buy_plan", price=p.get("price"))
+ except Exception as e:
+ logger.warning("[候选池] 读旧买入计划表失败: %s", e)
+
+ white_score = max([c["score"] for c in out], default=0.0) + 1.0
for c, d in sp.items():
- if not d.get("white") or c in held or c in black or c in seen:
- continue
- px = market.get_price(c)
- if not px:
- continue
- seen.add(c)
- out.append({"ts_code": c, "price": px, "score": 1.0, "sector": industry.get(c),
- "src": "whitelist"})
+ if d.get("white"):
+ _push(c, white_score, "whitelist")
+
+ if noprice:
+ logger.warning("[候选池] %d 只候选取不到价格已剔除 (前 10): %s", len(noprice), noprice[:10])
return out
diff --git a/app/services/industry.py b/app/services/industry.py
index 33ed5ce..0c16f67 100644
--- a/app/services/industry.py
+++ b/app/services/industry.py
@@ -8,6 +8,11 @@
custom_table 自定义映射表 pms_industry_map (默认建议; 用户后续灌何种划分都只是灌数)
gp_stock_category 决策系统生态已有的行业表 (映射表就绪前的临时数据源)
""(空) 未配置 → 行业约束自动停用, 页面显著提示 (不静默放行也不误拦)
+
+映射表现在有稳定的灌数来源: 上游选股计划 `/plan` 的 `evidence.theme`, 由
+`plan_feed` 每次刷新时 upsert 进 pms_industry_map (见 UPSTREAM_PLAN_API.md §2)。
+所以本模块**不需要**新增 plan_api 数据源 —— 即时查当日计划有个洞: 今天没上榜的持仓票
+查不到 theme, 它的行业硬拦截就悄悄失效了。落库以后覆盖面随每天刷新累积, 且页面可见可手改。
"""
from __future__ import annotations
diff --git a/app/services/param_store.py b/app/services/param_store.py
index e4adcd8..20492e7 100644
--- a/app/services/param_store.py
+++ b/app/services/param_store.py
@@ -61,7 +61,19 @@ DESC = {
"PMS_FILL_MAX_LOSS": "浮亏深于此不走回踩补足",
"PMS_WEAK_NEG_DAYS": "降仓清弱票: 安全垫连续为负天数",
"PMS_PROPOSAL_TTL_HOURS": "自主提议待确认有效期 (小时)",
- "PMS_SECTOR_SOURCE": "行业划分数据源: 空=约束停用 / custom_table / gp_stock_category",
+ "PMS_CANDIDATE_SOURCE": "升仓候选池来源: plan_api=上游选股计划接口 / buy_plan=旧 trading_buy_plan 表 / both=并集",
+ "PMS_PLAN_API_BASE": "上游选股计划接口根地址; 留空=停用, 此时候选池恒为空并告警",
+ "PMS_PLAN_API_PATH": "计划接口路径 (默认 /plan)",
+ "PMS_PLAN_TIMEOUT": "计划接口超时 (秒)",
+ "PMS_PLAN_CACHE_SEC": "计划缓存秒数 (上游日频产出)",
+ "PMS_PLAN_TOP_N": "主榜按 score 降序取前 N 只进候选池",
+ "PMS_PLAN_TIERS": "传导档白名单 (如 强传导), 留空=不按档过滤",
+ "PMS_PLAN_INCLUDE_OBSERVE": "观察档是否进候选池",
+ "PMS_PLAN_MIN_SCORE": "候选 score 下限, 0=不设",
+ "PMS_PLAN_MIN_SOURCES": "候选 evidence.n_sources 下限, 0=不设",
+ "PMS_PLAN_STALE_TDAYS": "计划日龄超此交易日数即判过期拒用 (防上游停更时拿旧榜当今天)",
+ "PMS_PLAN_THEME_SYNC": "刷新计划时把 evidence.theme 灌进 pms_industry_map (行业源 custom_table 的数据来源)",
+ "PMS_SECTOR_SOURCE": "行业划分数据源: 空=约束停用 / custom_table (推荐, 由上游计划的 theme 灌数) / gp_stock_category",
"PMS_SECTOR_MAX_NAMES": "同行业最大持仓只数 (硬拦截)",
"PMS_SECTOR_MAX_RATIO": "同行业最大占总仓比例 (硬拦截)",
"PMS_EXEC_WINDOW_TDAYS": "任务命令默认执行窗口 (交易日)",
@@ -248,6 +260,9 @@ _RANGES = {
"PMS_DCA_MAX_RATIO": (0, 1), "PMS_SECTOR_MAX_RATIO": (0, 1), "PMS_T0_RATIO_MAX": (0, 0.3334),
"PMS_MAX_NAMES": (1, 200), "PMS_TOTAL_SCALE": (0, 10 ** 12),
"PMS_EXEC_WINDOW_TDAYS": (1, 20), "PMS_BRAKE_DAYS": (0, 30),
+ "PMS_PLAN_TOP_N": (1, 1000), "PMS_PLAN_TIMEOUT": (1, 120),
+ "PMS_PLAN_CACHE_SEC": (5, 86400), "PMS_PLAN_STALE_TDAYS": (0, 20),
+ "PMS_PLAN_MIN_SOURCES": (0, 100),
}
@@ -258,6 +273,8 @@ def _range_check(key, v):
return "PMS_SECTOR_SOURCE 只能是 空 / custom_table / gp_stock_category"
if key == "PMS_DISPATCH_MODE" and v not in ("shadow", "ws"):
return "PMS_DISPATCH_MODE 只能是 shadow / ws"
+ if key == "PMS_CANDIDATE_SOURCE" and v not in ("plan_api", "buy_plan", "both"):
+ return "PMS_CANDIDATE_SOURCE 只能是 plan_api / buy_plan / both"
lo_hi = _RANGES.get(key)
if lo_hi and isinstance(v, (int, float)) and not isinstance(v, bool):
lo, hi = lo_hi
diff --git a/app/services/plan_feed.py b/app/services/plan_feed.py
new file mode 100644
index 0000000..b0a2bd3
--- /dev/null
+++ b/app/services/plan_feed.py
@@ -0,0 +1,378 @@
+# -*- coding: utf-8 -*-
+"""
+上游选股计划接入 (HTTP /plan) —— 候选池的事实源
+================================================
+上游「选股系统」2026-07-30 起提供计划接口, 取代原先从 `trading_buy_plan` 捞
+`is_active=7` 的路子 (那张表是 trading_service 时代的产物, 目标架构下没有明确写入方)。
+
+ GET {PMS_PLAN_API_BASE}{PMS_PLAN_API_PATH}?date=YYYY-MM-DD
+ → {date, counts{main,observe,gate_covered}, theme_cap, heat_date,
+ market_snapshot_days[], main:[...], observe:[...], changes, encoding}
+
+主榜 main 每项: {rank, code("SH600418" 前缀式), name, score, heat, upside, tier,
+ evidence{theme, n_sources, moved_ratio}}; 观察档 observe 同形但
+ 无 tier、upside 为 null。
+
+五条口径在此钉死 (口径本身的疑问见 UPSTREAM_PLAN_API.md 的待确认项):
+
+ 1. **计划里没有价格、没有金额。** 上游只回答「买什么、排第几」, 买多少、什么价位是
+ PMS 自己的活 (sizer/planner)。价格一律走 `market.get_price`, 取不到就不进池 ——
+ 绝不拿 score/upside 当价格用。
+ 2. **拿不到 ≠ 今天没票可买。** 超时、报错、JSON 变形、日期过期一律抛 `PlanFeedError`,
+ 由调用方记 ERROR 且让候选池为空, 绝不静默回退到旧表。沿用研判闸那条
+ 「UNAVAILABLE ≠ PASS」的纪律: 事实源缺失要显式失败, 不能被当成"没有候选"。
+ 3. **961 只主榜不是买入清单, 是排序池。** 按 score 降序取前 `PMS_PLAN_TOP_N`
+ (默认 30) 进候选, 档位再由 `PMS_PLAN_TIERS` 白名单过滤。
+ 4. **日期新鲜度硬校验。** 计划日期比今天旧超过 `PMS_PLAN_STALE_TDAYS` 个交易日即
+ 过期 (抛错)。防的是节假日/上游停更时拿上周的榜当今天用 —— 这种错在盘中是静默的。
+ 5. **upside 单位未确认** (样例 0.26~2.12, 既可能是 % 也可能是倍数), 因此**默认不参与
+ 任何过滤和排序**, 只透传给页面看。见 UPSTREAM_PLAN_API.md Q1。
+
+行业约束的数据源也从这里来: `evidence.theme` 随每次刷新 upsert 进 `pms_industry_map`,
+行业源仍是 `custom_table` (`PMS_SECTOR_SOURCE=custom_table`)。落库而不是即时查, 是因为
+即时查有个洞 —— 今天没上榜的持仓票查不到 theme, 行业硬拦截就对它悄悄失效了; 落库以后
+覆盖面随时间累积, 且页面可见可手改。
+
+模块级只依赖 stdlib + `app.core.command_spec` (纯逻辑), 其余 (requests / param_store /
+pms_repo / tradedays) 一律函数内懒加载 —— 让解析与筛选这两段纯逻辑可以零依赖单测。
+"""
+from __future__ import annotations
+
+import logging
+import threading
+import time
+
+from app.core.command_spec import normalize_code
+
+logger = logging.getLogger("pms.plan")
+
+BUCKET_MAIN, BUCKET_OBSERVE = "main", "observe"
+
+# 候选池来源 (PMS_CANDIDATE_SOURCE)
+SRC_PLAN_API, SRC_BUY_PLAN, SRC_BOTH = "plan_api", "buy_plan", "both"
+
+FAIL_CACHE_SEC = 60.0 # 失败也缓存一会儿, 免得每分钟的调度位把 10 秒超时叠成雪崩
+
+
+class PlanFeedError(RuntimeError):
+ """上游计划取不到 / 变形 / 过期。**必须**冒泡, 不得吞成空计划 (见头部口径 2)。"""
+
+
+# ================================================================ 纯逻辑: 解析
+def _int_or_none(v):
+ try:
+ if v is None or (isinstance(v, str) and not v.strip()):
+ return None
+ return int(float(v))
+ except (TypeError, ValueError):
+ return None
+
+
+def _float_or_none(v):
+ try:
+ if v is None or (isinstance(v, str) and not v.strip()):
+ return None
+ return float(v)
+ except (TypeError, ValueError):
+ return None
+
+
+def _text_or_none(v):
+ if v is None:
+ return None
+ s = str(v).strip()
+ return s or None
+
+
+def _rows(raw, bucket: str) -> list:
+ """一档榜单归一。坏行跳过而不是整体抛错 —— 单条变形不该让整张计划失效。"""
+ out, seen = [], set()
+ for i, it in enumerate(raw or []):
+ if not isinstance(it, dict):
+ continue
+ code = normalize_code(str(it.get("code") or it.get("ts_code") or ""))
+ if not code or code in seen:
+ continue
+ seen.add(code)
+ ev = it.get("evidence") if isinstance(it.get("evidence"), dict) else {}
+ rank = _int_or_none(it.get("rank"))
+ out.append({
+ "ts_code": code,
+ "name": _text_or_none(it.get("name")),
+ "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")), # 单位待确认, 不参与判断
+ "tier": _text_or_none(it.get("tier")),
+ "theme": _text_or_none(ev.get("theme")),
+ "n_sources": _int_or_none(ev.get("n_sources")),
+ "moved_ratio": _float_or_none(ev.get("moved_ratio")),
+ "bucket": bucket,
+ })
+ return out
+
+
+def parse_plan(payload) -> dict:
+ """应答 → 内部结构。缺 date 或两档全空都算变形 (抛 PlanFeedError)。"""
+ if not isinstance(payload, dict):
+ raise PlanFeedError(f"应答不是 JSON 对象: {type(payload).__name__}")
+ date = _text_or_none(payload.get("date"))
+ if not date:
+ raise PlanFeedError("应答缺 date —— 判不了新鲜度, 按取不到处理")
+ main = _rows(payload.get("main"), BUCKET_MAIN)
+ observe = _rows(payload.get("observe"), BUCKET_OBSERVE)
+ if not main and not observe:
+ raise PlanFeedError(f"计划 {date} 主榜与观察档都是空的")
+ counts = payload.get("counts") if isinstance(payload.get("counts"), dict) else {}
+ themes = {}
+ for r in main + observe: # 主榜在前, 同码以主榜的 theme 为准
+ if r["theme"] and r["ts_code"] not in themes:
+ themes[r["ts_code"]] = r["theme"]
+ return {
+ "date": date,
+ "heat_date": _text_or_none(payload.get("heat_date")),
+ "market_snapshot_days": [str(x) for x in (payload.get("market_snapshot_days") or [])],
+ "theme_cap": _int_or_none(payload.get("theme_cap")),
+ "encoding": _text_or_none(payload.get("encoding")),
+ "counts": {"main": _int_or_none(counts.get("main")),
+ "observe": _int_or_none(counts.get("observe")),
+ "gate_covered": _int_or_none(counts.get("gate_covered"))},
+ "returned": {"main": len(main), "observe": len(observe)},
+ "main": main, "observe": observe, "themes": themes,
+ }
+
+
+# ================================================================ 纯逻辑: 新鲜度
+def plan_age_tdays(plan_date, today=None) -> int:
+ """计划日期距今的交易日龄。当天=0, 上一个交易日=1; 日期在未来 (为下一交易日出的计划)=0。"""
+ from app.core import tradedays as td
+ left = td.trade_days_left(today, plan_date) # 含首尾; plan_date 晚于 today 时为 0
+ return max(0, left - 1)
+
+
+def assert_fresh(plan: dict, *, max_stale_tdays: int = 1, today=None) -> int:
+ age = plan_age_tdays(plan["date"], today)
+ if age > int(max_stale_tdays or 0):
+ raise PlanFeedError(
+ f"上游计划已过期: 日期 {plan['date']} 距今 {age} 个交易日 "
+ f"(上限 {max_stale_tdays})。上游可能停更 —— 拿旧榜当今天用比没有候选更危险")
+ return age
+
+
+# ================================================================ 纯逻辑: 筛选
+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:
+ """排序池 → 候选清单。
+
+ 排序: score 降序, 同分按 rank 升序 (上游 rank 已是它自己的最终次序, 拿来当稳定次序)。
+ tier 白名单**只对带 tier 的行生效** —— 观察档没有 tier, 它的闸门是 include_observe。
+ """
+ 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_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}
+ 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"]
+ if c in seen:
+ dropped["dup"] += 1
+ continue
+ seen.add(c)
+ if c in held:
+ dropped["held"] += 1
+ continue
+ if c in black:
+ dropped["black"] += 1
+ continue
+ if tiers and r.get("tier") is not None and r["tier"] not in tiers:
+ dropped["tier"] += 1
+ continue
+ if min_score is not None and (r.get("score") or 0.0) < min_score:
+ dropped["score"] += 1
+ continue
+ if min_sources and (r.get("n_sources") or 0) < min_sources:
+ dropped["sources"] += 1
+ continue
+ passed.append(r)
+
+ n = max(0, int(top_n or 0)) or len(passed)
+ dropped["capped"] = max(0, len(passed) - n)
+ items = [{"ts_code": r["ts_code"], "name": r["name"], "score": r.get("score") or 0.0,
+ "sector": r.get("theme"), "theme": r.get("theme"), "tier": r.get("tier"),
+ "heat": r.get("heat"), "upside": r.get("upside"), "rank": r.get("rank"),
+ "bucket": r["bucket"], "src": "plan_api"}
+ for r in passed[:n]]
+ return {"date": plan.get("date"), "considered": len(pool), "eligible": len(passed),
+ "items": items, "dropped": dropped}
+
+
+# ================================================================ 取数 (带缓存)
+_cache = {"at": 0.0, "key": None, "plan": None, "error": None}
+_lock = threading.Lock()
+
+
+def _params() -> dict:
+ from app.services import param_store as ps
+ return {
+ "base": (ps.get("PMS_PLAN_API_BASE", "") or "").strip().rstrip("/"),
+ "path": (ps.get("PMS_PLAN_API_PATH", "/plan") or "/plan").strip(),
+ "timeout": ps.get_int("PMS_PLAN_TIMEOUT", 10),
+ "cache_sec": ps.get_int("PMS_PLAN_CACHE_SEC", 300),
+ "top_n": ps.get_int("PMS_PLAN_TOP_N", 30),
+ "tiers": ps.get_list("PMS_PLAN_TIERS", []),
+ "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),
+ "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(),
+ }
+
+
+def enabled() -> bool:
+ return bool(_params()["base"])
+
+
+def fetch(*, date=None, base=None, path=None, timeout=None) -> dict:
+ """拉一次并解析 (不走缓存、不校新鲜度)。任何失败抛 PlanFeedError。
+
+ 三个参数全给齐时**不读参数中心** —— 纯取数函数不该依赖 ParamStore, 否则连
+ 「base 为空立即失败」这条守卫都没法脱库单测。
+ """
+ if base is None or path is None or timeout is None:
+ p = _params()
+ base = p["base"] if base is None else base
+ path = p["path"] if path is None else path
+ timeout = p["timeout"] if timeout is None else timeout
+ base = (base or "").strip().rstrip("/")
+ if not base:
+ raise PlanFeedError("上游计划接口未配置 (PMS_PLAN_API_BASE 为空)")
+ path = (path or "/plan").strip() or "/plan"
+ if not path.startswith("/"):
+ path = "/" + path
+ url = base + path
+ to = int(timeout or 10)
+ try:
+ import requests
+ r = requests.get(url, params=({"date": date} if date else None), timeout=to)
+ r.raise_for_status()
+ payload = r.json()
+ except PlanFeedError:
+ raise
+ except Exception as e:
+ raise PlanFeedError(f"拉取上游计划失败 {url}: {type(e).__name__}: {e}") from e
+ plan = parse_plan(payload)
+ plan["url"] = url
+ plan["fetched_at"] = time.time()
+ plan["requested_date"] = date
+ return plan
+
+
+def get_plan(*, force: bool = False, date=None) -> dict:
+ """带缓存的当前计划。失败同样缓存 FAIL_CACHE_SEC, 但每次调用都照样抛。"""
+ p = _params()
+ key = (p["base"], p["path"], date or "")
+ now = time.time()
+ with _lock:
+ fresh_hit = (not force and _cache["key"] == key and _cache["plan"] is not None
+ and now - _cache["at"] < max(1, p["cache_sec"]))
+ if fresh_hit:
+ return _cache["plan"]
+ if (not force and _cache["key"] == key and _cache["error"]
+ and now - _cache["at"] < FAIL_CACHE_SEC):
+ raise PlanFeedError(_cache["error"])
+ try:
+ plan = fetch(date=date)
+ assert_fresh(plan, max_stale_tdays=p["stale_tdays"])
+ except PlanFeedError as e:
+ with _lock:
+ _cache.update({"at": time.time(), "key": key, "plan": None, "error": str(e)})
+ raise
+ plan["age_tdays"] = plan_age_tdays(plan["date"])
+ if p["theme_sync"]:
+ plan["theme_sync"] = _sync_themes_quiet(plan)
+ with _lock:
+ _cache.update({"at": time.time(), "key": key, "plan": plan, "error": None})
+ logger.info("[上游计划] %s 主榜 %d / 观察 %d (日龄 %d 交易日) ← %s",
+ plan["date"], plan["returned"]["main"], plan["returned"]["observe"],
+ plan["age_tdays"], plan["url"])
+ return plan
+
+
+def invalidate():
+ with _lock:
+ _cache.update({"at": 0.0, "key": None, "plan": None, "error": None})
+
+
+# ================================================================ theme → 行业映射
+def sync_themes(plan: dict) -> dict:
+ """把 code→theme upsert 进 pms_industry_map (行业源 custom_table 的灌数口径)。"""
+ from app.repo import pms_repo
+ from app.services import industry
+ rows = [{"ts_code": c, "industry": t} for c, t in (plan.get("themes") or {}).items()]
+ if not rows:
+ return {"rows": 0, "affected": 0}
+ n = pms_repo.upsert_industry(rows)
+ industry.invalidate()
+ return {"rows": len(rows), "affected": n}
+
+
+def _sync_themes_quiet(plan: dict) -> dict:
+ """theme 落库失败不许阻断候选池 —— 行业约束停用是可接受的降级, 没候选不是。"""
+ try:
+ return sync_themes(plan)
+ except Exception as e:
+ logger.warning("[上游计划] theme 落 pms_industry_map 失败 (行业约束按未配置降级): %s", e)
+ return {"rows": 0, "affected": 0, "error": f"{type(e).__name__}: {e}"}
+
+
+# ================================================================ 对外: 候选与状态
+def candidates(*, held=(), black=()) -> dict:
+ """参数驱动的候选清单 (不含价格 —— 价格由调用方用 market.get_price 现取)。"""
+ p = _params()
+ plan = get_plan()
+ 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"])
+ out["age_tdays"] = plan.get("age_tdays")
+ out["theme_cap"] = plan.get("theme_cap")
+ return out
+
+
+def status() -> dict:
+ """页面/运维用: 绝不抛错, 失败也要能显示出来。"""
+ p = _params()
+ st = {"source": p["source"], "base": p["base"], "path": p["path"],
+ "top_n": p["top_n"], "tiers": p["tiers"],
+ "include_observe": p["include_observe"], "stale_tdays": p["stale_tdays"],
+ "theme_sync": p["theme_sync"], "enabled": bool(p["base"])}
+ if not p["base"]:
+ st.update({"ok": False, "hint": "上游计划接口未配置 (PMS_PLAN_API_BASE 为空) —— "
+ "候选池将为空, 升仓/建仓类命令无票可选"})
+ return st
+ try:
+ plan = get_plan()
+ except PlanFeedError as e:
+ st.update({"ok": False, "hint": str(e)})
+ return st
+ except Exception as e: # 兜底: status 不许抛
+ st.update({"ok": False, "hint": f"{type(e).__name__}: {e}"})
+ return st
+ st.update({"ok": True, "date": plan["date"], "age_tdays": plan.get("age_tdays"),
+ "heat_date": plan.get("heat_date"),
+ "market_snapshot_days": plan.get("market_snapshot_days"),
+ "counts": plan["counts"], "returned": plan["returned"],
+ "theme_cap": plan.get("theme_cap"), "encoding": plan.get("encoding"),
+ "theme_sync": plan.get("theme_sync"),
+ "fetched_at": plan.get("fetched_at"), "url": plan.get("url"),
+ "hint": f"计划 {plan['date']} 已就绪 (日龄 {plan.get('age_tdays')} 交易日)"})
+ return st
diff --git a/app/web/main.py b/app/web/main.py
index 3104b92..f2cacb2 100644
--- a/app/web/main.py
+++ b/app/web/main.py
@@ -404,6 +404,45 @@ def api_clear_resync():
return ok(_clear)
+# ================================================================ 上游选股计划
+@app.get("/api/upstream/plan")
+def api_upstream_plan(limit: int = Query(30), date: str = Query(None),
+ bucket: str = Query("main")):
+ """上游 /plan 预览。绝不抛错 —— 接口不通时 status.ok=false + hint, 页面照常渲染。
+
+ candidates_raw 是**不扣持仓/黑名单**的原始筛选结果 (纯看参数选出来什么),
+ 真正下命令时的候选池还会再剔除已持有、黑名单与取不到价的票。
+ """
+ def _plan():
+ from app.services import plan_feed
+ st = plan_feed.status()
+ out = {"ok": True, "status": st, "rows": [], "candidates_raw": None}
+ if not st.get("ok"):
+ return out
+ try:
+ plan = plan_feed.get_plan(date=(date or None))
+ rows = plan.get("observe" if bucket == "observe" else "main") or []
+ out["rows"] = rows[:max(1, int(limit or 30))]
+ out["candidates_raw"] = plan_feed.candidates()
+ except Exception as e:
+ out["status"] = {**st, "ok": False, "hint": f"{type(e).__name__}: {e}"}
+ return out
+ return ok(_plan)
+
+
+@app.post("/api/ops/plan-refresh")
+def api_plan_refresh(date: str = Query(None)):
+ """强刷上游计划, 并把 evidence.theme 灌进 pms_industry_map (行业源 custom_table 的数据来源)。"""
+ def _refresh():
+ from app.services import plan_feed
+ plan_feed.invalidate()
+ plan = plan_feed.get_plan(force=True, date=(date or None))
+ return {"ok": True, "date": plan["date"], "age_tdays": plan.get("age_tdays"),
+ "returned": plan["returned"], "theme_sync": plan.get("theme_sync"),
+ "industry": industry.status()}
+ return ok(_refresh)
+
+
@app.get("/api/ops/downstream-schema")
def api_downstream_schema():
"""导出下游三表的实际列定义 —— 用于回填 QMT_INTERFACE_REQUIREMENTS D1。"""
diff --git a/app/web/static/index.html b/app/web/static/index.html
index c02689e..b5a2003 100644
--- a/app/web/static/index.html
+++ b/app/web/static/index.html
@@ -52,6 +52,9 @@
{{ opsResult }}
+
+ {{ planResult }}
+