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