/** * 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', 'inferResultKind:and → 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);