feat: 增加宁德时代的图表

This commit is contained in:
R524809
2026-09-24 17:14:03 +08:00
parent 3a3d19d5dd
commit f646ee6718
15 changed files with 1459 additions and 240 deletions
+160
View File
@@ -0,0 +1,160 @@
/**
* 甜蜜击球区「现价」参数回归测试
*
* 覆盖:手输现价后,每张图的现价标线 x = 输入值,且标注里的估值指标与该价位的
* 独立公式一致(宁德:PE / 扣现金 PE / 含分红年化;腾讯:EV/EBIT / 扣非经营资产 EV、年化)。
*
* 为什么单独一个文件:test/regress.mjs 是「遍历 manifest 的通用样式回归」,
* 而现价是 tab 级交互参数,通用回归跑不到它(它永远按默认现价渲染)。
*
* 【独立性】下面的公式不是从 charts 里 import 的,而是照 scripts/build_sweet_spot_*.py
* 的基准常量重写一遍 —— 测试要能抓到「图表实现算错」,就不能复用被它测的那份代码。
*
* 用法:node test/sweet_spot_param.mjs
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { parseCSV } from '../js/csv.js';
import { getChartBuilder } from '../js/charts/index.js';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
/* ============================================================
* 宁德时代(300750)—— 与 scripts/build_sweet_spot_catl.py 同源
* ============================================================ */
const CATL_SHARES = 46.27; // 亿股
const CATL_NET_CASH = 2263.8; // 亿元
const CATL_PROFIT = 964; // 2026E 归母净利(亿元,中性)
const CATL_PAYOUT = 0.5; // 分红率
const CATL_TARGET = 605; // 三年目标价(元)
const CATL = {
name: '宁德时代 · PE 口径',
tab: 'catl',
/** 可手输区间 = 数据区(220–700 元,见 manifest 的 controls[].min/max)。 */
band: [220, 700],
/** 区间内 + 两个端点 + 越界值(越界时图内标注必为 NaN,正因如此 UI 要拦下) */
prices: [316.21, 400, 310, 220, 700, 201, 750],
cases: [
{ key: 'sweet-catl-price-pe', csv: 'data/sweetspot/catl_price_pe.csv', metric: '归母 PE' },
{ key: 'sweet-catl-net-cash', csv: 'data/sweetspot/catl_price_pe.csv', metric: '扣现金 PE' },
{ key: 'sweet-catl-any-price', csv: 'data/sweetspot/catl_price_returns.csv', metric: '含分红年化' },
],
/** 每张图的「现价标注」应当等于哪个公式 */
expected(key, price) {
if (key === 'sweet-catl-net-cash') return (price * CATL_SHARES - CATL_NET_CASH) / CATL_PROFIT;
if (key === 'sweet-catl-any-price') {
// 与生成脚本同式:(1 + 价差年化) × (1 + 股息率) − 1
const cagr = (CATL_TARGET / price) ** (1 / 3) - 1;
const yieldRate = (CATL_PAYOUT * CATL_PROFIT) / (price * CATL_SHARES);
return ((1 + cagr) * (1 + yieldRate) - 1) * 100;
}
return (price * CATL_SHARES) / CATL_PROFIT;
},
/** 标注显示精度:年化 1 位小数、倍数 2 位 → 容差随之放宽 */
tolerance: (key) => (key === 'sweet-catl-any-price' ? 0.1 : 0.01),
};
/* ============================================================
* 腾讯控股(00700.HK)—— 与 scripts/build_sweet_spot_tencent.py 同源
* ============================================================ */
const TENCENT_SHARES = 90.95; // 亿股
const TENCENT_FX = 0.86154; // 1 HKD = ? CNY
const TENCENT_NET_CASH = 581.9; // 亿元
const TENCENT_NON_OP = 5841.8; // 亿元(净现金 + 上市投资 + 非上市投资)
const TENCENT_EBIT = 2585.43; // TTM EBIT(亿元,基准)
const TENCENT = {
name: '腾讯控股 · EV/EBIT 口径',
tab: 'tencent',
band: [240, 640],
prices: [426.8, 500, 420, 240, 640, 220, 700],
cases: [
{ key: 'sweet-tencent-price-ev', csv: 'data/sweetspot/tencent_price_ev.csv', metric: '基准 EV/EBIT' },
{ key: 'sweet-tencent-non-op', csv: 'data/sweetspot/tencent_price_ev.csv', metric: '全扣 EV/EBIT' },
{ key: 'sweet-tencent-any-price', csv: 'data/sweetspot/tencent_price_returns.csv', metric: '退出 12x 年化' },
],
expected(key, price) {
// 图 1 的主角是基准情景、图 2 的栈底是「连投资组合一起扣」——两者都是 mult_base
if (key !== 'sweet-tencent-any-price') {
return (price * TENCENT_SHARES * TENCENT_FX - TENCENT_NON_OP) / TENCENT_EBIT;
}
// 目标价 =(12x × EBIT + 非经营资产)÷ 股本 ÷ 汇率,再年化;不给 EBIT 增长
const target = (12 * TENCENT_EBIT + TENCENT_NON_OP) / (TENCENT_SHARES * TENCENT_FX);
return ((target / price) ** (1 / 3) - 1) * 100;
},
tolerance: (key) => (key === 'sweet-tencent-any-price' ? 0.1 : 0.01),
};
const COMPANIES = [CATL, TENCENT];
/* ============================================================
* 断言
* ============================================================ */
let fail = 0;
let pass = 0;
/** 抓现价标注:它是 graphic 里唯一一段带「现价」的水平文字(markLine 标签是关掉的) */
function nowNote(option) {
const note = (option.graphic || []).find((g) => g.type === 'text' && g.style.text.includes('现价'));
if (!note) throw new Error('找不到现价标注');
return note.style.text;
}
/**
* 从「现价 316.21 元 = 中性 15.18x」里抠出数值。两处易错:
* 1. 要取 '=' 之后那一段(split('=').pop()),否则会先撞上现价本身;
* 2. 该段里可能还有别的数字 —— 腾讯图 3 的标签是「退出 12x 上沿 3.3%」,
* 指标值在**最后一个**数字里,取第一个会得到 12。
*/
function shownValue(text) {
const tail = text.split('=').pop();
const matches = tail.match(/-?[\d.]+/g);
return matches ? Number(matches[matches.length - 1]) : Number.NaN;
}
for (const company of COMPANIES) {
console.log(`\n########## ${company.name} ##########`);
for (const { key, csv, metric } of company.cases) {
const rows = parseCSV(fs.readFileSync(path.join(ROOT, csv), 'utf8'));
const build = getChartBuilder(key);
console.log(`\n===== ${key}(${metric})=====`);
for (const price of company.prices) {
const inBand = price >= company.band[0] && price <= company.band[1];
try {
const option = build(rows, { width: 1100, height: 740, params: { price } });
const marker = option.series.find((s) => s.markLine).markLine.data[0].xAxis;
const note = nowNote(option);
if (marker !== price) throw new Error(`标线 x=${marker},期望 ${price}`);
if (!inBand) {
// 区间外不打印 NaN,而是写明「超出数据区」
if (!note.includes('超出数据区')) throw new Error(`越界价未提示「超出数据区」:${note}`);
pass += 1;
console.log(` ✓ 现价 ${String(price).padStart(6)} → 标线 x=${marker} | ${note}`);
continue;
}
const shown = shownValue(note);
const expected = company.expected(key, price);
const tolerance = company.tolerance(key);
if (!Number.isFinite(shown) || Math.abs(shown - expected) > tolerance) {
throw new Error(`标注 ${shown},公式 ${expected.toFixed(3)}(容差 ${tolerance})`);
}
pass += 1;
console.log(` ✓ 现价 ${String(price).padStart(6)} → 标线 x=${marker}`
+ ` | ${note} | 公式 ${expected.toFixed(3)}`);
} catch (error) {
fail += 1;
console.log(` ✗ 现价 ${price}:${error.message}`);
}
}
}
}
console.log(`\n${pass} 项通过 / ${fail} 项失败`);
process.exit(fail === 0 ? 0 : 1);