美股估值与情绪看板
一、我为什么想要这么一个页面
每天开盘前,我会看几样东西:SPY 和 QQQ 离年内高点还有多远、VIX 在什么位置、CNN 的恐惧贪婪指数是多少、标普 500 的 PE 处在近五年什么分位、高收益债的信用利差有没有走阔。
这几样东西散落在四五个站点上,每个站点都要点三四下才能翻到。更要命的是三件事:
- 信息是分散的。 指数在东财或 Twelve Data,VIX 在 Cboe,情绪在 CNN,PE 在 Multpl,信用利差在 FRED。把它们拼在一张屏幕上,本身就消耗注意力。
- 口径是不透明的。 同样一句“标普 500 的 PE 是 28”,有的站用 TTM、有的用前瞻、有的是估算值。不标日期、不标来源、不标数据类型,这个数字就没法用来做判断。
- 判断是不可复现的。 “现在感觉有点贵”——贵到什么程度?下次再遇到同样的市场,我还能做出一样的决策吗?如果判断只存在于感觉里,它就永远无法被检验。
所以这个看板的三个目标从一开始就很明确:把它放到一页上、把每个数字的来源和日期写清楚、把判断规则写成代码。
二、它长什么样

页面分成三段式:顶部是标题 + 刷新按钮 + 明暗主题切换,中间是三个吸顶标签(指数指标 / 情绪与说明 / 策略与纪律),下面才是数据。截图里的黄色条是“数据源提示”,只要有任何一路数据降级或者走的是回退源,它就会冒出来告诉你——而不是安安静静地显示一个可能过期的数字。
移动端做了一套完全不同的布局:卡片单列、字号加大、地球背景改为静态。

右侧那个点阵地球是页面的背景素材,不是装饰图片而是真实渲染出来的:

它的点云来自 Natural Earth 1:50m 的公有领域数据,经 topojson / world-atlas 转成坐标后由 Canvas 绘制,城市之间还有连线。这个组件是整个项目里最好玩、也最容易出 bug 的部分——后面第七节会讲它怎么把我坑了两次。
三、六个面板,各看什么
| 面板 | 里面有什么 | 主要数据源 |
|---|---|---|
| 主要指数概览 | SPY / QQQ / DIA / SOXX / IWM 五个 ETF 代理 + 纳斯达克综合指数,含 52 周区间、距高点百分比、数据来源与数据日期 | Twelve Data → Nasdaq → FMP 回退 |
| 相对强弱 | 各标的近 20 / 60 个交易日相对 SPY 的超额收益 | 由指数日线本地计算 |
| 情绪信号 | 六项:VIX、CNN 恐惧贪婪、RSI(6)、标普 500 PE 近 5 年分位、52 周回撤、25 日涨跌幅;每项有“触发 / 观察 / 正常 / 陈旧”四态 | Cboe / CNN / Multpl / 本地计算 |
| 波动率结构 | VIX 的 9 天 / 30 天 / 90 天期限结构,以及 SKEW、VVIX 的 5 年分位 | Cboe |
| 市场宽度 | RSP(等权)与 SPY 的走势对比、两者相对 200 日均线的距离差、比值分位 | Twelve Data(RSP 单独缓存 2 小时) |
| 信用市场 | 美国高收益债 OAS 利差、5 年分位、20 日变化 | FRED(BAMLH0A0HYM2,T+1 数据) |
有一个细节值得单独说:ETF 价格不等于指数点位。SPY 的 763 美元不是标普 500 的 6000 多点,所以卡片上明确写着“ETF 代理”,并且价格只用于算涨跌幅、区间位置这类相对量,不当作指数点位使用。这一点如果不写清楚,很容易被自己误导。
四、数据层:三把 key、四路回退、三层缓存
4.1 每个数据源都有自己的脾气
| 数据 | 主源 | 回退与注意事项 |
|---|---|---|
| 指数行情 | Twelve Data | 失败先试 Nasdaq,再试 FMP;三个都挂才报错 |
| VIX / SKEW / VVIX | Cboe 官方 CSV | 无需 key,但字段格式偶有变化 |
| 恐惧贪婪指数 | CNN 内部接口 | 无 key,返回结构随前端版本变化 |
| 标普 500 PE | Multpl | 月度数据,滞后超过 45 天标记为陈旧 |
| 高收益债 OAS | FRED | 官方 T+1,永远比行情慢一天 |
| RSP 等权 | Twelve Data | 单独缓存 2 小时,减少 credits 消耗 |
Twelve Data 免费档大约每分钟 8 个 credits,而整页刷新一次要取 7 个符号——基本上刷一次就接近限额了。所以触到 429 之后不是死循环重试,而是进入 5 分钟冷却,期间沿用上一次成功拿到的值,并在页面上把“数据源提示”点亮。宁可告诉你“这是上次的数”,也不给你一个假装新鲜的数。
4.2 三层缓存,各有各的理由
| 层 | 机制 | 为什么 |
|---|---|---|
| 服务端 | 30 分钟缓存 + 进程内 Promise 复用 | 多个请求同时打进来时只真正取一次数,避免并发把上游打穿 |
| 页面 | ?refresh=1 有 60 秒节流 | 手动刷新不能变成对上游的压测 |
| 浏览器 | localStorage 存 24 小时 | 冷启动时先把上一次的数据画出来,10–20 秒的取数过程不至于白屏 |
4.3 失败就是失败
如果所有回退都失败,接口返回 HTTP 503:
1 | { "error": "真实数据加载失败", "detail": "..." } |
不会用模拟值填充。 一个金融看板最坏的行为,就是让假数字看起来像真的。缺哪个指标,那个卡片就空着或者标记为不可用,这是有意为之。
4.4 环境变量
1 | # .env.local |
五、把“感觉”写成代码:倍率规则
这是整个项目里我自己最满意的部分。
看板不只是“看”,它会在「策略与纪律」标签页里给出一个倍率:常态是 1 倍定投,市场越极端倍率越高,估值越贵倍率越低。这个倍率不是模型、不是预测,而是一套事先写死的规则,目的是让同样的情况永远得到同样的结论。
判定顺序是严格从上到下的,命中即停:
| 顺序 | 条件 | 倍率 |
|---|---|---|
| 1 | 参与计算的真实指标缺失 ≥ 2 项 | 1×(并标注“数据不足”) |
| 2 | 标普 500 PE 近 5 年分位 ≥ 90%(且未陈旧) | 0.25× |
| 3 | PE 近 5 年分位 ≥ 80% | 0.5× |
| 4 | 高置信信号触发 ≥ 1 项,或触发总数 ≥ 2 项 | 2× |
| 5 | 只有 1 项触发 | 1.5× |
| 6 | 无触发,但有指标进入观察区 | 1.5× |
| 7 | 其余 | 1× |
为什么是这个顺序:
- 数据不够就别装懂。 六项信号里有两项以上拿不到,说明这一屏不足以支撑判断,直接退回常态。
- 估值优先于情绪。 PE 分位 ≥ 90% 意味着过去五年里只有约 10% 的时间比现在更贵,这种位置不该因为“VIX 很高、便宜”而放大投入。规则把估值放在情绪之前,就是为了防住“越跌越买、越买越贵”这件事。
- 低置信指标需要第二项确认。 六项里只有 RSI(6) 被标为 low confidence——它纯粹是短线噪声,单独触发只能给出 1.5 倍,必须再出现一项才升级到 2 倍。
- 陈旧数据不参与计算。 Multpl 的 PE 是月度数据,如果最新一期已经滞后超过 45 天,页面上照常显示并标注“陈旧”,但倍率计算直接跳过它。
阈值本身集中在一个文件里(src/config/thresholds.ts),六项信号长这样:
| 信号 | 触发(triggerAt) | 观察(nearAt) |
|---|---|---|
| VIX | ≥ 30 | ≥ 25 |
| CNN 恐惧贪婪 | < 25 | < 45 |
| RSI(6) | ≤ 22 | ≤ 28 |
| PE 近 5 年分位 | ≤ 20%(低估值区) | ≤ 30% |
| 52 周回撤 | ≤ -20% | ≤ -15% |
| 25 日涨跌幅 | ≤ -12% | ≤ -8% |
每次页面渲染的倍率都会追加进 data/snapshots.jsonl(按交易日去重),所以「策略与纪律」页能看到一条倍率轨迹——这也是我坚持“要开源、要跑在自己的服务器上”的原因之一:数据得留在自己的磁盘上,才能看出规则在真实历史里的行为。
六、图表一个都不是用图表库画的
27 个仪表盘组件里,没有引入任何图表库。所有的折线、区间条、期限结构、分位柱,都是手写的 SVG 或 CSS。
理由有三个:
- 我对“长什么样”的控制欲比较强。 图表库默认的坐标轴、网格线、图例位置,在这个项目里基本都是要关掉的。
- 体积。 一个 ECharts 或 Recharts 的依赖会把首屏拖进去几十到几百 KB,而这些图并不需要交互缩放。
- 和服务端数据形状对齐。 数据结构本身就是为了这几张图设计的:一个
DashboardPayload从src/lib/server一路传到 React 组件,TS 严格模式下任何字段改动都会在编译期暴露。
真正的大头在数据聚合上:src/lib/server/dashboardData.ts 有 51 KB,负责把六路来源拼成一个 payload,处理回退、缓存、降级标记、时间戳合并。图表反而是最轻松的部分。
七、两个移动端 bug,和它们教我的事
v1.0.0 发出去第二天,手机上就发现了问题。
v1.0.1:吸顶标签被地球挡住了。 移动端标签栏是 position: sticky,而地球是一个固定定位的 Canvas 层,还带拖拽热区。结果标签栏虽然在视觉上更靠前,点击却被地球的命中测试吃掉了。修复方式是在触摸设备上直接禁用地球的命中测试并隐藏提示框,桌面端保持不变——同一个组件,两种设备,需要两套交互假设。
v1.0.2:锁屏回来地球变空白。 切到后台、锁屏、再切回来,地球就没了。原因很典型:移动浏览器会回收不可见 Canvas 的后备存储,画布尺寸还在、内容却没了。修复是监听 visibilitychange / pageshow / contextrestored 三个事件补画,再加一个 60 秒的兜底重绘。
这两件事的共同点是:它们在我自己的桌面浏览器上永远不会出现。而修复它们,比写完另外五个面板花的时间还多。
八、怎么跑起来
1 | git clone https://github.com/fomalhaut1998/us-market-dashboard.git |
要求 Node ≥ 20.9。本地首次冷启动会慢一些(10–20 秒),因为六路数据要挨个取。
部署有两条路:
- Vercel:一条命令的事,适合先看效果。注意它没有持久磁盘,
data/snapshots.jsonl的倍率轨迹可能会被清空。 - 自建 Node:
npm run build && npm start,设置PORT,把data/目录挂到持久卷上,前面套 Nginx 或 Caddy。这样轨迹才留得住。
九、它的边界
写在这里,也写在 README 里:
- 不是实时行情。 全部是日线收盘或延迟数据,Cboe 那边甚至是收盘后才更新。
- 不是投资建议。 我只是把公开数据摆在一起、把规则写清楚,规则本身的正确性需要你自己判断。
- 数据版权归各来源所有,项目不重新分发原始数据,只做展示与聚合。
- PE 是月度、OAS 是 T+1,看到“陈旧”标记是正常的,不要当成 bug。
十、许可证与链接
MIT,Copyright © 2026 Fomalhaut。数据部分版权归 Twelve Data / Cboe / CNN / Multpl / FRED 所有;地球点云来自 Natural Earth 的公有领域数据。
- 源码:github.com/fomalhaut1998/us-market-dashboard
- 演示:us-market-dashboard-gules.vercel.app
- 技术栈:Next.js 16(App Router)· React 19 · TypeScript 6(strict)· Tailwind 4
如果你也想跑一份自己的版本,欢迎提 issue;数据源的解析规则变动很频繁,这部分永远需要有人盯着。