# A股个股打分机制说明
## —— 晨星《投资的护城河》打分体系 × 巴菲特投资理念 的融合与A股落地

> 本文是「任务2」的交付物：详细解读《投资的护城河——晨星公司解密巴菲特股市投资法则》（希瑟·布里兰特、伊丽莎白·柯林斯 著，人民邮电出版社2016年版）中提到的打分机制，与巴菲特的投资理念相互融合，优化成一套在A股能够落地的个股打分机制。
>
> 配套交付物：
> - 打分引擎代码：`engine/config.py`（权重与阈值）、`engine/features.py`（特征工程）、`engine/scoring.py`（打分与估值）、`engine/dashboard.py`（看板）
> - 一键日更脚本：`update_daily.py`
> - 看板：`out/dashboard/index.html`
> - 全量评分结果：`out/dashboard/ranking.csv`

---

## 目录

1. [为什么要做融合：两套体系各自的缺口](#一)
2. [晨星打分机制的完整解读（原文机制）](#二)
3. [巴菲特投资理念的量化转译](#三)
4. [融合后的A股五维打分体系](#四)
5. [护城河评级 / 不确定性评级 / 公允价值 / 星级](#五)
6. [A股特殊性的逐条处理](#六)
7. [tushare字段到指标的映射表](#七)
8. [与巴菲特原文口径的差异（必须明示）](#八)
9. [多指数支持](#八点五)（含 9.3–9.26 各轮自检、审计、扩展、视觉重做、更新绑定、风险提示、可视化/比例/弹窗数据修复、首页落地页重做、首页四项优化、「19:00 未自动刷新」根因修复、公网发布、「只弹黑框」误报修复与部署中途状态说明、改名/微信登入/网站后台/个股点击引导、右上角登入与小程序出码实测、登入入口撤下、任务被禁用、发布包不同步与自启动清理、详情浮窗动画试验（已回退）、智能应用控制拦截 parquet 的根因与 polars 绕过记录）
10. [已知局限与改进方向](#十)

---

<a id="一"></a>
## 一、为什么要做融合：两套体系各自的缺口

晨星体系是一个**可操作的、半定量的股票研究流程**：先判断公司是否拥有经济护城河（竞争优势能否长期抵御竞争），再用三阶段贴现现金流估算公允价值，最后用「不确定性评级」决定需要多大的安全边际，据此给出 1~5 星。

巴菲特理念是这套体系的**思想源头**（晨星在书中明确致谢巴菲特 1999 年《财富》杂志那篇关于护城河的文章），但它本身更偏定性：能力圈、好生意、好管理层、好价格、长期持有。两套体系互补：

| 缺口 | 晨星体系 | 巴菲特理念 |
|---|---|---|
| 护城河的**来源**判断 | 给出五大来源与关键问题清单，偏定性 | 强调「持久竞争优势」，但很少给检查表 |
| 管理层 | 单独的管理评级（合格/模范/糟糕），**明确不进入护城河根基** | 管理层三品质是核心，甚至是一票否决项 |
| 估值 | 完整的三阶段 DCF + 星级门槛 | 内在价值 + 安全边际，方法更宽泛 |
| 组合纪律 | 护城河聚焦指数给出可执行的再平衡规则 | 集中持有、换手极低、永久持有 |
| **A股适配** | 无 | 无 |

因此融合的路线是：**以晨星的三分法为骨架，把巴菲特的原则补进「管理层 / 资本配置 / 能力圈 / 排雷」这几块，再把两者共同依赖的财务判据全部换成A股数据可算的口径，最后加一层A股特有的负面清单。**

---

<a id="二"></a>
## 二、晨星打分机制的完整解读（原文机制）

### 2.1 体系总览

晨星用三项要素回答两个问题：

- **问题1：如何识别伟大的公司？** → 寻找拥有**可持续竞争优势（经济护城河）**的公司。
- **问题2：何时是投资伟大公司的最佳时机？** → 在**价格低于内在价值**、且留有**与不确定性相称的安全边际**时。

体系构成（书中图6-6）：**基本面分析 → ①经济护城河评级 ②公允价值估计 ③不确定性评估 → 星级评定**。书中原话称这是「前瞻性的、经不确定性调整后的投资评级」。

### 2.2 护城河评级：绝对标准

评级前先问两个问题：

1. 未来公司的 **ROIC 是否有可能超过其 WACC**（即是否创造经济利润）？
2. 公司是否**至少具备五大可持续竞争优势之一**？

满足后再看**持续期**（这是最关键的一步）：

- **狭窄护城河**：确信经济利润**至少持续 10 年**。
- **宽阔护城河**：确信经济利润**至少持续 20 年**（且前 10 年近乎确定）。书中指出全球上市公司中获得宽阔评级的**不到 200 家**。

三条重要的方法论规则：

- **差额的重要性远不及持续期**。一家能把中度经济利润维持 20 年的公司，优于一家只有几年极高 ROIC 的公司。
- **必须在「周期中部」考察**。只在行业高峰产生强劲 ROIC 的公司不作候选；因谷底或一次性因素当前不盈利，也不排除可获评级。
- **评级是绝对而非相对的**。「行业最佳」≠ 拥有护城河；有些行业根本不存在有可持续竞争优势的公司。

### 2.3 护城河的五大来源（附书中关键问题）

| # | 来源 | 核心判据 | 书中关键问题（摘要） |
|---|---|---|---|
| 1 | **无形资产**（品牌/专利/牌照） | 能否带来**定价能力**，且不被高成本抵消 | 如何量化品牌实力？相对对手能收多高溢价？高定价是否被高成本抵消？利润率与无品牌对手差多少？凭什么相信未来10~20年可持续？ |
| 2 | **成本优势**（含规模经济） | 能否以更低成本提供产品或服务 | 成本优势来自何处（规模、工艺、区位、资源）？是否可被复制？规模经济的天花板在哪？ |
| 3 | **转换成本** | 客户转换是否产生不便或费用 | 客户转换要付什么代价？合同期限与条款？定制化程度？技术冲击会不会抹平转换成本？ |
| 4 | **网络效应** | 产品或服务的价值是否随用户增加而提升 | 用户参与度是否形成赢家通吃？是否存在多归属（multi-homing）？网络能否被拆分？ |
| 5 | **有效规模** | 市场规模是否只容得下一家或少数几家有效率地服务 | 潜在进入者是否有动力进入？新建产能需要多少资本？监管是否构成壁垒？ |

书中明确：**管理本身不能成为经济护城河的根基**——管理可以在护城河的加固或毁坏上发挥作用，但护城河必须由公司本身的结构化因素构成。资本配置失误会导致下调护城河评级或给出差的管理评级。

### 2.4 护城河趋势：独立于评级的第二维

护城河趋势衡量**竞争优势的变化方向**，分**正面 / 稳定 / 负面**三档。书中给出的**定量清单只有 5 项**：

1. 市场份额趋势
2. 营业利润率相对于周期中值的位置
3. **边际 ROIC 与当前 ROIC 的比较**
4. 行业利润占全部工业利润的比重
5. 价格趋势（含机会/威胁与壁垒的判断）

几条反直觉规则：ROIC 上升 ≠ 正面趋势（可能是产品周期、结构变化，甚至是「收割」式地削减投资）；ROIC 下降 ≠ 负面趋势（可能是为长期护城河做的资本开支或研发投入）；收入增长与趋势无关；业务组合变化不影响趋势评级（但会影响护城河评级）。

书中给出 2013 年中的样本分布：负面趋势约 16%、正面趋势约 8%、宽阔护城河约 13%——**正面趋势比宽阔护城河更稀有**。

### 2.5 公允价值：整合了护城河的三阶段 DCF

**ROIC 的定义**（书中第6章）：

```
ROIC = EBI / 平均投资资本
EBI  = 净利润 + 利息费用 × (1 − 税率)      （即扣除现金税款后的息前盈利）
投资资本 IC = 经营资产 − 经营负债
```

**WACC**：

```
WACC = Wd × COD × (1 − 税率) + We × COE
COE  = 无风险利率 + β × 股权风险溢价   （晨星在发达市场取 8%~14%）
```

**三阶段 DCF**（这是晨星区别于普通 DCF 的地方）：

| 阶段 | 内容 |
|---|---|
| 阶段1 | 明确预测期，5~10 年，逐年预测收入、盈利、资产负债表与现金产出 |
| 阶段2 | **超额收益被竞争侵蚀**。阶段长度由护城河决定：**宽阔 ≥20 年、狭窄 ≥10 年**（书中表述为阶段2时长取决于竞争优势） |
| 阶段3 | 永续期，假设 **RONIC = WACC**（超额收益已消失），以终值作为现金流代理直至永远 |

自由现金流 = EBI − 净新增投资（资本支出 + 营运资本净投资 + 收购 − 折旧 − 资产剥离）。

```
公允价值 = Σ(阶段1~3 现金流现值) − 净债务
每股公允价值 = 公允价值 / 发行股数
```

书中还强调：明确预测期最后一年的数值必须处于**「正常状况」或「周期中间」**，因为它是终值的基础。

**倍数法的定位**：书中不否定 P/E、P/B、P/S 等倍数，但只作为 DCF 的**交叉验证**，不用它来确定公允价值。

### 2.6 不确定性评级与星级门槛（原文数字）

不确定性分五档：**低度 / 中度 / 高度 / 非常高度 / 极端高度**。它衡量「未来现金流预测的难度」，考察三类风险：

- **系统风险**：公司收益相对整体经济的贝塔系数、经营杠杆、财务杠杆
- **公司特有风险**：监管变动、大的牌照/合同/专利到期、高度集中的顾客基础、竞争变化、被分裂或去中介化的风险、动荡市场中的价格接受者、不可预知的管理、有问题的资本配置
- **权益成本**：所有不包含在权益成本模型假设中的因素

**买入（5★）所需折价**：

| 不确定性 | 5★ 所需折价 |
|---|---|
| 低度 | **20%** |
| 中度 | **30%** |
| 高度 | **40%** |
| 非常高度 | **50%** |
| 极端高度 | 进一步加大，只确定一个「象征性」的公允价值，**通常不会买入** |

**卖出（1★）触发溢价**：

| 不确定性 | 1★ 触发价高于公允价值 |
|---|---|
| 低度 | **25%** |
| 中度 | **35%** |
| 高度 | **55%** |
| 非常高度 | **75%** |

书中还规定情景分析至少三种（基本 / 牛市 / 熊市），牛熊情景的概率各按 **25%** 处理。

### 2.7 护城河聚焦指数与实证结论（最值得注意的部分）

**宽阔护城河聚焦指数**的规则：从约 130 只宽阔护城河美股中，**只按「价格相对公允价值的折价幅度」排序，取折价最深的 20 只**，等权，按季再平衡。**不考虑护城河趋势、管理能力与不确定性**。回测期年均收益 **15.9%**，同期标普 500 为 **8.8%**；年换手 120%~150%。

但书中的实证章节给出了一个**极其重要的限制条件**：

- 按护城河评级分组的**月均收益三组都是 1.0%**（无差异）；
- 但**标准差**差异巨大：无护城河组约 13.2，宽阔护城河组约 6.9（**接近两倍**）；
- 月均 alpha：宽阔 +0.1%、狭窄 0.0%、无 −0.1%（**宽阔相对无护城河每月只有约 20bp**）；β 分别为 0.8 / 0.9 / 1.1；
- 估值十分位与收益的 R²：宽阔护城河组 0.84，无护城河组不到其一半；
- 2008 年危机期，宽阔护城河相对无护城河的溢价**大幅转正**。

**结论：单靠宽阔护城河不足以获得风险调整后的超额收益，宽阔护城河 + 低估才有效。** 护城河主要是一个**风险管理工具与选股过滤器**，而不是全天候的 alpha 来源。

这条结论直接决定了本项目的权重设计：**估值维度必须占足够大的权重（20/100），且「护城河强 + 估值贵」不能拿高分。**

---

<a id="三"></a>
## 三、巴菲特投资理念的量化转译

> 本节的完整证据链（原话、时间戳、股东信年份）见 `out/01_巴菲特投资逻辑_可量化版.md`。此处只列转译结果。

| 巴菲特原则 | 量化转译 | 进入打分体系的维度 |
|---|---|---|
| 能力圈：只投看得懂的生意 | ①业务集中度（主营构成是否单一清晰）②商业模式可验证性（不依赖未盈利的新故事）③上市时间与可追溯盈利历史长度 | A4 / A5 / 排雷（历史数据不足扣分） |
| 持久竞争优势（护城河） | ROIC−WACC 经济利润溢价的**水平 × 持续性**；毛利率/净利率的**水平 × 稳定性 × 行业相对位置** | A1 / A2 / A3 |
| 好生意：高资本回报、低资本消耗 | ROIC、ROE、边际 ROIC（RONIC）、资本开支强度、三费费用率、资产周转率 | A1 / A3 |
| 定价能力 | 毛利率水平、毛利率波动、净利率、销售费用率（品牌是否需要持续高投入来买收入） | A2 |
| 所有者收益（owner earnings） | 经营现金流、自由现金流、自由现金流/净利润的多年累计比值 | C2 / E3 |
| 拒绝杠杆 | 资产负债率、净负债/EBITDA、利息保障倍数、有息负债结构 | C1 / 排雷 |
| 管理层：诚信、能力、以股东利益为先 | 审计意见、分红连续性与支付率、股本稀释、大股东持股与股东户数变化、资本配置结果（ROE 趋势 vs EPS 趋势） | C4 / D1~D4 / 排雷 |
| 留存收益测试（每留存 1 美元至少创造 1 美元市值） | 留存收益带来的盈利增量（ROE 变化）与每股收益增长；配合股息率与回购 | D4 |
| 回购只在低于内在价值时才有意义 | 回购行为 + 当前 P/FV（在 E 维度中体现为「贵的时候回购不加分」） | D2 / E2 |
| 安全边际：只在明显低于内在价值时买入 | P/FV + 不确定性所需折价；E 维度以自身历史分位 + 横截面折价为主 | E1 / E2 |
| 长期持有、极低换手 | 打分体系天然倾向「可长期持有」的公司；看板同时输出「换手提示」 | 全体系 |
| 「别人贪婪我恐惧」 | 以指数整体 P/FV 中位数作为「市场温度」指标并做估值中性化 | E / 星级 |
| 他明确批评的东西 → 直接变成扣分规则 | 非标审计意见、高质押、高商誉、利润与现金流长期背离、应收激增、股本大幅稀释、ST、高杠杆 | 排雷层 |
| 他不投的东西 | 无法形成稳定盈利的新兴模式（A股对应：主业频繁变更、商誉驱动型并购、纯概念）；航空等价格接受者行业（行业先验分低） | A5 / 行业先验 |

---

<a id="四"></a>
## 四、融合后的A股五维打分体系

### 4.1 总览

综合得分满分 **100 分**，另有最多 **30 分**的A股「排雷」扣分。

| 维度 | 满分 | 对应来源 |
|---|---|---|
| **A 护城河强度** | 38 | 晨星护城河五大来源 + 巴菲特的持久竞争优势 |
| **B 护城河趋势** | 10 | 晨星护城河趋势的 5 项定量清单 |
| **C 财务健康与安全** | 18 | 巴菲特的拒绝杠杆 + 所有者收益 |
| **D 管理层与资本配置** | 14 | 巴菲特的管理层三品质 + 留存收益测试 + 晨星管理评级 |
| **E 估值与安全边际** | 20 | 晨星公允价值 + 星级门槛 + 巴菲特安全边际 |
| **排雷扣分** | −30（上限） | A股特有 |

```
综合得分 = A + B + C + D + E − 排雷扣分   （截断到 0~100）
```

**每个子项都先归一化到 0~1，再乘以子项权重**；某个子项数据缺失时，其权重会在同一维度内**重新归一化**（即缺失不会自动扣分，只会降低该维度的置信度，看板中体现为数据完整度）。

### 4.2 逐项定义与量化工式

#### A 护城河强度（38 分）

**A1 资本回报率与经济利润（12 分）** —— 对应晨星「ROIC−WACC 创造经济利润」与「必须周期中部考察」

| 分项 | 权重 | 量化工式 | 打分区间 |
|---|---|---|---|
| 经济利润溢价 | 0.35 | `roic_avg5/100 − WACC` | −2% → 0 分，+15% → 满分 |
| ROE 水平 | 0.22 | 5 年平均 ROE | 4% → 0，25% → 满分 |
| ROIC 稳定性 | 0.18 | 5 年 ROIC 标准差（负向） | 12 → 0，1.5 → 满分 |
| ROIC 最差值 | 0.12 | 5 年最低 ROIC | 0 → 0，12% → 满分 |
| 数据年限 | 0.13 | 可用于打分的年报年数 | 2 年 → 0，8 年 → 满分 |

> 「周期中部」的处理：用**5 年均值**而非单年值，并且刻意引入「5 年最低 ROIC」这一项——只在行业高峰表现好的公司会在这一项上失分。

**A2 定价能力 / 无形资产（8 分）** —— 对应晨星「品牌必须能转化为定价能力，且不被高成本抵消」

| 分项 | 权重 | 量化工式 | 打分区间 |
|---|---|---|---|
| 毛利率水平 | 0.28 | 5 年平均毛利率 | 8% → 0，55% → 满分 |
| 毛利率稳定性 | 0.18 | 5 年毛利率标准差（负向） | 9 → 0，0.8 → 满分 |
| 净利率水平 | 0.24 | 5 年平均净利率 | −2% → 0，22% → 满分 |
| 相对行业毛利率 | 0.20 | 毛利率 − 同行业中位数 | −5pp → 0，+20pp → 满分 |
| 销售费用率 | 0.10 | 5 年平均销售费用率（负向） | 30% → 0，3% → 满分 |

> 最后一项是关键的「反品牌」检验：真正的品牌不需要靠持续高额的销售费用买收入。

**A3 成本优势与规模经济（7 分）**

| 分项 | 权重 | 量化工式 | 打分区间 |
|---|---|---|---|
| 相对行业净利率 | 0.26 | 净利率 − 同行业中位数 | −6pp → 0，+14pp → 满分 |
| 三费费用率 | 0.20 | 5 年平均（销售+管理+财务费用率） | 45% → 0，8% → 满分 |
| 总资产周转率 | 0.20 | 5 年平均 | 0.15 → 0，1.4 → 满分 |
| 指数内规模分位 | 0.20 | 总市值在中证2000内的分位 | 0.25 → 0，1.0 → 满分 |
| **资本开支 ÷ 折旧** | 0.14 | 近 5 年（购建固定无形长期资产支付的现金 ÷ 折旧摊销） | **3.0 倍 → 0 分，0.7 倍 → 满分** |

> 最后一项是**巴菲特本人在股东信中给出的原话级判据**：他用 BNSF 与整个集团的实际数据说明，10 年资本开支 $121B 对折旧 $65B（**1.86 倍**）是明确的**劣势**——因为维持竞争地位所需的资本超过了折旧，账面利润就高估了真实的所有者收益。这正是他「所有者收益 = 利润 + 折旧摊销 − **维持性资本开支**」定义中 (c) 项的量化形式。
>
> 中证2000 的实测分布：10 分位 0.75、中位数 **2.37**、90 分位 6.76——即典型A股小盘股的资本开支接近折旧的 2.4 倍，普遍处于"需要持续投入才能维持地位"的状态。这是小盘股整体难以形成宽阔护城河的一个重要财务证据。

**A4 转换成本与网络效应（6 分）**

| 分项 | 权重 | 量化工式 | 打分区间 |
|---|---|---|---|
| 收入稳定性 | 0.38 | 近 8 年营收同比增速的标准差（负向） | 0.45 → 0，0.02 → 满分 |
| 订单/预收粘性 | 0.28 | (合同负债 + 预收款项) / 营业收入 | 0 → 0，0.25 → 满分 |
| 业务分散度 | 0.16 | 主营构成的分部数量 | 1 → 0，6 → 满分 |
| 研发强度 | 0.18 | 研发费用 / 营业收入 | 0 → 0，12% → 满分 |

**A5 有效规模与准入壁垒（5 分）**

| 分项 | 权重 | 量化工式 | 打分区间 |
|---|---|---|---|
| **行业壁垒先验分** | 0.72 | 见 `config.py` 的 `INDUSTRY_PRIOR`（104 个 tushare 行业逐一赋值） | 0 → 0，5 → 满分 |
| 固定资产占比 | 0.14 | 固定资产 / 总资产 | 0.05 → 0，0.55 → 满分 |
| 资本开支强度 | 0.14 | 购建固定无形长期资产支付的现金 / 营业收入 | 0 → 0，15% → 满分 |

> 行业先验分直接来自《投资的护城河》行业篇（第9~16章）的结论，例如：机场/铁路/公路/水务/电信运营 4.5~5 分；白酒 4.5 分；软件服务 4.0 分；**航空 0.5 分**（书中明确「大多数航空公司都缺乏可持续竞争优势」）；货运/批发/纺织 1.0~1.5 分。

#### B 护城河趋势（10 分）—— 严格照晨星的 5 项定量清单

| 子项 | 权重 | 量化工式 | 对应书中清单 |
|---|---|---|---|
| B1 | 3 | 5 年营收 CAGR − 同板块中位数 | 市场份额趋势 |
| B2 | 3 | (最新毛利率 − 5年均值) 与 (最新净利率 − 5年均值) 各半 | 营业利润率 vs 周期中值 |
| B3 | 2 | **边际 ROIC − 5年平均 ROIC**；边际 ROIC = ΔEBIT / Δ投资资本（近3年） | 边际 ROIC vs 当前 ROIC |
| B4 | 2 | 最新净利润同比 − 同板块中位数 | 行业利润份额 |

**评级**：B 维度归一化得分 ≥0.62 → **正面**；≥0.38 → **稳定**；否则 **负面**。

#### C 财务健康与安全（18 分）

| 子项 | 权重 | 量化工式 | 打分区间 |
|---|---|---|---|
| **C1 杠杆（6分）** | 0.46 / 0.30 / 0.24 | 资产负债率（**金融地产豁免**）／ 净负债÷EBITDA（负向）／ 利息保障倍数 | 75%→0, 25%→满分；3.5→0, 0→满分；2→0, 20→满分 |
| **C2 现金流质量（6分）** | 0.50 / 0.10 / 0.25 / 0.15 | 5年累计经营现金流÷5年累计净利润／该比值>0的保底项／自由现金流为正的年数／现金流与利润双正的年数 | 0.3→0, 1.2→满分；0→0, 5年→满分 |
| **C3 资产质量（3分）** | 0.42 / 0.34 / 0.24 | 商誉÷净资产（负向）／ 应收增速−营收增速（负向）／ 资产减值损失率（负向） | 45%→0, 0→满分；35pp→0, −10pp→满分；8%→0, 0→满分 |
| **C4 审计与合规（3分）** | 0.7 / 0.3 | 是否非标审计意见 ／ 是否 ST | — |

> C2 的「5年累计经营现金流 ÷ 5年累计净利润」是识别A股财务造假最有效的单一指标之一，也是巴菲特「所有者收益」思想最直接的落地。

#### D 管理层与资本配置（14 分）

| 子项 | 权重 | 量化工式 | 打分区间 |
|---|---|---|---|
| **D1 股东回报（5分）** | 0.34 / 0.36 / 0.30 | 累计分红年数 ／ 股息率TTM ／ **股息支付率（近3年累计）** | 0→0, 8年→满分；0→0, 3.5%→满分；0→0, 50%→满分 |
| **D2 股本纪律（3分）** | 0.70 / 0.30 | 3年股本扩张率（负向）／ 是否有回购 | 35%→0, 0→满分 |
| **D3 利益一致性（3分）** | 0.55 / 0.45 | 第一大股东持股比例（8%~45% 为佳）／ 股东户数变化（负向＝筹码集中） | — |
| **D4 资本配置效率（3分）** | 0.45 / 0.30 / 0.25 | 最新ROE − 5年平均ROE ／ 扣非EPS同比 ／ 每股净资产同比 | −8pp→0, +8pp→满分 |

> D3 用**倒U型**处理大股东持股：过低（<8%）意味着无实际控制人、容易被内部人控制；过高（>45%）在一股独大的A股语境下意味着中小股东制衡不足。这是A股相对于美股必须做的调整。
>
> D4 是巴菲特「留存收益测试」的近似实现：留存利润若不能提升 ROE 与每股指标，说明资本配置在毁灭价值。

#### E 估值与安全边际（20 分）

| 子项 | 权重 | 量化工式 |
|---|---|---|
| **E1 相对估值（8分）** | 0.34/0.24/0.16/0.14/0.12 | PE(TTM) 自身3年分位（负向）／ PB 分位（负向）／ PS 分位（负向）／ PE 绝对值（70→0, 6→满分）／ PB 绝对值（8→0, 0.6→满分） |
| **E2 绝对折价（7分）** | 0.60 / 0.40 | **横截面安全边际分位** ／ 绝对安全边际（−55%→0, +55%→满分） |
| **E3 现金回报（5分）** | 0.45 / 0.55 | 股息率TTM（0→0, 4.5%→满分）／ 自由现金流÷总市值（0→0, 8%→满分） |

> **E2 为什么用 60% 横截面分位 + 40% 绝对安全边际？** 因为 DCF 绝对水平在A股存在系统性偏差（详见第六节第 3 条），纯绝对口径会把绝大多数个股压缩到 0 分而失去区分度；但纯横截面口径在整体泡沫期会「矮子里拔将军」。两者加权既保留了经济含义，又保证了区分度。

### 4.3 排雷扣分（A股特有，最多 −30 分）

| 触发条件 | 扣分 | 设计依据 |
|---|---|---|
| 非标准审计意见 | −10 | 巴菲特：管理层诚信是一票否决 |
| 股权质押 > 40% / > 20% | −8 / −4 | A股特有：大股东爆仓会传导至上市公司 |
| 商誉 ÷ 净资产 > 40% / > 20% | −8 / −4 | A股特有：并购对赌期后的商誉暴雷 |
| 5年经营现金流 ÷ 净利润 < 0.5 | −8 | 利润含金量不足，造假高风险特征 |
| 应收增速 − 营收增速 > 30pp | −6 | 收入确认激进或渠道压货 |
| 3年股本扩张 > 30% | −6 | 稀释股东，与巴菲特「回购增厚、增发摊薄」的取向相反 |
| ST / *ST | −10 | 退市与持续经营风险 |
| 非金融企业资产负债率 > 75% | −5 | 巴菲特：拒绝高杠杆 |
| 可打分年报少于 3 年 | −5 | 无法验证「持续性」，等于落在能力圈外 |

---

<a id="五"></a>
## 五、护城河评级 / 不确定性评级 / 公允价值 / 星级

### 5.1 护城河评级（沿用晨星的绝对标准）

原书给的是**两道入口 + 一条核心判据**：

1. **能否创造经济利润**——ROIC 是否可能长期高于 WACC；
2. **是否至少具备五大来源之一**——无形资产 / 成本优势 / 转换成本 / 网络效应 / 有效规模；
3. **持续期**——狭窄约 10 年、宽阔约 20 年。原书特别强调「**差额的重要性远不及持续期**」。

落地时**不设任何人为的溢价百分比门槛**（旧版曾用「溢价 ≥ 2% / ≥ 4%」，这在原书中并无依据），
而是把「持续期」换算成**真实的逐年正溢价年数**——每年 ROIC（金融股用 ROE 代理）直接对比**当年** WACC，
逐个数出超过的年数，再用占比消除 A股只有约 10 年可得年报的限制：

```
三项门槛必须【同时】满足，任意一项不过即为「无」：

狭窄：平均溢价 > 0   且  A 得分 ≥ 15  且  可得年数 ≥ 3
      且  正溢价年数 ≥ max(3, 50% × 可得年数)

宽阔：平均溢价 ≥ 2%  且  A 得分 ≥ 24  且  可得年数 ≥ 8
      且  正溢价年数 ≥ 80% × 可得年数
```

**审核口径（重要）**：`平均溢价 > 0` 是硬门槛，它排除了「**只在行业景气顶部才赚到超额收益**」的公司。
原书对此有明确表述——周期性行业在景气高点获得的超额回报，不构成护城河的证据。
本体系据此判定：

| 个股 | A 得分 | 平均溢价 | 逐年正溢价 | 评级 | 理由 |
|---|---|---|---|---|---|
| 贵州茅台 600519 | 30.6 | +22.48% | **10 / 10** | 宽阔 | 十年每一年都创造经济利润，品牌无形资产 |
| 中国神华 601088 | 22.8 | +4.45% | 9 / 10 | 狭窄 | 成本优势真实，但受煤价周期扰动 |
| 长江电力 600900 | 23.1 | +0.84% | **10 / 10** | 狭窄 | 溢价薄但十年不断，水电有效规模 |
| 工商银行 601398 | 24.4 | +1.10% | 9 / 10（ROE 口径） | 狭窄 | 溢价薄，但负债成本优势可持续 |
| **中国石油 601857** | 20.6 | **+0.66%** | **4 / 10** | **无** | 仅 2022–2025 高油价期超过 WACC，十年 ROIC 2.3%→9.5% 而 WACC 8.00%，属景气顶部收益 |
| 中国石化 600028 | — | −2.39% | 0 / 10 | 无 | 十年从未创造经济利润 |
| 隆基绿能 601012 | 10.7 | −1.48% | 8 / 10 | 无 | 光伏产能过剩，回报被竞争侵蚀且 A 得分不足 |

> **为什么中国石油不能给护城河？** 这是本体系最容易被质疑、也最必须交代清楚的一条。
> 它的**护城河来源是真实的**（上游资源牌照与准入壁垒，已体现在 A 维度的行业先验分中，A=20.6），
> 但原书的评级要求的是**跨越周期的经济利润**，而不是「资源好」。数据上它近 10 年只有 4 年
> ROIC 超过 WACC，且全部集中在高油价窗口——按原书标准，这正是应当排除的情形。
> 若把门槛放宽到「曾经创造过经济利润」，就会把大量强周期股（钢铁、航运、煤炭、化工）
> 一并升格为「有护城河」，评级随即失去区分度。此门槛可通过 `engine/config.py` 的
> `MOAT_RATING.min_roic_wacc_spread` 调整，但调整前需明确接受上述后果。

**评级是绝对标准**，因此在中证2000 这类小盘指数中「无护城河」占多数是**正常且正确**的结果——这与书中「全球只有不到 200 家宽阔护城河公司」的立场一致。四个指数的实测分布见 §9.1。

**数据口径说明**：银行/保险/证券在 tushare 的 `fina_indicator` 中没有 `roic` 字段（结构性缺失，
非抓取不全），本体系改用 **ROE** 作为资本回报代理计算溢价与正溢价年数，并在个股详情页
「护城河评级依据」面板中显式标注口径为 `ROE`，便于复核。

### 5.2 不确定性评级（五档）

不确定性与「未来现金流预测难度」挂钩，用 10 个可观测项加权（0~100 分，越高越不确定）：

| 分项 | 权重 | 量化工式 |
|---|---|---|
| 系统性风险 | 0.14 | Beta（相对沪深300），0.6→0，1.7→满分 |
| 收入波动（周期敏感性） | 0.17 | 营收同比增速标准差 |
| 财务杠杆 | 0.13 | 资产负债率 |
| 净负债/EBITDA | 0.08 | 负向 |
| 小市值 | 0.13 | 指数内市值分位（负向） |
| 商誉 | 0.06 | 商誉/净资产 |
| 质押 | 0.07 | 质押比例 |
| **行业壁垒** | 0.12 | 行业先验分（负向）——壁垒越低越不确定 |
| 单业务依赖 | 0.04 | 最大业务收入占比 |
| 股价波动 | 0.06 | 年化波动率 |

分档：**<20 低 / 20~34 中 / 34~52 高 / 52~72 非常高 / ≥72 极端**。

> 晨星原文的对应项是「公司收益相对整体经济的贝塔、经营杠杆、财务杠杆 + 监管变动、牌照/合同/专利到期、顾客集中、竞争变化、去中介化、价格接受者、不可预知的管理、有问题的资本配置」。A股落地时把「顾客集中」用「最大业务收入占比」近似，把「监管变动」用**行业壁垒先验分**近似（壁垒越低、监管与竞争冲击越不可控）。

### 5.3 公允价值：多轨交叉验证

**轨道一：三阶段 DCF**（严格照晨星的护城河整合式 DCF）——**仅适用于非金融企业**

> ⚠ **金融股整条 DCF 关闭**（2026-09-19 修正）。银行 / 保险 / 券商的「经营现金流」包含存款、
> 保费、客户保证金的进出，属于**资产负债表流量**而非自由现金流。tushare 给出的 `fcf`/`fcff`
> 对它们是结构性失真的量级——实测**兴业银行 fcf0 = 4110 亿、中国人寿 = 6040 亿**，
> 据此算出的「内在价值」可达市价的 **16 倍**（兴业银行 DCF 值 293.72 元 vs 现价 17.78 元），
> 再经 `P/FV` 反过来**伪造出 76% 的安全边际**。这比留空危险得多，因此金融股的 `fv_dcf`
> 一律置空，估值锚改用下面的**轨道三**。

- 正常化自由现金流 FCF₀ = 近 5 年平均自由现金流，取不到则用最近一期 FCFF，再取不到则用 `EBIT×(1−25%)×0.55`
- 阶段1：5 年明确预测期，增速 `g₁ = 0.6×5年营收CAGR + 0.4×5年净利CAGR`，并用 `ROIC×0.8+2%` 做上限约束、截断到 [−5%, 22%]
- 阶段2：增速由 g₁ **线性衰减**到永续增速 g∞，年限 = **宽阔 15 年 / 狭窄 10 年 / 无 5 年**
- 阶段3：永续，`TV = FCF_N × (1+g∞) / (WACC − g∞)`
- `WACC = We×COE + Wd×COD×(1−25%)`
- `COE = 无风险利率 2.3% + β×股权风险溢价 6.0% + 特质风险溢价`
  - **特质风险溢价** = clip((年化波动率 − 28%) × 0.18, 0, 6%)
  - 为什么加这一项：A股小盘股的 CAPM β 相对沪深300 系统性偏低（中证2000 成分股 β 中位数约 **0.79**，与沪深300 的相关系数中位数仅 **0.28**），若只用 β，超过 70% 的个股会触到权益成本下限，必要报酬率失去区分度。加入由总波动率决定的特质风险溢价后，COE 中位数约 **11.2%**，分布区间 9%~20%，与其风险特征相符。
  - COE 截断到 9%~20%，WACC 截断到 8%~18%
- 股权价值 = 企业价值 − 净债务 − 少数股东权益；每股 = 股权价值 ÷ 总股本
- 若 `股权价值 ≤ 0`（净债务高于 DCF 企业价值），该股 `fv_dcf` 置空并在看板标注
  「DCF股权价值为负（净债务>企业价值）」——这是**有意义的经济结论**，不是数据缺失

**轨道二：合理倍数法**

```
正常化 EPS = 近 3 年扣非净利润均值 ÷ 总股本
合理 PE   = 14
          + 护城河溢价（宽阔 +8 / 狭窄 +4 / 无 0）
          + g₁ × 0.70（每 1% 增速加 0.7 倍 PE）
          + (ROIC−WACC) × 0.45（每 1% 经济利润溢价加 0.45 倍 PE）
          + 不确定性调整（低 0 / 中 −1.5 / 高 −3.5 / 非常高 −5.5 / 极端 −8）
          （截断到 6 ~ 45 倍）
公允价值 = 正常化 EPS × 合理 PE
```

**轨道三：合理市净率法**（**金融股专用**，2026-09-19 新增）

金融股没有「投资资本」口径、也没有企业自由现金流，DCF 对它们结构性不适用。
这类公司的行业标准估值是**市净率**，由戈登增长模型推出合理 P/B：

```
合理 P/B = (ROE − g∞) / (COE − g∞)        （截断到 0.30 ~ 4.00）
公允价值 = 每股净资产 × 合理 P/B
```

- ROE 取 5 年平均 ROE；每股净资产取最新年报 BPS（缺失时用 归母净资产 ÷ 总股本 补算）
- 仅当 `ROE > 0` 且 `COE > g∞ + 0.5%` 时成立
- **只对金融股启用**，以免改动其余个股的既有口径
- 效果：沪深300 的 **54 只**金融股现在 **100%** 有市净率估值。修正前兴业银行的合并公允价值
  为 75.09 元（被失真的 DCF 拉高），修正后为 **47.07 元**

**合并**：金融股走 `√(市净率法 × 合理倍数法)`；其余个股走 `√(DCF × 合理倍数法)`。同时输出：
- `fv_dcf`（DCF 法）、`fv_multiple`（合理倍数法）、`fv_pb`（市净率法·金融股）、`fv_conservative`（**保守法**）
- `fair_value_low` = 各法中的**最小值**；`fair_value_high` = 各法中的最大值 —— 作为**估值区间**下上限
- `P/FV(对保守内在价值)` = 现价 ÷ `fair_value_low`，对应巴菲特 2013 年股东信的原话规则：

> "look at a **5-year earnings range**, and buy against the **bottom boundary**"
> `[2013股东信]`

**保守法**用**近 5 年扣非净利润的下限**代替 3 年均值作为正常化盈利，再乘以同一个合理 PE。这是把巴菲特「以盈利区间下限定价」这条纪律直接落到代码里——它给出的估值通常显著低于 DCF 与均值法，是**最严格**的一把尺子（中证2000 全体 `P/FV(对保守内在价值)` 中位数约 **8.9**，而 `P/FV(绝对口径)` 中位数约 **4.6**）。

> 为什么要加第三把尺子？因为巴菲特本人在 2012–2019 年的信件里反复给出具体的**折价门槛**（"80¢ or less"、"90¢"、95¢ 被拒绝），说明他的折价要求是**按最保守的估值口径**来算的，而不是按中枢估计。看板把三把尺子并列展示，使用者可以自行选择严格程度。

> 为什么要加轨道二？因为对小盘股而言 DCF 的终值假设极度敏感（WACC 变动 1pp，估值可能变动 20%+），单一模型的偏误会被放大。倍数法用「正常化扣非盈利 × 由护城河、增速、经济利润、不确定性共同决定的合理倍数」，其输入全部来自前面四个维度，形成了**体系内部的自洽闭环**——这也是巴菲特「先判断生意好坏，再决定愿意付多少倍」的顺序。

### 5.4 星级与操作建议

直接套用晨星的折价门槛，但有**两套口径**：

| 口径 | 说明 |
|---|---|
| **绝对口径 `stars_abs`** | 直接对 `P/FV` 套用晨星门槛。用于回答「相对模型估算的内在价值，现在贵不贵」 |
| **相对口径 `stars`（看板默认）** | 先把 `P/FV` 除以中证2000 整体的 `P/FV` 中位数（即看板顶部的**市场温度**）做中性化，再套用**同一套**晨星门槛 |

星级映射（两套口径共用）：

```
5★：P/FV ≤ 1 − 所需折价
4★：1 − 所需折价 < P/FV ≤ 1 − 0.55×所需折价
3★：1 − 0.55×所需折价 < P/FV ≤ 1 + 0.5×触发溢价
2★：1 + 0.5×触发溢价 < P/FV ≤ 1 + 触发溢价
1★：P/FV > 1 + 触发溢价
```

对应的操作建议：5★ 买入 / 4★ 逢低买入或持有 / 3★ 持有观望 / 2★ 减持 / 1★ 卖出。

> **为什么需要相对口径？** 因为A股（尤其小盘股）整体估值长期高于 DCF 口径的内在价值：当前中证2000 的 PE(TTM) 中位数为 **51.1 倍**，按 3 年平均扣非盈利计算的 PE 中位数为 **32.7 倍**，而模型给出的合理 PE 中位数约 **10.9 倍**（对应 WACC 中位数 10.2% 的必要报酬率）。如果直接用绝对口径，2000 只成分股会有 **1523 只被判 1★**，星级完全失去区分度；相对口径下分布为 5★ 629 / 4★ 139 / 3★ 261 / 2★ 107 / 1★ 658（另有 206 只因无法估值不参与）。
>
> 相对口径保留了晨星「不确定性越高、要求的折价越大」的**规则形状**，只是把基准从「模型内在价值」换成「指数整体估值中性化后的模型内在价值」。看板同时展示两套口径与市场温度，使用者可以自行判断当前是「整体贵」还是「个股贵」。

---

### 5.5 分档：好生意 × 好价格（覆盖全部成分股，8 档）

晨星只给出五档星级，落到A股会出现一个问题：当前 **618 只个股**同时拿到 5★，数量过多、无法直接使用。巴菲特对选股逻辑最返璞归真的一句概括恰好提供了细分维度：

> **以好的价格买入好的股票。**

因此把这句话拆成两个可判定的维度，对**全部成分股**给出一个档位（**不留空**）：

| 维度 | 判据 | 依据 |
|---|---|---|
| **是不是好生意** | 护城河评级 ∈ {宽阔, 狭窄} | 沿用晨星的**绝对标准**（不是相对排名） |
| **是不是好价格** | 买入池用**池内**分位 ≥ 60%；非买入池用**全指数**价格分位 ≥ 60% | 折价深度 |

**为什么要用两套「好价格」基准？** 这是 2026-09-19 修复的一处设计缺陷。原先只对 5★ 个股分档，
导致沪深300 有 **65%** 的成分股「分档」列为空。但直接改用全指数分位也不可行——实测买入池
**100% 都落在全指数便宜的前 40%**（买入池本身就是折价最深的一批），池内所有个股都会被判「便宜」，
**A 档（好生意 · 价格待回落）会完全消失**。因此买入池保留池内相对分位，非买入池改用全指数分位。

由此得到 8 档（S+ 是 S 的子集）：

| 分档 | 名称 | 含义 | 沪深300 数量 | 中证2000 数量 |
|---|---|---|---|---|
| **S** | 好生意 + 好价格 | 巴菲特最理想的组合，优先研究 | 28 | 106 |
| **A** | 好生意 · 价格待回落 | 生意没问题，但折价不够深，可以等更好的价格 | 39 | 86 |
| **B** | 生意一般 · 好价格 | 暂无护城河评级但折价深，格雷厄姆式的价格保护 | 15 | 142 |
| **C** | 分数达标 · 两项平平 | 综合分达标，但护城河与折价都不突出 | 23 | 284 |
| **D** | 好生意 · 未到买点 | **非 5★** 但有护城河——好公司，等价格 | 37 | 59 |
| **E** | 便宜 · 生意一般 | **非 5★**、无护城河，但价格居指数最便宜前 40% | 9 | 91 |
| **F** | 两项均未达标 | 既无护城河、价格也不便宜，本体系下不构成标的 | 138 | 1023 |
| **G** | 无法估值 | 扣非净利润为负，安全边际无定义（只做护城河判断） | 11 | 209 |
| **S+** | 严格双优（S 的子集） | 属 S 档，**且现价低于保守内在价值** | 25 | 17 |

> S/A/B/C 的合计等于该指数的 5★ 数量：沪深300 = 28+39+15+23 = **105** ✓。

**两个辅助分数**（看板中直接展示，带条形图）：

```
品质分 = 0.55 × (A维度得分/38) + 0.25 × (C维度得分/18) + 0.20 × (D维度得分/14)
       + 护城河加成（宽阔 +0.10 / 狭窄 +0.05），截断到 0~1，再 ×100
价格分 = 安全边际在【全指数】内部的分位 × 100     ← 覆盖全部个股，口径统一
```

> **权重为什么这样分配？** 品质分刻意偏重**护城河强度（0.55）**，因为巴菲特的原话顺序是"先看生意，再看人"；财务健康与管理层合计占 0.45。

**S+ 档的实现**：用 `fair_value_low`（DCF、合理倍数法、**近 5 年扣非盈利下限法**三者取最小）作为分母，现价低于它即入选。这正是巴菲特 2013 年股东信的原话规则：

> "look at a **5-year earnings range**, and buy against the **bottom boundary**"
> `[2013股东信]`

**必须说明的一点**：「价格分」与分档门槛的**分位基准不同**。价格分是**全指数**口径——只有统一口径才能
覆盖全部个股（此前只在买入池内排名，导致 65% 的个股该列为空）。而 S/A/B/C 的门槛是
**买入池内部**口径，这样池内仍能分出「折价更深／更浅」。因此可能出现「价格分很高但档位是 A」的情况：
那表示它相对全指数已经很便宜，但在买入池这批同样便宜的标的里还不够深。

**G 档留空是正确结果**：扣非净利润为负的公司算不出安全边际，价格维度无定义。看板对此标注
「扣非净利为负·无法估值故无价格分」，而不是留一个无法解释的「—」。

---

<a id="六"></a>
## 六、A股特殊性的逐条处理

| # | A股特殊性 | 处理方式 |
|---|---|---|
| 1 | **整体估值中枢高于成熟市场** | 提高 COE 下限到 9%（晨星发达市场为 8%）、ERP 取 6.0%、并叠加特质风险溢价；同时引入**相对星级口径**与**市场温度**指标 |
| 2 | **小盘股风险溢价更高** | β 下限提高到 0.65、上限 1.8；WACC 下限 7.5%；不确定性评级引入「指数内市值分位」 |
| 3 | **DCF 绝对水平系统性偏低** | 公允价值改为双轨（DCF + 合理倍数）取几何平均；E2 用 60% 横截面分位 + 40% 绝对安全边际 |
| 4 | **财务造假与「洗大澡」** | C2 用 5 年累计经营现金流/净利润；C3 用「应收增速−营收增速」；排雷层用审计意见与商誉 |
| 5 | **商誉暴雷（并购对赌）** | 商誉/净资产进入 C3 与排雷层双重扣分 |
| 6 | **股权质押爆仓传导** | 质押比例进入不确定性评级与排雷层 |
| 7 | **定增摊薄与股本无序扩张** | D2 与排雷层均对股本扩张扣分 |
| 8 | **一股独大 / 无实际控制人** | D3 用倒U型打分（8%~45% 为佳） |
| 9 | **散户主导、筹码结构影响大** | 引入股东户数变化（筹码集中度）、获利盘比例（`cyq_perf`）等A股特有数据，进入 D3 与不确定性评级 |
| 10 | **行业属性差异极大（金融地产的高杠杆是常态）** | C1 的资产负债率对金融地产**豁免**；行业先验分覆盖全部 104 个 tushare 行业 |
| 11 | **会计口径差异（少数股东权益占比高）** | 企业价值 → 股权价值时扣除少数股东权益；用扣非净利润而非净利润做正常化盈利 |
| 12 | **政策性行业（公用事业、航空）** | 通过行业壁垒先验分区分：受监管的公用事业中等偏上，价格接受者（航空、货运）低分 |
| 13 | **ST / 退市制度** | 排雷层 −10；名称含 ST 直接触发 |
| 14 | **数据可得性限制** | 只有 10 年可得年报，无法完整验证晨星的「20 年持续期」；用「A 维度得分 + 逐年正溢价年数占比 + 平均溢价>0」三重门槛近似，且用占比替代绝对年数 |

---

<a id="七"></a>
## 七、tushare字段到指标的映射表

### 7.0 展示单位约定（看板 / CSV / 报告完全一致）

tushare 的原始单位是混用的（金额有「元、万元、千元」三套，比率有的是小数、有的已经是百分数）。本项目在所有**面向使用者的输出**中统一为：

| 类别 | 统一单位 | 覆盖字段 |
|---|---|---|
| **金额** | **亿元** | 总市值、近3年留存收益、现金分红、市值增长、正常化自由现金流、DCF 企业价值、净债务、近20日均成交额 |
| **每股价值** | **元/股** | 内在价值（DCF / 合理倍数法 / 保守法 / 区间下限 / 合并值）、现价 |
| **比率** | **倍** | PE、PB、PS、P/FV、净负债÷EBITDA、流动比率、利息保障倍数、资本开支÷营收、资本开支÷折旧、经营杠杆、合理市盈率 |
| **比例** | **%** | WACC、ROIC−WACC 溢价、安全边际、毛利率/净利率、资产负债率、各类分位、年化波动率、最大回撤、区间涨跌幅、业务集中度、股本扩张、商誉占比、股息率 |
| **得分** | 原值 + 满分标注 | 如 `护城河强度 26.9(/38)` |
| **年 / 年计数** | **年** 或 `/5` | 可用年报年数、超额收益衰减年限、自由现金流为正年数 |

换算集中定义在 `engine/dashboard.py` 的 `DISPLAY_MULT`：

```python
DISPLAY_MULT = {
    # ---- 金额 → 亿元 ----
    "total_mv": 1e-4,          # tushare daily_basic 原单位是万元
    "amount_20d_avg": 1e-5,    # 项目内自算，原单位是千元
    "fcf0": 1e-8,              # tushare 财务数据原单位是元
    "retained_3y": 1e-8, "cum_div_3y": 1e-8, "mv_gain_3y": 1e-8,
    "enterprise_value": 1e-8, "netdebt_last": 1e-8,
    # ---- 小数比率 → 百分数 ----
    "wacc": 100.0, "roic_spread": 100.0, "margin_of_safety": 100.0,
    "g1": 100.0, "rev_cagr5": 100.0, "ni_cagr5": 100.0, "vol_250d": 100.0,
    "pe_pctile": 100.0, "pb_pctile": 100.0, "ps_pctile": 100.0,
    ...   # 完整清单见代码
}
```

> ⚠️ **`data/scored.parquet` 保留 tushare 原始单位**（元、小数比率），便于程序化再计算；只有看板、`ranking.csv` 与报告做了单位统一。若直接使用 parquet，请注意这一点。

**修正记录**：统一之前曾有字段单位标注错误——`wacc`（实际是小数 0.1026）与 `roic_spread`（实际是小数 0.148）被标成 `%`，界面上显示为 `0.10%` / `0.15%`；`margin_of_safety`（小数 0.687）显示为 `0.687`。现已按上表换算：WACC 正确显示为 `10.26%`、安全边际显示为 `68.7%`。


| 指标 | tushare 接口 / 字段 | 说明 |
|---|---|---|
| 成分股与权重 | `index_weight(index_code='932000.CSI')` | 中证2000 最新一期成分与权重 |
| PE / PB / PS / 股息率 / 市值 / 换手 | `daily_basic` | `pe_ttm, pb, ps_ttm, dv_ttm, dv_ratio, total_mv, turnover_rate, total_share` |
| **ROIC** | `fina_indicator.roic` | 直接可得 |
| ROE / ROA / 毛利率 / 净利率 / 费用率 | `fina_indicator` | `roe, roe_dt, roa, grossprofit_margin, netprofit_margin, saleexp_to_gr, adminexp_of_gr, finaexp_of_gr, expense_of_sales` |
| 杠杆 | `fina_indicator` | `debt_to_assets, netdebt, interestdebt, ebit, ebitda`；`current_ratio, quick_ratio` |
| 自由现金流与投资资本 | `fina_indicator` / `cashflow` | `fcff, fcfe, invest_capital`；`free_cashflow, n_cashflow_act, c_pay_acq_const_fiolta, depr_fa_coga_dpba`（用于资本开支/折旧） |
| 利润表 | `income_vip` | `total_revenue, oper_cost, int_exp, total_profit, income_tax, n_income_attr_p, rd_exp, sell_exp, admin_exp, fin_exp` |
| 资产负债表 | `balancesheet_vip` | `total_share, money_cap, accounts_receiv, inventories, goodwill, fix_assets, total_assets, total_liab, total_hldr_eqy_exc_min_int, contract_liab, minority_int` |
| 扣非净利润 | `fina_indicator.profit_dedt` | 正常化盈利的基础 |
| 审计意见 | `fina_audit_vip` | `audit_result` |
| 主营构成 | `fina_mainbz_vip` / 逐股 `fina_mainbz` | 业务集中度 |
| 股东结构 | `top10_holders` / `top10_floatholders` | 集中度与第一大股东 |
| 股东户数 | `stk_holdernumber` | 筹码集中度变化 |
| 质押 | `pledge_stat` | 质押比例 |
| 分红 | `dividend` / `daily_basic.dv_ttm` | 连续性与支付率。**注意：`dv_ratio` 与 `dv_ttm` 都是「股息率」而非支付率**，支付率用「近3年累计分红 ÷ 近3年累计归母净利润」自算（`payout_3y`） |
| 回购 | `repurchase` | 回购行为 |
| 行情 | `daily`（320 个交易日全市场） | 动量、波动、最大回撤、Beta |
| 筹码分布 | `cyq_perf` | 获利盘比例、成本分位 |
| 资金流 | `moneyflow_dc` | 主力净流入占比 |
| 基准指数 | `index_daily`（000300.SH 等） | Beta 与相对强度 |
| 估值历史分位 | `daily_basic` 近 40 个月末快照 | PE/PB/PS 自身 3 年分位 |

> 数据缺失时的兜底：`pledge_stat` 与 `daily_basic` 覆盖不全的部分，通过 `tools/fetch_supplement.py` 逐股调用接口补齐；仍然缺失的字段在该子项上按权重**重新归一化**，不视为扣分。

---

<a id="八"></a>
## 八、与巴菲特原文口径的两处差异（必须明示）

把巴菲特的逻辑落成代码时，有两处**无法同时忠于两边**。本节明确说明本项目的选择与理由，避免"用模型的口径冒充巴菲特的原话"。

### 8.1 折现率：巴菲特用的是「长期无风险利率」，本体系用的是「风险调整后的 WACC」

**巴菲特的原话口径**（1997 年股东大会，明确给出数字）：

> 折现率用**长期无风险利率**（当时明示 **7%**），**不加股权风险溢价**；风险不体现在折现率里，而是通过「**通过 / 否决**」来表达——看不懂或不够确定就不投。
> `[14.1997年伯克希尔股东大会（五）@5s / @2960s]`
> 芒格当场反驳（"Charlie says I never do anyway, and he's correct"）——**两人的分歧已如实记录**。

他在 2011 年前后又补充：**不用当期利率，而用未来 20~30 年的平均利率** `[129 @924s]`，并且**拒绝任何"可以代入变量"的估值公式** `[133 @1685s]`。

**本体系的选择**：用 `WACC = We·COE + Wd·COD·(1−t)`，其中 `COE = 无风险利率 + β×股权风险溢价 + 特质风险溢价`。

**为什么必须偏离**：如果严格按巴菲特的口径，用**当期**中国 10 年期国债 2.3% 折现，`1/2.3% ≈ 43 倍`的永续倍数会让几乎所有股票都"极度低估"，打分体系完全失去区分度；而巴菲特本人在 1997 年用的是 7%，在他的市场环境下 `1/7% ≈ 14 倍`——这才是他那句话的真实量化含义。**口径不可跨市场直接搬运。**

**他还更直接地否定了本体系采用的 WACC 与风险溢价**：

> 资本成本应当是「**次优机会的收益率**」（next-best opportunity），而**不是** WACC —— 并且明确说 "**we're right and they're wrong**"。
> `[2014年伯克希尔股东大会（一）@2567s]`

> 在无风险利率之上加 6% 的股权风险溢价，被巴菲特斥为 "**mathematical gibberish**"（数学胡言）。
> `[10. 1996年伯克希尔股东大会（三）@165–347s]`

> ⚠️ 请注意：本项目的 `ERP = 6.0%` **恰好就是他说"胡言"的那个数字**。这是一个必须披露的口径冲突，而不是可以含糊过去的地方。

**敏感性分析（完整表见 `out/sensitivity_discount_rate.md`）**：

| 折现率口径 | 内在价值中位数 | P/FV 中位数 | 宽阔护城河 | 狭窄护城河 | 5★(绝对) | **5★(相对)** |
|---|---|---|---|---|---|---|
| 2.3%（巴菲特字面：长期国债） | 11.45 | 1.31 | 83 | 946 | 522 | **667** |
| 3.5%（长期国债+1.2pp） | 10.88 | 1.38 | 73 | 793 | 489 | **665** |
| 5.3%（长期国债+3pp） | 5.90 | 2.53 | 61 | 548 | 179 | **644** |
| 8.0%（本项目 WACC 下限） | 4.01 | 3.63 | 45 | 295 | 62 | **603** |
| **10.26%（本项目实际中位数）** | **3.33** | **4.61** | **25** | **147** | **50** | **623** |
| 12.0%（本项目 WACC 上限） | 3.07 | 4.95 | 19 | 110 | 22 | **614** |

**这张表说明了两件重要的事**：

1. **绝对口径的结论对折现率极度敏感**：宽阔护城河从 2.3% 口径下的 83 家降到 12% 口径下的 19 家（差 4 倍以上），`P/FV` 中位数从 1.31 变到 4.95。**所以任何"DCF 算出内在价值是 X"的说法，都必须连同折现率口径一起交代。**
2. **相对星级口径对这个选择几乎免疫**：最后一列在各口径下稳定在 603~667 之间（波动约 ±5%）。这从实证上证明了「先用指数整体 P/FV 中位数中性化、再套用晨星门槛」这一设计是稳健的——**这也是本体系把相对口径设为看板默认的原因**。

**如何缓解这个偏离**：

1. 本体系同时输出 **`P/FV(对保守内在价值)`**——用「近 5 年扣非盈利下限」×合理 PE，正是巴菲特「以盈利区间下限定价」的落地，属于**不依赖折现率**的保守尺子；
2. 风险在本体系里**同时**通过两条路径表达：折现率（WACC）**与**星级所需折价（不确定性 → 20/30/40/50%）——后者与巴菲特「风险用通过/否决表达」的精神一致；
3. `engine/config.py` 中的 `RF/ERP/COE_FLOOR/COE_CAP` 均可调整；`tools/sensitivity_discount.py` 可以一键跑出上表，供使用者按自己的折现率信念重新校准。

### 8.2 风险定义：巴菲特明确否定波动率，本体系因此改用「基本面不确定性」

**巴菲特的原话口径**：风险 = **永久性资本损失的可能性 × 持有期**；他**明确否定**把波动率、β、VaR 当作风险，称其是"被提出过的最愚蠢的想法之一" `[part1 B?? / part1 引用]`；晨星的不确定性评级同样把"**公司收益相对整体经济的贝塔**"（盈利贝塔）而非**股价贝塔**列为主要输入。

**本体系的修正**（这是本项目在读完语料后做出的实质调整）：

| 项目 | 修正前 | 修正后 |
|---|---|---|
| 不确定性评级中的"波动"项 | 使用 **250 日股价年化波动率** | **删除**，改用**经营杠杆**（EBIT 增速波动 ÷ 营收增速波动） |
| 不确定性评级中的"系统性风险" | β（股价 β，权重 0.14） | 保留 β 但权重降到 0.13，并把它理解为"相对整体经济的敏感度"的近似 |
| 权重再分配 | 营收波动 0.17 | **营收波动 0.18 + 经营杠杆 0.09**（对应晨星原文的"经营杠杆"项） |

**保留的部分及理由**：股价 β 与波动率**仍用于 WACC**（计算必要报酬率），但在**风险评级**中不再出现。这样既尊重巴菲特对"风险≠波动"的定义，又保留了CAPM 在必要报酬率上的实用价值。

> 完整的不确定性评级构成见第 5.2 节；`op_leverage` 的实测分布：10 分位 1.39、中位数 4.11、90 分位 21.0（A股小盘股的经营杠杆普遍很高，这也是它们整体被评为"高不确定性"的原因之一）。

### 8.3 管理层：巴芒明确反对「把管理层评估公式化」，本体系因此把它做成「行为痕迹筛查」而不是「管理层评级」

**原话口径**（2002 年股东大会，明确、且是直接回答"你们怎么给管理层打分"这类提问）：

> 巴菲特与芒格**明确拒绝把管理层评估做成公式或加权评分**。
> `[43.2002年伯克希尔股东大会 @1855s–@2021s]`

**这与本体系的做法存在直接冲突**：D 维度（14 分）确实是四个加权子项的合成。必须坦白说明本项目的定位：

| | 巴菲特/芒格的做法 | 本体系的做法 | 定位差别 |
|---|---|---|---|
| 评估方式 | 定性判断 + 一票否决（诚信有问题就不投） | 4 个加权子项（分红 / 股本纪律 / 利益一致 / 资本配置结果） | 本体系**不声称**在给"管理层"打分 |
| 输入 | 与管理者长期接触、读其历史决策 | **只看可观测的行为痕迹**（审计意见、分红连续性、股本稀释、股东户数、ROE 与每股指标趋势） | 本体系只衡量**行为的结果**，不衡量动机与能力 |
| 可否量化 | 巴菲特认为不可以 | 本项目承认这只是**代理指标**，且刻意把权重压到 14/100 | 见第 11 节的"无法量化清单" |

**因此本体系对管理层的三点设计约束**：

1. **权重刻意压低**（14 分，五维中最小），因为这是最不可靠的一维；
2. **诚信类问题走「排雷层」而非加分**：审计非标 −10、质押过高 −8/−4、股本稀释 −6 等——**这是"一票否决"精神的落地**，而不是把诚信变成可以"用其他分数补回来"的加分项；
3. **不构造任何主观评分**（如"管理层能力 8/10 分"），只输出可被财报验证的客观行为。

> 换言之：**本体系能做的是「筛掉有问题的」，而不是「选出优秀的」。** 后者仍需使用者自己读年报、看历史决策记录。这与巴菲特"能力圈是投资者自己的边界"的立场一致。

### 8.4 关于巴菲特「一美元留存测试」为何只展示、不进入打分

巴菲特最著名的量化判据是「每留存 1 美元必须至少创造 1 美元市值」。本项目**已经把它算出来了**（`retention_ratio` 与扣除指数涨幅的 `retention_ratio_adj`，见看板个股详情「管理层与资本配置」组），但做出了一个**明确的取舍：只作为展示指标，不进入 D 维度打分**。

**原因（三条，均已在上文/语料中得到支持）**：

1. **可得数据窗口不够**：本项目能拿到的月度市值快照只有约 **3.2 年**（40 个月），而巴菲特要求 **5 年滚动窗口**，并强调该检验"高度依赖终点时点"。
2. **终点敏感性极高**：巴菲特在后来的信件中**自己承认 1984 年的原始表述有缺陷**（见 `out/extract_meetings2_part4.md` 记录的语料）。
3. **会被市场整体涨跌淹没**：在本次样本期内中证2000 上涨约 **25.5%**，若不做指数调整，几乎所有公司的"市值增长"都远大于其留存收益（实测中位数 1.02，且超过 25% 的个股触及上限）；做了指数调整后，该指标又主要变成"这只股票跑赢还是跑输指数"，而不是"留存收益的使用效率"。

**替代方案**：D4 改用**增量资本回报（边际 ROIC，见 A1/B3）+ ROE/EPS/BPS 趋势**作为主口径——这正是巴菲特在 2013 年信里"以 5 年盈利区间下限"以及"资本开支/折旧"背后真正关心的东西：**新投入的每一块钱能赚回多少**。

**改进方向**：若把月度市值快照扩到 8~10 年（约 100 次 `daily_basic` 调用，成本很低），即可按巴菲特的原意做**多终点滚动**的留存测试，届时可以把它加入打分。

### 8.5 其他已如实标注的口径差异

| 项 | 巴菲特/晨星原文 | 本项目做法 | 位置 |
|---|---|---|---|
| 折现率的期限 | 用未来 20~30 年平均利率 | 用当期无风险利率 + 溢价 | 8.1 |
| "ROE > 15%" | **非巴菲特原话** | 本项目的操作化阈值 | `out/01` 第 0.1 节 |
| 护城河持续期 | 10 年 / 20 年（晨星原文） | 逐年正溢价年数占比（狭窄 ≥50%、宽阔 ≥80%），因A股只有 10 年可得年报，用占比替代绝对年数 | 第 5.1 节 |
| 护城河溢价门槛 | 只强调持续期，不设百分比门槛 | 同样**不设**人为门槛，仅以「平均溢价 > 0」作硬门槛；宽阔另需 ≥2% | 第 5.1 节 |
| 回购价格上限 | 110% → 120% 账面价值（历史），后改为"低于内在价值" | 用 D2 记录行为、E2 判断贵贱 | `out/01` 第 10 节 |
| 经济特许经营权三条件 | 被需要 / 无相近替代品 / 不受价格管制 | 用 A2（定价能力）+ A5（壁垒）近似 | `out/01` 第 3 节 |
| **管理层评估** | **明确反对公式化/加权** | 仍用 4 个子项加权（但权重最低、且诚信走否决层） | 8.3 |
| **一美元留存测试** | 5 年滚动、以市值增长验证 | **只展示不打分**（数据窗口仅 3.2 年） | 8.4 |
| **回测/统计因子** | 巴菲特 2002 年**明确拒绝用回测统计因子选股** | 本轮的权重设定以**逻辑与原文**为依据，未做因子回测；回测列为第 9 节第一改进项 | 第 9 节 |
| 安全边际的具体幅度 | 桥梁比喻；另有场合提"确定性高则可接近 1 美元买 1 美元" | 用晨星的 20/30/40/50% 折价门槛（制度化） | 第 5.4 节 |

---

<a id="八点五"></a>
## 九、多指数支持

同一套打分逻辑已封装为可切换的「指数（universe）」，目前覆盖四个规模段 + 一个全市场视图：

| 指数 | 代码 | 成分股 | 规模段 | 输出目录 |
|---|---|---|---|---|
| 沪深300 | `000300.SH` | 300 | 大盘 | `out/csi300/` |
| 中证500 | `000905.SH` | 500 | 中盘 | `out/csi500/` |
| 中证1000 | `000852.SH` | 1000 | 中小盘 | `out/csi1000/` |
| 中证2000 | `932000.CSI` | 2000 | 小盘 | `out/` |
| **中证全指** | `000985.CSI` | **5122** | **全市场** | `out/csiall/` |

前四个按编制规则**互不重叠**，可做规模段横向对比；**中证全指是全市场视图，它是前四个的超集**
（`000985.CSI` 覆盖沪深两市几乎全部A股，含前四个指数的所有成分股），因此**不参与规模段排序**，
单独放在末尾作为「全市场个股数据总览」。它的选股结果是前四个的**并集再扩容**，两者不可直接互相比较——
同一个打分体系在全市场截面里的分位数与在子指数里的分位数不同，这是横截面指标的正常性质。

五个指数**共享全市场行情与财务数据缓存**——`fina_indicator`、`income`、`balancesheet`、`cashflow`、`daily_basic`、`price_history`、`db_history` 都是全市场批量数据，一次抓取所有指数共用；只有**逐股补充数据**（股东结构、股东户数、主营构成、分红历史）需要按指数分别抓取。

**新增一个指数只需两步**：
1. 在 `engine/paths.py` 的 `UNIVERSES` 中加一条（指数代码、名称、规模段、输出子目录），并把 key 加入 `UNIVERSE_ORDER`；
2. 运行 `python update_daily.py --index <key>`。

所有路径都由 `engine/paths.py` 解析（环境变量 `BSCORE_UNIVERSE` 切换），业务逻辑中没有任何硬编码的指数代码。

### 9.1 五指数实证对照（数据日期 2026-09-19）

| 指标 | 沪深300（大盘） | 中证500（中盘） | 中证1000（中小盘） | 中证2000（小盘） | 中证全指（全市场） |
|---|---|---|---|---|---|
| 成分股 | 300 | 500 | 1000 | 2000 | **5122** |
| 平均综合得分 | **53.4** | 49.6 | 46.9 | 43.7 | **43.4** |
| 宽阔 / 狭窄护城河 | 25 / 79 | 10 / 111 | 16 / 196 | 17 / 235 | 73 / 832 |
| **护城河达标率** | **34.7%** | 24.2% | 21.2% | **12.6%** | 17.7% |
| 5★（买入） | 105 | 181 | 346 | 618 | 1595 |
| 买入池 S 档（含 S+） | 28 | 48 | 70 | 106 | 338 |
| 其中 S+ 档 | 25 | 32 | 25 | 17 | 117 |
| PE(TTM) 中位数 | **18.5 倍** | 26.5 倍 | 35.7 倍 | **52.4 倍** | 40.1 倍 |
| PB 中位数 | 2.19 | 2.32 | 2.59 | 2.71 | 2.60 |
| 5年平均 ROIC 中位数 | **9.30%** | 7.72% | 6.74% | **5.02%** | 5.56% |
| 5年平均 ROE 中位数 | **11.14%** | 9.57% | 8.55% | **5.80%** | 6.77% |
| 市场温度（P/FV 中位数） | **1.77** | 2.76 | 3.54 | **4.73** | 3.80 |
| `fv_dcf` 覆盖率 | 65.0% | 69.6% | 79.8% | 80.5% | 76.9% |
| `fv_pb` 覆盖率 | 17.7% | 7.6% | 3.2% | 1.1% | 3.0% |

> **前四列是引擎有效性最强的旁证**：随着市值规模下降，**资本回报率单调下降**（ROIC 9.30% → 7.72% → 6.74% → 5.02%）、**估值单调上升**（PE 18.5 → 26.5 → 35.7 → 52.4 倍）、**护城河达标率单调下降**（34.7% → 24.2% → 21.2% → 12.6%）、**市场温度单调上升**（1.77 → 4.73）。
>
> 四个指标同时呈现**严格单调**，说明打分体系捕捉的是真实的规模—质量—估值结构差异，而不是噪声或口径假象。这也与「小盘股整体更贵、质量更参差」的市场常识一致。
>
> 顺带也解释了为什么中证2000 的「买入」名单会有 618 只（占 31%）——那是**相对星级口径**下的结果（见第 5.4 节），在绝对口径下它的 5★ 会少得多。

> **第五列「中证全指」不参与上面的单调性比较**，因为它是**全市场超集**（5122 只，包含前四列的全部成分股），
> 它的中位数落在四个规模段中间（达标率 17.7%、ROIC 5.56%、PE 40.1 倍、市场温度 3.80）是**结构决定的必然结果**，
> 而不是对单调性的反例。把它当成「第五个规模段」去排序会得出错误结论——它不是规模段，是全集。

### 9.2 金融股的资本回报口径（一处必要的适配）

tushare 的 `fina_indicator.roic` 对**银行 / 保险 / 券商**普遍为空——因为「投资资本」这一口径对金融机构不适用（负债本身就是经营原料）。

影响范围：沪深300 的 300 只里有 **54 只**金融股（银行 / 保险 / 证券 / 多元金融）；
中证500 有 42 只、中证1000 有 41 只、中证2000 有 35 只。

**处理方式**：对金融行业改用 **ROE 作为资本回报代理**，并在打分链路中做三处加固：

1. `roic_effective` = `roic_avg5`，缺失且属金融行业时取 `roe_avg5`（并置标记 `roic_is_proxy` 供追溯）；
2. A1 的「ROIC 稳定性 / 最低值」在缺失时用 ROE 的同名指标兜底；
3. DCF 的增长率上限与合理 PE 的经济利润溢价项，**只在对应输入可得时生效**，缺失时按 0 处理而不是把整条链路传染成 NaN。
4. **金融股整条 DCF 关闭**，估值改用**合理市净率法**（`P/B = (ROE−g)/(COE−g)`）。
   原因见 §5.3 与 §9.4 缺陷 6：银行的经营现金流含存款进出，`fcf` 量级失真，
   据此算出的 DCF 值可达市价 16 倍并伪造出假安全边际。

修复后：沪深300 无星级 **11** 只；中证500 **23 → 2**；中证1000 **57 → 1**。剩余的无星级个股是**扣非净利润为负**的亏损公司，属于「无法用盈利估值」，是正确结果而非缺陷。

> 用 ROE 替代 ROIC 对银行是**行业惯例**（巴菲特在股东大会上谈银行时也强调「ROE = ROA × 杠杆」，并要求拆解杠杆来源）。这一处理已在 `out/01` 第 10 节的原始证据中有对应。

### 9.3 自检发现并修复的数据缺陷（2026-09-19）

针对「大盘股评级有巨大失误、很多是空白数据」的反馈做了一次全链路自检，共发现 **4 个真实缺陷**，全部已修复：

> 该轮自检最初是在**上证50** 上做的。后经确认，用户原本要找的是**沪深300**
> （因为想看的平安银行 `000001.SZ` 是深市股票，不在上证50），因此本项目已把
> 「大盘段」指数由上证50（`000016.SH`，50 只）**整体替换为沪深300**
> （`000300.SH`，300 只）。下面的结论对两个指数同样成立。

**缺陷 1（严重）：`fina_indicator` 年报行未去重，污染了所有多年均值**

`fina_indicator` 中同一 `(ts_code, end_date)` 会因**财报更正 / 多来源**存在重复行——全样本 102,711 条年报行对应约 68,460 个真实「股票×年度」组合，即 **1.5 倍重复**。
`engine/features.py` 的 `latest_per()` 工具一直存在，但只被用于 `inc` / `bs` / `cf`，**主 `ann` 表漏掉了**。

后果（严重且隐蔽）：

- `g.tail(5)` 名义取「近 5 年」，实际只覆盖约 **3.3 个不同年份**，且个别年份被重复计权 → **所有 `*_avg5` / `*_std5` / `*_min5` 全部失真**；
- 趋势类指标（`gm_trend` / `nm_trend` / `roic_trend`）的基准值同样失真；
- 序列类指标（`roic_series`）出现 `2022,2022,2022,2022` 这种年份重复。

修复：在 `build_annual_features()` 入口对 `ann` 调用 `latest_per(ann, ["ts_code", "end_date"])`（保留 `ann_date` 最新的一行）。

**缺陷 2（严重）：逐年回报序列的「年份」取的是行号**

```python
# 修复前 —— _v.index 是 groupby 切片保留的原始 RangeIndex，不是日期
rec[_tag] = ",".join(_v.index.astype(str).str[:4]) + "|" + ...

# 修复后
_yrs = g.loc[_v.index, "end_date"].astype(str).str[:4].tolist()
```

`str[:4]` 作用在行号上会得到 `6066`、`3610`、`3611` 这类数值（行号 36100~36109 全部截成 `"3610"`），
年份字段完全失效。**校验结果：修复前大量个股年份非递增，修复后 6733 只股票中 0 只违反严格递增。**

**缺陷 3：`marginal_roic`（边际 ROIC）大面积缺失**

原实现固定用 `iloc[-4]` 作基准年，且只接受 `Δ投入资本 > 0`，导致
（a）受缺陷 1 影响基准年错位、`ΔIC` 常为负；（b）资本收缩（回购/剥离）的公司被直接丢弃。
中证2000 曾有 **552 只（27.6%）** 缺失。

修复：改为按定义 **ΔNOPAT / Δ投入资本**（`NOPAT = roic × invest_capital`），
窗口优先 3 年（与本引擎其余「近三年」口径一致），不足时依次放宽到 5 / 4 / 2 / 1 年。

**缺陷 3b：边际 ROIC 的分母爆炸（修完缺陷 3 后暴露出来的新问题）**

只要求 `ΔIC > 0` 是不够的。ΔIC 趋近 0 时比值会爆炸，实测分布：

| 门槛 | 覆盖率 | p1 | p95 | 最大值 | 落在 [−50,100] 之外 |
|---|---|---|---|---|---|
| 无（仅 ΔIC>0） | 86.9% | −1164% | +107% | **+985,069%** | 17.5% |
| ΔIC ≥ 5% 当期资本 | 82.7% | −231% | +59% | +2,058% | 10.7% |
| ΔIC ≥ 20% 当期资本 | 69.1% | −91% | +35% | +328% | 3.0% |

即使把门槛提到 20%，尾部依然无界——因为 ΔNOPAT 的波动可以远大于 ΔIC，
所以单靠门槛解决不了。最终采用两段处理：

1. **实质性门槛 5%**（`RONIC_MATERIALITY`）：增量资本至少要达到当期资本基数的 5%。
   这也符合经济直觉——三年累计只增投不到 5% 资本的「边际回报」在统计上不可测；
2. **截断到 [−50%, +100%]**：B3 本就按 `边际ROIC − 当期ROIC` 截在 ±8pp，
   截断几乎不改变评分，但让看板展示诚实。详情页字段名已标注「截断±」。

修复后中证2000 的边际 ROIC 分布回到合理区间，且不再出现六位数的百分比。

**缺陷 4：金融股的 ROA 被子虚乌有地留空**

tushare 的 `roa` 字段对银行/保险/证券结构性为空，但 **ROA 对银行恰恰是最核心的指标**。
修复：用 `归母净利润 / 总资产` 自行补算，并置标记 `_roa_basis = "净利/总资产(补算)"`。

**分红口径加固**：同一财年的「中期 + 年度」两条「实施」分红记录会占掉两个「年」的名额，
已在取近 3 个财年前先按 `(ts_code, end_date)` 聚合。

**看板侧同步改进**：

1. 新增**「护城河评级依据」面板**——直接把三项门槛的取值摆出来：
   ① 平均经济利润溢价（含「>0 通过 / <=0 不通过」判定）、② 逐年正溢价年数 / 可得年数、
   ③ A 得分，以及**回报口径（ROIC 还是 ROE 代理）**。任何个股的评级都可当场复核，不必猜。
   该面板位于个股详情的**漂浮层**中（见下方第 4 条）。
2. **金融股结构性不适用的字段明确标注「不适用（金融）」**，而不再留白——银行没有毛利率、
   没有流动比率、负债是经营原料而非杠杆。纳入标注的字段：`gm_*`、`expense_avg5`、`rd_to_rev`、
   `netdebt_to_ebitda`、`ar_growth_minus_rev_growth`、`op_leverage`、`current_ratio`、`quick_ratio`、
   `capex_to_rev`、`capex_to_da`。「空」与「不适用」是两件事，看板不再混淆。
3. 新增 `marginal_roic_years` 字段，展示边际 ROIC 实际使用的计算窗口。
4. **个股详情改为漂浮层（浮层弹窗）**：取消原来的「② 个股得分详情」独立标签页，
   改为在全成分股排名中点击任意一行即弹出居中浮层，带半透明遮罩与背景虚化。
   关闭方式有三种：右上角 ✕、点击遮罩、按 Esc。浮层内部独立滚动，关闭按钮固定在浮层右上角。
   标签页相应简化为 ① 全成分股排名 / ② 分布与统计 / ③ 打分机制说明。
5. **移除「建议」列与顶部「建议买入」统计框**：两者都直接输出买/卖结论，与「星级」「分档」
   表达的信息重复，且容易让使用者把模型输出误当成投资建议。底层的 `action` 字段仍保留在数据中，
   因此「仅看建议买入」筛选项继续可用（如需一并移除请说明）。
6. **「买入分档」改名为「分档」**：列标题、筛选下拉、个股详情 KPI、CSV 表头同步改为「分档(含S+)」，
   「非买入档」改为「未入档」。
7. **字号体系重做为 rem 阶梯 + 视口自适应**：原先各处散落的 `px` 字号（10/11/11.5/12/13/14/18/26）
   统一收进 `:root` 的 7 级变量 `--fs-2xs…--fs-2xl`（全部 `rem`）。因此
   **浏览器缩放与「默认字号」设置会整站等比放大**，不会出现某些元素跟着放大、另一些不动的错位。
   另外加了 4 条视口媒体查询：≥1900px 放大到 106%、≥2400px 放大到 114%（4K 屏下不至于字太小），
   ≤1280px 收到 96%、≤1024px 收到 92%（减少横向溢出）。
   表格数据单元（`td`）由 12px 提到 13px 并统一 `tabular-nums` 等宽数字对齐，表头（`th`）压到 11px
   并加字距，形成「表头轻、数据重」的层级；代码与名称两列加 `td.em` 提亮。
8. **总入口页 `out/index.html` 新增全市场个股搜索**：内嵌 3550 只个股的紧凑索引
   （代码 / 名称 / 行业 / 所属指数 / 综合分 / 护城河 / 星级 / PE / P/FV / 安全边际，约 250 KB）。
   支持按**代码前缀、代码片段、名称关键字**三种方式命中（代码命中优先，同级按综合分降序），
   键盘 ↑↓ 选择、Enter 打开、`/` 快捷聚焦、Esc 清空。点选结果直接跳到对应指数看板，
   并通过 **URL hash 深链**（如 `csi300/dashboard/index.html#600519.SH`）自动展开该个股的漂浮详情层。
   首页同时：把「交付文档」改名为「**打分逻辑**」；把原先无链接而显示为灰色的「03 各指数打分结果」
   改为 **4 个可点击链接**（与 01/02 同为蓝色）；移除「每日更新」操作说明，
   仅保留「生成时间」。

> 缺陷 1 与缺陷 2 都会**系统性地影响全部四个指数的得分**，属于底层数据正确性问题；
> 修复后各指数的评级分布会与修复前不同，这是修正而非回归。

### 9.4 全量数据真值审计（2026-09-19 第二轮）

针对「成分股是否有遗漏、每个数据是否精准」的质疑，用 tushare 原始接口做了逐项对照。

**一、成分股：与 tushare 官方名单完全一致**

| 指数 | 指数代码 | tushare 快照日 | tushare 只数 | 本地只数 | 本地缺失 | 本地多余 |
|---|---|---|---|---|---|---|
| 沪深300 | 000300.SH | 20260831 | 300 | 300 | **0** | **0** |
| 中证500 | 000905.SH | 20260831 | 500 | 500 | **0** | **0** |
| 中证1000 | 000852.SH | 20260831 | 1000 | 1000 | **0** | **0** |
| 中证2000 | 932000.CSI | 20260831 | 2000 | 2000 | **0** | **0** |

逐一比对（`index_weight` 最新快照 vs 本地 `members_*.parquet`），**四个指数零缺失、零多余**，
快照日也完全一致。审计脚本：`tools/audit_members.py`。

> **关于平安银行（000001.SZ）**：它确实**不在上证50**——上证50（000016.SH）**只纳入上交所股票**，
> 而平安银行是**深市**代码（tushare `area=深圳`），结构上不具备入选资格。
> 它真正所属的是**沪深300**。本项目原先未覆盖沪深300，现已补上：
> **平安银行现在纳入打分**，在沪深300 中排名 **62 / 300**，综合分 **63.0**，
> 护城河「**狭窄**」（ROE 口径，5 年平均 ROE 9.63%），**5★**，买入分档 **S · 好生意+好价格**；
> 合理市净率 1.10 对市场 PB **0.485**，安全边际 **58.8%**。

**二、行情与估值数据：逐项零偏差**

对沪深300 全部 300 只，把本地值与 tushare `daily_basic`（交易日 20260918）逐项相减：

| 字段 | 偏差超阈值的项数 |
|---|---|
| `pe_ttm` | **0** |
| `pb` | **0** |
| `ps_ttm` | **0** |
| `dv_ttm` | **0** |
| `total_mv` | **0** |

（`total_mv` 本地按 tushare 原始单位「万元」存储，看板经 `DISPLAY_MULT[total_mv]=1e-4`
换算为亿元后展示，校验的是原始值本身。审计脚本：`tools/audit_csi300.py`。）

**三、本轮修复的三个真实缺陷**

**缺陷 5：`bps` / `eps` 被加载却从未汇总（导致金融股无法估值）**

`bps`、`eps` 一直在 `FI_COLS` 采集清单里、原始值也确实取到了，但
`build_annual_features()` 只对 `roic/roe/gm/nm/roa` 做了 5 年均值汇总，**漏了 bps 和 eps**。
连带后果是 `scoring.py` 里 `pe_prof = S(df.get("eps"), ix)` 这条兜底其实是**死代码**
（该列根本不存在）。缺 `bps` 直接导致无法做市净率估值——而市净率恰恰是银行唯一有意义的口径。

修复后：`bps_last` / `bps_avg5` / `eps_last` 在沪深300 的覆盖率从 **0% → 100%**，
并增加 bps 兜底（tushare 缺失时用 归母净资产 ÷ 总股本 补算）。

**缺陷 6（严重）：DCF 被错误地套用在金融股上，伪造出巨大的假安全边际**

这是本轮最危险的一个。修正 DCF 缺口时发现，部分金融股**并非**缺 DCF，而是**算出了一个荒谬的值**：

| 个股 | 原 `fv_dcf` | 现价 | 虚高倍数 |
|---|---|---|---|
| 兴业银行 601166.SH | **293.72 元** | 17.78 元 | **16.5 倍** |
| 中国人寿 601628.SH | **425.16 元** | 37.80 元 | 11.2 倍 |
| 中国太保 601601.SH | 215.22 元 | 32.08 元 | 6.7 倍 |
| 交通银行 601328.SH | 26.77 元 | 7.46 元 | 3.6 倍 |

根因：银行 / 保险 / 券商的「经营现金流」包含**存款、保费、客户保证金的进出**，
属于资产负债表流量而非自由现金流。tushare 给出的 `fcf`/`fcff` 对它们是量级失真的
（实测兴业银行 fcf0 = **4110 亿**、中国人寿 = **6040 亿**）。这些假 DCF 值经
`P/FV = 现价 ÷ 公允价值` 换算后，**反过来伪造出 76% 的安全边际**，
把高杠杆金融股推成「极度低估」——这比留空危险得多。

修复：**金融股整条 DCF 关闭**（`fv_dcf`/`fcf0`/`enterprise_value` 一律置空），
估值锚改用**轨道三：合理市净率法**（见 §5.3）。修正后：

| 个股 | 修正前合并公允价值 | 修正后 | 现价 |
|---|---|---|---|
| 兴业银行 | 75.09 元 | **47.07 元** | 17.78 元 |
| 中国平安 | 106.29 元 | **80.24 元** | 53.37 元 |
| 中国人寿 | 79.23 元 | **48.77 元** | 37.80 元 |

**缺陷 7：空值没有原因说明**

此前看板对空值只显示一个「—」，用户无法分辨是**结构性不适用**、**口径不成立**，
还是**数据确实抓漏了**。现在每一个空值都会附上具体原因（悬停可见完整说明）：

| 原因 | 沪深300 中的数量 |
|---|---|
| 不适用（金融） | 600 |
| 市净率法仅用于金融股 | 494 |
| tushare 无此项 | 338 |
| 近3年无分红记录 | 239 |
| 非买入池·价格分位不适用 | 195 |
| 近5年有亏损·盈利估值不适用 | 52 |
| 不适用（金融·DCF口径失真） | 51 |
| DCF股权价值为负（净债务>企业价值） | 41 |
| 基期亏损·无法算增长率 | 30 |
| 亏损·市盈率不适用 | 29 |
| 无有息负债·该倍数无意义 | 19 |
| 现金流或净利润存在负值 | 17 |
| 自由现金流无法正常化 | 13 |
| 未披露研发费用 | 1 |

**沪深300 的 2119 个空值现在 100% 带原因说明，光秃秃的「—」为 0 个。**

> 其中「**无有息负债·该倍数无意义**」值得单说：几乎没有有息负债的公司利息费用趋近于零，
> 利息保障倍数在数学上是无穷大——留空比瞎填一个数更诚实。

**四、仍存在的真实缺口（如实披露）**

1. `fv_dcf` 在沪深300 的覆盖率为 **65.0%**（195/300）。缺口 105 只 = 54 只金融股（**设计如此**，
   估值改用市净率法）+ 51 只为**净债务高于 DCF 企业价值**的重资产或高杠杆公司
   （中国建筑、华电新能、部分电力与建筑股）。后者是**有意义的经济结论**而非缺陷，看板已标注原因。
2. `fv_pb` 覆盖率随市值下沉而下降（沪深300 17.7% → 中证2000 1.1%），因为它**只对金融股启用**，
   而金融股在指数中的占比本来就随规模下沉而降低。中证2000 的 35 只金融股中有 22 只算得出，
   缺的是 ROE ≤ 0 或每股净资产缺失的小型金融机构——市净率法对亏损金融股不成立，属于正确留空。
3. 中证500/1000/2000 的 `fv_dcf` 缺口分别为 152 / 202 / 389 只，主要是**金融股 + 亏损公司**，
   与沪深300 同理。

### 9.5 分档覆盖全部成分股（2026-09-19 第三轮）

**问题**：看板上「分档」这一列大面积空白——沪深300 **65.0%**、中证500 63.8%、中证1000 65.4%、
中证2000 69.1% 的成分股该列为空，「价格分」同样是空的。

**根因**：`buy_tiers()` 的这两行——

```python
is_buy = df["action"].eq("买入")            # 只认 5★
p = mos.where(is_buy).rank(pct=True)        # 价格分只在买入池内排名 → 其余 NaN
tier = pd.Series("—", index=ix)             # 默认「—」，只有买入池被赋值
```

分档本来只定义为「5★ 名单内部的二级细分」。这在只覆盖 50 只的上证50 上不明显，换成 300 只的
沪深300 后，非 5★ 占 65%，空白就非常刺眼。

**修复过程中的一个关键发现**：最直觉的做法是把「好价格」也改成全指数分位，但**实测不可行**。
`非5★中 MOS>0 的 = 0`——所有非 5★ 个股的绝对安全边际都 ≤ 0。原因是星级用的是**中性化** P/FV
（沪深300 市场温度 1.77，绝对 P/FV = 1.5 的个股中性化后只有 0.85，足以入选 5★）。
因此买入池 **100% 都落在全指数便宜的前 40%**；若统一用全指数分位，池内全部被判「便宜」，
**A 档（好生意 · 价格待回落）会从 39 只直接归零**，分档随即失去区分度。

**最终方案**：

| 池 | 档位 | 「好价格」基准 |
|---|---|---|
| 5★ 买入池 | S+ / S / A / B / C | **池内**安全边际分位 ≥ 60%（维持原逻辑，A 档保留 39 只） |
| 非 5★ | D / E / F | **全指数**价格分位 ≥ 60% |
| 无法估值（扣非净利为负） | G | 无价格维度，只判护城河 |

同时把 `price_score` 改为**全指数**口径（统一基准才能覆盖全部个股）。

**结果**：

| 指数 | 修复前 buy_tier 空白 | 修复后 | price_score 空白 |
|---|---|---|---|
| 沪深300 | 195 / 300（65.0%） | **0** | 11（全部为 G 档） |
| 中证500 | 319 / 500（63.8%） | **0** | 23 |
| 中证1000 | 654 / 1000（65.4%） | **0** | 62 |
| 中证2000 | 1382 / 2000（69.1%） | **0** | 209 |

`price_score` 剩余的空白**全部是 G 档**（扣非净利润为负，安全边际无定义），
看板对其标注「扣非净利为负·无法估值故无价格分」，不再是无解释的「—」。

看板同步改动：分档卡片从 5 张扩到 9 张（8 档 + S+），幅度条基准改为
「S/A/B/C 相对买入池、D/E/F/G 相对全指数」；档位筛选下拉自动包含新档；
个股详情的「为什么是这一档」面板现在对**全部个股**显示。

### 9.6 新增「中证全指」全市场视图 + 看板体积优化（2026-09-19 第四轮）

**一、新增第五个指数：中证全指（000985.CSI，5122 只）**

用户要求增加一个「全市场个股数据情况」的页面。`000985.CSI` 在 tushare `index_weight` 中
可用（快照 20260831，5122 只，权重合计 100.04%）。

它被**刻意放在 `UNIVERSE_ORDER` 的末尾**，而不是排在四个规模段之间——因为它是前四个的**超集**，
不是第五个规模段。这一点在 §9.1 的解读里已单独说明。

**二、抓取时发现并修复的限频缺陷**

5122 只 × 5 个逐股数据集 = 25,610 次 API 调用。首轮抓取每个数据集都固定失败约 110–122 只
（覆盖率只有 97.6%–97.9%）。根因是 `fetch_supplement.py` 的节流设置：

```python
time.sleep(0.02)      # ≈ 3000 次/分钟
```

而 tushare 对逐股接口限制 **500 次/分钟**。修复两处：

1. **节流调到 0.13 秒**（≈ 460 次/分钟），稳定落在限额内；
2. **加入断点续抓**：已有结果被复用，只补抓缺失的个股（原先看到 parquet 存在就直接返回，
   缺失的个股永远补不回来）。

修复后补抓一轮：**5 个数据集全部达到 5122/5122，0 失败**。

**三、看板体积优化：JSON 列式打包（14.4 MB → 4.4 MB）**

中证全指看板首次生成时 **14.09 MB**，浏览器打开会明显卡顿。定位到两个原因：

1. **字段名逐行重复**。payload 是 `[{字段:值, ...}, ...]`，116 个字段名每行都要写一遍，
   5122 行 × 约 18 字符 ≈ **12 MB 全是重复的 key**。
   改为**列式**：`{fields:[...], rows:[[...]]}`，字段名只写一次，前端一行代码还原成对象数组。
2. **每行一个「空值原因」字典**。规则其实只依赖该行自身的数据
   （`industry` / `margin_of_safety` / `enterprise_value` / `netdebt_last`），
   所以把 `_na_reason` **移植到前端** `naReason()` 现算，payload 里不再携带。
   Python 侧保留一份参考实现供审计脚本核对，已验证**两者对 132 组（空值,字段）完全相同**。

| 看板 | 优化前 | 优化后 | 降幅 |
|---|---|---|---|
| 沪深300 | 0.88 MB | **0.32 MB** | −64% |
| 中证500 | 1.40 MB | **0.49 MB** | −65% |
| 中证1000 | 2.74 MB | **0.91 MB** | −67% |
| 中证2000 | 5.52 MB | **1.74 MB** | −68% |
| **中证全指** | **14.09 MB** | **4.35 MB** | **−69%** |

首页搜索索引同步扩到 **8922 只**（300+500+1000+2000+5122），
五个指数的卡片、看板、报告、CSV 全部生成；首页对「中证全指」单列一张带
「全市场」标记的卡片并注明它不参与规模段对比。

### 9.7 全量数据二次核对（2026-09-19 第五轮）

对网页上出现的**每一个数据**做了一次独立于生成代码的复核。核对脚本 `tools/recheck_all.py`，
结果在 `data/_recheck.txt`。三段链路：

**A. 序列化链（网页 payload ←→ scored parquet）**

逐行逐字段比对「网页上真正渲染的那个值」与 parquet 值 × 单位倍率取整后的结果：

| 指数 | 比对单元数（个股 × 字段） |
|---|---|
| 沪深300 | 30,300 |
| 中证500 | 50,500 |
| 中证1000 | 101,000 |
| 中证2000 | 202,000 |
| 中证全指 | 517,322 |
| **合计** | **901,122** |

**全部一致，0 处不符。**

**B. 源头链（parquet ←→ tushare 原始缓存，独立重算）**

不看生成代码，直接从 `daily_basic_<交易日>.parquet` 与 `fina_indicator.parquet` 重算再比对：

- `pe_ttm` / `pb` / `ps_ttm` / `dv_ttm` / `total_mv` / `price`：**全部 0 偏差**
- `roic_avg5` / `roe_avg5` / `gm_avg5` / `nm_avg5` / `roa_avg5` 五年均值独立重算：**全部 0 不符**
- `roic_series` 的年份严格递增：通过

**C. 内部恒等式**

| 校验项 | 结果 |
|---|---|
| `A+B+C+D+E − 排雷扣分 == net_score` | 最大偏差 **0.000000** |
| `total_score == clip(net_score, 0, 100)` | 完全一致 |
| 排名严格覆盖 1..N 且无重复 | 通过（5 个指数全部） |
| 排名与总分单调一致 | 通过 |
| 护城河三项门槛复算 == `moat` 字段 | 不符 **0 只** |
| 指数权重合计 = 100% | 100.0000% / 100.0030% / 100.0130% / 100.0370% |
| `meta` 各项 == 明细行统计 | 通过 |

#### 本轮核对暴露并修复的 2 个真实缺陷

**缺陷 8（严重）：tushare 财务表的「更正前后」两行被随机取舍**

tushare 的 `fina_indicator` / `income` / `balancesheet` / `cashflow` 对同一
`(ts_code, end_date)` 常返回**两行**：`update_flag=0` 是首次披露值、`update_flag=1` 是更正后的值。
关键在于**这两行的 `ann_date` 往往完全相同**——实测：

| 表 | 总行数 | 重复的组合数 | 其中 `ann_date` 相同 | 两个版本 `roic` 不同 |
|---|---|---|---|---|
| `fina_indicator` | 144,356 | 51,672 | **51,022** | **1,840** |
| `income` | 116,093 | 32,081 | 31,513 | — |
| `balancesheet` | 102,975 | 20,713 | 20,124 | — |
| `cashflow` | 93,587 | 21,567 | 21,280 | — |

原来 `latest_per()` 只按 `ann_date` 排序再 `drop_duplicates(keep="last")`。`ann_date` 相同时
pandas 的默认快排**不稳定**，保留哪一行取决于内部顺序 —— 也就是说，
**修正后的净利润、ROE、ROIC 有可能被更正前的旧值覆盖，而且是随机的**。
个别差异极大：`002463.SZ`（沪电股份）2025 年 ROIC，更正前 **50.11%** vs 更正后 **21.30%**。

修复：把 `update_flag` 作为次级排序键并使用**稳定排序**（`kind="mergesort"`），
保证「最新公告 + 已更正」的那一行胜出、且结果可复现。

**缺陷 9：被扣到负分的个股，网页上的分解式对不上**

`total_score = (raw_score − penalty).clip(0, 100)` 有 0 下限，但网页只展示 `total_score`。
于是像 `688573.SH`（信宇人）五维合计 16.18、扣 29 分、网页显示 0 分，
用户按「A+B+C+D+E−扣分」核算会得到 −12.82，**看起来像算错了**。

修复：新增 `net_score`（截断前的净分）字段，并在看板详情页新增
**「得分构成（可逐项核对）」** 分组，把 A/B/C/D/E、五维合计、扣分、净分、最终得分、排名
全部列出，分解式当场可以对上。

**同时修复**：`rank` 原先用 `method="min"`，同分并列会共享名次——被扣到 0 分的有 4 只并列，
导致中证全指 5122 行只产出 5119 个名次。改为「总分降序 + 代码升序」的确定性排序后赋予
严格连续的 1..N。

### 9.8 看板视觉重做（去「AI 感」）

原来的配色是高饱和藏青底 + 霓虹蓝强调 + 渐变页头 + 发光聚焦环 + 满屏 1px 边框，
是典型的「AI 生成看板」观感，长时间盯数据容易疲劳。本轮做了系统性重做：

| 维度 | 原来 | 现在 |
|---|---|---|
| 底色 | 藏青 `#0f1420`（偏蓝） | 中性石墨 `#0e1014` |
| 强调色 | 霓虹蓝 `#4da3ff` | 低饱和雾蓝 `#7ea6e8`，且只用于可交互项与关键状态 |
| 页头 | 深色渐变 + 1px 描边 | 纯色平面 + 发丝分割线 |
| 区域划分 | 每块都画边框 | 靠留白 + 发丝线 + 层级底色 |
| 表格行分隔 | 每个单元格画下划线 | **斑马纹**（视线才能跟住行） |
| 护城河/趋势 | 实心胶囊色块 | **圆点 + 文字**（大幅降低色块噪声） |
| 标签页 | 实心填充胶囊 | 下划线式（更接近编辑器/文档的成熟范式） |
| 悬停反馈 | 卡片上浮 + 投影发光 | 仅边框与底色变化 |
| 聚焦环 | `0 0 0 2px rgba(77,163,255,.18)` 发光 | `0 0 0 3px` 低透明度柔和光晕 |
| 图形符号 | `⌕` 字符、`▾▴` 三角、`⚠` 警告符 | 内联 SVG 放大镜、`↑↓`、小圆点 |
| 圆角 / 间距 | 14px 圆角、间距随手写 | 统一 6/9/12px 三档圆角 + 4px 基准间距 |
| 数字对齐 | 部分列未对齐 | **全表 `tabular-nums`**，小数点逐列对齐 |

细节处理：

- 分档卡片不再「上浮 + 发光」，改为边框与底色变化；幅度条从 3px 降到 2px。
- 详情页的键值列表（`.dl`）用**交替底色**代替虚线，长列表更安静。
- 弹层右上角关闭按钮改为中性色，不再用红色渐变。
- 页头 KPI 组改成「1px 缝隙的分隔带」结构，比 8 个独立小卡片更整齐。
- 风险标签由 `⚠ 文字` 改为「小圆点 + 文字」。
- 首页同步采用同一套设计语言；搜索图标换成内联 SVG，结果行去掉边框只留发丝分隔。

> 校验：CSS 变量审计显示 5 个页面 + 首页**零个未定义引用**；
> 用 headless Chrome 实际渲染验证，沪深300 渲染 300 行、中证全指渲染 5122 行，JS 执行正常。

### 9.9 每日更新与站点产物的绑定（2026-09-20）

**问题**：用户反馈「网站上的数据和页面要绑定每日更新打分」。核查后确认**存在断点**——
`update_daily.py` 逐指数重建了看板与报告，但**从未重建首页** `out/index.html`；
`每日更新打分.bat` 里也只有 `if not exist` 才构建首页。后果是：每日更新之后，
**首页仍是旧数据、旧搜索索引、旧生成时间**。

另外发现 `tools/sensitivity_discount.py` **已经坏了**——它读的是早期单指数时代的
`data/features.parquet`（该文件早已被 `features_<universe>.parquet` 取代），
一跑就 `FileNotFoundError`。首页「打分逻辑 04」链接的
`out/sensitivity_discount_rate.md` 因此停留在 **2026-09-17**，而看板已经是 09-19。

**修复**：

1. **`update_daily.py` 新增 `rebuild_site()`**，在所有指数跑完之后统一重建站点级页面：
   折现率敏感性分析 → 首页。顺序不能颠倒——首页要从每个指数的 `data.json` 读 meta，
   并重建全市场搜索索引（8922 只）。
2. **`sensitivity_discount.py` 重写为多指数版**：改为读 `features_path_for(u)`，
   对 `UNIVERSE_ORDER` 中每个指数各跑 6 个折现率情景，输出含 5 个小节的对照文档。
3. **`每日更新打分.bat` 去掉 `if not exist` 守卫**，改为无条件重建首页（兜底：
   即使 `update_daily.py` 中途失败，也不会留下过期首页），并在开头列出本次会重建的全部页面。
4. **新增 `verify_artifacts()`**：每轮结束打印全部 22 个产物的「更新 / 未更新」状态与体积。
   没有这一步，某个指数静默失败时用户只会看到一个没更新的页面而无从察觉。

**绑定后的产物清单**（每跑一次全部重建）：

| 产物 | 内容 |
|---|---|
| `out/index.html` | 首页：5 个指数统计卡片 + 8922 只个股搜索索引 |
| `out/<指数>/dashboard/index.html` | 各指数看板（5 个） |
| `out/<指数>/dashboard/data.json` | 看板数据（首页从这里读 meta） |
| `out/<指数>/dashboard/ranking.csv` | 排名明细 |
| `out/<指数>/03_<指数名>打分结果.md` | 打分结果报告 |
| `out/<指数>/03b_<指数名>完整排名.md` | 完整排名 |
| `out/sensitivity_discount_rate.md` | 折现率口径敏感性分析 |

**端到端验证**：记录基线时间戳 → 跑一次完整 `python update_daily.py --skip-fetch`（291 秒）
→ 独立复核（不依赖脚本自检）：

- 17 个关键文件**全部刷新，0 个过期/缺失**（首页 23:51 → 00:04，看板 23:46 → 00:00~00:03）
- 首页内嵌「生成时间」= 本轮时间
- 首页 5 张卡片的成分股数与平均分**与各看板 `data.json` 的 meta 逐项一致**
- 敏感性文档从 2 KB 恢复到 **8.1 KB，含 5 个指数小节**

> 顺带得到一个有说服力的结论（已写进敏感性文档）：**相对星级口径对折现率的选择几乎免疫**。
> 以中证全指为例，折现率从 2.3% 拉到 12%：
> 「5星（绝对口径）」从 **1607 只塌缩到 125 只**（差 13 倍），
> 而「5星（相对口径）」仅在 **1583 ~ 1744 只**之间波动。
> 这正是本体系默认采用相对口径的原因。

### 9.10 风险提示与更新时段（2026-09-20）

**一、每个页面都加了风险提示**

原先只有「打分机制说明」标签页里有一句提示，且首页完全没有。现在 **6 个页面**
（5 个看板 + 首页）统一带两处提示：

1. **页头紧凑提示**：`模型输出 · 不构成投资建议`（琥珀色小圆点标签，与「市场温度」并列）
2. **页脚完整声明**（`.riskfoot`）：说明评分/评级/内在价值/分档均为**量化模型的机械输出**，
   不构成投资建议、收益承诺或买卖要约；并逐条列出风险来源——数据误差、口径局限、
   参数主观性、长周期财务数据可得性有限、历史规律不代表未来；
   以及A股特有的退市、停牌、商誉减值、股权质押、财务造假等**模型无法完全识别**的个体风险；
   结尾「**股市有风险，入市需谨慎**」与「本工具仅用于研究与学习，**不提供任何荐股服务**」。

主页脚同时标注数据溯源信息（数据来源 / 数据日期 / 生成时间 / 更新时段）。

**二、首页注明「每日数据更新 19:00」**

首页页脚明确写入 `每日数据更新 19:00`，看板页头与页脚也同步标注。
但**页面写 19:00，更新就必须真的在 19:00 发生**，否则这句话是误导。因此配套提供：

| 文件 | 作用 |
|---|---|
| `注册每日19点自动更新.bat` | 一键注册 Windows 计划任务「A股量化打分每日更新」，每天 19:00 运行 |
| `取消每日19点自动更新.bat` | 删除该计划任务 |
| `tools/自动更新_静默.bat` | 计划任务实际调用的入口：不暂停、不开浏览器，输出追加到 `out/update_log.txt` |

计划任务是**当前用户级**的（不需要管理员权限）；电脑需处于开机状态，错过的时段不会补跑。
**该任务不会自动注册**，需手动双击一次「注册每日19点自动更新.bat」；
不注册也不影响手动运行「每日更新打分.bat」。

**验证**：`tools/_risk_check.py` 逐个页面检查五项文本
（风险提示 / 免责声明 / 股市有风险 / 19:00 / 不荐股）与占位符残留——
**6 个页面全部通过，缺失项 0，占位符残留 0**；
headless Chrome 渲染确认 `.riskfoot`、`.rftitle`、`.warnbadge` 均实际出现在 DOM 中。

### 9.11 看板改为「可视化为主」（2026-09-20）

应用户要求，5 个看板（**不含首页**）的视觉风格改为参考一张金融仪表盘设计稿：
**暖调深灰底 + 大圆角卡片 + 大号数字 + 柔绿进度条/环形图 + 图标芯片**，
并把「能够量化的数据」尽可能用图形而不是纯文字表达。

**一、配色：冷蓝 → A股红/黄/蓝（**全站零绿色**）**

> ⚠ 首版误用了「柔绿」作主色，被指出不合A股习惯后整体推翻重做。
> 原因很实在：**A股的红绿含义与欧美相反——红是涨、绿是跌**，
> 把绿当作主品牌色在中文财经语境里是明确的错误。
> 因此第二轮改为**红 / 黄 / 蓝**体系，并立了一条硬规则：**全站不出现任何绿色系色值**
> （已用色相判定脚本逐文件复查：`engine/dashboard.py`、`tools/build_landing.py`
> 的绿色 hex 与 rgba **均为 0**）。

语义约定：

| 色 | 含义 |
|---|---|
| **红 `#e2574a`** | 主强调色；强、涨、高优先（宽阔护城河、S 档、正面趋势） |
| **黄 `#e8b44b`** | 次强调；警示、中性偏注意（狭窄护城河、B 档、高不确定性） |
| **蓝 `#6b9fd8`** | 第三色；弱、跌、负向（**代替绿表示下跌**，如负面趋势、1★ 之外的低分档） |
| 灰 | 无关 / 无数据 |

| 角色 | 首版（被否） | 现在 |
|---|---|---|
| 页面底色 | `#131311` 中性暖黑 | `#141110` **暖黑带红棕** |
| 卡片底 | `#1d1d1a` | `#1e1a18` |
| 主强调色 | 柔绿 `#b3dd8c` | **红 `#e2574a`** |
| 次强调色 | 黄绿 `#d9e88f` | **金黄 `#e8b44b`** |
| 负向 / 下跌 | 绿系 | **蓝 `#5b8fd4`** |
| 表格斑马纹 / 表头 / 悬停 / 选中 | 冷灰 → 中性灰 | 统一带红棕味的暖灰（`#221d1b` / `#2a2320` / `#302825` / `#3a2a27`） |
| 圆角 | 8 / 9 / 12px | **8 / 14 / 18px**（更接近设计稿的圆润感） |

分级配色（保证同级可区分、跨级有秩序）：

- **护城河**：宽阔 = 红，狭窄 = 橙 `#e89a4b`，无 = 暖深灰
- **分档**：S 红 → A 橙 → B 黄 → C 灰 → D 蓝 → E 浅蓝 → F 深灰 → G 灰褐
- **星级**：5★ 金 → 4★ 红 → 3★ 橙 → 2★ 灰 → 1★ 暗（品质递减，不引入蓝色）
- **五维**：A 红 / B 橙 / C 黄 / D 蓝 / E 紫
- **不确定性**：低 蓝（凉）→ 中 灰 → 高 黄 → 非常高 橙 → 极端 红（热）

**二、新增可视化：把量化数据画出来**

排名页顶部新增 3 张可视化摘要卡（随筛选实时重算，与下方表格是同一份数据）：

| 卡片 | 图形 |
|---|---|
| **护城河构成 + 分档构成** | 两条**堆叠条** + 图例（每段带只数与百分比） |
| **五维平均得分** | 5 条**横向进度条**（对照各维度满分，形如设计稿的 `Essentials 70%`） |
| **综合得分分布** | **迷你柱状图**（10 个分档）+ 中位数/均值/样本数 |

「分布与统计」页整体升级：

| 图表 | 原来 | 现在 |
|---|---|---|
| 护城河评级 | 三条横条 | **环形图**（中心显示「护城河达标率 34.7%」，形如设计稿的 68% Growth 环） |
| 综合得分分布 | 细柱 + 刻度 | 圆角柱 + 渐变 + 中位数/均值图例 |
| 五维得分 / 不确定性 / 趋势 / 行业 Top20 | 灰色细条 | **统一进度条**（柔绿/黄绿系，带数值与百分比） |

页头 KPI 也重做为设计稿那种卡片：**图标芯片（内联 SVG）+ 大号数字（27px）+ 说明副标题**，
例如「宽阔护城河 / 24 / 占比 8.0%」。

**三、验证**

| 检查 | 结果 |
|---|---|
| headless Chrome 渲染（沪深300 / 中证全指） | `vizsum` 1、`vcard` 3、`stack` 2、`prow` 9、`spark` 3、`legend` 6、KPI 图标 7 个，**JS 报错 0** |
| 「分布与统计」页单独探针（强制切页后 dump DOM） | 环形图中心 `34.7% / 护城河达标率`；五维 5 条、不确定性 5 条、趋势 3 条、行业 19 条进度；**JS 报错 0** |
| **绿色系色值全站复查**（按色相判定，逐文件） | `engine/dashboard.py` **0**；`tools/build_landing.py` **0** |
| 旧冷色残留（`#4da3ff` `#2ecc8f` `#181c23` 等） | **0** |
| CSS 未定义变量引用 | **0** |
| `tools/verify_all.py` | **114 通过 / 0 失败** |

> 首页也一并切到同一套配色（只换颜色，版式、内容、结构都没动）——
> 否则从首页点进看板会出现冷暖两个色系的跳变。

### 9.12 页面比例与「上下脱节」修复（2026-09-20）

用户反馈「页面比例存在很大问题、上下脱节」。用**几何探针**（注入脚本把 `getBoundingClientRect()`
写进 `<title>`，再用 headless Chrome dump 出来）实测后，定位到 4 个可量化的成因：

| # | 问题 | 实测 | 后果 |
|---|---|---|---|
| 1 | **左右边缘没对齐**：`header` 全宽，`main` 限宽 `max-width:1800px` 居中 | 宽屏上标题与内容左边缘相差数百 px | 视觉上「上半页和下半页不是一页」 |
| 2 | **吸顶页头过高**：标题 + 副行 + 7 张大 KPI 卡全部吸顶 | **280px**，占 1080p 视口 26% | 常驻遮挡，内容区被压扁 |
| 3 | **表格上方叠了 6 条独立「带」**：页头 / KPI 卡片行 / 标签栏 / 筛选卡片 / 可视化卡 / 表格 | 表格起点 **768px** | 数据表被挤出首屏 |
| 4 | **信息重复**：可视化卡已有「分档构成」堆叠条，下面又排 9 张档位卡 | 9 张卡 ≈ 320px | 同一份数据说两遍，还多占一屏 |

**修复动作**

1. **统一栅格**：新增 `--maxw / --padx` 变量，页头内层 `.hwrap`、`main`、`.riskfoot`
   共用同一套 `max-width + padding`；页头改为 `padding:12px 0`（横向交给内层容器）。
   → 标题 / KPI / 标签页 / 可视化 / 表格 / 页脚**六个内容左边缘全部落在 24px**。
2. **页头压成一行**：标题左、数据日期等元信息右（`hwrap` 用 flex + `margin-left:auto`），
   **280px → 52px**（占视口 5.8%）。KPI 卡片移出吸顶页头，随页面滚动。
3. **减少分带**：筛选区不再单独成卡片（去掉边框与底色，改为「控件 + 一条发丝线」）；
   **删除 9 张档位卡**，其功能由可视化卡的「分档构成」色条与可点击图例承担
   （点色条或图例即筛选，比原来点卡片更快）。
4. **可视化卡瘦身**：护城河与分档两条由上下堆叠改为**左右并排**，两条图例**合并成一整行**；
   五维 6 行改**两栏 3 行**；图例去掉冗余百分比（占比已由色条长度表达），只留绝对只数。
5. **表格高度按视口计算**：`max-height:calc(100vh - 120px)`，去掉多余边框。
6. **矮屏媒体查询**（`max-height:820px`）：只压纵向间距与字号，不隐藏任何数据，
   让 1366×768 这类笔记本也能在首屏看到数据表。

**实测结果（几何探针，非目测）**

| 视口 | 页头 | KPI 行 | 可视化 | 表格起点 | 占视口 | 左缘对齐 |
|---|---|---|---|---|---|---|
| 1366×768（实际 1344×670） | 52px | 81px | 172px | **499px** | **74%**（原 94%） | ✅ |
| 1440×900 | 52px | 81px | 172px | 499px | 62% | ✅ |
| 1680×1000 | 52px | 103px | 172px | 521px | 58%（原 70%） | ✅ |
| 1920×1080 | 52px | 103px | 172px | 521px | 53% | ✅ |
| 2560×1400 | 56px | 109px | 179px | 550px | 42% | ✅ |

**同时修正**：`tools/verify_all.py` 里有一条断言仍在要求「至少 5 张档位卡」（`tcard`），
档位卡合并后该断言必然失败。已改为校验新版可视化元素
（`vcard ≥ 3` 且 `stack ≥ 2` 且 `legend ≥ 2`），并同步校验 KPI 数。**
> 这也是那条校验的价值所在：**它真的抓到了这次功能改动**。

### 9.13 个股弹窗修复 + 三处真实数据缺陷（2026-09-20）

用户反馈「点击个股后的弹窗比例有问题、分档有些字被挡住、下面数据也有问题」。
用弹窗几何探针（打开详情后量 `scrollWidth/clientWidth` 找溢出元素）逐项查证，
**四条反馈全部属实**，其中三条是**真实的数据缺陷**。

**一、弹窗布局**

| 问题 | 实测 | 修复 |
|---|---|---|
| 弹窗内 9 张 KPI 卡**全部溢出** | 卡宽 160px，但分档值需要 163px、可用仅 136px | KPI 列宽 `minmax(148px)`（页头用）→ 弹窗内单独用 `minmax(238px)`，排成匀称的 **5+4** 两行 |
| **分档徽章的「S+ · 双优且低于保守内在价值」被截断** | `.tier` 的 `white-space:nowrap` + 溢出 **27px**，整卡溢出 13px | 弹窗内的 `.tier` 允许换行（`white-space:normal` + `line-height:1.5`），溢出 **27px → 0** |
| 详情 8 个分组竖排过长 | 内容可滚 **2020px** | 分组改**双栏流式**（`column-count:2` + `break-inside:avoid`）→ 滚 **1838px** |
| 弹窗尺寸偏紧 | 1280×90vh | 1360×88vh，内边距同步收紧 |

**二、三处真实数据缺陷**

**缺陷 10：行业相对值用了「含自身的中位数」，导致 12.7% 的个股恒为 0**

`gm_vs_ind = 毛利率 − 行业中位数`。当行业成员数为**奇数**时，中位数恰好等于某一只股票自身，
那只股票必然得到 **0.00** —— 显示成「正好等于行业平均」，实际含义是「它就是中位数」。
实测沪深300 有 **38/300** 只的 `nm_vs_ind` 恒为 0、**36/252** 只的 `gm_vs_ind` 恒为 0。
A股行业分类很细（乳制品、啤酒、软饮料…常只有 1~2 只），这个问题极普遍。

修复：改为**剔除自身的中位数**（leave-one-out），并且行业只有自己时返回空（无同行可比）。

| 指数 | 修复前 `nm_vs_ind==0` | 修复后 |
|---|---|---|
| 沪深300 | 38 / 300 | **0 / 284** |
| 中证500 | 34 / 500 | **0 / 481** |
| 中证1000 | 47 / 1000 | **0 / 988** |
| 中证2000 | 60 / 2000 | **0 / 1991** |
| 中证全指 | 92 / 5122 | **0 / 5121** |

**缺陷 11（严重）：中证2000 的「扣除指数涨幅」列完全是假的**

留存测试的「扣除指数版」需要同期指数收益 `index_return_3y`。它读
`data/cache/index_daily_csi2000.parquet` —— **该文件根本不存在**。
原代码在取不到指数时把收益当成 **0**，于是 `mv_gain_excess_3y == mv_gain_3y`，
**中证2000 全部 1152 只个股的「扣除指数」列都精确等于原值**，看起来像算了其实没算。

修复两处：
1. **补数据**：重新抓取 `index_daily_csi2000`（1629 行），并给取数加**三级回退**
   （本指数 → 指数代码命名 → 沪深300 兜底）；
2. **不再静默降级**：取不到指数收益时，`mv_gain_excess_3y` 与 `retention_ratio_adj`
   **整列置空**，绝不冒充原值。

修复后中证2000 计入指数收益 **27.83%**，「两列相同」从 **1152/1152 → 234/1152**。

**缺陷 12：留存测试的截断区间过窄，25% 的个股撞界**

原截断 `(-5, 20)`，实测沪深300 有 **63/251（25%）** 被压到下界 −5，
「原值」与「扣除指数版」双双显示 `-5.00`，看起来像两列都没算。
修复：放宽到 `(-20, 20)`；并且**撞界时不再显示成精确数值**，而是显示
`≥ 20（已达截断界）` 或 `≤ −20（已达截断界）`。

复核确认：修复后所有仍「两列相同」的个股，**100% 都是撞同一截断界，真·巧合 0 只**。

**三、口径标注（数值本身没错，但容易误读）**

| 字段 | 修订后的标注 | 原因 |
|---|---|---|
| `bps_last` | 每股净资产（**最新年报**） | 与下面 PB 的口径不同 |
| `pb` | PB（**tushare 最新报告期**） | tushare 用最新季度净资产，所以 `现价÷bps_last ≠ pb`（例：112.70÷32.49=3.47，而 PB=3.61） |
| `gm_vs_ind` / `nm_vs_ind` | 相对**同行**（剔除自身中位数） | 与方法一致 |

**验证**：`tools/recheck_all.py` **0 处不一致**（90 万+ 单元格）；
`tools/verify_all.py` **114 通过 / 0 失败**；弹窗探针在被测的三个分辨率下
`.tier` 溢出 **0**、被裁切元素 **0**。

### 9.14 首页重做为「纯黑 + 红」落地页（2026-09-20）

按要求把首页改造成落地页风格：**顶部导航 + 大标题 Hero + 红色 CTA + 底部数据条**，
配色**主红辅黑**（纯黑 `#000000`，红 `#e5382b`）。**首页主体逻辑完全不变** ——
全市场搜索、5 个指数卡片、打分逻辑清单、风险提示页脚全部保留。

**一、新增的内容**

| 区块 | 内容 |
|---|---|
| 顶部导航（吸顶） | 品牌标（红色八角星 Svg）+ 锚点导航（指数看板 / 打分逻辑 / 风险提示）+ 右侧红色「进入网页」按钮 |
| **Hero 大标题** | **哪些股票<em>值得买</em>？／我拿数据告诉你！**（两行，`clamp(2.3rem, 5.2vw, 3.9rem)`，字重 800，行高 1.06，字距 −0.032em；「值得买」用红色强调） |
| 副标题 | 说明引擎、覆盖范围与指标数 |
| **红色 CTA** | 「进入网页」→ `href="#board"`，**平滑滚动到下方的「A股量化打分看板」区块** |
| **底部数据条** | `5` 个指数 ／ `5,122` 只个股 ／ `103` 项量化指标 ／ `19:00` 每日更新（四格，中间竖分隔线，顶部一条通栏细线） |
| 新增区块标题 | 「A股量化打分看板」（锚点 `#board`）与「打分逻辑」（锚点 `#logic`）—— 这就是要求里「多一个标题」的落地：保留原有内容，另加 Hero 主标题 |

**二、视觉手法（对齐参考图）**

- **纯黑底**，不用渐变、不用发光边框
- 右上角一处**低饱和红色径向光晕**；右侧叠加一层极淡的网格纹理，
  用 `mask-image` 只让它出现在右半边，避免干扰左侧文字
- 大字标题 + 小字副标题 + 红色按钮的强对比层级
- 导航、卡片、文档一律细发丝线（`rgba(255,255,255,.10)`）
- 卡片悬停由「上浮发光」改为**边框转红**；按钮箭头在悬停时右移 3px
- 搜索下拉的当前项用 `inset 2px 0 0 var(--red)` 做左侧红条

**三、顺带修掉一个搜索缺陷**

原索引把各指数的成分股**直接拼接**，而中证全指是其余四个的**超集**，
于是索引有 **8,922 条**而实际只有 **5,122 只** —— 搜「600519」会列出同一条股票 2 次
（一次标「沪深300」、一次标「中证全指」）。

修复：按 `ts_code` **去重**，保留 `UNIVERSE_ORDER` 里更靠前（更具体）的指数作为跳转目标，
并把该股票所属的**全部指数**收进第三个字段，前端显示为多个标签。

| 指标 | 修复前 | 修复后 |
|---|---|---|
| 搜索索引条数 | 8,922（含 3,800 条重复） | **5,122** |
| 搜「600519」命中 | 2 条 | **1 条** |
| 首页体积 | 638 KB | **406 KB** |
| Hero 数据条 | 8,922 只（错误） | **5,122 只** |

**四、验证**（`tools/_landing_check.py`，探针真实触发一次搜索）

| 检查 | 结果 |
|---|---|
| 搜索索引 / 代码搜索 / 名称搜索 | 5,122 只 ／ 命中 1 条 ／ 命中 1 条 |
| 导航锚点 | `#top` `#board` `#logic` `#risk` `#board` 全部存在 |
| Hero 标题 | 「哪些股票值得买？ 我拿数据告诉你！」 |
| 红色按钮 | 2 个（导航 1 + Hero 1） |
| 数据条 | `5` / `5,122` / `103` / `19:00` |
| 区块标题 | 「A股量化打分看板」「打分逻辑」 |
| 指数卡片 / 打分逻辑条目 | 5 张 ／ 4 条 |
| 横向溢出 | **0 px**（1680 与 1366 两种视口） |
| **绿色系色值**（hex 与 rgba 双检） | **0** |
| `tools/verify_all.py` | **114 通过 / 0 失败** |

> Hero 里的「103 项量化指标」不是手写的，是生成时从 `engine.dashboard.DETAIL_GROUPS`
> **数出来的**，指标增减会自动跟着变，不会过期。

> **验证**：5 个看板 headless 渲染 `vizsum=1 / vcard=3 / stack=2 / prow / spark / donut / legend` 齐全、
> **JS 报错 0**；「分布与统计」页单独探针通过；CSS 未定义变量引用 0；绿色系色值 0；
> `tools/verify_all.py` **114 通过 / 0 失败**。

---

### 9.15 首页四项优化：去掉入口按钮、改名、副标题收敛、搜索框前置放大（2026-09-21）

本轮只动首页（`tools/build_landing.py`），**打分引擎、五个看板、全部数据一律未动**。

**一、四项修改的逐条落地**

| # | 要求 | 修改前 | 修改后 |
|---|---|---|---|
| 1 | 把两个「进入网页」全部去掉 | 导航右侧 1 个 + Hero 里 1 个，共 2 个红色 CTA | **0 个**（`.cta` 元素数 = 0，正文中「进入网页」字样 = 0） |
| 2 | 「A股量化打分」改成「A股估值管理」 | 品牌标 `A股量化打分` | 品牌标 **`A股估值管理`**（`.topnav` 左）+ 区块标题 **`A股估值管理看板`** |
| 3 | 副标题去掉「同一套引擎（…）」外壳与末句，「A股排雷」改「个股排雷」，去掉逗号，并按「分段」在「个股排雷」后断行 | `同一套引擎（晨星经济护城河 × 巴菲特投资理念 + A股排雷），覆盖 5 个指数、5,122 只个股、103 项量化指标。每一分都能追溯到原始财报，每一项空值都写明原因。` | 分两行：**`晨星经济护城河 × 巴菲特投资理念 + 个股排雷`** / **`覆盖 5 个指数、5,122 只个股、103 项量化指标。`**（HTML 用 `<br>` 硬断行，无逗号、无多余空白；渲染文本与要求逐字相等） |
| 4 | 搜索框做到「明显突出」 | 灰底细边输入框，位于看板标题之下、首屏之外 | **红色描边 + 红色外发光 + 红色底衬面板 + 20px 红色放大镜 + 「搜一只个股」标题行**，并**整体前移到 Hero 内、大标题与数据条之间** |

> 追加一处：大标题两行「哪些股票值得买？／我拿数据告诉你！」的行距按要求**稍微放大**，
> `line-height` 由 `1.06` 调到 **`1.18`**（1680/1366 视口下字号 62.4px，行距由 66.1px → **73.6px**，
> 两行顶到顶实测 **74px**，即 +7.5px）。搜索框因此下移 15px（467→482 px），
> 仍在首屏之内。

**二、搜索框为什么搬到 Hero 里**

原来它排在「A股估值管理看板」标题之后，1366×768 下输入框位于 **753–812 px**，
而首屏只到 768 px —— **输入框基本被压在折线以下**，谈不上「让大家都知道这里能搜个股」。

现在顺序是：**大标题 → 副标题 → 搜索框 → 数据条**，输入框落在 **467–526 px**，
在 1366×768（有效视口 670 px）下**完整出现在首屏**，且紧贴主标题，视觉上就是页面的主入口。

配套改动：

- Hero 原本 `overflow:hidden`（用来裁掉右上角红色光晕），会**连带把搜索结果下拉裁掉**。
  现在把装饰层独立成 `.herodeco{overflow:hidden}`，`.hero` 本身 `overflow:visible`，
  下拉可以自由跨出 Hero；探测确认**没有任何祖先裁切下拉**。
- 用 `elementFromPoint` 实测层级：下拉展开时，落在数据条与卡片上的命中元素
  仍是 `#results .res` —— **下拉确实压在数据条与指数卡片之上**。
- 搜索框左缘与 Hero 大标题、副标题、看板内容左缘**严格同为 210 px（1680 视口）/ 53 px（1366 视口）**。

**三、顺带修掉一个一直在的对齐缺陷**

`.hero .inner` 复用了 `.wrap`（左右内边距 28 px）却又写了 `padding:96px 0 0` 的简写，
**把左右内边距清零**，导致 Hero 大标题、副标题比下方所有内容**左移 28 px**。
改成 `padding-top:96px` 后，大标题 / 副标题 / 搜索面板 / 看板卡片左缘完全对齐。

| 元素 | 修复前左缘（1680） | 修复后左缘（1680） |
|---|---|---|
| Hero 大标题 / 副标题 | 182 | **210** |
| 导航品牌标 | 210 | 210 |
| 搜索框 | 203 | **231**（= 内容左缘 + 面板内边距 21） |
| 指数卡片 / 看板内容 | 210 | 210 |

**四、验证**（`tools/_landing_check.py`，**4 种视口 × 真实触发搜索 = 115 项断言，全部通过**）

| 检查 | 结果 |
|---|---|
| 红色按钮 / 「进入网页」/「A股量化打分」 | **0 / 0 / 0** |
| 品牌标 · 区块标题 | `A股估值管理` · `A股估值管理看板` |
| 副标题 | 分两行且与要求**逐字相等**：第一行 `晨星经济护城河 × 巴菲特投资理念 + 个股排雷`，第二行 `覆盖 5 个指数、5,122 只个股、103 项量化指标。`（`<br>` 数 = 1，实测渲染 **2 行**，不含任何逗号） |
| 搜索框尺寸 / 字号 / 边框 / 外发光 | **1182×58 px** ／ 16 px ／ `2px rgba(229,56,43,.75)` ／ `rgba(229,56,43,.09) 0 0 0 5px` |
| 搜索标题行 | 「搜一只个股　支持 6 位代码或名称，覆盖全部 5,122 只成分股」16 px / 700 |
| 首屏可见 | 482→541 px（视口 670 px）**完全在首屏内** |
| 大标题行距 | `line-height 1.18`，字号 62.4px → 行距 **73.6px**，两行顶到顶实测 **74px** |
| 下拉层级 | `elementFromPoint` 命中 `#results .res`，**压过数据条与卡片** |
| 下拉裁切 | **无**（hero `overflow=visible`） |
| 左缘对齐 | 品牌 / 大标题 / 副标题 = 内容左缘；搜索框 = 内容左缘 + 面板内边距 |
| 视口 | 1680×1000 ／ 1440×900 ／ 1366×768 ／ 512×900（Chrome 最小窗宽） |
| 横向溢出 | **0 px**（全部视口） |
| **绿色系色值**（hex + rgba 双检） | **0** |
| `tools/verify_all.py` | **全部通过** |

> 数据条里的「103 项量化指标」与副标题里的 `5 / 5,122 / 103` 仍是生成时从
> `engine.dashboard.DETAIL_GROUPS` 与成分股表**现算**的，不是手写常量。
> 首页产物 406 KB，搜索索引 5,122 只（去重后），代码 / 名称搜索各命中 1 条。

> 遗留（未改，等确认）：五个看板的页内标题仍是 `… · 巴菲特 × 晨星护城河 量化打分看板`，
> 与首页新品牌名 `A股估值管理` 用词不一致；`data/` 下还留着已删除的 `sup_sse50_*.parquet`
> 缓存（仅本地缓存，无页面引用）。

---

### 9.16 「19:00 没有自动更新」的根因排查与修复（2026-09-21）

**现象**：今天 19:00 网页数据没有刷新 —— 页面上的「数据日期」停在 **20260918**（上周五），
而 2026-09-21（周一）已经收盘。除了把 5 个指数的数据、看板与首页全部刷新到 **20260921**，
还把「为什么没人自动刷新」查到了底：一共 **3 个独立缺陷**，任何一个都足以让自动更新失败。

**一、三个根因（都有实测证据）**

| # | 根因 | 证据 | 后果 |
|---|---|---|---|
| 1 | **计划任务从未注册** | `schtasks /Query /TN "A股量化打分每日更新"` → `ERROR: The system cannot find the file specified.`；遍历全部 382 个任务，只有缠论项目的 `\ChanlunDailyPipeline`，本项目一个都没有 | 19:00 根本没有任何东西被触发（**主因**：注册脚本从未被运行过） |
| 2 | **静默 bat 里的 `chcp 65001` + UTF-8 中文会让 cmd.exe 解析批处理时错位** | 把静默 bat 原样复制成无害副本后执行，stderr 抓到 `'\'全部输出写进\' is not recognized as an internal or external command`、`'\'et\' is not recognized ...`（注释行被当成命令执行）；计划任务里 `Last Result: 0` 但日志一个字都没写 | **即使当初注册了任务，19:00 依然不会更新** —— 任务显示「成功」，实际什么都没做。这是最难查的一类静默失败 |
| 3 | **bat 把 python 的 stdout 重定向进 `out\update_log.txt`，而 `update_daily.py` 自己也在往同一文件追加** | 任务日志：`PermissionError: [Errno 13] Permission denied: '...\out\update_log.txt'`（`update_daily.py` 的 `log()` 内），退出码 1 | cmd.exe 持有重定向句柄且拒绝写共享，python 一启动就死。**这是原版脚本里就存在的缺陷**，与根因 2 叠加 |

> 排查方式（对照实验，`tools/_sched_diag.py` / `_sched_root_cause.py` / `_sched_struct.py`）：
> 先用 6 个探针 bat 逐项排除「任务机制 / 中文路径 / CRLF-LF 行尾 / chcp / 中文内容」，
> 确认任务机制与 LF 行尾都没有问题；再用「真结构 + 无害载荷」复现，
> 从 stderr 抓到了 cmd.exe 把注释行当命令执行的确凿输出，才定位到根因 2、3。

**二、修复**

| 对象 | 修复 |
|---|---|
| `tools/自动更新_静默.bat` | 改为**纯 ASCII**（0 个非 ASCII 字节）、全 CRLF、不再使用 `chcp`；所有日志写入改用**前置重定向**（`>>"%LOG%" echo ...`，避免 `echo 0>> file` 被解析成句柄 0 重定向）；python 的 stdout 走 `>nul`（`update_daily.py` 本来就逐行写日志），stderr 单独进 `out\update_stderr.txt` |
| `tools/install_daily_task.py`（新增） | 由脚本**生成**上面这个 bat 并自检（纯 ASCII / 全 CRLF / 以 `exit /b` 收尾），把当前可用的 **python 绝对路径**写进 `tools\python_path.txt`（计划任务的 PATH 通常没有 python），再用 **UTF-16 任务 XML** 注册：`StartWhenAvailable`（错过时间后补跑）、`WakeToRun`（休眠唤醒）、`ExecutionTimeLimit=3h`、`MultipleInstances=IgnoreNew`；任务名统一为 **A股估值管理每日更新**；支持 `--show / --run / --remove` |
| `注册每日19点自动更新.bat` / `取消每日19点自动更新.bat` | 改为调用上述脚本，不再用一行 `schtasks /TR`（中文路径与引号在控制台代码页下极易写坏） |
| `update_daily.py` | ① `refresh_shared_data` **按指数各抓一次**成分股与指数日线（原来只跑默认指数，其余指数的成分股/指数日线会永久停在旧日期）；② 新增 `data_freshness()`：逐指数核对「特征数据日期 vs tushare 最新可用交易日」，落后即记 **未更新** 并 **`exit 2`**；③ 新增并发锁 `data\_update.lock`（防计划任务与手动更新撞车，超 6 小时视为陈旧可接管，`--force-lock` 强制）；④ `log()` 在日志被独占时退到备用文件，不再因写不了日志而中断整轮更新 |
| `tools/fetch_data.py` | ① **空结果不再落盘**，读到 0 行的缓存视为未缓存并删除重抓（历史事故：`px_20260917` 被缓存成 0 行，该交易日永久缺失）；② `cyq_perf` / `moneyflow_dc` / `hk_hold` / `stk_factor` 这类「固定文件名的最新快照」**每轮强制覆盖**（此前只在首次抓取时写入，其中 `cyq_perf` / `moneyflow_dc` **参与打分**却一直冻结在 20260916）；③ 指数自身日线 `index_daily_*` 与 5 个基准指数改为每轮刷新；④ 成分股 `members_*` 超过 30 天未刷新则重抓（跟随指数调样） |

**三、修复后的验证（当天实测）**

| 检查 | 结果 |
|---|---|
| 计划任务 | `Next Run Time 2026/9/22 19:00:00`、`Status Ready`、`Logon Mode Interactive only`、`Run As User wangz`、`Schedule Type Daily / Start Time 19:00:00` |
| 手动触发任务（等价于 19:00 到点） | `Last Run Time 2026/9/21 19:26:02`、**`Last Result: 0`**；日志出现 `================ scheduled task run ... ================` 与 `interpreter: D:\Enverioments\python3.13\python.exe` |
| 该轮全流程 | 22/22 产物全部更新，总耗时约 6 分钟，`exit code 0`，`out\update_stderr.txt` **0 字节**（无崩溃输出） |
| 数据日期 | 5 个指数的特征数据、看板、首页 **全部 = 20260921** = tushare 最新可用交易日 |
| 每日快照 | `cyq_perf` 20260921、`moneyflow_dc` 20260921（修复前冻结在 **20260916**）；`index_daily_csi300/500/1000/2000/csiall` 与 5 个基准指数全部刷新 |
| 修好空缓存洞 | `px_20260917` 由 **0 行 → 5,553 行** |
| `tools/verify_all.py` | **全部通过** |
| 首页 | 115 项断言全部通过；页脚显示「数据日期 20260921 / 更新时间 2026-09-21 19:31」 |

> 首页脚注的「每日数据更新 19:00」现在是有依据的：任务已注册，且已用一次真实触发证明它能跑完整轮更新。
> 唯一前提是**电脑在 19:00 处于开机或休眠状态**（开机到点执行、休眠会被唤醒、关机错过则下次开机补跑）。
> 想立刻跑一次：`python tools\install_daily_task.py --run`；看日志：`out\update_log.txt`。

---

### 9.17 公网发布：把网站放到阿里云服务器（2026-09-21）

**目标**：让任何人打开 `https://bafeite.website` 就能看到这套打分看板。
为此新建了 **`E:\Astock\巴菲特\网站文件\`** 目录（可反复重新生成），
里面是「该公开的全部文件 + 服务器部署脚本 + Windows 上传工具 + 新手教程」。

**一、先做了一次「能不能发布」的体检**

| 检查项 | 结果 |
|---|---|
| 外链（CDN / 字体 / 统计代码） | **0 个** —— 页面完全自包含，不依赖任何外部资源 |
| 绝对路径 / 带盘符路径（Linux 上会 404） | **0 个** |
| 大小写不一致（Windows 能找到、Linux 找不到） | **0 处**（逐个引用与磁盘核对） |
| 引用了不存在的文件 | **0 处** |
| 中文文件名引用 | 13 处（Linux 支持，`charset utf-8` 已配好） |
| **tushare token / 密钥是否泄漏到网页** | **没有**（关键：token 只在本地脚本里） |
| 本地绝对路径是否泄漏 | 没有 |
| 看板是否需要额外文件 | **不需要** —— 5 个看板把数据内嵌成 `const DATA = {...}`，单文件自包含 |

> 由此确定：`out/` 里 329 个文件中，**真正该上传的只有 24 个**（6 个网页 + 18 个文档）。
> `data.json`、`ranking.csv` 都是本地中间产物（页面上「导出 CSV」是浏览器现算的），
> 语料（`corpus/`、`_work/` 等 40+ MB）与验证日志更不该公开。

**二、`网站文件\` 目录结构**

```
网站文件\
├─ 00_先读我.txt              三分钟看懂这个文件夹（UTF-8+BOM，记事本能正常显示）
├─ 01_部署步骤_小学生版.md      ★ 从零到上线的完整教程（含备案、安全组、解析）
├─ 02_常见问题_排错手册.md      13 类故障的排查表（连不上/打不开/403/证书/权限…）
├─ 03_文件清单说明.md          每个文件干什么、什么没上传、为什么
├─ 文件清单.txt                自动生成的精确清单（含大小）
├─ site\                       ★ 网站本体 24 个文件 10.6 MB（→ /var/www/bafeite.website/）
│   ├─ index.html              首页
│   ├─ favicon.ico / .svg      标签页图标（新做的：黑底 + 三根红柱）
│   ├─ 404.html / robots.txt / sitemap.xml
│   ├─ 01_…md 02_…md 03_…md sensitivity_discount_rate.md
│   ├─ dashboard\index.html    中证2000 看板
│   └─ csi300\ csi500\ csi1000\ csiall\   各指数的 看板 + 报告 + 完整排名
├─ server\                     服务器脚本（→ ~/deploy/）
│   ├─ 01_一键部署.sh           装 nginx + 放网页 + 写配置 + 启动 + 自检（幂等）
│   ├─ 02_开启HTTPS.sh          certbot 免费证书 + 自动续期 + http 强制跳转
│   └─ 03_健康检查.sh           文件齐全性 / 数据日期 / 每个页面返回码 / 日志 / 磁盘
├─ windows\                    自己电脑上双击即可
│   ├─ 1_检查连接.bat           测配置与连通性，顺带报告服务器有没有装 nginx
│   ├─ 2_生成SSH密钥.bat        生成密钥并自动装到服务器（以后免密码，仅需一次）
│   ├─ 3_首次部署.bat           打包→上传→在服务器上跑一键部署（会提示输 sudo 密码）
│   ├─ 4_日常更新网页.bat       ★ 最常用：重新打包→上传→服务器解压覆盖
│   ├─ 5_申请HTTPS证书.bat      开启 https
│   ├─ 6_服务器健康检查.bat
│   ├─ 0_只打包不上传.bat
│   ├─ 上传配置.txt             服务器 IP / 用户名 / 网站目录 / 是否自动上传
│   ├─ _run.bat, deploy_upload.py
├─ site.tar.gz                 网站打包（2.98 MB，上传的就是这一个文件）
└─ server.tar.gz               服务器脚本打包
```

**三、几个关键设计决定（都是为了「不出错」）**

1. **用「单个 tar.gz + 服务器解压」而不是逐个文件上传 scp**：
   一次连接传完、体积极小（10.6 MB → 2.98 MB）、**避免中文文件名在传输中被搞乱**、
   服务器端只需系统自带的 `tar`，不用装任何东西。
2. **`.bat` 一律纯 ASCII 英文，中文提示全部交给 Python 打印**：
   这正是 9.16 节踩过的坑（cmd.exe 遇到 `chcp 65001` + UTF-8 中文会解析错位，
   表现为「脚本报成功、其实什么都没做」）。窗口里正常显示的中文由 Python 负责输出。
3. **服务器端清理旧文件**：`site.tar.gz` 里带了 `.manifest`（本次应存在的文件清单），
   解压后服务器会删掉清单外的旧文件，保证**线上与本地完全一致**（纯覆盖上传做不到这点）。
   该文件以点开头，nginx 配置里已禁止访问。
4. **`sudo` 下不用 `$HOME`**：`sudo` 会把 `$HOME` 换成 `/root`，
   所以脚本用 `BASH_SOURCE` 自己定位所在目录、用 `getent passwd $SUDO_USER` 取家目录，
   再到多个候选位置找 `site.tar.gz` —— 否则会出现「明明上传了却说找不到文件」。
5. **权限一次配好**：一键部署把网站目录 `chown` 给登录用户、文件 `644`，
   这样**日常上传不需要 sudo**，nginx 也读得到。
6. **nginx 安全项**：拒绝访问隐藏文件与 `.sh/.py/.tar.gz/.log` 等后缀；
   `.md` 按 `text/plain;charset=utf-8` 直接显示（而不是变成下载）；
   开启 gzip（首页 398 KB、中证全指看板 4.5 MB，压缩后约十分之一）。
7. **不缓存 HTML**（每天更新，避免访客看到旧数据），图标/文档短缓存；
   自定义 404 页面指向首页。
8. **每日自动更新直接串进现有 19:00 计划任务**：
   `update_daily.py` 新增 `deploy_public_site()`，读「上传配置.txt」里的
   `自动上传=是/否`（默认否，没配服务器的人不受影响）。
   **数据没刷新到最新交易日时（`exit 2`）不会上传**，避免把旧数据发到线上。

**四、部署流程（教程里的 9 步，这里只列骨架）**

```
0  判断服务器地域 → 大陆要备案（7~20 工作日）、香港/海外不用
1  阿里云控制台「Workbench 远程连接」登录服务器（浏览器里，不用装软件）
2  云解析 DNS：A 记录 @ 和 www → 服务器公网 IP
3  安全组入方向放行 80、443（新手最常卡在这里）
4  windows\上传配置.txt 填 IP/用户名/邮箱 → 双击 1_检查连接.bat
5  双击 2_生成SSH密钥.bat（免密码，仅一次）
6  双击 3_首次部署.bat → 自动装 nginx 并上线，浏览器打开 http://bafeite.website
7  双击 5_申请HTTPS证书.bat → https://bafeite.website 出现小锁
8  上传配置.txt 改「自动上传=是」→ 每天 19:00 自动更新线上
9  按教程附带的验收清单逐条打勾
```

**五、发布包验收（`tools/_verify_bundle.py`，本地起 HTTP 服务器模拟 nginx）**

| 检查 | 结果 |
|---|---|
| 17 个公开网址（首页/5 看板/图标/404/robots/sitemap/6 个文档） | **全部 200**，Content-Type 正确 |
| 内部文件（`.manifest`、`site.tar.gz`、配置、部署脚本） | 服务器 nginx 规则**禁止访问** |
| 6 个页面用无头浏览器实跑 | **JS 报错 0**；看板行数 2000/300/500/1000/**5122**、数据日期 20260921、卡片与图表齐全；首页搜索「600519」命中 1 条 |
| 密钥 / 绝对路径 / 内部文件扫描 | **未发现** |
| `site.tar.gz` | 24 个文件与磁盘**一一对应**、12 个中文名完好、`.manifest` 无重复行 |
| `windows\` 全部 .bat | **非 ASCII 字节 0、LF-only 行 0**（纯 ASCII + CRLF） |
| `server\` 三个 .sh | 引号成对、`if/fi`、`for/done` 配对；**不在 sudo 下使用 `$HOME`**；nginx heredoc 用引号保护（`$uri` 不会被 bash 展开） |
| `tools/verify_all.py` | **全部通过**（打分引擎未受影响） |

> 可重复生成：任何时候执行 `python tools\build_website_bundle.py`，
> 它会把 `out/` 的产物重新同步进 `网站文件\site\`、删掉过时文件、重打包并更新清单。
> 双击 `4_日常更新网页.bat` 时会自动先做这一步。

---

### 9.18 「19:00 只弹出一个黑框、数据没更新」的真相与修复（2026-09-22）

**用户的反馈**：19:00 只出来一个 cmd 黑框，网页数据没有自动更新。
**实际查明的真相**：那一轮**已经完整跑完并且数据就是最新的**——但有两个真实缺陷让结果看起来像失败，
而且其中一个真的会导致「不发布」。

**一、事实核对（19:00 那一轮）**

| 证据 | 结果 |
|---|---|
| 任务是否触发 | `Last Run Time = 2026/9/22 19:00:00`，日志有 `scheduled task run 2026/09/22 19:00:00.44` |
| 跑了多久 | 19:00:00 → 19:06:16（约 6 分钟） |
| 产物 | 22/22 全部重建（日志「全部 22 个产物均为本轮最新」） |
| 数据日期 | 5 个指数特征 = **20260922** = tushare 最新交易日；首页「本页生成时间 **2026-09-22 19:06:16**」 |
| 任务退出码 | **2**（这就是问题所在） |

**二、两个真实缺陷（都是我引入的）**

**缺陷 1：把「数据源当晚还没发布」误判成「更新失败」→ 退出码 2**

tushare 的筹码分布 `cyq_perf` 在 19:00 时**当天数据还没出**，抓取日志写着
`! cyq_perf: 20260922 返回空，保留上一版 20260921`。而我在 9.17 把 `cyq_perf`
设成了**硬性要求**，于是新鲜度核对报 `**未更新** cyq_perf` → 整轮被判定失败、
日志写 `** UPDATE FAILED ** rc=2`，并且会执行这一行：

```python
log("!! 本轮不上传公网，避免把旧数据发到线上")
sys.exit(2)          # ← 一旦配了「自动上传=是」，19:00 的网页就不会更新
```

这就是唯一一个**真会**造成「网页没更新」的路径（本地文件其实已经刷新了）。
（19:13 我重跑时 `cyq_perf` 已经发布，立刻变成 `=20260922`，证明它只是"晚一点到"。）

**缺陷 2：窗口 6 分钟一片空白，无法判断在跑还是卡死**

为了让 `update_daily.py` 自己写日志（避免 9.16 的 PermissionError），我把 bat 里
python 的 stdout 重定向到了 `nul`。结果计划任务弹出的是一个**静止的黑框**，
用户看不到任何进度和结论 —— 这正是「只出来了一个 cmd 的框」的原因。

**缺陷 3（这次复查时又踩到的）**：控制台输出里用了 `✓`，而中文 Windows 控制台是 GBK。
当 stdout 是管道而不是真控制台时，`✓` 编不出来会直接 `UnicodeEncodeError`
**把整轮更新打断**（我在本次复跑时就崩了一次）。这类字符必须一个都不能有。

**三、修复**

| 对象 | 修复 |
|---|---|
| `data_freshness()` 判定分档 | **硬失败只剩两种**：① 网页数据日期（各指数特征 `trade_date`）落后于 tushare 最新交易日；② 参与打分的每日快照（`cyq_perf` / `moneyflow_dc`）**连续落后超过 3 个交易日**。当天「尚未发布」只写一行 `今日未发布 … 保留上一版，不影响本轮发布`，不再算失败。未参与打分的 `hk_hold` / `stk_factor` 只做信息记录 |
| 窗口可读 | `update_daily.py` 拆成两种输出：`log()` 写全部明细进日志文件；`console()` 只在窗口里打 **4 个阶段 + 5 行结论**（开始信息 / 拉数据 / 5 个指数逐个完成 / 重建首页 / 数据日期 / 用时 / Ctrl+F5 提示）。bat 里设 `BSCORE_QUIET_CONSOLE=1` 控制刷屏程度；窗口标题改成 `A-share valuation site - daily update (keep this window open)` |
| stdout 归位 | bat 不再把 stdout 重定向到 `nul`（也不重定向进日志文件——那会 PermissionError），只把 **stderr** 落到 `out\update_stderr.txt`，stdout 留在窗口里可见 |
| 编码防呆 | 新增 `_safe_print()`：`log()` / `console()` 一律走它，GBK 编不出的字符降级成替换字符继续跑，**任何一行输出都不再能中断整轮更新**；`console()` 文案里不再出现 `✓ ✅ ❌` 这类字符（已用脚本扫描确认 0 个） |
| 失败时的措辞 | 失败时窗口里明确写 `[失败] 更新未完成 —— 网页数据没有刷新到最新交易日` + 未通过项目 + 「本轮没有上传公网」+ 日志位置 |

**四、修复后的验证（当天实测）**

| 检查 | 结果 |
|---|---|
| 手动全量刷新 | 19:13:40 → 19:20:03，**383 秒**，**退出码 0** |
| 窗口结论（新） | `[完成] 网页数据已刷新` / `网页数据日期：20260922（tushare 最新交易日）` / `已重建：5 个指数看板 + 首页 + 全部报告与排名` / `用时：383 秒` / `看最新数据：浏览器里按 Ctrl+F5 强制刷新一下` |
| 新鲜度核对（新口径） | 5 个指数 `已更新 [csi300/500/1000/2000/csiall] 特征数据日期 20260922`；`已更新 快照 cyq_perf=20260922`、`moneyflow_dc=20260922`；`hk_hold / stk_factor 参考(未用)`；**0 个失败项** |
| 页面 | 5 个看板 + 首页全部 `数据日期 20260922`，生成时间 19:15–19:20；首页 `本页生成时间 2026-09-22 19:20:02` |
| `tools/verify_all.py` | **全部通过** |
| 入口结构自检 | 用「同结构 + 无害载荷」的探针 bat 由**计划任务真实触发**：载荷被执行、`Last Result 0`（证明 cmd.exe 能正确解析整个 bat） |
| 引号/重定向自检 | 与静默 bat 完全相同的形式 `"解释器" -W ignore <脚本> 2>>"%ERR%"`：返回码 0、无 stderr（bat 内只用相对路径，文件保持纯 ASCII） |
| 任务状态 | `Next Run Time 2026/9/23 19:00:00`、`Status Ready`、`Daily 19:00`、`Run As User wangz` |

**五、给使用者的两点说明**

1. **数据本来就没丢**：19:00 那轮结束后页面已经是 20260922；如果看到旧数据，
   要么是那时还没跑完（19:00 开始、约 19:06 结束），要么是浏览器缓存 —— 按 `Ctrl+F5`。
2. **窗口现在会说话**：19:00 弹出的黑框会逐行显示进度，最后一行是结论；
   成功显示 `[完成] 网页数据已刷新`，失败会明确写出是哪一项没过、并且**不会**把旧数据发到线上。

---

### 9.19 部署中途「拒绝连接」是正常的：新增连通性自检工具（2026-09-22）

**用户疑问**：做完第 3 步（放行 80/443）后打开 `bafeite.website`，
显示「无法访问此网站 / 拒绝了我们的连接请求」，是不是配错了？

**结论：不仅正常，而且是「前 3 步都做对了」的证据。** 用新工具从本机实测：

```
[OK] bafeite.website -> 39.102.208.104          ← 第 2 步域名解析已生效
端口 80 被「拒绝」= 包已经成功到达服务器，只是没有程序在监听（耗时 2.21 秒）
      说明【域名解析 OK】【安全组 OK】都对；就差【第 6 步：装 nginx 并把网站传上去】
端口 443 同样被拒绝                              ← 证书要到第 7 步才有
```

**原理**：两种「打不开」的机制完全不同 ——

| 浏览器表现 | 网络层发生了什么 | 含义 |
|---|---|---|
| 一直转圈 →「响应时间过长」TIMED_OUT | 数据包在阿里云安全组被**丢弃**（无响应） | 安全组没放行（第 3 步） |
| 秒出「拒绝了我们的连接请求」REFUSED | 数据包到达服务器，服务器回了 **RST** | **包通了**，只是没程序监听 80 → nginx 还没装（第 6 步） |
| 「该网站未备案」提示页 | HTTP 请求被机房按 Host 拦截 | 大陆服务器 + 未备案（第 0 步情况 B） |

所以「秒回拒绝」= 域名 ✓ + 安全组 ✓。**部署到第 3 步时看到它，是正确状态。**

**新增工具**：`网站文件\windows\7_检查域名连通性.bat`（对应 `tools/check_public_site.py`）
从使用者的电脑上依次检查并给出中文结论：
① 域名解析（并比对「配置里的服务器 IP」是否一致）；
② 分别对 **域名** 与 **公网 IP** 探测 80 端口（这一对比能区分「未备案拦截」：
   域名被拦、IP 能开 → 就是备案问题）；③ 探测 443；
④ 附一张「三种报错分别代表什么」的对照表，每个结果都点明对应教程第几步。

**顺带修正 / 加固的三处**：

1. 教程第 3 步结尾原来写「这时打开域名会显示 **502 或无法访问**」——**不准确**，
   已改成精确表述：会看到 **ERR_CONNECTION_REFUSED**，并附三种报错的对照表。
2. 排错手册第 2 节新增「先问自己现在做到第几步」段落 + 各阶段预期现象表；
   并强调 **`ping` 走 ICMP、网页走 TCP，ping 通不代表 80 通**，不要用 ping 判断网站。
3. 三个面向用户的 Python 工具（`check_public_site.py` / `deploy_upload.py` /
   `build_website_bundle.py`）统一加编码兜底：`sys.stdout.reconfigure(errors="replace")`，
   并把打印里的 `✓ ✗ ⚠` 换成 ASCII（中文 Windows 控制台是 GBK，这类字符在输出被重定向时
   会抛 `UnicodeEncodeError` 直接中断）。新增 `tools/_gbk_scan.py` 静态扫描，
   现在四个脚本**可打印字符串里 GBK 危险字符为 0**（仅剩 3 处在文档字符串中，不参与打印）。

> 附带信息：该服务器 IP `39.102.208.104` 的地理信息指向**北京 / 阿里云**（`AS37963`），
> 大概率是**中国大陆地域** —— 这意味着**必须先完成 ICP 备案**，否则第 6 步之后
> 用域名访问仍会看到备案提示页（而用公网 IP 能打开）。准确答案请看控制台
> 「实例 → 地域」；若确实是大陆地域，备案建议尽早提交（7~20 个工作日）。

---

### 9.20 改名、微信登入、网站管理后台、个股可点击引导（2026-09-23）

本轮三件事：**改名 / 登入+后台 / 让用户知道个股能点开**，另加「同步到发布包」的收尾。

**一、改名：A股估值管理 → 巴菲特的计算器**

新增 `engine/brand.py` 作为**全站唯一的站名来源**（`SITE_NAME / SITE_DOMAIN / SITE_URL /
API_BASE / 本地存储键`）。首页、5 个看板标题、页头品牌标、页脚、每日更新的窗口标题、
计划任务名、发布包说明文档全部从它取 —— 以后再改名只改一处。

| 位置 | 改后 |
|---|---|
| 首页 `<title>` / 导航品牌标 | `哪些股票值得买？我拿数据告诉你！· 巴菲特的计算器` / `巴菲特的计算器` |
| 看板 `<title>` | `沪深300（000300.SH） · 巴菲特的计算器 量化打分看板` |
| 每日更新窗口标题 | `巴菲特的计算器 · 每日数据更新` |
| Windows 计划任务名 | **巴菲特的计算器每日更新**（注册新任务时会自动删除两个历史任务名，避免双跑） |

**二、左上角登入 + 微信扫码 + 网站管理后台**

- **前台**（`engine/sitewidget.py`，首页与 5 个看板共用一份实现）：
  最左上角「登入」按钮（微信绿图标）→ 点击弹出**扫码登入**窗口 → 登入后原位变成
  **头像 + 昵称 + 微信标识**，下拉可看登入方式 / 首次登入时间 / 退出。
- **后台**（`server/site_server.py`，**纯 Python 标准库 + SQLite，零依赖**）：
  - 接口：`/api/health`、`/api/track`、`/api/me`、`/api/login/guest`、`/api/logout`、
    `/api/wx/config`、`/api/wx/qr`、`/api/wx/poll`、`/api/wx/callback`（网站应用）、
    `/api/wx/mini/confirm`（小程序）、`/api/admin/*`（统计 / 用户 / 设置 / CSV）
  - 管理后台页面 `/admin`：**访问量 PV、访客数 UV、会话数、今日 PV/UV、累计用户、
    个股浮窗打开次数、登入成功次数** + 每日趋势 + 页面排行 Top25 + 来源 + 设备 + 浏览器
    + **用户管理（启用 / 禁用 / 删除）** + 最近 40 条明细 + 一键导出 CSV + 登入设置
  - 部署：`server/04_安装后台服务.sh` 建专用系统用户、装 systemd 服务（开机自启、
    崩溃自动重拉、只监听 127.0.0.1）、把 `/api/` 与 `/admin` 反代进 nginx。
    **`01_一键部署.sh` 会自动调用它**，所以 `3_首次部署.bat` 一次就把网页 + 后台都装好。
- **隐私设计**：**不保存访客 IP** —— UV 用「当天盐值 + IP + UA」的哈希统计，盐每天更换，
  既算得出 UV 又无法反推 IP、无法跨天追踪；页面不加载任何第三方脚本；数据只在本机 SQLite。
- **微信登入的硬约束（必须提前知道）**：微信**没有**给个人开发者提供「网站扫码登入」。

  | 方案 | 前置条件 | 费用 | 说明 |
  |---|---|---|---|
  | 微信开放平台「网站应用」 | **企业营业执照** + 资质认证 | 300 元/年 | 个人主体注册不了 |
  | 公众号网页授权 | 已认证服务号 | 300 元/年 | 需企业主体 |
  | **个人主体小程序** | 个人实名 | **0 元** | 个人唯一可行：出小程序码 → 扫码 → 小程序里确认 → 网页登入 |

  因此做法是「**框架先做好，配 AppID 即生效**」：后台「登入设置」里选 `web` / `mini`、
  填 AppID/AppSecret 即可，**不用改任何代码**；未配置时前台显示「尚未接入」并给出两条路径，
  同时允许**体验账号**登入，功能不中断。另有一个硬前提：**域名必须已备案**（微信侧校验回调域名）。
  > 另一个必须对齐的预期：**微信不会把用户的「微信号」给第三方**。登入后能拿到的是
  > 昵称 + 头像 + openid，所以界面显示「昵称 + 微信标识（openid 尾号，如 `wx_a1b2c3`）」。
- **后台挂了不影响看数据**：网页仍由 nginx 直接发静态文件，后台只管 `/api/*` 与 `/admin`；
  接口不通时前端静默降级（登录态退化为浏览器本地存储），看板、搜索、浮窗、导出全部照常。

**三、让用户知道「个股可以点开」**

| 手段 | 实现 |
|---|---|
| 常驻提示条 | 排名表上方：「**点任意一行看这只股票的详细数据**（也可以点带虚线的股票名，或右侧「详情」）」，可收起（记在本地） |
| 股票名视觉标记 | 股票名加**红色虚线**下划线，悬停变实线；同一格里再加一个红色「**详情**」小标，悬停时反白填充 |
| 整行反馈 | 行 `cursor:pointer`，悬停时左侧出现 3px 红色竖条 |
| **新手引导** | 每台设备**第一次进看板**自动弹出 10 秒引导：①点任意一行看浮窗 ②表格可排序筛选 ③右上角可导出 CSV；勾选「不再自动显示」永久关闭，页头「**怎么看**」按钮可随时重开（首页不弹，因为没有表格） |
| 首页文案 | 「巴菲特的计算器看板」区块小字改为「点进任何一个指数，再点任意一行即可看个股详情」 |

**四、同步到发布包（本地 = 线上）**

`网站文件\` 已按新内容整体重建：`site/` 24 个文件（改名 + 登入 + 引导后的 6 个页面）、
`server/` 6 个文件（新增 `04_安装后台服务.sh`、`site_server.py`、`bafeite-site.service`）、
`windows/` 10 个（新增 `8_只更新后台服务.bat`）、说明文档 5 篇（新增
《04_登录与后台说明.md》）。`site.tar.gz` 3.03 MB。

**五、实测验证（本地起后台 + 无头 Chrome 真跑，报告 `out/_e2e_report.txt`）**

| 检查 | 结果 |
|---|---|
| 后台健康 | `/api/health` 200 → `{"ok":true,"app":"巴菲特的计算器"}` |
| 首页 | title/brand = **巴菲特的计算器**；左上角登入按钮在；点击弹窗打开；未配微信时显示「暂未开启微信登入」+ 两条方案说明 + 体验账号入口 |
| 体验登入 | 输入昵称 → 左上角变成「**测试用户小巴 体验_1d3d**」（昵称 + 微信标识），弹窗自动关闭 |
| 看板 | 首次进入**新手引导自动弹出**、可关闭、可经「怎么看」重开；提示条文案正确；股票名 `underline/dotted`；「详情」小标存在；行 `cursor:pointer`；**点一行浮窗打开且有内容** |
| JS 报错 | 首页 0、看板 0 |
| 访问统计落库 | PV=4、UV=1、会话=4、用户=4、**浮窗打开=2**、**登入成功=4**；页面排行 / 设备 / 来源均有数据；事件序列含 `pv / login_open / login_ok / onboard_open / onboard_done / stock_open` |
| 用户管理 | `/api/admin/users` 正常返回 |
| 管理后台页面 | `/admin` 200，含 KPI / 用户管理 / 导出等区块 |
| `tools/verify_all.py` | **全部通过**（打分引擎未受影响） |
| 发布包验收 `_verify_bundle.py` | **全部通过**（17 个公开网址 200、6 页 JS 报错 0、无密钥泄漏、10 个 .bat 纯 ASCII） |
| 今日 19:00 计划任务 | **已按新逻辑跑通**：19:00:01 → 19:06:06，`exit code 0`，5 个指数全部 = 20260923，窗口显示 `[完成] 网页数据已刷新` |

---

### 9.21 登入按钮移到右上角 + 小程序扫码出码实测（2026-09-23）

**一、微信登入按钮从左上角移到右上角**

`.bfk-auth` 从页头第一位移到**最后一位**（首页放在 `.navlinks` 之后，看板放在 `.sub` 与「怎么看」
之后），配合原有的 `margin-left:auto` 自动贴到最右侧；用户下拉菜单也从 `left:0` 改成 `right:0`，
避免贴右边时菜单溢出屏幕。实测（无头 Chrome，1440 宽）：

| 页面 | 按钮右边缘 | 容器右边缘 | 是否在右侧 |
|---|---|---|---|
| 首页 | 1314 | 1342 | **是**（差 28px = 容器内边距） |
| 沪深300 看板 | 1379 | 1403 | **是**（差 24px） |

**二、用用户给的小程序 AppID 实测微信接口**

AppID：`wxb511ecb8259bb154`（已预置为后台默认值，模式默认 `mini`，版本默认 `trial` 体验版）

| 请求 | 微信返回 | 解读 |
|---|---|---|
| 只给 AppID、不给 AppSecret | `errcode 41004 appsecret missing` | 微信受理了请求，只是缺 secret |
| AppID + 故意写错的 AppSecret | `errcode 40125 invalid appsecret` | **AppID 有效**（若 AppID 不存在会返回 `40013 invalid appid`） |

**结论：AppID 是对的，只缺 AppSecret。**

**三、把「缺什么」做进了界面（本轮最实用的改动）**

原先后台的 `POST /api/wx/qr` 接口**根本没实现**（前端在调、后端没有），
且前端 `jpost` 遇到 4xx/5xx 会把响应体丢掉 → 出不了码时只显示一句没用的「获取失败」。本轮修正：

1. 补上 `POST /api/wx/qr`：**mini** 模式调 `wxa/getwxacodeunlimit` 生成小程序码（返回 base64 图片）；
   **web** 模式返回官方授权地址（前端按需加载微信官方 `wxLogin.js` 渲染）；
2. 新增 `GET /api/wx/test.png`，供管理后台「**测试出码**」按钮直接看图；
3. 新增 `WX_HINTS` 错误码翻译表（41004 / 40125 / 85079 / 85080 / 41030 / 45009 …）；
4. 前端 `jget/jpost` 改为**无论状态码都解析 JSON**，出码失败时把**微信原文**显示在弹窗里，
   并列出 4 个常见原因；管理后台「登入设置」新增**小程序版本（trial/release/develop）**、
   确认页路径，以及「测试出码」按钮。

实测效果（弹窗里真的显示这些字）：

```
还没填 AppSecret（管理后台 → 登入设置）
原因：还没填 AppSecret（管理后台 → 登入设置）
常见原因：① 后台还没填 AppSecret（41004）② 小程序还没上传体验版（85080）
③ 小程序里没有 pages/login/login（41030）④ 服务器连不上微信接口。
```

**四、交付整套小程序代码（`网站文件\小程序代码\`，9 个文件）**

微信开发者工具**可直接导入**：`project.config.json`（已写好该 AppID）、
`app.json / app.js / sitemap.json`、`pages/login/login.{js,json,wxml,wxss}`
（扫码确认页：`wx.login()` 拿 code → POST `/api/wx/mini/confirm` → 显示成功或失败原因）、
以及《说明_先看这个.md》。

**五、仍然缺的东西（已写进文档与界面）**

| # | 缺什么 | 在哪配 |
|---|---|---|
| 1 | **AppSecret** | 公众平台 → 开发管理 → 开发设置 → 开发者ID → AppSecret → 填到后台「登入设置」 |
| 2 | **服务器域名白名单** `https://bafeite.website` | 公众平台 → 开发设置 → 服务器域名 → request 合法域名（**要求域名已备案 + HTTPS**） |
| 3 | **上传一版体验版小程序** | 开发者工具导入 `小程序代码` → 上传 → 公众平台设为体验版 |
| 4 | **网站本身在线**（备案 → 部署 → HTTPS） | 见《01_部署步骤_小学生版.md》，**备案仍是硬门槛** |

**六、验证**

| 检查 | 结果 |
|---|---|
| 右上角位置 | 首页 / 看板实测均在右侧（见上表） |
| 出码失败提示 | 弹窗显示微信原文 + 4 条常见原因（不再是「获取失败」） |
| 端到端（`_e2e_test.py`） | 首页与看板 **JS 报错 0**；体验登入后右上角显示「测试用户小巴 体验_4fd8」；新手引导弹出、可关可重开；股票名 `underline/dotted`；点行浮窗打开；统计落库（PV=6 / UV=1 / 用户=2 / 浮窗打开=1 / 登入成功=2） |
| 管理后台 | `/admin` 200，含「测试出码」按钮与版本选择 |
| `tools/verify_all.py` | **全部通过** |
| 发布包验收 | **全部通过**（`site/` 24 个、`server/` 6 个、`windows/` 10 个、`小程序代码/` 9 个、文档 5 篇） |

---

### 9.22 按要求把「登入」入口从网页撤下（代码保留，一个开关可恢复）（2026-09-23）

用户要求：**先把登入功能从网页上下掉，但不要删除**。

**做法：加一个功能开关，而不是删代码。**

`engine/brand.py` 新增：

```python
LOGIN_ENABLED = False   # False = 网页上完全不出现登入按钮与扫码弹窗
```

`engine/sitewidget.py` 里三处联动（其余逻辑一行未动）：

| 位置 | 关闭时的行为 |
|---|---|
| `body_end_html()` | **不输出登入弹窗的 DOM**（页面里干净得没有任何登入痕迹） |
| JS `renderUser()` | 槽位内容清空并 `display:none`，不插入「登入」按钮 |
| JS `boot()` | 跳过 `/api/me` 请求（少一次网络请求） |

> 注意：`LOGIN_ON` 只控制「入口显示」。`openLogin()`、扫码轮询、体验账号、
> `/api/wx/*`、`/api/login/*`、后台「登入设置」、整套小程序代码**全部保留**。

**实测（无头 Chrome，`out/_login_off_e2e.txt`）**

| 检查 | 首页 | 沪深300 看板 |
|---|---|---|
| 登入按钮 `#bfkLoginBtn` | **不存在** | **不存在** |
| 扫码弹窗 `#bfkLoginMask` | **不存在**（DOM 都没输出） | **不存在** |
| 右上角槽位 | 空、宽度 **0 px**（不占位） | 空、宽度 **0 px** |
| 页头文字 | `巴菲特的计算器 指数看板 打分逻辑 风险提示`（无「登入」） | 标题 + 「怎么看」（无「登入」） |
| 新手引导 | 保留（首页不弹，因无表格 ✓） | 保留且**照常自动弹出** |
| 点击提示条 /「怎么看」 | — | 保留 |
| 点一行开浮窗 | — | **照常可用** |
| JS 报错 | **0** | **0** |

**怎么恢复**（已写进《04_登录与后台说明.md》开头）：

1. `engine/brand.py` 里把 `LOGIN_ENABLED` 改回 `True`
2. 跑 `python update_daily.py --skip-fetch --skip-sensitivity`
3. 双击 `网站文件\windows\4_日常更新网页.bat`（或等 19:00 自动传）

**撤下期间仍然保留的功能**：访问统计与后台 `/admin`（PV/UV/用户/页面排行/导出）、
个股点击新手引导与提示条、导出 CSV、每日 19:00 自动更新。

**同步**：6 个页面已重建，发布包已重新生成并验收通过（`site/` 24 个、`server/` 6 个、
`windows/` 10 个、`小程序代码/` 9 个、文档 5 篇）。`tools/verify_all.py` **全部通过**。

---

### 9.23 「今天没自动更新」再排查：任务被改成静默启动器后**被留成禁用**（2026-09-28）

用户要求「更新数据，并打开页面」。更新本身跑通了（20260928，退出码 0），
但顺手核对时发现 **2026-09-28 19:00 并没有触发** —— 于是查到了新原因。

**一、事实核对**

| 证据 | 结果 |
|---|---|
| 任务当前动作 | `wscript.exe //nologo "C:\ProgramData\Astock\run_bafeite_update.vbs"` ← **不是我注册的写法** |
| 任务状态 | **Disabled**（`Next Run Time: N/A`） |
| 上次运行 | `2026/9/27 19:00:01`，结果 0 |
| 历史上是否按点跑过 | 09-23 / 09-24 / 09-25 / 09-27 都是 `19:00:01` 触发 ✓；09-26 是 `20:18`（开机补跑，`StartWhenAvailable` 生效 ✓） |

**二、这不是故障，是「另一个会话做了改动，但漏了最后一步」**

2026-09-27 约 22:01，机器的另一个自动化会话把几个项目的启动方式统一改成了
「**VBScript 静默启动器**」，放在 `C:\ProgramData\Astock\`：

| 文件 | 作用 |
|---|---|
| `run_bafeite_update.vbs` | **本项目**的静默启动器（窗口样式 0 = 不弹窗），等价于原来的 `cmd /c 自动更新_静默.bat` |
| `run_astock_data.vbs` / `run_chanlun_pipeline.vbs` / `run_watchdog.vbs` | 另外三个项目 / 看门狗 |
| `silent_run.vbs` / `start_panel_silent.ps1` / `watch_panel_silent.ps1` | 通用静默启动与看板守护 |
| `backup\` | 改动前的备份 |

我核对了 `run_bafeite_update.vbs`：工作目录与要执行的 bat 都指向本项目，**写法正确**
（这其实是个改进：不再弹黑框）。问题是这次改动**把两个「数据更新」任务都留成了禁用**
（本项目的「巴菲特的计算器每日更新」和另一个项目的「A股数据…」），所以 09-28 没有自动更新。

**三、处理（最小干预，保留对方的改进）**

1. **只重新启用，不覆盖对方的配置**：`schtasks /Change /TN "巴菲特的计算器每日更新" /ENABLE`
2. 复验：`Status: Ready`、`Scheduled Task State: Enabled`、`Next Run Time: 2026/9/29 19:00:00`
   （动作仍是对方的 VBS 静默启动器，保持静默、不弹窗）
3. **没有**重跑我的 `install_daily_task.py` —— 那会把动作改回 `cmd.exe /c ...bat`，
   覆盖掉对方「不弹窗」的改进。

**四、新增一道防呆：每轮更新自查计划任务**

这个坑已经踩过两次（① 任务从未注册 ② 任务被禁用），两次都表现为「网页数据没更新」，
却都要人工去查。现在 `update_daily.py` 新增 `check_scheduled_task()`，
在每轮收尾时查询任务状态并写进日志与窗口：

```
---- 计划任务体检（每天 19:00 自动更新）----
  状态：Enabled ｜ 下次运行：2026/9/29 19:00:00 ｜ 上次运行：2026/9/27 19:00:01
  正常：下次到点会自动更新
```

若任务不存在或被禁用，会直接打印修复命令并在窗口里打 `[警告]`。
排错手册开头也加了同样的一分钟自查步骤。

**五、顺带确认（本轮）**

| 检查 | 结果 |
|---|---|
| 全量更新 | 2026-09-28 20:11:58 → 20:18:02，**364 秒**，退出码 **0** |
| 数据日期 | 5 个指数全部 = **20260928**（tushare 最新交易日） |
| 本地预览（`网站文件\site`） | 首页 + 5 个看板数据日期均为 **20260928**，成分股 300/500/1000/2000/5122 ✓ |
| 后台服务 | `/api/health` 200；首页 HTTP 返回内容里 `数据日期 = 20260928` ✓ |
| 页面已打开 | 已在默认浏览器打开 `http://127.0.0.1:8787/` |

> 给使用者的提醒：静默启动器不弹窗，所以「到底更新没更新」要看
> ① 页面页脚的「本页生成时间」② `out\update_log.txt` 末尾的体检结论。

---

### 9.24 「刷新工具又坏了」：副本没同步 + 手动 bat 有解析坑 + 按用户要求删掉全部自启动（2026-10-08）

用户反馈：**刷新工具又又又坏了，刷新网页，并修正刷新文件。**

**一、先分清「哪一环坏了」**（19:00 的自动更新其实没坏）

| 环节 | 诊断结果 |
|---|---|
| 每日更新的 19:00 任务 | **正常**：10-08 `19:00:01` 触发，19:05:49 结束，`exit code 0` |
| `out/` 每日更新产物 | **20261008**（最新交易日）✓ |
| `网站文件\site`（本地预览 / 上线打包用的副本） | **20260928** ✗ 停在 10 天前 |
| 本地预览服务（127.0.0.1:8787） | **没有运行** ✗（重启电脑后消失） |
| `每日更新打分.bat`（手动刷新文件） | **有致命隐患**：`chcp 65001` + UTF-8 中文 → cmd.exe 解析错位 |

**二、三个根因**

1. **每日更新不同步发布包**：`update_daily.py` 的 `rebuild_site()` 只跑
   敏感性分析 + 首页，**没有** `build_website_bundle` 这一步 ——
   所以 `out/` 天天新，而 `网站文件\site`（预览与上线用的副本）永远滞后。
2. **手动刷新文件自带解析坑**：`每日更新打分.bat` 里有 `chcp 65001` 与 UTF-8 中文，
   正是 9.16 节那个「cmd.exe 按字节偏移重读、把后面的命令跳过去」的老问题 ——
   表现就是双击后窗口一闪、数据没刷新。
3. **预览服务是常驻进程**，重启电脑就没了（这一点我一开始想用「登录时自启」解决，
   但用户明确要求**不要任何自启动**，见第四节）。

**三、修复**

| 对象 | 修复 |
|---|---|
| `update_daily.py` | `rebuild_site()` 末尾新增 `tools/build_website_bundle.py`（同步 `网站文件\site` + `site.tar.gz`），并加 `--skip-bundle` 开关。**以后每日更新与手动刷新都会自动同步发布包** |
| `每日更新打分.bat` | 完全重写为**纯 ASCII + CRLF**（用 `.encode("ascii")` 强制，写不进中文就直接报错），中文提示全部交给 Python 打印；结尾新增两步：**同步发布包** + **打开最新页面** |
| `tools/open_site.py`（新增） | 刷新完自动打开页面：本地预览在跑就打开 `127.0.0.1:8787`，否则打开 `out\index.html`；同时打印「两边数据日期是否一致」 |

**四、按用户要求删除所有自启动**

| 动作 | 结果 |
|---|---|
| 删除我刚建的「巴菲特的计算器本地预览」（登录触发）计划任务 | ✓ 已删除 |
| 删除配套的 `run_preview_silent.vbs`、`install_preview_task.py`、`_preview_task.xml` | ✓ 已删除 |
| 删除为自启设计的 `tools/preview_local.py` | ✓ 已删除 |
| 审计：计划任务里本项目的**登录/开机触发**数 | **0 个** |
| 审计：启动文件夹（用户 + 公共） | 只有 `desktop.ini`，无本项目条目 |
| 审计：注册表 `HKCU/HKLM/WOW6432 Run` 键 | 与本项目相关：**无** |
| 本地预览改为**纯手动**（`9_本地预览网站和后台.bat`，双击才启动、关窗口即停） | ✓ |

> **保留的唯一自动项**：每天 **19:00 的数据更新任务**。它不是开机/登录自启，
> 只是到点跑一次数据刷新 —— 这正是用户此前反复要求的「19:00 自动更新数据」。
> 若也不要它：双击 `_project\取消每日19点自动更新.bat`，
> 或执行 `schtasks /Delete /TN "巴菲特的计算器每日更新" /F`。
>
> 另：服务器（Linux）上的 nginx 与站点后台仍设为开机自启 —— 那是让网站重启后还能被访问，
> 与本机自启动是两件事；若这里也改手动，网站每次重启都会 502。

**五、验证**

| 检查 | 结果 |
|---|---|
| 全量刷新 | 2026-10-08 23:29:38 → 23:35:56，**379 秒**，退出码 **0**，数据 = **20261008** |
| 手动 bat 形态 | 非 ASCII **0** ｜ LF-only **0** ｜ 无 `chcp` 命令 ✓ |
| 手动 bat 结构 | 用无害载荷在**真实 cmd.exe** 里跑：`STEP-1-update_daily` / `STEP-2-build_website_bundle` / `STEP-3-open_site` **三步全部执行** ✓ |
| 手动 bat 真跑 | `每日更新打分.bat --index csi300 --skip-fetch --skip-sensitivity` → **RC=0**；`out\index.html` 与 `网站文件\site\index.html` **数据日期都是 20261008、生成时间都是 23:37:44**（两边一致 ✓）；发布包重建（site 24 个 / `site.tar.gz` 3.04 MB）；页面已打开 |
| 发布包里的 bat | `网站文件\windows\9_...bat` 非 ASCII 0、CRLF 50 ✓ |

---

### 9.25 详情浮窗五维能量柱动画：做过，**已按用户要求回退**（2026-10-08）

曾给详情浮窗里护城河强度 / 护城河趋势 / 财务健康 / 管理层 / 估值这 5 根条加了
「从左到右长出来」的动画（宽度 0→目标、五根依次错开 95ms、高光扫过、整行淡入）。

用户反馈：**不好看，也不是页面刚需** → 已完整回退，恢复为静态条。

| 检查 | 回退后 |
|---|---|
| 生成器 `engine/dashboard.py` 里的 `dimfill` / `dimsheen` / `dimrowin` / `--w:` / `--d:` / `prefers-reduced-motion` | **全部 0 处残留** |
| 已生成看板 HTML 里的 `@keyframes` 与动画声明 | **0 处**；`.dimrow .fill` 恢复为 `{height:100%;border-radius:4px}` |
| 五维条宽度（浏览器实测，贵州茅台 600519.SH） | 内联 83.16% / 58% / 94.44% / 80.71% / 78%，实测宽度与之一致 ✓ |
| 五维条数值显示 | 31.6 / 38、5.8 / 10、17.0 / 18、11.3 / 14、15.6 / 20 ✓ |
| 动画实例数 / 动画名 | **0 个 / `none`** ✓ |
| JS 报错 | **0** ✓ |
| 5 个看板 + 发布包 | 已全部重新生成并同步 |

> 结论：这 5 根条维持**静态显示**。用户没要求过动效，也不再往这里加动画。

---

### 9.26 刷新又失败的真凶：Windows「智能应用控制」拦掉了 parquet 读写（2026-10-12）

#### 一、现象

用户报「数据刷新又又又遇到问题了」。当天 19:00 的计划任务本身**确实跑了、退出码 0**，
但页面数据停在 `20261009`，一个字都没动。

#### 二、排查过程（三层，逐层排掉）

| 层 | 怀疑对象 | 结论 |
|---|---|---|
| 1 | 计划任务没跑 | **排除**：Last Run 2026/10/12 19:00:01，Last Result 0，Next Run 10/13 19:00 |
| 2 | 卡在旧的锁文件 | **排除**：锁文件 pid 已死，且当轮拿锁成功 |
| 3 | 取数脚本自身报错 | **命中**：`tools/fetch_data.py` 在 `df.to_parquet(...)` 处抛 `ImportError` |

真实报错（`out\_block_1012.txt` 抓到的原文）：

```
ImportError: Unable to find a usable engine; tried using: 'pyarrow', 'fastparquet'.
 - The pyarrow installation is not built with support for the Parquet file format
   (DLL load failed while importing _parquet: 应用程序控制策略已阻止此文件。)
 - Missing optional dependency 'fastparquet'.
```

关键在这句中文：**「应用程序控制策略已阻止此文件」**——不是 pyarrow 装坏了，是**系统不让它加载**。

#### 三、根因：Smart App Control（智能应用控制）处于强制模式

- 注册表 `HKLM\SYSTEM\CurrentControlSet\Control\CI\Policy\VerifiedAndReputablePolicyState = 1`
  → 智能应用控制 = **开启（强制）**。
- 事件日志 `Microsoft-Windows-CodeIntegrity/Operational` 在 20:05–20:06 连续记录
  **Id 3033 / 3077**，对象就是 `pyarrow\_parquet.pyd`。
- 该机制**没有白名单、没有"允许一次"**，被拦就是被拦；`pyarrow` 25.0.1 本身完好，
  换版本、重装都无效。
- 影响面：本机**所有** pyarrow 读写 parquet 的代码，本项目里有 **102 处调用点**，
  还包括其它项目（缠论、ds 因子模型等）。

#### 四、修复方案：装 `polars` + 统一走一层薄封装（不动业务代码）

1. `python -m pip install polars` → 装到 **polars 2.0.0**，实测**可正常读写 parquet**（不受拦截影响）。
2. 新增 **`engine/pq.py`**，作为全项目唯一的 parquet 出入口：
   - `read(path)`：依次尝试 `pyarrow → polars → fastparquet`，谁行用谁；
   - `write(df, path)`：同理；写不出时退回**逐列转字符串**再写，保证不抛异常；
   - `install()`：把 `pandas.read_parquet` / `DataFrame.to_parquet` **打补丁**指向本模块
     → 那 102 处老代码**一行都不用改**，自动走通；
   - `engine_note()`：返回实际生效的后端名，方便日志里核对。
3. `engine/__init__.py`（原来是空文件）里加一行 `from engine import pq as _pq; _pq.install()`。
   → 只要 `import engine`，补丁就已就位，不存在"某个脚本忘了改"的漏网。
4. 另外给 `tools/fetch_db_history.py`、`tools/corpus.py` 及 10 个 `tools\_*.py` 诊断脚本
   补了同一套兼容导入块，**直接单跑这些脚本也不会炸**。

#### 五、顺手加的两道保险（防"退出码 0 但其实没更新"）

这次最坑的不是报错，是**报错却被吞掉、最后还返回 0**——所以任务显示"成功"，数据却是旧的。

| 新增 | 位置 | 作用 |
|---|---|---|
| `check_env()` | `update_daily.py` | 开工前先写一个小 parquet 再读回来。做不到就打印 `[失败]` 并 `sys.exit(4)`，**绝不带病开跑** |
| `data_freshness()` | `update_daily.py` | 收尾核对：页面数据日期落后，或打分快照落后 **>3 个交易日** → 判定失败，不再报"刷新成功" |
| `_pid_alive()` | `update_daily.py` | 用 ctypes `OpenProcess` 判断进程存活（Windows 上**不能用** `os.kill(pid,0)`，那会误杀/误判） |
| `acquire_lock()` 重写 | `update_daily.py` | 锁文件里的 pid 已死 → **立刻接管**，不再傻等 6 小时 |
| `check_scheduled_task()` | `update_daily.py` | 顺带报告计划任务是否被禁用/被改 |

#### 六、验证结果（2026-10-12 全量重跑）

| 项目 | 结果 |
|---|---|
| parquet 后端 | **polars 2.0.0** 生效（`engine_note()` 确认） |
| 读 `trade_cal.parquet` | 4383 行 ✓ |
| 写→读 往返比对 | `equal = True` ✓ |
| 读 `features_csiall` | 5122 × 184 ✓ |
| 全量刷新 | 20:09:40 开始 → 20:16:56 结束，耗时 **436 秒**，`EXITCODE=0` |
| 页面数据日期 | **20261012**（= tushare 最新交易日） |
| 5 个看板 HTML | csi300 / csi500 / csi1000 / csi2000 / csiall **全部重新生成** |
| `out\` vs `网站文件\site` | 7 个 html **文件名、字节数、修改时间完全一致** ✓ |

#### 七、给用户的说明

**为什么会突然这样？** 智能应用控制是 Windows 11 的一个安全开关，微软会分批下发强制开启。
一旦开启，凡是"未签名/未受信任声誉"的原生扩展（`.pyd` / `.dll`）都会被拒绝加载。
本项目此前一直正常，正是因为它最近才真正生效。

**要不要关掉它？** 可以不关——**现已用 polars 绕过，功能完全恢复**。
如果哪天想关（能一并解决其它项目的同类问题），必须在
`设置 → 隐私和安全性 → Windows 安全中心 → 应用和浏览器控制 → 智能应用控制` 里关闭。
⚠️ **重要：关闭是单向的，关掉之后只能通过重装 Windows 才能重新开启。** 请自行权衡。

---
## 十、已知局限与改进方向

**局限**

1. **只有 10 年可得年报**，无法真正验证晨星要求的「20 年经济利润持续期」。当前用 A 维度得分 + 溢价年数近似，对长期护城河的判断偏乐观。
2. **行业先验分是人工赋值**，虽然逐条对应书中行业篇结论，但仍是主观的，且A股行业分类（104 个）与书中行业分类并非一一对应。
3. **ROIC 依赖 tushare 计算口径**，未自行调整研发资本化、租赁资本化、商誉是否计入投资资本等（书中讨论了这些口径差异会显著影响 ROIC：帕特森公司计入商誉后 ROIC 从 16.2% 降到 10.4%）。
4. **DCF 绝对水平偏低**（市场温度约 4.6），说明模型内在价值系统性低于A股定价。这既可能是模型保守，也可能确实是A股小盘股的高估——**需要靠后续回测来分辨**。敏感性与口径说明见第八节。
5. **与巴菲特原文的两处口径冲突**（折现率用 WACC + 6% 风险溢价；用 β 而非"通过/否决"表达风险）。第八节已完整披露，并给出敏感性与替代方案。
6. **未做回测**。本轮交付的是打分体系与当期结果，尚未验证打分与未来收益的关系。这是本体系最大的未完成项。
7. **未包含ESG、诉讼、关联交易、违规处罚、衍生品名义敞口、表外担保**等数据（tushare 权限内不可得或需额外数据源）。巴菲特 2002 年信中对衍生品的 9 条扣分规则因此尚未落地。
8. **股东信有 1 年（1989）确实缺失**，2005 年信也不在文件夹中；这两年的内容（含著名的"Mistakes of the First Twenty-five Years"清单）无法引用。

**改进方向（按优先级）**

1. **回测**：用 2018 年以来的历史数据构造面板，并用 `ann_date` 做严格的时点对齐以避免前视偏差，检验各维度得分对 T+1 年收益的 IC / 分层收益，据此重新校准权重。这是从「合理」走向「有效」的关键一步，也是当前最大的缺口。
2. **ROIC 口径自算**：用 `income` + `balancesheet` 自行计算 EBI 与投资资本，并提供「含商誉 / 不含商誉」两种口径（对应书中帕特森的例子）。
3. **行业中性化**：目前只有估值与相对指标做了行业对比，可以把 A/B/C 维度做行业内标准化。
4. **把巴菲特 2002 年信里的衍生品扣分规则落地**：需要衍生品名义本金、Level 3 资产占比、公允价值变动占利润比等字段（部分可从年报附注抓取）。
5. **接入ESG、监管处罚、诉讼、股权质押明细**（可用公开数据源补充）。
6. **引入「能力圈」的显式建模**：例如用「上市以来主业变更次数」「经营范围变更次数」「审计师更换次数」等构造指标。
7. **加入分析师预期数据**（需更高 tushare 权限）以改进 g₁ 的估计——但注意这与巴菲特「future projections are of little interest to us」的立场相冲突，应作为**辅助校验**而非主输入。

**已完成的改进（本轮已落地，非待办）**

- ✅ **资本开支 ÷ 折旧**（巴菲特原文判据，A3 子项，权重 0.14）
- ✅ **保守内在价值**（近 5 年扣非盈利下限 × 合理 PE，对应 2013 年信「以盈利区间下限买入」）
- ✅ **特质风险溢价**（修正 A股小盘股 β 系统性偏低导致的必要报酬率失真）
- ✅ **不确定性评级改用经营杠杆**（移除股价波动率，尊重巴菲特对"风险≠波动"的定义）
- ✅ **股东信 OCR 补回**（17 份矢量图形 PDF，约 93 万字）
- ✅ **口径敏感性分析工具**（`tools/sensitivity_discount.py`）

---

*本文档由打分引擎代码同步生成的口径描述，所有阈值均可在 `engine/config.py` 中调整。*
