# 上线与协同阶段工作方案与规格书(2026-09-17):修复小包、持仓管理系统同序、周报底本 > 2026-09-17 下午写,供拍板与开发。承接两份规格书:《选股计划调整方案与规格书_2026-09-17》(排序轴换质地,代码已交付,复审见 `docs/评审记录_2026-09-17.md`)与《部署与判收阶段方案与规格书_2026-09-16》(数据基座 09-18、09-22、09-29 三个部署日,原样不动)。 > 开发者可以是人,也可以是大模型。写法遵守 `.claude/output-styles/readable-chinese.md`。第一到第十一节是正文,附录是实现细节,标了「可不读」,但开发者动手前必须读对应工作包的附录。 > 基线:选股系统仓库 `main` 4d7ca55(随本方案推送),选股系统机器停在 3d3f317,等 09-21 拉。数据基座 `master` 79abcf2、开发分支 `nextphase-2026-09` 末尾为本方案副本提交,基座机停在 3f45294,等 09-18 拉。持仓管理系统仓库 `main` 停在 a78e737 之后的文档提交,机器上的代码未动。 > 本文件是四个仓库逐字同步的副本之一,改一份要同步其余。 --- ## 一、这一阶段为最终目标买什么 排序轴换质地的代码已经写完并复审。这一阶段要把它干净地上线,并让下游真正吃到这个序。买三样东西。 1. **09-22 的第一份质地序计划,两条路给出同一份。** 复审查出装配路与快照路对"已启动成员"的主题限额判定不一致,同一天 md 与接口可能给出不同的前二十。修复小包把截取规则收成一处实现,再上线。 2. **持仓管理系统的候选序与选股系统同一个序。** 今天它拿到三百只前缀后自己按"基本面立场桶、原始分"重排,两边是两套质地序各排各的。协同项让它在上游是质地轴时按上游名次排、关掉自己的分桶,选股系统、入池、持仓管理系统三处同源。这条 09-17 的规格书里标为"另批",本方案就是那次审批的规格。 3. **09-25 起的周报读的是当天真正出过的计划。** 周报现在把每个计划日重新装配一遍,用的是跑周报那一刻的评析报告。四周复核比的是两份名单谁更好,两份都应该是当天落盘的那份。这一条 2026-09-17 已拍板:做。 --- ## 二、原则 沿用五条:加不改;开关关掉逐字回旧;同日重跑结果一致;不做回测、因子合成、IC;评价只用候选单复盘周报。本阶段再守两条。 1. 一处规则一处实现。两条路要同一个结果,就让两条路调同一个函数,不在两处各写一遍再靠单测钉。 2. 下游按上游的序,不各自发明序。上游给了名次与主轴名,下游读它,不重排。 --- ## 三、做哪几件事 三个工作包。甲在选股系统仓库,乙在持仓管理系统仓库外加选股系统的一处小改,丙在选股系统仓库(拍板点一已通过)。 ### 工作包甲:修复小包(选股系统仓库,必做) 对应评审记录第四节的三处必改,一个提交,09-18 复审后推送,09-21 拉。 1. **主题限额的截取规则收成一处。** 新建一个模块级纯函数做"按主题限额截取",`collect` 里的 `_pick` 与模块级的 `pick_rows` 都调它,只是各自告诉它怎么从一条记录取主题。主题的取法与 `_row` 写 `evidence` 的取法同源,含已动成员视图的补充。这样已启动成员在两条路上都算进所在主题、受限额;没有主题的行在质地轴下免限额、不计数。为什么这样改:不是给装配路加一个"已启动成员也算主题"的特判,而是让两条路根本没有第二份规则。 2. **`encoding` 句只在质地轴下追加。** 旧轴下这句话与实际次序相反,持仓管理系统会把它原样展示给人看。 3. **交接文档补一段。** 在 `docs/交接_2026-09-10.md` 末尾加一节带日期的补记,台账那一行改成 063,四仓库同步。 4. **单测。** `test_plan_rank_axis.py` 加两例:已启动成员在两条路上都算进所在主题;无主题行在质地轴下免限额、在旧轴下归"无传导"桶计数。`test_plan_snapshot.py` 里"拿真实快照逐行比对"那一例不动,它在部署机上是这件事的最终读数。 细节见附录甲。 ### 工作包乙:持仓管理系统按上游名次排(持仓管理系统仓库,另加选股系统一处小改) **做什么。** 持仓管理系统取候选时,上游计划带 `rank_axis` 为 quality 就按上游名次升序排,不再按立场桶与原始分重排;上游没带这个键(旧计划、或选股系统回退)就走现在的路。一个开关,默认开,靠上游的键自门控,所以 09-22 之前它什么都不改。 **为什么。** 入池已经改成"新序里被指向的前五十",选股系统的候选单已经是质地序。持仓管理系统若仍按自己的立场桶排,三处里就它一处是另一个序,四周复核时说不清它拿到的前三十为什么与选股系统的前三十不一样。它的立场桶本来就是在没有上游质地序时的替代品,上游有了,替代品退场。 **六件事。** 1. 计划应答归一时把每行新加的四个键与顶层的 `rank_axis`、`rank_rule` 带进来,缺就是空。 2. 候选选择函数加一个参数"按上游名次排",只在计划的 `rank_axis` 为 quality 时生效,生效时排序键是上游名次升序、同名次按原始分降序,立场桶不再参与。返回里加一键写明本次用的是哪种序。 3. 参数表加 `PMS_PLAN_RANK_BY_UPSTREAM`,默认开,说明文字写清自门控。 4. 名册快照的元数据里写一键 `rank_mode`,让选股系统的周报能还原当天交付名单的序。 5. 选股系统 `plan_review.pms_roster` 读到元数据里 `rank_mode` 为 upstream 时按名册里的名次升序取前 N,否则按原始分降序取前 N(今天的写法)。这一处小改放在选股系统仓库,随工作包丙或单独一个提交。 6. 单测、台账、对接说明。既有的 `scripts/test_batch29_units.py` 里已有按质地分桶的三例,旁边加三例。台账一条,编号 016。持仓管理系统的对接说明加一段"前缀效应",与选股系统下游对接说明那段同义。 **上线。** 持仓管理系统在选股系统机器上,源码挂载进容器,改代码只要重启进程,重启要用户同意、收盘后做。放在 09-23 收盘后,前提是 09-22 的六处读数干净。不与 09-21 的选股系统拉代码同一天,避免两处同时变化说不清。 细节见附录乙。 ### 工作包丙:周报改读当日快照(选股系统仓库,拍板点一已通过,必做) **做什么。** 周报对每个计划日先找当天的 JSON 快照,读它的全量主榜与观察档行、候选单、关注单;快照不存在或读不出才回落到现在的重新装配。加开关 `PLAN_REVIEW_SOURCE`,默认 snapshot,设 live 回今天的做法。周报第一节注记每天用的是哪种底本。 **为什么。** 重新装配用的是跑周报那一刻的评析报告,评析查询取每只票最新一次运行、不按日期截断,所以 09-22 的名单在 09-25 算会用上 09-24 的报告。四周复核比的是当天真正出过的两份名单。快照落盘时的注释写着"它是复盘与对账的唯一底本",周报应该读它。 **它改变什么。** 从 09-02 有快照那天起的所有周报名单都改按快照取数,与之前周报的口径不同。09-25 之前不会有人比对旧口径,所以现在切最省事。旧口径靠开关随时能回。 细节见附录丙。 ### 已排定、本方案不改的事 数据基座三个部署日按《部署与判收阶段方案与规格书_2026-09-16》附录甲、乙、丙执行,09-28 建议级小包复审(拍板点十一)与 09-30 判收读数不变。选股系统 09-21 拉代码与接口容器重启按《选股计划调整方案_2026-09-17》第四节执行。 --- ## 四、日程 北京时间。每天只写这一阶段新增或改动的事,数据基座的部署日只列名字。 | 日期 | 星期 | 做什么 | |---|---|---| | 09-17 | 三 | 复审完成。开发者做工作包甲与工作包丙,同一个提交(拍板点一已通过) | | 09-18 | 四 | 上午我复审工作包甲(含丙),通过即推送。收盘后数据基座 09-18 部署窗(附录甲,原样)。开发者开始工作包乙 | | 09-19 | 六 | 工作包乙交付,含选股系统那一处小改。我复审 | | 09-21 | 一 | 收盘后选股系统机器拉代码,接口容器重启并预热,经审批,避开七点到七点半与八点半到九点零五 | | 09-22 | 二 | 07:15 后核首份质地序计划的六处读数,再核"两条路同一份前二十"。收盘后数据基座第二批(附录乙) | | 09-23 | 三 | 09-22 读数干净,则收盘后重启持仓管理系统进程上线工作包乙,经审批 | | 09-24 | 四 | 08:45 后核持仓管理系统的候选序等于上游名次序 | | 09-25 | 五 | 首份带两份对照名单的周报,底本按拍板点一 | | 09-28 | 一 | 数据基座建议级小包复审(若做) | | 09-29 | 二 | 数据基座第三批与前端重建(附录丙) | | 09-30 | 三 | 数据基座判收读数 | | 10-16 前后 | | 排序轴四周复核,按《选股计划调整方案》工作包四第四条决定保留或切回 | | 长假后第一周 | | 总评审 | --- ## 五、开关与参数 新增三个,其余沿用两份规格书。 | 键 | 仓库 | 默认 | 说明 | |---|---|---|---| | `PLAN_REVIEW_SOURCE` | 选股系统 | snapshot | 周报底本。live 回"每日重新装配"。拍板点一已通过 | | `PMS_PLAN_RANK_BY_UPSTREAM` | 持仓管理系统 | 开 | 上游计划 `rank_axis` 为 quality 时按上游名次排、关掉立场桶;上游没带这个键时无作用 | | 名册元数据 `rank_mode` | 持仓管理系统写、选股系统读 | 无 | upstream 或 quality_bucket 或 score,周报据此还原交付名单的序 | 选股系统机器的 `.env` 仍不需要加任何键。持仓管理系统的参数走它自己的参数表,页面可改。 --- ## 六、验证方式 每条命令标明在哪台机器上运行。选股系统机器与持仓管理系统在同一台:`ssh -p 2280 factor@192.168.16.155`,两个仓库分别在 `~/project/akg-factor-bridge` 与 `~/project/tradingSystem`。 **本地(选股系统仓库,工作包甲交付后)。** 预期三行都是 ALL OK。 ```bash cd /Users/baobao/Documents/work/project/akg-factor-bridge && python3 test_plan_rank_axis.py 2>&1 | tail -1 && python3 test_plan_snapshot.py 2>&1 | tail -1 && python3 test_plan_quality_rank.py 2>&1 | tail -1 ``` **本地(选股系统仓库)。** 逐字回旧。预期末行 ALL OK。 ```bash cd /Users/baobao/Documents/work/project/akg-factor-bridge && PLAN_RANK_AXIS=transmission CARD_SORT_BY_QUALITY=0 POOL_SOURCE=tier REVIEW_TARGETS_AXIS=score python3 test_plan_rank_axis.py 2>&1 | tail -1 ``` **本地(持仓管理系统仓库,工作包乙交付后)。** 预期末行 ALL SUITES PASS,例数只增不减。 ```bash cd /Users/baobao/Documents/work/project/tradingSystem && python3 scripts/run_tests.py 2>&1 | tail -3 ``` **选股系统机器(09-22 早上 07:15 后)。** 两条路同一份前二十。这条读数是必改一的最终判收。预期打出快照文件名与 True。 ```bash cd ~/project/akg-factor-bridge && docker exec akg_factor_bridge python3 -c "import json,glob,plan; p=sorted(glob.glob('data/plan/plan_*.json'))[-1]; s=json.load(open(p)); sp=s['shown_params']; got=[r['code'] for r in plan.pick_rows(s['main'], sp['top'], sp['theme_cap'])]; print(p, got==[r['code'] for r in s['main_shown']])" ``` **选股系统机器(09-22)。** 首份质地序计划的六处读数,命令与预期照《选股计划调整方案_2026-09-17》第六节,不再抄一遍。 **选股系统机器(09-24 早上 08:45 后)。** 持仓管理系统的候选序等于上游名次序。读它当天的名册快照与元数据。预期 `rank_mode` 为 upstream,打出的名次序列严格递增。 ```bash cd ~/project/akg-factor-bridge && docker exec akg_factor_bridge python3 -c " import json, db df = db.read_mysql('pms', 'SELECT roster_json, meta_json FROM pms_plan_snapshot ORDER BY id DESC LIMIT 1') meta = json.loads(df.iloc[0]['meta_json'] or '{}'); roster = json.loads(df.iloc[0]['roster_json'] or '[]') print('rank_mode =', meta.get('rank_mode')) rows = [r for r in roster if str(r.get('b') or 'main') == 'main'] print('名册前十的名次:', [r.get('r') for r in rows[:10]])" 2>&1 | grep -v Warning ``` **选股系统机器(09-25 周报后)。** 周报底本。预期第一节每天的注记写"底本:快照",两份对照名单都在。 ```bash cd ~/project/akg-factor-bridge && grep -n "底本\|新序前二十\|旧序前二十" $(ls -t data/review/复盘_*_next_close.md | head -1) | head -12 ``` --- ## 七、回退 - 工作包甲没有开关,它修的是规则不一致,不需要回退路径。真要回到 4d7ca55 的行为只能回代码,但那个行为本身是错的。 - 工作包乙把 `PMS_PLAN_RANK_BY_UPSTREAM` 关掉即回今天的做法,不用重启,参数表页面可改。 - 工作包丙把 `PLAN_REVIEW_SOURCE` 设 live 即回今天的做法。 - 排序轴整体回退仍按《选股计划调整方案》第七节四个开关设旧值。持仓管理系统那边因为自门控,上游一回退它自动回旧,不用动。 --- ## 八、拍板点 代定的写"建议",动工前不再问。要改就改这里。 1. **周报底本改读快照(工作包丙)。** 2026-09-17 用户拍板:做。随工作包甲同一个提交,默认读快照,开关可回。理由在第三节。 2. **持仓管理系统协同项的默认值与上线日。** 建议默认开、靠上游的 `rank_axis` 自门控,09-23 收盘后上线。不与 09-21 同一天。 3. **提交 4d7ca55 随本方案推送。** 代定。修复小包在它上面再提一个。 4. **两份对照名单的口径。** 代定:两份都是全量序前二十,不设主题限额,与周报其余名单同口径。它比的是排序规则,不是当天交付的那二十只;交付名单另有"候选单"与"强传导交付名单"两行。 5. **工作包乙里选股系统那一处小改(`pms_roster` 按 `rank_mode` 取序)** 代定随工作包丙同一个提交。 --- ## 九、交付要求 1. 每个工作包一个提交,全中文、无前缀,格式"主题:改了什么",落台账的带台账号。正文写清改了哪些函数、没改什么、为什么不合并。 2. 先写单测再改代码,尤其是工作包甲那两例。 3. 工作包甲交付证据:提交号、三个测试文件末行、逐字回旧那一例的名字、`git show --stat`。 4. 工作包乙交付证据:提交号、`run_tests.py` 末三行与例数对比、三例新单测的名字、台账 016 原文、选股系统那一处小改的提交号。 5. 工作包丙交付证据:提交号、开关名、本地用一份合成快照跑通周报取数的单测末行。 6. 只加键不删键,既有键的值与含义不变。 --- ## 十、评审方式 评审者只看证据。工作包甲核三件事:截取规则是不是只剩一处实现、两条路的主题取法是否同源、旧轴下无主题行是否仍归"无传导"桶计数。工作包乙核四件事:自门控是否只看计划顶层的 `rank_axis`、立场桶在上游序下是否完全不参与、旧计划下是否逐字回旧、名册元数据是否写了 `rank_mode`。工作包丙核两件事:无快照日是否回落、开关设 live 是否逐字回今天。判定三档同前。评审记录写 `docs/评审记录_<日期>.md`。 ## 十一、风险与预判 1. 09-22 早上"两条路同一份前二十"那条读数若是 False,说明工作包甲没修干净,当天接口以快照路为准,持仓管理系统拿到的仍是自洽的一份,不影响交易,但要当天修。 2. 持仓管理系统切到上游序后,页面上"候选榜第 N 名"与它的候选序第一次一致,人看到的名次会跳一次,属预期。 3. 周报改读快照后,09-18 与 09-21 两个旧序日的"主榜新序前二十"其实是旧序前二十,注记写明。 4. 工作包乙上线那天若持仓管理系统进程重启失败,回退是不重启,代码在挂载目录里但进程还是旧的,行为不变。 5. 名册元数据的 `rank_mode` 只从工作包乙上线那天起有,之前的名册周报仍按原始分还原,注记写明"历史 N 与序不可还原"那句已有。 --- ## 附录甲(可不读):工作包甲实现 行号按选股系统仓库 `main` 4d7ca55。 **截取函数。** 在 `plan.py` 模块级新增: ```python def take_by_theme(items, n, theme_cap, theme_of, uncapped_no_theme): """按主题限额截取。items 可迭代;theme_of(item) 回主题字符串,无主题回 None。 有主题的每主题最多 theme_cap 条(0 不设限)。无主题的:uncapped_no_theme 为真时 免限额、不计数;为假时归"(无传导)"一个桶计数(旧轴原样)。取够 n 条即停。""" ``` - `collect` 里的 `_pick`(约 523 到 541 行)改成一行调用:`take_by_theme(ranked.items(), n, theme_cap, lambda kv: _theme_of(kv[0]), uncapped_no_theme)`。 - `_theme_of(k)` 是 `collect` 内的小函数,与 `_row`(约 546 到 552 行)写 `evidence` 的取法同源:传导视图有证据行取 `e[0]`;没有证据行、且候选卡的 `started_source` 为 moved_view 且带 `theme`,取 `c["theme"]`;否则 None。最好把 `_row` 里那几行也改成先调 `_theme_of` 再拼字典,让两处只有一份取法。 - `pick_rows`(约 1014 到 1037 行)改成调用同一个函数:`theme_of` 取 `((r.get("evidence") or {}).get("theme")) or None`,`uncapped_no_theme` 的缺省推断不变。 - `encoding`(约 712 行):把追加那半句挪到 `axis == "quality"` 的分支里,或改成"名次按 PLAN_RANK_AXIS:quality 时质地带优先,传导只在带内作次级键;transmission 时按 09-14 的调整分序"这样两种都对的写法。 - 交接段骨架:标题"十一、2026-09-17 补记:排序主轴换成公司质地";四段:改了什么(一句)、下游看到什么(四个新键、名次含义、逐票对账接口仍是原始分序)、怎么回退(四个开关)、读数在哪(评析目标名单表、周报两份名单)。台账那一行改成"最新到第 063 条"。 **单测(`test_plan_rank_axis.py` 加两例)。** 11. 已启动成员算进所在主题:六条记录同主题"锂电",其中第六条通过 `theme_of` 取到主题的路子是候选卡的 moved_view 补充;`take_by_theme` 在 `theme_cap=5` 下只放五条;再用同样六条构造成带 `evidence.theme` 的行喂 `pick_rows`,也只放五条。 12. 无主题行两轴各归各:六条无主题记录,`uncapped_no_theme` 为真放六条,为假放五条(归"无传导"桶)。 既有 `test_plan_snapshot.py` 第 50 到 58 行两例保持通过。 ## 附录乙(可不读):工作包乙实现 行号按持仓管理系统仓库 `main` 当前代码,文件 `app/services/plan_feed.py`。 - `_rows`(约 279 到 330 行)加四键:`"quality_band": _int_or_none(it.get("quality_band"))`、`"pointed"`(值非空时转布尔,否则 None)、`"rank_axis": _text_or_none(it.get("rank_axis"))`、`"rank_old": _int_or_none(it.get("rank_old"))`。 - `parse_plan`(约 339 到 395 行)返回字典加两键:`"rank_axis": _text_or_none(payload.get("rank_axis"))`、`"rank_rule": _text_or_none(payload.get("rank_rule"))`。 - `select_candidates`(约 440 行起)签名加 `rank_by_upstream: bool = False`。生效条件 `upstream = bool(rank_by_upstream) and plan.get("rank_axis") == "quality"`。生效时排序键为 `(x.get("rank") or 10 ** 9, -(x.get("score") or 0.0))`,`rank_by_quality` 不再参与;不生效时两个既有分支逐字不动。返回字典加 `"rank_mode"`:upstream、quality_bucket、score 三选一。`items` 每项加 `quality_band`、`pointed`、`rank_axis`、`rank_old` 四键原样透传。 - 参数块(约 620 到 636 行)加 `"rank_by_upstream": ps.get_bool("PMS_PLAN_RANK_BY_UPSTREAM", True)`;调用点(约 1086 到 1094 行)传入。`app/services/param_store.py` 约 119 行旁加登记:`"PMS_PLAN_RANK_BY_UPSTREAM": (True, bool, "上游计划带 rank_axis=quality 时按上游名次排、关掉立场分桶;上游没带时无作用(关掉即回按分桶排)")`。 - 名册快照元数据:`app/repo/pms_repo.py` 约 839 行的插入语句已有 `meta_json` 列,在 `plan_feed` 里构造元数据的那处加 `"rank_mode"`,值取自本次 `select_candidates` 返回的 `rank_mode`。 - 选股系统 `plan_review.pms_roster`(`plan_review.py` 约 120 到 140 行):查询多取 `meta_json`;`rank_mode` 为 upstream 时 `rows.sort(key=lambda r: r.get("r") if r.get("r") is not None else 1 << 60)`,否则保持按 `s` 降序。注记字符串加一句"序按名册元数据 rank_mode 还原"。 - 单测:`scripts/test_batch29_units.py` 约 298 到 324 行旁加三例:上游 `rank_axis` 为 quality 且开关开时按名次排且立场桶不参与;上游没带 `rank_axis` 时开关开也逐字回按分桶排;`rank_mode` 三种取值各对。若 `run_tests.py` 的文档字符串里写了各批例数,按哨兵改数。 - 台账 016,五段式。对接说明加"前缀效应"一段。 - 上线(选股系统机器,收盘后,经审批):`docker compose --profile sched --profile ws restart -t 30`,重启前用 `docker exec pms-ws date` 看容器时间确认已收盘。 ## 附录丙(可不读):工作包丙实现 文件 `plan_review.py`,行号按 4d7ca55。 - `run`(约 374 行)把 `data = plan.collect(day, top=5000, obs_top=5000, theme_cap=0)` 改成先试快照:路径用 `regime.snapshot_path(day)`,读出的字典里 `main` 与 `observe` 就是全量行,`candidates` 与 `watch` 在顶层。把它整理成与 `collect` 返回同形的字典(`_full` 里放 `main` 与 `observe`),后面的代码一行不改。快照不存在、坏 JSON、或 `main` 不是列表,回落到 `plan.collect`,注记写"底本:现算(无快照)"。 - 开关 `config.PLAN_REVIEW_SOURCE`,默认 snapshot;设 live 时直接走 `plan.collect`。 - 每天的注记里加"底本:快照"或"底本:现算",落进第一节。 - 单测:新文件 `test_plan_review_source.py`,合成一份最小快照写进临时目录,断言读到的 `_full.main` 与 `candidates` 来自快照;删掉文件后断言回落路径被走到(用假的 `plan.collect` 计数)。不连库。 ## 附录丁(可不读):文件清单 工作包甲:`plan.py`、`test_plan_rank_axis.py`、`docs/交接_2026-09-10.md`(四仓库)。 工作包乙:持仓管理系统 `app/services/plan_feed.py`、`app/services/param_store.py`、名册元数据的构造处、`scripts/test_batch29_units.py`、`scripts/run_tests.py`(仅例数)、`docs/复盘决定台账.md`、对接说明;选股系统 `plan_review.py`。 工作包丙:`plan_review.py`、`config.py`、`.env.example`、新 `test_plan_review_source.py`。 清单外一律不动。