文案第四批:计划的环境标签与周报的表头、导读、口径栏

环境标签那行原先印出「环境标签:UNKNOWN,弱势指数 None/8」。三处毛病:UNKNOWN 是
枚举值;8 是硬写的分母,而快照里其实带着真实的指数总数;None 是取不到时的空值。
改成三种情况分开写,并补上一句此前没说的要紧话——这个标签不影响任何一只票买不买,
只在事后复盘时用来分组。不写的话,看到「弱势日」会以为下面的名单被压过一轮。

周报整张表的表头都是英文字段名。翻译放在渲染函数里,每张表都受益。有三处不能想当然
地译:比全池多涨几个点与跑赢全池的占比都跟全池比,但一个是收益差、一个是只数比例,
译成同一个说法会在同一张表里紧挨着撞车;往后看几个交易日不能译成持有天数,脚本开头
反复写明不建仓、不计成本、不是回测。

导读段落原先按英文列名写,表头一改就指向不存在的列,而且里面有一处事实错误:写着
样本纪律三档,代码里只有两档。一并重写并改正。

自评与账本那两栏的口径说明里有库表名与枚举值。改成说清算法本身,特别是"为什么这个
数不能和上一行直接比大小"——那正是并列这两行时最容易读错的地方。

两处会把数据库异常原文印进周报的,改成只进运行日志。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
zlt 2026-09-04 15:00:54 +08:00
parent 7651dbc815
commit 0bf29287a1
3 changed files with 91 additions and 22 deletions

View File

@ -252,5 +252,5 @@
- 顺带接上一个此前从没被渲染过的字段:候选卡的缺失项一路从选股系统带到 PMS 前端,页面上一次都没显示过。现在"关注"态终于说得出到底缺什么。 - 顺带接上一个此前从没被渲染过的字段:候选卡的缺失项一路从选股系统带到 PMS 前端,页面上一次都没显示过。现在"关注"态终于说得出到底缺什么。
- **发现一个环境问题,与本批改动无关但影响核验可信度**`docker compose exec pms-web` 打到的不是服务容器,而是一个 2026-08-03 由 `docker compose run` 留下的孤儿容器(容器名 tradingsystem-pms-web-run-a4f4a87e7662跑了四周、状态 unhealthy、用的是四周前的镜像。它跑的是 scripts/watch.py不接端口、不影响生产但会让 exec 做的核验落在旧代码上。 - **发现一个环境问题,与本批改动无关但影响核验可信度**`docker compose exec pms-web` 打到的不是服务容器,而是一个 2026-08-03 由 `docker compose run` 留下的孤儿容器(容器名 tradingsystem-pms-web-run-a4f4a87e7662跑了四周、状态 unhealthy、用的是四周前的镜像。它跑的是 scripts/watch.py不接端口、不影响生产但会让 exec 做的核验落在旧代码上。
- 识别办法:`docker compose ps -q pms-web` 报的容器 id 与 `docker compose exec -T pms-web hostname` 不一致就是撞上了。核验要紧的读数时用 `docker exec <真实容器 id>` 直接指名。 - 识别办法:`docker compose ps -q pms-web` 报的容器 id 与 `docker compose exec -T pms-web hostname` 不一致就是撞上了。核验要紧的读数时用 `docker exec <真实容器 id>` 直接指名。
- 处置:清掉那个孤儿容器要经用户同意,本次未动 - 处置:09-04 经用户同意后已清除。清除前逐项确认过四条compose 自己标着 oneoff=True一次性命令留下的不是编排里的服务脚本开头写明只读不写实际扫代码零写库零写文件不接任何端口、没有别的容器把它当依赖跑了四周累计占用 7 小时 23 分处理器时间做没人看的事
- 复核日期:清理之后确认 exec 与 ps 报的是同一个容器 - 清除后复验通过exec 与 ps 报的是同一个容器 ac9b9289四个服务容器都在、通道在线心跳 0.9 秒;那个"依赖装不上"的矛盾消失

26
plan.py
View File

@ -112,6 +112,29 @@ def _val(series: pd.Series, k: str):
return None if v is None or pd.isna(v) else float(v) return None if v is None or pd.isna(v) else float(v)
def _fmt_regime(reg) -> str:
"""市场环境那一行。三种情况分开写,因为读的人要做的事不一样。
原先写环境标签UNKNOWN弱势指数 None/8三处毛病UNKNOWN 是枚举值
8 是硬写的分母快照里其实带着真实的指数总数None 是取不到时的空值
三样都是内部表示不该印在给人读的报告里
另外必须写清一件事这个标签**不影响任何一只票买不买**它只在事后复盘时用来
分组不写的话读的人看到弱势日会以为下面的名单被压过一轮
"""
tail = "这个标签只在事后复盘时用来分组,不参与今天选股,也不影响任何一只票买不买。"
if (reg or {}).get("status") != "OK" or reg.get("weak_count") is None:
return ("市场环境:今天没拿到读数(择时决策系统的环境数据当天没送过来)。"
"这里留空不代表市场好或坏,下面的名单照常,不受影响。")
n = reg["weak_count"]
total = reg.get("weak_total")
line = config.REGIME_WEAK_COUNT
scope = f"{total} 个主参考指数里有 {n} 个走弱" if total else f"{n} 个主参考指数走弱"
if reg.get("weak_day"):
return f"市场环境:{scope}。走弱满 {line} 个就算弱势日,今天算。{tail}"
return f"市场环境:{scope},没到 {line} 个,今天不算弱势日。{tail}"
def _state_out(st) -> dict | None: def _state_out(st) -> dict | None:
"""逻辑状态发给下游的形状:只留人要读的和判决要用的,不发内部中间量。 """逻辑状态发给下游的形状:只留人要读的和判决要用的,不发内部中间量。
@ -630,8 +653,7 @@ def render_md(d: dict) -> str:
L.append("") L.append("")
reg = d.get("regime") reg = d.get("regime")
if reg: if reg:
L.append(f"环境标签:{reg.get('status')},弱势指数 {reg.get('weak_count')}/8" L.append(_fmt_regime(reg))
f"{',弱势日' if reg.get('weak_day') else ''}(只展示与复盘分组,不作交易前置)。")
L.append("") L.append("")
mk = d.get("market") mk = d.get("market")
if mk: if mk:

View File

@ -125,7 +125,9 @@ def pms_roster(day: str) -> tuple[list[str], str]:
n_df = db.read_mysql( n_df = db.read_mysql(
"pms", "SELECT param_value FROM pms_runtime_param WHERE param_key='PMS_PLAN_TOP_N'") "pms", "SELECT param_value FROM pms_runtime_param WHERE param_key='PMS_PLAN_TOP_N'")
except Exception as e: # noqa: BLE001 except Exception as e: # noqa: BLE001
return [], f"PMS 快照读取失败({e!r}),生产名单退为桥重算" # 异常原文会被印进周报的逐日注记。只进日志,不进报告。
print(f" PMS 计划快照读取失败: {e!r}")
return [], "当天没能连上 PMS 数据库,取不到计划快照"
if df.empty: if df.empty:
return [], "PMS 无当日快照,生产名单退为桥重算" return [], "PMS 无当日快照,生产名单退为桥重算"
n = int(n_df.iloc[0, 0]) if not n_df.empty else 30 n = int(n_df.iloc[0, 0]) if not n_df.empty else 30
@ -196,10 +198,15 @@ def timing_self_eval(since: str, until: str | None) -> str:
n = int(df.iloc[0]["n"] or 0) if not df.empty else 0 n = int(df.iloc[0]["n"] or 0) if not df.empty else 0
hits = int(df.iloc[0]["hits"] or 0) if not df.empty else 0 hits = int(df.iloc[0]["hits"] or 0) if not df.empty else 0
except Exception as e: # noqa: BLE001 except Exception as e: # noqa: BLE001
return f"未接入decision_outcome 读取失败:{type(e).__name__}" # 表名是 PMS 的库表名,不该出现在周报的读数列里。原因写进运行日志。
print(f" (择时决策系统的打分表读取失败: {e!r}")
return (f"读不到择时决策系统的打分数据,"
f"{since}{until or '今日'} 这段的五日方向命中率这期算不出来")
if n == 0: if n == 0:
return "未接入(区间内无已判分的日终策略行)" # 与"读不到"分开写:这两种情况在表里要一眼看得出差别。
return f"五日方向命中 {hits}/{n} = {hits / n * 100:.0f}%(基准日 {since}{until or '今日'}" return "择时决策系统这段时间没有打过分的日终策略记录,这期没有命中率"
return (f"看 5 个交易日后涨跌方向判断对不对,{n} 条里对了 {hits} 条,"
f"命中率 {hits / n * 100:.0f}%{since}{until or '今日'}")
def decision_ledger_entries(path: str = DECISION_LEDGER_PATH) -> list[dict]: def decision_ledger_entries(path: str = DECISION_LEDGER_PATH) -> list[dict]:
@ -230,11 +237,40 @@ def forward(pivot: pd.DataFrame, days: list[str], day: str, h: int, start: str)
return block.sum(min_count=h) return block.sum(min_count=h)
# 表头的中文说法。周报是给人读的,列名不该是代码里的字段名。
# 有几处必须区分开,不能想当然地译:
# excess_all 与 beat 都跟"全池"比,但一个是收益差、一个是只数比例,
# 译成同一个说法会在同一张表里紧挨着撞车;
# h 不能译成"持有天数"——这个脚本开头反复写明不建仓、不计成本、不是回测,
# 它只是"往后看几个交易日"
# grade 只有两档(可读、方向),代码在 :387导读段落此前写着"三档"是错的。
_COL_CN = {
"days": "一共几个计划日", "n": "一共几只股票", "ret": "名单平均涨跌%",
"excess_all": "比全池多涨几个点", "excess_main": "比主榜多涨几个点",
"excess_cap": "比同市值股票多涨几个点",
"beat": "跑赢全池的股票占比%", "share": "最多的一天占了多少%",
"grade": "样本够不够看", "h": "往后看几个交易日",
"list": "名单", "group": "分组", "date": "计划日",
"regime_post": "这几天全池是涨是跌", "regime_pre": "下单当天是不是弱势日",
}
def _col_cn(c) -> str:
"""列名翻成中文。带 _w 后缀的是同一个读数的样本加权版本,只用于对账。"""
name = str(c)
if name.endswith("_w"):
base = _COL_CN.get(name[:-2])
return f"股票多的日子算得重·{base}" if base else name
return _COL_CN.get(name, name)
def _md(df: pd.DataFrame) -> str: def _md(df: pd.DataFrame) -> str:
"""自己拼 Markdown 表:桥镜像没装 tabulatepandas.to_markdown 用不了。""" """自己拼 Markdown 表:桥镜像没装 tabulatepandas.to_markdown 用不了。
表头统一在这里翻成中文每张表都受益不用逐处改"""
if df is None or df.empty: if df is None or df.empty:
return "(无数据)" return "(无数据)"
cols = [str(c) for c in df.columns] cols = [_col_cn(c) for c in df.columns]
lines = ["| " + " | ".join(cols) + " |", "|" + "---|" * len(cols)] lines = ["| " + " | ".join(cols) + " |", "|" + "---|" * len(cols)]
for _, r in df.iterrows(): for _, r in df.iterrows():
lines.append("| " + " | ".join("" if (isinstance(v, float) and pd.isna(v)) else str(v) lines.append("| " + " | ".join("" if (isinstance(v, float) and pd.isna(v)) else str(v)
@ -384,7 +420,7 @@ def run(since: str, until: str | None, horizons: tuple, start: str, out_dir: str
w = n / n.sum() if n.sum() else n w = n / n.sum() if n.sum() else n
days, total = g["date"].nunique(), int(n.sum()) days, total = g["date"].nunique(), int(n.sum())
# 样本纪律(方案 2.4)机器标注,只有两档:可读=计划日≥20 且样本≥100其余只看方向 # 样本纪律(方案 2.4)机器标注,只有两档:可读=计划日≥20 且样本≥100其余只看方向
grade = "可读" if (days >= 20 and total >= 100) else "方向" grade = "" if (days >= 20 and total >= 100) else "不够,只看方向"
return pd.Series({"days": days, "n": total, return pd.Series({"days": days, "n": total,
"ret": round(g["ret"].mean(), 2), "ret": round(g["ret"].mean(), 2),
"excess_all": round(g["excess_all"].mean(), 2), "excess_all": round(g["excess_all"].mean(), 2),
@ -406,14 +442,21 @@ def run(since: str, until: str | None, horizons: tuple, start: str, out_dir: str
[["date", "n", "ret", "excess_all", "excess_cap", "beat", "regime_post"]].sort_values("date") [["date", "n", "ret", "excess_all", "excess_cap", "beat", "regime_post"]].sort_values("date")
md = [f"# 候选单复盘 · {days_plan[0]}{stamp}(起点价 {start}", "", md = [f"# 候选单复盘 · {days_plan[0]}{stamp}(起点价 {start}", "",
f"计划日 {len(days_plan)} 期;样本纪律:分组样本少于一百只或覆盖计划日少于二十个只看方向;" f"这份周报覆盖 {len(days_plan)} 个计划日。"
f"口径切换的总样本少于六十个计划日只写方向。", "", f"一份名单要够二十个计划日、且总共够一百只股票,读数才算够看;"
f"不够的只看方向,不看具体数值。", "",
"## 一、六份名单 × 期限", "", "## 一、六份名单 × 期限", "",
"列的读法excess_all 相对全池等权excess_main 相对主榜等权(剥掉大票对小票的贝塔)," "怎么读这张表。"
"excess_cap 相对同市值桶(成交额除以换手率三分位,方案 1.8c 口径beat 是跑赢全池比例," "「比全池多涨几个点」是拿这份名单和当天全部股票的平均涨跌相比;"
"全池自己的 beat 只有四成多分布右偏读它时与全池等权那一行相减share 是最大单日样本占比" "「比主榜多涨几个点」是和主榜相比,这样能去掉大盘股和小盘股整体差异的影响;"
"超过三成说明读数由一天主导只看逐日表grade 是样本纪律三档。" "「比同市值股票多涨几个点」是只和市值相近的股票比。"
"不带后缀的列按计划日等权(主口径),带 _w 的列按样本加权,只用于与方案第一节手工读数对账。", "", "「跑赢全池的股票占比」和上面几列不是一回事:上面是收益差,这一列是只数比例。"
"全池自己这一行的占比只有四成多,因为少数大涨的股票把平均值拉高了;"
"所以读别的名单时要拿它和全池那一行相减,不能直接看绝对值。"
"「最多的一天占了多少」超过三成,说明整个读数被一天主导,那就只看逐日那张表。"
"「往后看几个交易日」不是持有天数——这个脚本不建仓、不计成本,不是回测。"
"不带前缀的列按计划日等权,一天算一票,这是主口径;"
"带「股票多的日子算得重」前缀的列是另一种算法,只用来和手工读数对账。", "",
_md(lists_tbl), "", _md(lists_tbl), "",
"## 二、分组读数", "", _md(groups_tbl), "", "## 二、分组读数", "", _md(groups_tbl), "",
f"## 二之二、候选单逐日(期限 {h0} 日;第一节按日等权的读数就是这张表的平均,看集中度)", "", f"## 二之二、候选单逐日(期限 {h0} 日;第一节按日等权的读数就是这张表的平均,看集中度)", "",
@ -449,7 +492,7 @@ def _list_reading(lists_tbl: pd.DataFrame, name: str) -> str:
parts = [] parts = []
for _, r in sub.iterrows(): for _, r in sub.iterrows():
parts.append(f"{int(r['h'])} 日:计划日 {int(r['days'])},样本 {int(r['n'])}" parts.append(f"{int(r['h'])} 日:计划日 {int(r['days'])},样本 {int(r['n'])}"
f"超额(全池){r['excess_all']:+.2f},跑赢 {r['beat']:.1f}%纪律 {r['grade']}") f"比全池多涨 {r['excess_all']:+.2f} 个点,跑赢全池的占 {r['beat']:.1f}%样本{r['grade']}")
return "".join(parts) return "".join(parts)
@ -460,11 +503,15 @@ def compare_table(lists_tbl: pd.DataFrame, since: str, until: str | None) -> pd.
{"系统": "选股系统", "口径": "候选单按计划日等权,相对全池等权超额与跑赢比例(第一节主口径)", {"系统": "选股系统", "口径": "候选单按计划日等权,相对全池等权超额与跑赢比例(第一节主口径)",
"读数": _list_reading(lists_tbl, "候选单")}, "读数": _list_reading(lists_tbl, "候选单")},
{"系统": "择时决策系统", {"系统": "择时决策系统",
"口径": "自评:判分表 decision_outcome 日终策略五日方向命中率,按原始收益方向判、非超额(其判分脚本口径)", "口径": "择时决策系统自己打的分。每个交易日收盘后给出的策略,看 5 个交易日后涨跌"
"方向判断对不对,统计判对的比例。只看股票自己的涨跌,没有减去大盘涨跌,"
"与上一行候选单的超额口径不同,两个数字不能直接比大小。",
"读数": timing_self_eval(since, until)}, "读数": timing_self_eval(since, until)},
{"系统": "PMS 账本", {"系统": "PMS 账本",
"口径": "动作账本当日 OPEN 行机器通过judge PASS、人批user PASS、人拒user REJECT" "口径": "这一行是 PMS 当天的新开仓审批记录,拆成三份名单:系统自动放行的、"
"另列择时看多(结论表 BUY四份名单与候选单同口径算收益", "人工同意的、人工否决的。否决的也照样算收益,用来看当初拒得对不对。"
"另外单列一份择时决策系统当天看多的股票。这四份名单的买入时点、"
"往后看几天、跟谁比,都和候选单完全一样,可以直接跟候选单那一行对着看。",
"读数": "".join(f"{name}{_list_reading(lists_tbl, name)}" "读数": "".join(f"{name}{_list_reading(lists_tbl, name)}"
for name in (*LEDGER_LISTS, TIMING_LIST))}, for name in (*LEDGER_LISTS, TIMING_LIST))},
] ]