161 lines
7.3 KiB
JavaScript
161 lines
7.3 KiB
JavaScript
/**
|
||
* 甜蜜击球区「现价」参数回归测试
|
||
*
|
||
* 覆盖:手输现价后,每张图的现价标线 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);
|