Files
smart-fin-chart/test/sweet_spot_param.mjs
2026-09-24 17:14:03 +08:00

161 lines
7.3 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 甜蜜击球区「现价」参数回归测试
*
* 覆盖:手输现价后,每张图的现价标线 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);