迭代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 通过
This commit is contained in:
2026-09-10 14:05:06 +08:00
parent 3dfa39b8bf
commit ec02a791ee
40 changed files with 3369 additions and 659 deletions
+218
View File
@@ -0,0 +1,218 @@
/**
* 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);