候选按上游名次排:上游选股计划排序主轴换成公司质地后,选股序、入池、PMS 候选三处同序(台账 016)

上游选股系统 2026-09-17 把选股计划的排序主轴从传导档位换成公司质地,计划顶层带 rank_axis=quality、每行带 quality_band/pointed/rank_old。这是上线协同阶段工作包乙:PMS 候选在上游是质地轴时按上游名次升序排、关掉自己的基本面立场分桶,让三处同一个序;上游没带 rank_axis(旧计划或选股系统回退)时逐字回到按立场分桶排,靠上游的键自门控。规格见 docs/上线与协同阶段工作方案与规格书_2026-09-17.md 工作包乙与附录乙。

改了哪些函数。plan_feed._rows 归一时带上 quality_band/pointed/rank_axis/rank_old 四键(缺就是空);parse_plan 返回加顶层 rank_axis、rank_rule;新增 _rank_mode 定这次的排序模式(upstream/quality_bucket/score);select_candidates 加参数 rank_by_upstream,生效条件是它为真且计划 rank_axis=quality,生效时排序键为上游名次升序、同名次原始分降序、立场桶不参与,返回加 rank_mode、items 透传四键;snapshot 名册快照的 meta 写 rank_mode(拉计划处按 _rank_mode 算好传入);_params 读 PMS_PLAN_RANK_BY_UPSTREAM,候选调用点透传。param_store 登记 PMS_PLAN_RANK_BY_UPSTREAM 默认开。

没改什么、为什么不合并。立场分桶排与纯分数排两支逐字不动(附录乙要求),上游没给质地轴时自动落回它们。三门槛一确认三风险、研判闸、下单链路、单表访问守卫都没动。不重启容器——上线是 09-23 收盘后重建镜像加 force-recreate、经用户同意,不在本提交、也不与 09-21 选股系统拉代码同一天。选股系统那一处小改(plan_review.pms_roster 按 rank_mode 还原序)在选股系统仓库单独一个提交,不在本提交。

单测。scripts/test_batch29_units.py 加三例(附录乙):上游 rank_axis=quality 且开关开时按上游名次排、立场桶不参与、rank_mode=upstream;上游没带 rank_axis 时逐字回按分桶排、rank_mode=quality_bucket;rank_mode 三态与四键透传、新开关登记。run_tests.py 的 batch29 例数 24→27。

实测读数。本地 py_compile 四个改动文件通过。本机缺 pydantic_settings 等依赖,按 CLAUDE.md 单测在容器内跑(make test 或 docker compose run pms-web python scripts/test_batch29_units.py),预期 ALL SUITES PASS、例数只增不减,交由 09-19 复审在部署机核。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
zlt 2026-09-17 12:54:45 +08:00
parent 4f2c6861ca
commit f5cc1b8730
6 changed files with 113 additions and 9 deletions

View File

@ -662,3 +662,13 @@ make changes # 页面同款结论
隔一天再跑一次 `make changes`, 就能看到第一份真正的跨日比对 —— 那时候才验得到 `cross_day`
与持仓票视图。账本还空着, 所以持仓那段现在必然是空的, 等账本重建后才有内容。
## 10. 排序主轴换成公司质地与前缀效应 (2026-09-17, 台账 016)
上游 2026-09-17 把选股计划的排序主轴从传导档位换成公司质地。对 PMS 的接入有三件事要知道。
**多了几个键。** 每行多 `quality_band` (质地带: 好 3 / 中 2 / 无评析 1 / 差 0)、`pointed` (是否被产业链指向)、`rank_axis` (quality 或 transmission)、`rank_old` (旧序名次, 供对照); 顶层多 `rank_axis``rank_rule` (一句话次序规则)。旧计划、或上游回退到传导轴时这些键缺, 即 None, `_rows``parse_plan` 按缺就是空处理。
**候选按上游名次排 (自门控)。** 计划顶层 `rank_axis` 为 quality 时, `select_candidates` 按上游名次升序排、关掉 PMS 自己的基本面立场分桶 (参数 `PMS_PLAN_RANK_BY_UPSTREAM`, 默认开); 上游没带这个键时无作用, 逐字回到按立场分桶排。这样选股序、入池、PMS 候选三处同一个序。本次用哪种序记在候选返回与名册快照元数据的 `rank_mode` 里 (upstream / quality_bucket / score)。
**前缀效应 —— 强传导票变少不是故障。** 质地序把好公司排到前面, 质地差但强传导的票会往后掉, 可能落到我们要的三百只主榜前缀之外, 于是这批票在候选里变少甚至消失。这与"质地差降关注"同向, 是预期结果, 不要当成上游漏发或接口出错。要看全量强传导票, 去上游的逐票对账接口 `/plan/verdict` (它的"主榜第 N 名"仍按原始分序, 应答带 `rank_axis: transmission` 说明), 或把 `PMS_PLAN_RANK_BY_UPSTREAM` 关掉临时回到按分桶排。

View File

@ -117,6 +117,8 @@ RUNTIME_EXTRA = {
# ── 2026-09-11 三源合议工作包二·接入 (台账 006/009): 持仓增持门与候选按质地排序。
"PMS_TECH_GATE_INCREASE": (True, bool, "持仓增持门: 合议看空停回踩补足/盈利加仓/补仓, 技术面看空停回踩补足与盈利加仓, 基本面看空停补仓 (减持不受影响; 关掉即回到不设门)"),
"PMS_PLAN_RANK_BY_QUALITY": (True, bool, "候选先按基本面立场分桶 (质地看多的桶排前) 再按分数, 在主题限额与截断之前生效; 技术面不参与排序 (关掉即回到只按分数排)"),
# ── 2026-09-17 上线协同阶段工作包乙 (台账 016): 上游选股计划排序主轴换成公司质地后, 候选按上游名次排。
"PMS_PLAN_RANK_BY_UPSTREAM": (True, bool, "上游计划带 rank_axis=quality 时按上游名次升序排、关掉自己的立场分桶; 上游没带这个键 (旧计划或选股系统回退) 时无作用 (关掉即回到按 PMS_PLAN_RANK_BY_QUALITY 的分桶排)"),
"PMS_PLAN_OBSERVE_IF_FUND_BULL": (False, bool, "把观察档里质地看多的票也纳入候选池 (默认关, 守宁缺毋滥; 开了它们与主榜一同参与分桶排序)"),
# ── 2026-09-11 三源合议工作包三·离场纪律 (台账 010): SAR 转空自动离场。翻向新鲜天数复用 PMS_TECH_FLIP_FRESH_DAYS。
"PMS_TECH_EXIT_AUTONOMY": ("full", str, "技术面转空离场档位: off 不评 / propose_only 交人确认 / full 自动执行 (与保垫减仓同档, 按既定默认)"),

View File

@ -332,6 +332,11 @@ def _rows(raw, bucket: str) -> list:
"company_review": _company_review_or_none(it.get("company_review")),
"company_review_text": _text_or_none(it.get("company_review_text")),
"company_gate": _text_or_none(it.get("company_gate")),
# 排序轴四键 (2026-09-17 工作包乙): 上游质地序带出来的键, 原样透传, 旧计划缺就是 None。
"quality_band": _int_or_none(it.get("quality_band")),
"pointed": (bool(it.get("pointed")) if it.get("pointed") is not None else None),
"rank_axis": _text_or_none(it.get("rank_axis")),
"rank_old": _int_or_none(it.get("rank_old")),
})
return out
@ -376,6 +381,10 @@ def parse_plan(payload, *, requested=None) -> dict:
"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")),
# 排序轴 (2026-09-17 工作包乙): 上游主轴名与一句话次序规则。按上游名次排的自门控只看 rank_axis;
# 旧计划、或选股系统回退到 transmission 时缺这个键 (即 None), 自门控自动不生效。
"rank_axis": _text_or_none(payload.get("rank_axis")),
"rank_rule": _text_or_none(payload.get("rank_rule")),
"counts": {"main": _int_or_none(counts.get("main")),
"observe": _int_or_none(counts.get("observe")),
"gate_covered": _int_or_none(counts.get("gate_covered"))},
@ -437,11 +446,22 @@ def _fund_stance_of(row) -> str:
return "无读数"
def _rank_mode(plan: dict, rank_by_upstream: bool, rank_by_quality: bool) -> str:
"""本次候选与名册用的排序模式2026-09-17 工作包乙):
upstream = 上游是质地轴计划顶层 rank_axis=quality且开了按上游名次排此时按上游名次升序立场桶不参与
quality_bucket = 上游没给质地轴但开了 PMS 自己的立场分桶score = 都没开纯原始分降序
选股系统周报据名册元数据里这一键还原当天交付名单的序"""
if rank_by_upstream and (plan or {}).get("rank_axis") == "quality":
return "upstream"
return "quality_bucket" if rank_by_quality else "score"
def select_candidates(plan: dict, *, held=(), black=(), top_n: int = 30, tiers=None,
include_observe: bool = False, min_score=None,
min_sources: int = 0, min_upside=None, theme_cap: int = 0,
exclude_st: bool = True, route_by_verdict: bool = False,
rank_by_quality: bool = False, observe_if_fund_bull: bool = False) -> dict:
rank_by_quality: bool = False, rank_by_upstream: bool = False,
observe_if_fund_bull: bool = False) -> dict:
"""排序池 → 候选清单。
route_by_verdict: 按上游判决分流时, 判为仅展示的行在 top_n 截断**之前**剔掉并列进
@ -487,7 +507,14 @@ def select_candidates(plan: dict, *, held=(), black=(), top_n: int = 30, tiers=N
passed, seen, per_theme, st_unknown, display_only = [], set(), {}, [], []
# 排序: 默认按分数降序、同分按 rank 升序 (与 2026-09-11 之前逐字相同)。开了按质地排序后, 先按
# 基本面立场分桶 (质地看多的桶排在前) 再按分数, 在主题限额与截断之前生效; 技术面不参与排序。
if rank_by_quality:
# 2026-09-17 工作包乙: 上游是质地轴且开了按上游名次排时, 直接按上游名次升序 (同名次原始分降序),
# 立场桶不参与 —— 选股序、入池、这里三处同一个序, 四周复核才说得清。上游没给质地轴 (旧计划或回退)
# 时自动落回下面两支, 逐字不动。
mode = _rank_mode(plan, rank_by_upstream, rank_by_quality)
if mode == "upstream":
def _sort_key(x):
return (x.get("rank") or 10 ** 9, -(x.get("score") or 0.0))
elif rank_by_quality:
def _sort_key(x):
return (0 if _fund_stance_of(x) == "看多" else 1,
-(x.get("score") or 0.0), x.get("rank") or 10 ** 9)
@ -563,11 +590,14 @@ def select_candidates(plan: dict, *, held=(), black=(), top_n: int = 30, tiers=N
"industry_catalyst": r.get("industry_catalyst"),
"industry_catalyst_text": r.get("industry_catalyst_text"),
"company_review": r.get("company_review"), "company_review_text": r.get("company_review_text"),
"company_gate": r.get("company_gate")}
"company_gate": r.get("company_gate"),
# 排序轴四键 (2026-09-17 工作包乙): 原样透传给动作引擎与提议卡, 缺就是 None。
"quality_band": r.get("quality_band"), "pointed": r.get("pointed"),
"rank_axis": r.get("rank_axis"), "rank_old": r.get("rank_old")}
for r in passed[:n]]
return {"date": plan.get("date"), "considered": len(pool), "eligible": len(passed),
"items": items, "dropped": dropped, "st_unknown": st_unknown,
"display_only": display_only,
"display_only": display_only, "rank_mode": mode,
"tier_complete": _tier_complete(pool, tiers)}
@ -632,6 +662,9 @@ def _params() -> dict:
"route_by_verdict": ps.get_bool("PMS_PLAN_ROUTE_BY_VERDICT", True),
# 三源合议工作包二 (2026-09-11): 候选先按基本面立场分桶再按分数; 观察档里质地看多的行是否进池。
"rank_by_quality": ps.get_bool("PMS_PLAN_RANK_BY_QUALITY", True),
# 上游名次排 (2026-09-17 工作包乙): 上游计划 rank_axis=quality 时按上游名次排、关掉立场桶;
# 上游没带这个键时无作用。默认开, 靠上游的键自门控, 上游切质地序之前它什么都不改。
"rank_by_upstream": ps.get_bool("PMS_PLAN_RANK_BY_UPSTREAM", True),
"observe_if_fund_bull": ps.get_bool("PMS_PLAN_OBSERVE_IF_FUND_BULL", False),
}
@ -828,7 +861,9 @@ def get_plan(*, force: bool = False, date=None) -> dict:
if p["theme_sync"]:
plan["theme_sync"] = _sync_themes_quiet(plan)
if p["snapshot"]:
plan["snapshot"] = _snapshot_quiet(plan, keep=p["snapshot_keep"])
plan["snapshot"] = _snapshot_quiet(
plan, keep=p["snapshot_keep"],
rank_mode=_rank_mode(plan, p["rank_by_upstream"], p["rank_by_quality"]))
with _lock:
_cache.update({"at": time.time(), "key": key, "plan": plan, "error": None})
logger.info("[上游计划] %s 主榜 %d / 观察 %d (日龄 %d 交易日) ← %s",
@ -869,7 +904,7 @@ def _sync_themes_quiet(plan: dict) -> dict:
# ================================================================ 名册快照与榜单变化
def snapshot(plan: dict, *, keep: int = 200) -> dict:
def snapshot(plan: dict, *, keep: int = 200, rank_mode=None) -> dict:
"""把这一份计划的名册落进 pms_plan_snapshot。
同一份榜重复拉不重复落 ( uk_date_digest)返回里带 `stored` 落进去了才是新版本,
@ -883,6 +918,9 @@ def snapshot(plan: dict, *, keep: int = 200) -> dict:
"theme_cap", "heat_date", "market_snapshot_days",
"age_tdays", "url",
"plan_source", "snapshot_generated_at", "gate_on")}
# 排序模式 (2026-09-17 工作包乙): 让选股系统周报按它还原当天交付名单的序。旧调用没传即不写。
if rank_mode is not None:
meta["rank_mode"] = rank_mode
truncated = plan.get("truncated") or {}
n = pms_repo.insert_plan_snapshot(
plan_date=plan.get("date"), digest=digest, roster=pdf.roster_rows(roster), meta=meta,
@ -897,7 +935,7 @@ def snapshot(plan: dict, *, keep: int = 200) -> dict:
return out
def _snapshot_quiet(plan: dict, *, keep: int = 200) -> dict:
def _snapshot_quiet(plan: dict, *, keep: int = 200, rank_mode=None) -> dict:
"""快照落库失败**不许阻断候选池** —— 没有变化提示是可接受的降级, 没候选不是。
`_sync_themes_quiet` 的口径: 这是个"锦上添花"的旁路, 它的故障不该传染主链路
@ -905,7 +943,7 @@ def _snapshot_quiet(plan: dict, *, keep: int = 200) -> dict:
(拿旧版本当上一版比)
"""
try:
return snapshot(plan, keep=keep)
return snapshot(plan, keep=keep, rank_mode=rank_mode)
except Exception as e:
logger.warning("[上游计划] 名册快照落库失败 (榜单变化提示将不可用): %s", e)
return {"stored": False, "error": f"{type(e).__name__}: {e}"}
@ -1091,6 +1129,7 @@ def candidates(*, held=(), black=(), route_by_verdict: bool = False) -> dict:
theme_cap=p["theme_cap_local"], exclude_st=p["exclude_st"],
route_by_verdict=route,
rank_by_quality=p["rank_by_quality"],
rank_by_upstream=p["rank_by_upstream"],
observe_if_fund_bull=p["observe_if_fund_bull"])
out["age_tdays"] = plan.get("age_tdays")
out["theme_cap"] = plan.get("theme_cap")

View File

@ -233,3 +233,19 @@
**复核日期。** 部署后的下一个交易日盘中先只读试算一次,看 executed 里有没有 route 为 auto、why 含「系统采纳」的条目,或 rejected 里有没有评审方 system 的条目。收盘后跑 make consensus-review 看第五张表系统自决逐条、第六张表研判不可用占比。
**评审补记2026-09-14 下午,同日评审后修订)。** 四处补齐,不改上面的决定。一,到价自动清仓前停买入侧改成与命令清仓同样的三件:撤方案、驳回该票待确认的买入提议、撤该票在途的买入指令;原来只撤方案,已下发的网格买单会与清仓单对倒。不再只在有活动方案时才做,一张在途补仓买单同样要撤。二,只有一方表态改按合议强弱打标记:强弱为弱一律按试探仓,不看路由;原来只在路由为交人时打,弱表态开关 PMS_CONSENSUS_WEAK_CONFIRM 关掉后会按标准仓采纳。三自动采纳只认带应答体的真通过动作不在研判范围时研判直接回通过且没有应答体这种一律交人。四decline 与 full 档下逻辑存疑的新建仓不送研判直接放弃省一次研判调用与时间预算record 档照旧送研判。页面加两种处置词:系统已采纳、系统已放弃。
## 016 · 2026-09-17 · 候选按上游名次排上游排序主轴换成公司质地后选股序、入池、PMS 候选三处同序
**改动。** 上游选股系统 2026-09-17 把选股计划的排序主轴从传导档位换成公司质地,计划顶层带 rank_axis=quality、每行带 quality_band/pointed/rank_old。PMS 的候选选择在上游是质地轴时改按上游名次升序排、关掉自己的基本面立场分桶让选股序、入池、PMS 候选三处是同一个序。上游没带 rank_axis旧计划或选股系统回退到传导轴时逐字回到按立场分桶排一处不改。
**做了哪几件。** plan_feed 的 _rows 与 parse_plan 归一时带上四个新键与顶层 rank_axis、rank_rule缺就是空select_candidates 加参数 rank_by_upstream生效条件是它为真且计划 rank_axis=quality生效时排序键为上游名次升序、同名次原始分降序立场桶不参与返回与名册快照元数据都写一键 rank_modeupstream / quality_bucket / score选股系统周报据它还原当天交付名单的序。参数 PMS_PLAN_RANK_BY_UPSTREAM 默认开、靠上游的键自门控。选股系统那侧 plan_review.pms_roster 读到 rank_mode 为 upstream 时按名次还原(另一仓库单独提交)。
**依据。** 入池已改成"新序里被指向的前五十"、选股系统候选单已是质地序PMS 若仍按自己的立场桶排,三处里就它一处是另一个序,四周复核说不清它拿到的前三十为什么与选股系统不一样。立场桶本来就是没有上游质地序时的替代品,上游有了就退场。
**预期。** 09-22 上游切质地序后PMS 候选序等于上游名次序09-24 早上核 rank_mode 为 upstream、名册前十名次严格递增。质地差的强传导票可能落到三百只前缀之外与"质地差降关注"同向,是预期,不是故障。
**开关与回退。** PMS_PLAN_RANK_BY_UPSTREAM 关掉即回到按 PMS_PLAN_RANK_BY_QUALITY 的立场分桶排上游一回退rank_axis 不再是 quality它自动回旧不用动 PMS。off 逐字回旧由 test_batch29 的两支断言守。
**上线。** 09-19 交付、我复审后 09-23 收盘后重启 PMS 进程(重建镜像加 force-recreate经用户同意。不与 09-21 选股系统拉代码同一天,避免两处同时变化说不清。
**复核日期。** 上线次日 09-24 核候选序等于上游名次序四周10-16 前后)随选股系统排序轴一起复核。

View File

@ -81,7 +81,8 @@
test_batch29_units.py 三源合议·工作包二接入下单链路 (2026-09-11): scan_open 合议分流(跳过/观察带
wait_tech/交人强制确认/放行/缺合议退旧路/开关关掉逐字回旧)/定档改用 advise_v2
且六键进硬数字而不进送研判白名单/持仓增持门矩阵与 scan 端到端(减持不受影响
开关关掉回旧)/装配服务持仓挂合议/候选按质地分桶排序与观察档进池开关 (24 )
开关关掉回旧)/装配服务持仓挂合议/候选按质地分桶排序与观察档进池开关/
上游名次排(rank_axis=quality 按上游名次旧计划回分桶逐字不变rank_mode 三态; 2026-09-17 工作包乙) (27 )
test_batch30_units.py 三源合议·工作包三离场纪律 (2026-09-11): eval_tech_exit 转空自动离场(确认清仓/
未确认减三分之一/翻向超期不评/同一翻空一次/无读数弃权/propose_only 交人/可卖量
夹紧/开关关掉逐字回旧)/卖出优先级目标价<研究走弱<技术面转空<保垫减仓/scan 端到端/

View File

@ -337,6 +337,42 @@ def _():
assert ps.RUNTIME_EXTRA["PMS_PLAN_OBSERVE_IF_FUND_BULL"][0] is False # 守宁缺毋滥, 默认关
# ================================================================ G 上游名次排 (2026-09-17 工作包乙)
@case("G 上游名次排·rank_axis=quality 且开关开: 按上游名次升序、立场桶不参与、rank_mode=upstream")
def _():
# 高分好票名次靠后(rank=3)、低分无评析名次靠前(rank=1): upstream 按名次排 → 无评析在前, 证明立场桶不参与
plan = {"date": "2026-09-17", "rank_axis": "quality",
"main": [_row("600000.SH", 100, "", rank=3), _row("000001.SZ", 60, rank=1)], "observe": []}
out = pf.select_candidates(plan, rank_by_upstream=True, rank_by_quality=True)
assert [x["ts_code"] for x in out["items"]] == ["000001.SZ", "600000.SH"]
assert out["rank_mode"] == "upstream"
@case("G 上游名次排·上游没带 rank_axis: 逐字回按分桶排 (rank_by_quality 生效, rank_mode=quality_bucket)")
def _():
plan = {"date": "2026-09-17", # 无 rank_axis 键 (旧计划或选股系统回退)
"main": [_row("600000.SH", 100, rank=1), _row("000001.SZ", 90, "", rank=2)], "observe": []}
out = pf.select_candidates(plan, rank_by_upstream=True, rank_by_quality=True)
assert [x["ts_code"] for x in out["items"]] == ["000001.SZ", "600000.SH"] # 看多分桶在前, 与既有用例同口径
assert out["rank_mode"] == "quality_bucket"
@case("G 上游名次排·rank_mode 三态、四键透传、新开关登记")
def _():
q = {"date": "2026-09-17", "rank_axis": "quality", "main": [_row("600000.SH", 100, rank=1)], "observe": []}
assert pf.select_candidates(q, rank_by_upstream=True, rank_by_quality=True)["rank_mode"] == "upstream"
assert pf.select_candidates(q, rank_by_upstream=False, rank_by_quality=True)["rank_mode"] == "quality_bucket"
assert pf.select_candidates(q, rank_by_upstream=False, rank_by_quality=False)["rank_mode"] == "score"
p2 = {"date": "2026-09-17", "rank_axis": "quality", "observe": [],
"main": [{**_row("600000.SH", 100, rank=1),
"quality_band": 3, "pointed": True, "rank_axis": "quality", "rank_old": 5}]}
it = pf.select_candidates(p2, rank_by_upstream=True)["items"][0]
assert (it["quality_band"], it["pointed"], it["rank_axis"], it["rank_old"]) == (3, True, "quality", 5)
assert "PMS_PLAN_RANK_BY_UPSTREAM" in ps.RUNTIME_EXTRA
assert ps.RUNTIME_EXTRA["PMS_PLAN_RANK_BY_UPSTREAM"][1] is bool
assert ps.RUNTIME_EXTRA["PMS_PLAN_RANK_BY_UPSTREAM"][0] is True
def main():
ok = 0
for name, fn in RESULTS: