- {{ s.row.upside }}
+
+
+ {{ s.row.upside==null ? '-' :
+ ((s.row.upside>0?'+':'') + (s.row.upside*100).toFixed(0) + '%') }}
+
- 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