迭代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
+139
View File
@@ -0,0 +1,139 @@
/**
* R-027 回归测试:策略 tab 静态化 + 字段配置存储归一化(迭代 22 阶段一)
* 覆盖:
* 1. 策略常量:getStrategies 恒为内置两项(存量自建策略被忽略)
* 2. 旧数据兼容:strategies[].configSchema → 读取回填;strategyFields 优先
* 3. 字段归一化:类型白名单 / decimals 夹取 / formula 字段不带 def
* 4. 单策略写入:updateStrategyFields 只改目标策略;未知 strategyId 抛 strategy-not-found
* 5. tabs 归一化:丢弃自建策略条目、保留 visible/order、缺失补齐、旧布尔对象兼容
* 6. updateTabs 只接受内置条目
* 7. 端点:strategies/schema-update 校验 + 落库;已退役端点调用被拒
* 纯内存 mock(技术约束-011:不触真实数据)
*/
import {
BUILTIN_STRATEGIES, getStrategies, getStrategyFields, updateStrategyFields, normalizeFields,
getTabs, updateTabs, normalizeTabs, strategySchema,
} from '../src/settings.js';
import { STRATEGY_METHODS, handleStrategy } 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 + ']'); }
function makeScope(initial) {
const store = structuredClone(initial ?? {});
return {
get: () => structuredClone(store),
update: async (patch) => { Object.assign(store, structuredClone(patch)); },
_store: store,
};
}
async function throwsAsync(fn, code, msg) {
try { await fn(); assert(false, msg + '(未抛错)'); }
catch (e) { assert(e?.code === code, msg + 'code=' + e?.code + ''); }
}
// ---------- 1. 策略常量 ----------
section('1. 策略恒为内置两项');
const legacyScope = makeScope({
strategies: [
{ id: 'grid-supermarket', name: '网格超市', configSchema: [{ key: 'g', label: '网格大小', type: 'number', def: 1, unit: '元' }] },
{ id: 'manual-t', name: '手动做T', configSchema: [{ key: 't', label: 'T仓成本价', type: 'number', unit: '元' }] },
{ id: 'long-term', name: '长线持有', configSchema: [{ key: 'x', label: '目标价', type: 'number' }] },
],
});
const list = getStrategies(legacyScope);
assert(list.length === 2, '策略表恒 2 项(自建策略被忽略)');
assert(list.map((s) => s.id).join(',') === BUILTIN_STRATEGIES.map((s) => s.id).join(','), '顺序与内置常量一致');
assert(!list.some((s) => s.id === 'long-term'), '自建策略不出现在读取结果');
// ---------- 2. 旧数据兼容 ----------
section('2. 字段定义读取(旧数据回填 / 新键优先)');
assert(getStrategyFields(legacyScope, 'grid-supermarket').length === 1, '旧 strategies[].configSchema 回填');
assert(getStrategyFields(legacyScope, 'grid-supermarket')[0].label === '网格大小', '回填内容正确');
const newScope = makeScope({
strategies: [{ id: 'grid-supermarket', name: '网格超市', configSchema: [{ key: 'old', label: '旧的', type: 'text' }] }],
strategyFields: { 'grid-supermarket': [{ key: 'new', label: '新的', type: 'number', def: 1 }] },
});
assert(getStrategyFields(newScope, 'grid-supermarket')[0].label === '新的', 'strategyFields 优先于旧键');
assert(getStrategyFields(newScope, 'manual-t').length === 0, '另一策略无定义 → []');
// ---------- 3. 字段归一化 ----------
section('3. normalizeFields');
const norm = normalizeFields([
{ key: 'a', label: 'A', type: '不合法', def: 1 },
{ key: 'b', label: 'B', type: 'formula', formula: ' 现价 × 份额 ', decimals: 9, def: 123 },
{ key: 'c', label: 'C', type: 'number', def: 5, unit: '元' },
]);
assert(norm[0].type === 'text', '非法类型回退 text');
assert(norm[1].formula === '现价 × 份额', '公式去首尾空白');
assert(norm[1].decimals === 4, 'decimals 夹取上限 4');
assert(!('def' in norm[1]), 'formula 字段不带 def');
assert(norm[2].def === 5 && norm[2].unit === '元', '普通字段保留 def/unit');
// ---------- 4. 单策略写入 ----------
section('4. updateStrategyFields');
const wScope = makeScope({ strategyFields: { 'manual-t': [{ key: 't', label: 'T仓成本价', type: 'number' }] } });
const updated = await updateStrategyFields(wScope, 'grid-supermarket', [{ key: 'g', label: '网格大小', type: 'number', def: 1 }]);
assert(updated.configSchema.length === 1, '返回更新后的策略对象');
assert(wScope._store.strategyFields['grid-supermarket'].length === 1, '写入 strategyFields[目标策略]');
assert(wScope._store.strategyFields['manual-t'].length === 1, '另一策略定义不受影响');
assert(!wScope._store.strategies, '不写 strategies[] 键(真相源单一)');
await throwsAsync(() => updateStrategyFields(wScope, 'long-term', []), 'strategy-not-found', '未知策略 id 被拒');
// ---------- 5. tabs 归一化 ----------
section('5. normalizeTabs(静态化)');
const tScope = makeScope({
tabs: [
{ id: 'tab-strategy-long-term', kind: 'strategy', refId: 'long-term', name: '长线持有', visible: true, order: 0 },
{ id: 'tab-strategy-manual-t', kind: 'strategy', refId: 'manual-t', name: '手动做T', visible: false, order: 1 },
{ id: 'tab-strategy-grid-supermarket', kind: 'strategy', refId: 'grid-supermarket', name: '网格超市', visible: true, order: 2 },
{ id: 'tab-trade-records', kind: 'builtin', refKey: 'tradeRecords', name: '交易记录', visible: true, order: 3 },
],
});
const tabs = normalizeTabs(tScope);
assert(tabs.length === 5, 'tabs 恒 5 条(3 内置 + 2 策略)');
assert(!tabs.some((t) => t.refId === 'long-term'), '自建策略 tab 条目被丢弃');
const mt = tabs.find((t) => t.refId === 'manual-t');
assert(mt && mt.visible === false, '保留存量 visible 偏好(手动做T 隐藏)');
assert(tabs.findIndex((t) => t.refId === 'manual-t') < tabs.findIndex((t) => t.refId === 'grid-supermarket'),
'保留存量 order 相对次序(手动做T 在网格超市前)');
assert(tabs.some((t) => t.refKey === 'allPositions'), '缺失的内置条目被补齐');
const legacyBool = normalizeTabs(makeScope({ tabs: { allPositions: false, tradeRecords: true, watchlist: false } }));
assert(legacyBool.find((t) => t.refKey === 'allPositions').visible === false, '旧布尔对象格式兼容');
assert(legacyBool.some((t) => t.kind === 'strategy'), '旧格式下策略行补齐');
assert(getTabs(tScope).length === 5, 'getTabs 走归一化');
// ---------- 6. updateTabs 过滤 ----------
section('6. updateTabs 只接受内置条目');
const uScope = makeScope({});
await updateTabs(uScope, [
{ id: 'tab-strategy-long-term', kind: 'strategy', refId: 'long-term', visible: true, order: 0 },
{ id: 'tab-all-positions', kind: 'builtin', refKey: 'allPositions', visible: true, order: 1 },
]);
assert(uScope._store.tabs.length === 1 && uScope._store.tabs[0].id === 'tab-all-positions', '未知条目被过滤');
// ---------- 7. 端点 ----------
section('7. 端点:schema-update 与退役端点');
assert(STRATEGY_METHODS.has('strategies/schema-update'), 'STRATEGY_METHODS 含 schema-update');
assert(!STRATEGY_METHODS.has('strategies/add') && !STRATEGY_METHODS.has('strategies/remove') && !STRATEGY_METHODS.has('strategies/update'),
'退役端点已从方法表移除');
const runtime = { manager: {}, settings: makeScope({}), storage: {}, marketHub: null };
await throwsAsync(() => handleStrategy('strategies/add', { name: 'x' }, runtime), 'not-found', 'strategies/add 被拒');
await throwsAsync(() => handleStrategy('strategies/update', { strategies: [] }, runtime), 'not-found', 'strategies/update 被拒');
await throwsAsync(() => handleStrategy('strategies/remove', { strategyId: 'manual-t' }, runtime), 'not-found', 'strategies/remove 被拒');
const apiScope = makeScope({});
const apiRuntime = { manager: {}, settings: apiScope, storage: {}, marketHub: null };
const saved = await handleStrategy('strategies/schema-update', {
strategyId: 'grid-supermarket',
configSchema: [{ key: 'grid_size', label: '网格大小', type: 'number', def: 1, unit: '元' }],
}, apiRuntime);
assert(saved.configSchema.length === 1 && saved.configSchema[0].label === '网格大小', 'schema-update 写入成功');
assert(apiScope._store.strategyFields['grid-supermarket'].length === 1, '落库到 strategyFields');
await throwsAsync(() => handleStrategy('strategies/schema-update', { strategyId: 'nope', configSchema: [] }, apiRuntime),
'strategy-not-found', '未知策略写入被拒');
assert(strategySchema != null, 'strategySchema 导出可用');
console.log('\n结果: ' + pass + ' 通过 / ' + fail + ' 失败');
process.exit(fail === 0 ? 0 : 1);