Files
one_divine_lot/scripts/test-r026-formula-fields.mjs
kyugao ec02a791ee 迭代22+23: 策略tab静态化 + 计算字段(数字/判定型)+ 网格超市信号字段
- R-027 静态化:策略恒为内置两项(不可增删改名,保留显隐/排序);设置页移除「策略分组」;
  字段配置入口迁到策略 tab 内「列设置」旁(FieldConfigDialog + schema-update 单策略写入)
- R-026 计算字段:自写公式引擎(中文变量、四则/括号/round·abs·min·max、缺值短路→—)+
  变量目录(行情/合约/持仓/自定义字段)+ strategy-positions 逐行现算(只读内存缓存,不落库、列只读)
- R-028 判定型:比较运算 + and/or + inferResultKind + 结果类型一致性校验 + 判定列 ✓/— 渲染;
  网格超市落地 可下空单=涨停价>基准值+网格大小、可下多单=跌停价<基准值-网格大小
- 变量选择改标签平铺(老师反馈);公式手册 docs/99-其他材料/计算字段公式说明.md
- 约束同步:产品约束-002/003/004/009/013/014、技术约束-014/015/022/023/024、UI约束-002/003/005/008
- 回归:新增 test-r026/test-r027,更新 r011/r013/r017,17 个脚本全绿;typecheck/build 通过
2026-09-10 14:05:06 +08:00

219 lines
17 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.
/**
* R-026 回归测试:策略计算字段(公式引擎 + 变量目录 + 现算 + 校验 + 试算)(迭代 22 阶段二)
* 覆盖:
* 1. 引擎:中文变量 / 全角归一 / 优先级 / 一元负 / 函数 / 语法错误 / 未知变量与函数 / 缺值短路 / 除零 / 浮点归一
* 2. 变量目录:内置三组 + 自定义字段组;变量名集合排除 formula 字段(零循环依赖)
* 3. 上下文取数:行情/合约/持仓账本/自定义字段值;昨收兜底;布尔 → 0/1;空值 → null
* 4. 现算:computed 附着 / 缺数据 → null / 无 formula 字段零开销 / 不写 values
* 5. 保存校验:语法、未知变量、变量名冲突、重复展示名、字段名引用公式字段 → 拒绝
* 6. 试算:正常 / 无持仓 / 缺数据
* 7. R-028 判定型:比较运算 / and·or / 全角 ≥ ≤ ≠ / inferResultKind / 结果类型一致性校验 / 布尔直通 / 判定试算
* 8. 端到端:网格超市真实夹具(2026-09-10 实盘涨跌停 + 真实基准值)→ 可下空单 / 可下多单
* 纯内存 mock(技术约束-011:不触真实数据)
*/
import { validateFormula, evaluateFormula, compileFormula, inferResultKind } from '../src/formula/evaluator.js';
import { buildContext, catalogFor, allowedVarNames, BUILTIN_VAR_NAMES } from '../src/formula/variables.js';
import { computeRows } from '../src/formula/FormulaService.js';
import { handleStrategy, STRATEGY_METHODS } from '../src/api/strategies.js';
let pass = 0, fail = 0;
function assert(cond, msg) {
if (cond) { pass++; console.log(' ✅ ' + msg); } else { fail++; console.log(' ❌ ' + msg); }
}
function section(t) { console.log('\n[' + t + ']'); }
const approx = (a, b) => typeof a === 'number' && Math.abs(a - b) < 1e-6;
function makeScope(initial) {
const store = structuredClone(initial ?? {});
return { get: () => structuredClone(store), update: async (p) => { Object.assign(store, structuredClone(p)); }, _store: store };
}
async function throwsAsync(fn, code, msg) {
try { await fn(); assert(false, msg + '(未抛错)'); }
catch (e) { assert(e?.code === code, msg + 'code=' + e?.code + ''); }
}
const VARS = ['现价', '成本价', '份额', '涨停价'];
// ---------- 1. 引擎 ----------
section('1. 公式引擎');
assert(validateFormula('(现价 - 成本价) * 份额', VARS).ok, '中文变量公式通过校验');
assert(validateFormula('(现价 ÷ 成本价 - 1)× 100', VARS).ok, '全角括号/乘除归一化');
assert(validateFormula('现价 × 份额', VARS).ok, '全角乘号');
assert(!validateFormula('现价 ×', VARS).ok && validateFormula('现价 ×', VARS).error.code === 'syntax', '语法错误被拒');
assert(validateFormula('现价 + 网格距', VARS).error.code === 'unknown-var', '未知变量被拒');
assert(validateFormula('sqrt(现价)', VARS).error.code === 'unknown-func', '未知函数被拒');
assert(validateFormula('round(现价, 1, 2)', VARS).error.code === 'arity', '函数参数个数校验');
assert(compileFormula('').error.code === 'empty', '空公式');
const ctx = { 现价: 11.2, 成本价: 10.5, 份额: 1000, 涨停价: 12 };
assert(evaluateFormula('(现价 - 成本价) * 份额', ctx) === 700, '浮动盈亏 = 700(浮点归一)');
assert(approx(evaluateFormula('(现价 / 成本价 - 1) * 100', ctx), 6.666667), '盈亏比例');
assert(approx(evaluateFormula('(涨停价 - 现价) / 现价 * 100', ctx), 7.142857), '距涨停');
assert(evaluateFormula('现价 * 份额 * 0.5', ctx) === 5600, '网格占用(手填+计算组合)');
assert(evaluateFormula('现价 / 0', ctx) === null, '除零 → null');
assert(evaluateFormula('现价 + 缺变量', ctx) === null, '缺值短路 → null');
assert(evaluateFormula('max(1, 2) + min(4, 5) + abs(-3) - round(2.6)', ctx) === 6, '函数组合(2+4+3-3');
assert(evaluateFormula('2 + 3 * 4', ctx) === 14 && evaluateFormula('(2 + 3) * 4', ctx) === 20, '优先级与括号');
assert(evaluateFormula('-现价 + 10', ctx) === -1.2, '一元负号');
assert(compileFormula('(现价', VARS).ok === false, '缺右括号被拒');
const longExpr = '现价' + ' + 现价'.repeat(120);
assert(compileFormula(longExpr).ok === false, '超长公式被拒');
// ---------- 2. 变量目录 ----------
section('2. 变量目录');
const fieldDefs = [
{ key: 'gridGap', label: '网格间距', type: 'number', def: 0.5, unit: '元' },
{ key: 'pct', label: '盈亏比例', type: 'formula', formula: '(现价 / 成本价 - 1) * 100', decimals: 2 },
];
assert(BUILTIN_VAR_NAMES.has('现价') && BUILTIN_VAR_NAMES.has('最后成交价'), '内置变量名集合');
const names = allowedVarNames(fieldDefs);
assert(names.has('现价') && names.has('网格间距'), '变量名含内置 + 手填字段展示名');
assert(!names.has('盈亏比例'), 'formula 字段不进变量目录(零循环依赖)');
const cat = catalogFor(fieldDefs);
assert(cat.groups.length === 4 && cat.groups[3].group === '自定义字段', '目录含自定义字段组');
assert(cat.groups[0].group === '行情' && cat.groups[0].items[0].name === '现价', '行情组首位为现价');
// ---------- 3. 上下文取数 ----------
section('3. 上下文取数');
const row = { code: '000001.SZ', name: '平安银行', shares: 1000, avgPrice: 10.5, lastTradePrice: 10.4, values: { gridGap: 0.5 } };
const c1 = buildContext({ row, quote: { lastPrice: 11.2, lastClose: 11 }, instrument: { upStopPrice: 12, downStopPrice: 9 }, fieldDefs });
assert(c1['现价'] === 11.2 && c1['昨收'] === 11 && c1['涨停价'] === 12 && c1['份额'] === 1000 && c1['网格间距'] === 0.5, '四类数据集取数');
const c2 = buildContext({ row, quote: { lastPrice: 11.2 }, instrument: { preClose: 10.9 }, fieldDefs });
assert(c2['昨收'] === 10.9, '昨收兜底取合约 preClose');
const c3 = buildContext({ row, quote: null, instrument: null, fieldDefs });
assert(c3['现价'] === null && c3['涨停价'] === null && c3['份额'] === 1000, '缺行情 → null,持仓账本仍可取');
const c4 = buildContext({ row: { ...row, values: { gridGap: '' } }, quote: null, instrument: null,
fieldDefs: [{ key: 'gridGap', label: '网格间距', type: 'text' }, { key: 'on', label: '开关', type: 'boolean', def: true }] });
assert(c4['网格间距'] === null && c4['开关'] === 1, '空串 → null;布尔 → 0/1');
// ---------- 4. 现算 ----------
section('4. computeRows 现算');
const hub = {
quotes: new Map([['000001.SZ', { lastPrice: 11.2, lastClose: 11 }]]),
instruments: new Map([['000001.SZ', { upStopPrice: 12 }]]),
};
const rows = computeRows({ rows: [row, { code: 'X.SZ', name: '无行情', shares: 100, avgPrice: 5 }], fieldDefs, hub });
assert(approx(rows[0].computed.pct, 6.666667), '有行情 → computed 算出');
assert(rows[1].computed.pct === null, '无行情 → computed 为 null(前端显示 —)');
assert(row.computed === undefined, '原行对象未被就地修改');
const noFormula = computeRows({ rows: [row], fieldDefs: [{ key: 'g', label: 'G', type: 'number' }], hub });
assert(noFormula[0] === row && noFormula[0].computed === undefined, '无 formula 字段 → 原数组零开销');
const mixed = computeRows({ rows: [row], fieldDefs: [
{ key: 'b', label: 'B', type: 'formula', formula: '现价 ×' },
{ key: 'g', label: 'G', type: 'formula', formula: '现价 * 份额' }], hub });
assert(mixed[0].computed.b === undefined && approx(mixed[0].computed.g, 11200), '非法公式列无值(前端 —),合法列照常算');
const allBad = computeRows({ rows: [row], fieldDefs: [{ key: 'b', label: 'B', type: 'formula', formula: '现价 ×' }], hub });
assert(allBad[0].computed === undefined, '公式全非法 → 不附加 computed(前端统一 —)');
// ---------- 5. 保存校验 ----------
section('5. schema-update 校验');
assert(STRATEGY_METHODS.has('formula/variables') && STRATEGY_METHODS.has('formula/trial'), '新增端点入方法表');
const scope = makeScope({ strategyFields: { 'manual-t': [{ key: 't_cost', label: 'T仓成本价', type: 'number', unit: '元' }] } });
const runtime = { manager: {}, settings: scope, storage: {}, marketHub: hub };
const okSave = await handleStrategy('strategies/schema-update', {
strategyId: 'manual-t',
configSchema: [
{ key: 't_cost', label: 'T仓成本价', type: 'number', unit: '元' },
{ key: 'pnl', label: '浮动盈亏', type: 'formula', formula: '(现价 - 成本价) × 份额', unit: '元', decimals: 2 },
{ key: 'pnl_pct', label: '盈亏比例', type: 'formula', formula: '(现价 ÷ 成本价 - 1) × 100', unit: '%', decimals: 2 },
],
}, runtime);
assert(okSave.configSchema.length === 3 && okSave.configSchema[1].decimals === 2, '合法字段(含公式)保存成功');
await throwsAsync(() => handleStrategy('strategies/schema-update', { strategyId: 'manual-t', configSchema: [
{ key: 'a', label: 'A', type: 'formula', formula: '现价 ×' }] }, runtime), 'field-validation', '语法错误公式被拒');
await throwsAsync(() => handleStrategy('strategies/schema-update', { strategyId: 'manual-t', configSchema: [
{ key: 'a', label: 'A', type: 'formula', formula: '现价 * 不存在的字段' }] }, runtime), 'field-validation', '未知变量公式被拒');
await throwsAsync(() => handleStrategy('strategies/schema-update', { strategyId: 'manual-t', configSchema: [
{ key: 'a', label: '现价', type: 'number' }] }, runtime), 'field-validation', '变量名与内置冲突被拒');
await throwsAsync(() => handleStrategy('strategies/schema-update', { strategyId: 'manual-t', configSchema: [
{ key: 'a', label: '同名', type: 'number' }, { key: 'b', label: '同名', type: 'number' }] }, runtime), 'field-validation', '重复展示名被拒');
await throwsAsync(() => handleStrategy('strategies/schema-update', { strategyId: 'manual-t', configSchema: [
{ key: 'a', label: 'A', type: 'formula', formula: '1', },
{ key: 'b', label: 'B', type: 'formula', formula: 'A + 1' }] }, runtime), 'field-validation', '公式引用公式字段被拒(零循环依赖)');
// ---------- 6. strategy-positions / trial ----------
section('6. 端点:持仓现算与试算');
const manager = { getStrategyPositions: async () => structuredClone([row]) };
const rt2 = { manager, settings: scope, storage: {}, marketHub: hub };
const positions = await handleStrategy('strategy-positions', { strategyId: 'manual-t' }, rt2);
assert(positions.length === 1 && approx(positions[0].computed.pnl, 700), 'strategy-positions 附 computed');
const trial = await handleStrategy('formula/trial', { strategyId: 'manual-t', formula: '(现价 - 成本价) × 份额' }, rt2);
assert(trial.code === '000001.SZ' && trial.name === '平安银行' && approx(trial.value, 700) && trial.reason === null, '试算返回代码/名称/值');
await throwsAsync(() => handleStrategy('formula/trial', { strategyId: 'manual-t', formula: '现价 ×' }, rt2),
'field-validation', '试算非法公式被拒');
const rtEmpty = { manager: { getStrategyPositions: async () => [] }, settings: scope, storage: {}, marketHub: hub };
const tNoHolding = await handleStrategy('formula/trial', { strategyId: 'grid-supermarket', formula: '现价 × 份额' }, rtEmpty);
assert(tNoHolding.reason === 'no-holding' && tNoHolding.value === null, '无持仓 → reason=no-holding');
const rtNoData = { manager: { getStrategyPositions: async () => [row] }, settings: scope, storage: {}, marketHub: { quotes: new Map(), instruments: new Map() } };
const tNoData = await handleStrategy('formula/trial', { strategyId: 'manual-t', formula: '现价 × 份额' }, rtNoData);
assert(tNoData.reason === 'no-data' && tNoData.code === '000001.SZ', '缺行情 → reason=no-data');
const vars = await handleStrategy('formula/variables', { strategyId: 'manual-t' }, rt2);
assert(Array.isArray(vars.builtin) && vars.groups.some((g) => g.group === '自定义字段'), 'formula/variables 返回目录');
await throwsAsync(() => handleStrategy('formula/variables', { strategyId: 'nope' }, rt2), 'strategy-not-found', '目录端点守卫未知策略');
// ---------- 7. R-028 判定型 ----------
section('7. 判定型(比较运算 / and·or / 结果类型)');
const jctx = { 涨停价: 8.22, 跌停价: 6.72, 基准值: 7, 网格大小: 1, 现价: 7.47, 份额: 800 };
assert(validateFormula('涨停价 > 基准值 + 网格大小', ['涨停价', '基准值', '网格大小']).ok, '判定公式(比较)通过校验');
assert(evaluateFormula('涨停价 > 基准值 + 网格大小', jctx) === true, '涨停价 > 基准值 + 网格大小 → true(积成电子实况)');
assert(evaluateFormula('跌停价 < 基准值 - 网格大小', jctx) === false, '跌停价 < 基准值 - 网格大小 → false');
assert(evaluateFormula('涨停价 >= 8.22 and 现价 < 涨停价', jctx) === true, 'and 组合');
assert(evaluateFormula('跌停价 > 基准值 or 现价 > 基准值', jctx) === true, 'or 组合');
assert(evaluateFormula('涨停价 > 基准值 && 现价 > 基准值', jctx) === true, '&& 等价 and');
assert(evaluateFormula('涨停价 ≥ 8.22 and 跌停价 ≤ 6.72', jctx) === true, '全角 ≥ ≤ 归一');
assert(evaluateFormula('现价 ≠ 基准值', jctx) === true, '全角 ≠ 归一');
assert(evaluateFormula('涨停价 > 基准值 and 缺失变量 > 1', jctx) === null, '判定缺值短路 → null');
assert(inferResultKind(compileFormula('涨停价 > 基准值 + 1').ast) === 'boolean', 'inferResultKind:比较 → boolean');
assert(inferResultKind(compileFormula('涨停价 - 基准值 - 1').ast) === 'number', 'inferResultKind:算式 → number');
assert(inferResultKind(compileFormula('涨停价 > 基准值 and 现价 > 1').ast) === 'boolean', 'inferResultKindand → boolean');
const jDefs = [
{ key: 'current_base', label: '基准值', type: 'number' },
{ key: 'grid_break', label: '网格大小', type: 'number' },
{ key: 'sig_up', label: '可下空单', type: 'formula', formula: '涨停价 > 基准值 + 网格大小', resultKind: 'boolean' },
];
const jScope = makeScope({ strategyFields: { 'grid-supermarket': jDefs } });
const jRuntime = { manager: {}, settings: jScope, storage: {}, marketHub: hub };
const savedSig = await handleStrategy('strategies/schema-update', { strategyId: 'grid-supermarket', configSchema: jDefs }, jRuntime);
assert(savedSig.configSchema[2].resultKind === 'boolean', '判定字段保存后 resultKind=boolean');
await throwsAsync(() => handleStrategy('strategies/schema-update', { strategyId: 'grid-supermarket', configSchema: [
{ key: 'a', label: 'A', type: 'formula', formula: '1 + 1', resultKind: 'boolean' }] }, jRuntime),
'field-validation', '声明判定但公式无比较 → 拒绝');
await throwsAsync(() => handleStrategy('strategies/schema-update', { strategyId: 'grid-supermarket', configSchema: [
{ key: 'a', label: 'A', type: 'formula', formula: '涨停价 > 1', resultKind: 'number' }] }, jRuntime),
'field-validation', '公式是判定但声明数字 → 拒绝');
// ---------- 8. 端到端:网格超市真实夹具 ----------
section('8. 端到端(2026-09-10 实盘涨跌停 + 真实基准值)');
const realRows = [
{ code: '002339.SZ', name: '积成电子', shares: 800, base: 7, up: 8.22, down: 6.72, gap: 1, expectUp: true, expectDown: false },
{ code: '601117.SH', name: '中国化学', shares: 600, base: 8, up: 8.16, down: 6.68, gap: 1, expectUp: false, expectDown: true },
{ code: '603028.SH', name: '赛福天', shares: 800, base: 7, up: 7.87, down: 6.44, gap: 1, expectUp: false, expectDown: false },
];
const gridDefs = [
{ key: 'current_base', label: '基准值', type: 'number' },
{ key: 'grid_break', label: '网格大小', type: 'number' },
{ key: 'sig_short', label: '可下空单', type: 'formula', formula: '涨停价 > 基准值 + 网格大小', resultKind: 'boolean' },
{ key: 'sig_long', label: '可下多单', type: 'formula', formula: '跌停价 < 基准值 - 网格大小', resultKind: 'boolean' },
];
const gRows = realRows.map((r) => ({
code: r.code, name: r.name, shares: r.shares, avgPrice: 7, lastTradePrice: 7,
values: { current_base: r.base, grid_break: r.gap },
}));
const gHub = {
quotes: new Map(realRows.map((r) => [r.code, { lastPrice: 7.4 }])),
instruments: new Map(realRows.map((r) => [r.code, { upStopPrice: r.up, downStopPrice: r.down }])),
};
const gOut = computeRows({ rows: gRows, fieldDefs: gridDefs, hub: gHub });
realRows.forEach((r, i) => {
assert(gOut[i].computed.sig_short === r.expectUp, r.name + ' 可下空单=' + r.expectUp);
assert(gOut[i].computed.sig_long === r.expectDown, r.name + ' 可下多单=' + r.expectDown);
});
const gScope = makeScope({ strategyFields: { 'grid-supermarket': gridDefs } });
const gRt = { manager: { getStrategyPositions: async () => structuredClone(gRows) }, settings: gScope, storage: {}, marketHub: gHub };
const gPos = await handleStrategy('strategy-positions', { strategyId: 'grid-supermarket' }, gRt);
assert(gPos[0].computed.sig_short === true && gPos[2].computed.sig_short === false, 'strategy-positions 附判定 computed');
const gTrial = await handleStrategy('formula/trial', { strategyId: 'grid-supermarket', formula: '涨停价 > 基准值 + 网格大小' }, gRt);
assert(gTrial.value === true && gTrial.code === '002339.SZ', '判定型试算返回布尔值');
console.log('\n结果: ' + pass + ' 通过 / ' + fail + ' 失败');
process.exit(fail === 0 ? 0 : 1);