2026-07-30 16:21:16 +08:00
|
|
|
|
# -*- coding: utf-8 -*-
|
|
|
|
|
|
"""
|
|
|
|
|
|
上游选股计划接口实机探活 (只读, 不写任何表)
|
|
|
|
|
|
============================================
|
|
|
|
|
|
docker compose run --rm --no-deps pms-web python scripts/probe_plan_api.py
|
|
|
|
|
|
docker compose run --rm --no-deps pms-web python scripts/probe_plan_api.py --date 2026-07-29
|
|
|
|
|
|
docker compose run --rm --no-deps pms-web python scripts/probe_plan_api.py --top 30 --with-price
|
|
|
|
|
|
|
|
|
|
|
|
干什么: 拿真实应答验证四件事 —— ① 接口通不通、② 字段口径与单测 fixture 是否一致、
|
|
|
|
|
|
③ 按当前参数筛出来的候选池长什么样、④ (--with-price) 这些票行情里到底有没有价。
|
|
|
|
|
|
第 ④ 项最容易翻车: 计划不带价格, 价格取不到的票会在候选池里被静默剔除。
|
|
|
|
|
|
|
|
|
|
|
|
只读: 不会 upsert pms_industry_map (那由 /api/ops/plan-refresh 或盘前调度做), 也不下单。
|
|
|
|
|
|
"""
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
import argparse
|
|
|
|
|
|
import json
|
|
|
|
|
|
import os
|
|
|
|
|
|
import sys
|
|
|
|
|
|
|
|
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
|
|
|
|
|
|
|
|
|
|
from app.services import plan_feed as pf # noqa: E402
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _fmt(v, n=4):
|
|
|
|
|
|
return "-" if v is None else (f"{v:.{n}f}" if isinstance(v, float) else str(v))
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-30 16:38:24 +08:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-30 17:00:15 +08:00
|
|
|
|
LIMIT_KEYS = ["limit", "top", "topn", "top_n", "n", "size", "page_size", "per_page",
|
|
|
|
|
|
"count", "max", "rows", "num", "full"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _try_limit(ps, args) -> int:
|
|
|
|
|
|
"""探"取全量"的参数名。
|
|
|
|
|
|
|
|
|
|
|
|
上游默认只回主榜 20 条, 而 counts.main=961 —— 候选池实际只在这 20 条里选, 且那 20 条
|
|
|
|
|
|
已经被上游按自己的「每主题限额 5」裁过。参数名探出来填进 PMS_PLAN_QUERY_EXTRA 即可,
|
|
|
|
|
|
不用改代码。全都不认就只能等上游给分页方式 (UPSTREAM_PLAN_API.md Q3)。
|
|
|
|
|
|
"""
|
|
|
|
|
|
base = (args.base or ps.get("PMS_PLAN_API_BASE", "") or "").strip().rstrip("/")
|
|
|
|
|
|
path = ps.get("PMS_PLAN_API_PATH", "/plan")
|
|
|
|
|
|
if not base:
|
|
|
|
|
|
print("PMS_PLAN_API_BASE 为空, 先把地址配上 (或用 --base)")
|
|
|
|
|
|
return 2
|
|
|
|
|
|
try:
|
|
|
|
|
|
base_plan = pf.fetch(base=base, path=path, timeout=15, date=args.date)
|
|
|
|
|
|
except pf.PlanFeedError as e:
|
|
|
|
|
|
print(f"基准请求就失败了: {e}")
|
|
|
|
|
|
return 1
|
|
|
|
|
|
n0, c0 = base_plan["returned"]["main"], base_plan["counts"]["main"]
|
|
|
|
|
|
print(f"[基准] 不带任何参数: 主榜回 {n0} 条 (上游 counts={c0})")
|
|
|
|
|
|
if not base_plan["truncated"]["main"]:
|
|
|
|
|
|
print(" 没有截断 —— 已经是全量, 不需要分页参数。")
|
|
|
|
|
|
return 0
|
|
|
|
|
|
print(f"[探参] 依次试 {len(LIMIT_KEYS)} 个常见参数名, 取值 {args.limit_value}")
|
|
|
|
|
|
hits = []
|
|
|
|
|
|
for k in LIMIT_KEYS:
|
|
|
|
|
|
try:
|
|
|
|
|
|
p = pf.fetch(base=base, path=path, timeout=20, date=args.date,
|
|
|
|
|
|
extra_params={k: args.limit_value})
|
|
|
|
|
|
n = p["returned"]["main"]
|
|
|
|
|
|
flag = " ← 生效" if n > n0 else ""
|
|
|
|
|
|
print(f" {k:<10}= {args.limit_value:<6} → 主榜 {n} 条{flag}")
|
|
|
|
|
|
if n > n0:
|
|
|
|
|
|
hits.append((k, n))
|
|
|
|
|
|
except pf.PlanFeedError as e:
|
|
|
|
|
|
print(f" {k:<10}= {args.limit_value:<6} → 报错: {str(e).split(': ', 1)[-1][:90]}")
|
|
|
|
|
|
print("-" * 62)
|
|
|
|
|
|
if not hits:
|
|
|
|
|
|
print(f"没有一个参数名生效, 主榜始终 {n0} 条。")
|
|
|
|
|
|
print("→ 这条只能问上游: 取全量 961 条的正确方式是什么 (分页? 另一个端点?)")
|
|
|
|
|
|
print(" 见 UPSTREAM_PLAN_API.md Q3。在此之前候选池就是从这 20 条里选,")
|
|
|
|
|
|
print(" 并且要知道: 这 20 条已被上游按「每主题限额 5」裁过, PMS 侧的行业约束")
|
|
|
|
|
|
print(" 是在一个已经被裁过的池子上再裁一次。")
|
|
|
|
|
|
return 1
|
|
|
|
|
|
hits.sort(key=lambda x: -x[1])
|
|
|
|
|
|
k, n = hits[0]
|
|
|
|
|
|
print(f"生效: {k}={args.limit_value} → 主榜 {n} 条 (原 {n0})")
|
|
|
|
|
|
print("→ 页面「参数设置」把 PMS_PLAN_QUERY_EXTRA 填成下面这串, 或直接:")
|
|
|
|
|
|
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_QUERY_EXTRA\",\"value\":\"{k}={args.limit_value}\"}}'")
|
|
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-30 16:21:16 +08:00
|
|
|
|
def main():
|
|
|
|
|
|
ap = argparse.ArgumentParser()
|
|
|
|
|
|
ap.add_argument("--date", default=None, help="指定计划日期 YYYY-MM-DD (缺省=上游最新)")
|
|
|
|
|
|
ap.add_argument("--top", type=int, default=20, help="打印主榜前 N 行")
|
|
|
|
|
|
ap.add_argument("--observe", action="store_true", help="连观察档一起打印")
|
|
|
|
|
|
ap.add_argument("--with-price", action="store_true",
|
|
|
|
|
|
help="逐只查行情价 (需 Redis 通; 用来看候选池会不会因无价掉票)")
|
2026-07-30 16:38:24 +08:00
|
|
|
|
ap.add_argument("--base", default=None,
|
|
|
|
|
|
help="临时覆盖 PMS_PLAN_API_BASE (只影响本次, 不落库)")
|
|
|
|
|
|
ap.add_argument("--try-bases", action="store_true",
|
|
|
|
|
|
help="逐个试候选地址, 报告哪个能通 —— 容器连不上宿主机时先跑这个")
|
2026-07-30 17:00:15 +08:00
|
|
|
|
ap.add_argument("--try-limit", action="store_true",
|
|
|
|
|
|
help="探取全量的参数名 (上游默认只回主榜 20 条, counts 却是 961)")
|
|
|
|
|
|
ap.add_argument("--limit-value", default="1000", help="--try-limit 用的取值 (默认 1000)")
|
2026-07-30 16:21:16 +08:00
|
|
|
|
ap.add_argument("--json", action="store_true", help="原样打印解析后的结构")
|
|
|
|
|
|
args = ap.parse_args()
|
|
|
|
|
|
|
|
|
|
|
|
from app.services import param_store as ps
|
2026-07-30 16:38:24 +08:00
|
|
|
|
if args.try_bases:
|
|
|
|
|
|
return _try_bases(ps, args)
|
2026-07-30 17:00:15 +08:00
|
|
|
|
if args.try_limit:
|
|
|
|
|
|
return _try_limit(ps, args)
|
2026-07-30 16:38:24 +08:00
|
|
|
|
base = (args.base or ps.get("PMS_PLAN_API_BASE", "") or "").strip()
|
2026-07-30 16:21:16 +08:00
|
|
|
|
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")
|
|
|
|
|
|
if not base:
|
|
|
|
|
|
print(" → PMS_PLAN_API_BASE 为空。页面「参数设置」填上再跑。")
|
|
|
|
|
|
return 2
|
|
|
|
|
|
|
2026-07-31 08:54:36 +08:00
|
|
|
|
q = pf.effective_query()
|
|
|
|
|
|
print(f" 发出的查询参数 {q or '(无 —— 将吃上游默认 top=20/obs_top=10/theme_cap=5)'}")
|
2026-07-30 16:21:16 +08:00
|
|
|
|
try:
|
2026-07-31 08:54:36 +08:00
|
|
|
|
plan = pf.fetch(date=args.date, base=base, extra_params=q)
|
2026-07-30 16:21:16 +08:00
|
|
|
|
except pf.PlanFeedError as e:
|
|
|
|
|
|
print(f"[2] 取数失败: {e}")
|
|
|
|
|
|
return 1
|
|
|
|
|
|
print(f"[2] 取数成功 {plan['url']}")
|
|
|
|
|
|
print(f" date={plan['date']} heat_date={plan['heat_date']} "
|
|
|
|
|
|
f"snapshot={plan['market_snapshot_days']} theme_cap={plan['theme_cap']}")
|
2026-07-31 08:54:36 +08:00
|
|
|
|
print(f" 请求参数 {plan['requested']}")
|
|
|
|
|
|
f = plan["funnel"]
|
|
|
|
|
|
print(f" 漏斗: 打分池 主榜 {f['scored_main']} / 观察 {f['scored_observe']}"
|
|
|
|
|
|
f" →(券商预期 + 目标价≥现价 + 主题限额 + top)→"
|
|
|
|
|
|
f" 实收 主榜 {f['returned_main']} / 观察 {f['returned_observe']}")
|
2026-07-30 17:00:15 +08:00
|
|
|
|
if plan["truncated"]["main"]:
|
2026-07-31 08:54:36 +08:00
|
|
|
|
print(f" ! 主榜正好吃满 top={plan['requested']['top']} —— 可能还有更多, "
|
|
|
|
|
|
f"调大 PMS_PLAN_TOP 再看")
|
|
|
|
|
|
else:
|
|
|
|
|
|
print(f" 主榜没吃满 top={plan['requested']['top']}, 说明这就是上游能给的全部"
|
|
|
|
|
|
f" (受主题限额 {plan['theme_cap']} 与价格筛限制)")
|
2026-07-30 16:21:16 +08:00
|
|
|
|
|
|
|
|
|
|
age = pf.plan_age_tdays(plan["date"])
|
|
|
|
|
|
limit = ps.get_int("PMS_PLAN_STALE_TDAYS", 1)
|
|
|
|
|
|
verdict = "新鲜" if age <= limit else f"**过期** (上限 {limit}, 候选池会拒用)"
|
|
|
|
|
|
print(f"[3] 新鲜度 日龄 {age} 个交易日 → {verdict}")
|
|
|
|
|
|
|
|
|
|
|
|
print(f"[4] 主榜前 {args.top} (score 降序即上游 rank 序)")
|
|
|
|
|
|
hdr = f" {'rank':>4} {'代码':<11} {'名称':<8} {'score':>8} {'档位':<6} {'主题':<10} " \
|
2026-07-30 16:38:24 +08:00
|
|
|
|
f"{'heat':>7} {'预期空间':>9}"
|
2026-07-30 16:21:16 +08:00
|
|
|
|
print(hdr + (" " + "行情价" if args.with_price else ""))
|
|
|
|
|
|
rows = plan["main"][:max(1, args.top)]
|
|
|
|
|
|
if args.observe:
|
|
|
|
|
|
rows += plan["observe"][:max(1, args.top)]
|
|
|
|
|
|
prices = {}
|
|
|
|
|
|
if args.with_price:
|
|
|
|
|
|
from app.services import market
|
|
|
|
|
|
for r in rows:
|
|
|
|
|
|
try:
|
|
|
|
|
|
prices[r["ts_code"]] = market.get_price(r["ts_code"])
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
prices[r["ts_code"]] = f"ERR {type(e).__name__}"
|
|
|
|
|
|
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} "
|
2026-07-30 16:38:24 +08:00
|
|
|
|
f"{(r['theme'] or '-'):<10} {_fmt(r['heat']):>7} {_pct(r['upside']):>9}")
|
2026-07-30 16:21:16 +08:00
|
|
|
|
if args.with_price:
|
|
|
|
|
|
p = prices.get(r["ts_code"])
|
|
|
|
|
|
line += f" {p if p else '**无价(会被剔除)**'}"
|
|
|
|
|
|
print(line)
|
|
|
|
|
|
|
|
|
|
|
|
themes = {}
|
|
|
|
|
|
for r in plan["main"]:
|
|
|
|
|
|
themes[r["theme"] or "(无)"] = themes.get(r["theme"] or "(无)", 0) + 1
|
|
|
|
|
|
top_themes = sorted(themes.items(), key=lambda x: -x[1])[:12]
|
|
|
|
|
|
print(f"[5] 主榜主题分布 (共 {len(themes)} 个主题): " +
|
|
|
|
|
|
", ".join(f"{k}×{v}" for k, v in top_themes))
|
|
|
|
|
|
|
|
|
|
|
|
sel = pf.select_candidates(
|
|
|
|
|
|
plan, 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) or None),
|
2026-07-31 08:54:36 +08:00
|
|
|
|
min_sources=ps.get_int("PMS_PLAN_MIN_SOURCES", 0),
|
|
|
|
|
|
min_upside=(ps.get_float("PMS_PLAN_MIN_UPSIDE", 0.0) or None),
|
|
|
|
|
|
theme_cap=ps.get_int("PMS_PLAN_THEME_CAP_LOCAL", 5))
|
2026-07-30 16:21:16 +08:00
|
|
|
|
print(f"[6] 按当前参数筛选 (top_n={ps.get_int('PMS_PLAN_TOP_N', 30)} "
|
|
|
|
|
|
f"tiers={ps.get_list('PMS_PLAN_TIERS', [])} "
|
2026-07-31 08:54:36 +08:00
|
|
|
|
f"observe={ps.get_bool('PMS_PLAN_INCLUDE_OBSERVE', False)} "
|
|
|
|
|
|
f"PMS侧主题限额={ps.get_int('PMS_PLAN_THEME_CAP_LOCAL', 5)})")
|
2026-07-30 16:21:16 +08:00
|
|
|
|
print(f" 排序池 {sel['considered']} → 合格 {sel['eligible']} → 取 {len(sel['items'])} 只")
|
|
|
|
|
|
print(f" 丢弃明细 {sel['dropped']} (此处未扣持仓/黑名单, 下命令时还会再扣)")
|
2026-07-31 08:54:36 +08:00
|
|
|
|
st = {}
|
|
|
|
|
|
for x in sel["items"]:
|
|
|
|
|
|
st[x.get("theme") or "(无)"] = st.get(x.get("theme") or "(无)", 0) + 1
|
|
|
|
|
|
print(" 入池主题分布: " + ", ".join(f"{k}×{v}" for k, v in
|
|
|
|
|
|
sorted(st.items(), key=lambda y: -y[1])))
|
2026-07-30 16:21:16 +08:00
|
|
|
|
print(" " + ", ".join(x["ts_code"] for x in sel["items"]))
|
|
|
|
|
|
|
|
|
|
|
|
if args.json:
|
|
|
|
|
|
print("[7] 解析结构")
|
|
|
|
|
|
print(json.dumps({k: v for k, v in plan.items() if k not in ("main", "observe")},
|
|
|
|
|
|
ensure_ascii=False, indent=2))
|
|
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
|
sys.exit(main())
|