diff --git a/docs/复盘决定台账.md b/docs/复盘决定台账.md index 8ad0e4e..5a9b250 100644 --- a/docs/复盘决定台账.md +++ b/docs/复盘决定台账.md @@ -252,5 +252,5 @@ - 顺带接上一个此前从没被渲染过的字段:候选卡的缺失项一路从选股系统带到 PMS 前端,页面上一次都没显示过。现在"关注"态终于说得出到底缺什么。 - **发现一个环境问题,与本批改动无关但影响核验可信度**:`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>` 直接指名。 -- 处置:清掉那个孤儿容器要经用户同意,本次未动。 -- 复核日期:清理之后确认 exec 与 ps 报的是同一个容器。 +- 处置:09-04 经用户同意后已清除。清除前逐项确认过四条:compose 自己标着 oneoff=True(一次性命令留下的,不是编排里的服务);脚本开头写明只读不写,实际扫代码零写库零写文件;不接任何端口、没有别的容器把它当依赖;跑了四周累计占用 7 小时 23 分处理器时间做没人看的事。 +- 清除后复验通过:exec 与 ps 报的是同一个容器 ac9b9289;四个服务容器都在、通道在线心跳 0.9 秒;那个"依赖装不上"的矛盾消失。 diff --git a/plan.py b/plan.py index 398416e..6f44689 100644 --- a/plan.py +++ b/plan.py @@ -112,6 +112,29 @@ def _val(series: pd.Series, k: str): 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: """逻辑状态发给下游的形状:只留人要读的和判决要用的,不发内部中间量。 @@ -630,8 +653,7 @@ def render_md(d: dict) -> str: L.append("") reg = d.get("regime") if reg: - L.append(f"环境标签:{reg.get('status')},弱势指数 {reg.get('weak_count')}/8" - f"{',弱势日' if reg.get('weak_day') else ''}(只展示与复盘分组,不作交易前置)。") + L.append(_fmt_regime(reg)) L.append("") mk = d.get("market") if mk: diff --git a/plan_review.py b/plan_review.py index d650a0c..2cd5795 100644 --- a/plan_review.py +++ b/plan_review.py @@ -125,7 +125,9 @@ def pms_roster(day: str) -> tuple[list[str], str]: n_df = db.read_mysql( "pms", "SELECT param_value FROM pms_runtime_param WHERE param_key='PMS_PLAN_TOP_N'") except Exception as e: # noqa: BLE001 - return [], f"PMS 快照读取失败({e!r}),生产名单退为桥重算" + # 异常原文会被印进周报的逐日注记。只进日志,不进报告。 + print(f" (PMS 计划快照读取失败: {e!r})") + return [], "当天没能连上 PMS 数据库,取不到计划快照" if df.empty: return [], "PMS 无当日快照,生产名单退为桥重算" 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 hits = int(df.iloc[0]["hits"] or 0) if not df.empty else 0 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: - 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]: @@ -230,11 +237,40 @@ def forward(pivot: pd.DataFrame, days: list[str], day: str, h: int, start: str) 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: - """自己拼 Markdown 表:桥镜像没装 tabulate,pandas.to_markdown 用不了。""" + """自己拼 Markdown 表:桥镜像没装 tabulate,pandas.to_markdown 用不了。 + + 表头统一在这里翻成中文,每张表都受益,不用逐处改。""" if df is None or df.empty: return "(无数据)" - cols = [str(c) for c in df.columns] + cols = [_col_cn(c) for c in df.columns] lines = ["| " + " | ".join(cols) + " |", "|" + "---|" * len(cols)] for _, r in df.iterrows(): 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 days, total = g["date"].nunique(), int(n.sum()) # 样本纪律(方案 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, "ret": round(g["ret"].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") md = [f"# 候选单复盘 · {days_plan[0]} 至 {stamp}(起点价 {start})", "", - f"计划日 {len(days_plan)} 期;样本纪律:分组样本少于一百只或覆盖计划日少于二十个只看方向;" - f"口径切换的总样本少于六十个计划日只写方向。", "", + f"这份周报覆盖 {len(days_plan)} 个计划日。" + f"一份名单要够二十个计划日、且总共够一百只股票,读数才算够看;" + f"不够的只看方向,不看具体数值。", "", "## 一、六份名单 × 期限", "", - "列的读法:excess_all 相对全池等权,excess_main 相对主榜等权(剥掉大票对小票的贝塔)," - "excess_cap 相对同市值桶(成交额除以换手率三分位,方案 1.8c 口径);beat 是跑赢全池比例," - "全池自己的 beat 只有四成多(分布右偏),读它时与全池等权那一行相减;share 是最大单日样本占比" - "(超过三成说明读数由一天主导,只看逐日表);grade 是样本纪律三档。" - "不带后缀的列按计划日等权(主口径),带 _w 的列按样本加权,只用于与方案第一节手工读数对账。", "", + "怎么读这张表。" + "「比全池多涨几个点」是拿这份名单和当天全部股票的平均涨跌相比;" + "「比主榜多涨几个点」是和主榜相比,这样能去掉大盘股和小盘股整体差异的影响;" + "「比同市值股票多涨几个点」是只和市值相近的股票比。" + "「跑赢全池的股票占比」和上面几列不是一回事:上面是收益差,这一列是只数比例。" + "全池自己这一行的占比只有四成多,因为少数大涨的股票把平均值拉高了;" + "所以读别的名单时要拿它和全池那一行相减,不能直接看绝对值。" + "「最多的一天占了多少」超过三成,说明整个读数被一天主导,那就只看逐日那张表。" + "「往后看几个交易日」不是持有天数——这个脚本不建仓、不计成本,不是回测。" + "不带前缀的列按计划日等权,一天算一票,这是主口径;" + "带「股票多的日子算得重」前缀的列是另一种算法,只用来和手工读数对账。", "", _md(lists_tbl), "", "## 二、分组读数", "", _md(groups_tbl), "", f"## 二之二、候选单逐日(期限 {h0} 日;第一节按日等权的读数就是这张表的平均,看集中度)", "", @@ -449,7 +492,7 @@ def _list_reading(lists_tbl: pd.DataFrame, name: str) -> str: parts = [] for _, r in sub.iterrows(): 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) @@ -460,11 +503,15 @@ def compare_table(lists_tbl: pd.DataFrame, since: str, until: str | None) -> pd. {"系统": "选股系统", "口径": "候选单按计划日等权,相对全池等权超额与跑赢比例(第一节主口径)", "读数": _list_reading(lists_tbl, "候选单")}, {"系统": "择时决策系统", - "口径": "自评:判分表 decision_outcome 日终策略五日方向命中率,按原始收益方向判、非超额(其判分脚本口径)", + "口径": "择时决策系统自己打的分。每个交易日收盘后给出的策略,看 5 个交易日后涨跌" + "方向判断对不对,统计判对的比例。只看股票自己的涨跌,没有减去大盘涨跌," + "与上一行候选单的超额口径不同,两个数字不能直接比大小。", "读数": timing_self_eval(since, until)}, {"系统": "PMS 账本", - "口径": "动作账本当日 OPEN 行:机器通过(judge PASS)、人批(user PASS)、人拒(user REJECT);" - "另列择时看多(结论表 BUY);四份名单与候选单同口径算收益", + "口径": "这一行是 PMS 当天的新开仓审批记录,拆成三份名单:系统自动放行的、" + "人工同意的、人工否决的。否决的也照样算收益,用来看当初拒得对不对。" + "另外单列一份择时决策系统当天看多的股票。这四份名单的买入时点、" + "往后看几天、跟谁比,都和候选单完全一样,可以直接跟候选单那一行对着看。", "读数": ";".join(f"{name}—{_list_reading(lists_tbl, name)}" for name in (*LEDGER_LISTS, TIMING_LIST))}, ]