120 lines
5.6 KiB
Python
120 lines
5.6 KiB
Python
|
|
# -*- 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))
|
|||
|
|
|
|||
|
|
|
|||
|
|
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 通; 用来看候选池会不会因无价掉票)")
|
|||
|
|
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()
|
|||
|
|
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
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
plan = pf.fetch(date=args.date)
|
|||
|
|
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']}")
|
|||
|
|
print(f" counts(上游全量)={plan['counts']} returned(本次应答)={plan['returned']}")
|
|||
|
|
if plan["counts"]["main"] is not None and plan["returned"]["main"] != plan["counts"]["main"]:
|
|||
|
|
print(f" ! 主榜 counts={plan['counts']['main']} 但只回了 "
|
|||
|
|
f"{plan['returned']['main']} 条 —— 上游做了截断, 确认是否分页")
|
|||
|
|
|
|||
|
|
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} " \
|
|||
|
|
f"{'heat':>7} {'upside':>8}"
|
|||
|
|
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} "
|
|||
|
|
f"{(r['theme'] or '-'):<10} {_fmt(r['heat']):>7} {_fmt(r['upside']):>8}")
|
|||
|
|
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),
|
|||
|
|
min_sources=ps.get_int("PMS_PLAN_MIN_SOURCES", 0))
|
|||
|
|
print(f"[6] 按当前参数筛选 (top_n={ps.get_int('PMS_PLAN_TOP_N', 30)} "
|
|||
|
|
f"tiers={ps.get_list('PMS_PLAN_TIERS', [])} "
|
|||
|
|
f"observe={ps.get_bool('PMS_PLAN_INCLUDE_OBSERVE', False)})")
|
|||
|
|
print(f" 排序池 {sel['considered']} → 合格 {sel['eligible']} → 取 {len(sel['items'])} 只")
|
|||
|
|
print(f" 丢弃明细 {sel['dropped']} (此处未扣持仓/黑名单, 下命令时还会再扣)")
|
|||
|
|
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())
|