迭代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:
+63
-42
@@ -1,11 +1,13 @@
|
||||
/**
|
||||
* R-011 API 层集成测试:handleStrategy tabs 域(联动链路)
|
||||
* R-011 API 层集成测试:handleStrategy tabs 域(R-027 策略静态化更新)
|
||||
* 独立内存 mock settings scope + mock manager,验证:
|
||||
* 1. tabs 端点:返回归一化后的统一数组
|
||||
* 1. tabs 端点:返回归一化统一数组(恒 5 条:3 内置 + 2 策略)
|
||||
* 2. tabs/update:整表更新(顺序 + 显隐)
|
||||
* 3. strategies/add:联动追加 tab 条目(末尾)
|
||||
* 4. strategies/remove:联动删除 tab 条目 + 清份额(manager)
|
||||
* 5. strategies/update:重命名(tabs 行 name join 跟随)
|
||||
* 3. strategies/add:已退役(方法表不含 + 调用抛 not-found)
|
||||
* 4. strategies/remove:已退役(方法表不含 + 调用抛 not-found)
|
||||
* 5. strategies/update:已退役(方法表不含 + 调用抛 not-found)
|
||||
* 6. strategies/schema-update:单策略字段定义写入(读回一致)
|
||||
* 7. 端点表不含 strategies/move(已废弃)
|
||||
* 纯内存,不触真实数据(技术约束-011)
|
||||
*/
|
||||
import { handleStrategy, STRATEGY_METHODS } from '../src/api/strategies.js';
|
||||
@@ -25,60 +27,79 @@ function makeScope(initial) {
|
||||
};
|
||||
}
|
||||
|
||||
/** 断言某端点已退役:STRATEGY_METHODS 不含 + 调用抛 code=not-found */
|
||||
async function assertRetired(method, args, runtime) {
|
||||
assert(!STRATEGY_METHODS.has(method), "STRATEGY_METHODS 不含 " + method);
|
||||
try {
|
||||
await handleStrategy(method, args, runtime);
|
||||
assert(false, method + " 应抛 not-found(未抛错)");
|
||||
} catch (e) {
|
||||
assert(e.code === "not-found", method + " 抛 code=not-found");
|
||||
}
|
||||
}
|
||||
|
||||
const scope = makeScope({
|
||||
strategies: [{ id: "grid-supermarket", name: "网格超市" }],
|
||||
strategies: [
|
||||
{ id: "grid-supermarket", name: "网格超市" },
|
||||
{ id: "manual-t", name: "手动做T" },
|
||||
],
|
||||
tabs: [
|
||||
{ id: "tab-all", kind: "builtin", refKey: "allPositions", name: "全部持仓", visible: true, order: 0 },
|
||||
{ id: "tab-g", kind: "strategy", refId: "grid-supermarket", name: "网格超市", visible: true, order: 1 },
|
||||
{ id: "tab-all-positions", kind: "builtin", refKey: "allPositions", name: "全部持仓", visible: true, order: 0 },
|
||||
{ id: "tab-trade-records", kind: "builtin", refKey: "tradeRecords", name: "交易记录", visible: true, order: 1 },
|
||||
{ id: "tab-watchlist", kind: "builtin", refKey: "watchlist", name: "关注列表", visible: true, order: 2 },
|
||||
{ id: "tab-strategy-grid-supermarket", kind: "strategy", refId: "grid-supermarket", name: "网格超市", visible: true, order: 3 },
|
||||
{ id: "tab-strategy-manual-t", kind: "strategy", refId: "manual-t", name: "手动做T", visible: true, order: 4 },
|
||||
],
|
||||
});
|
||||
const cleared = [];
|
||||
const manager = {
|
||||
async clearStrategyShares(id) { cleared.push(id); },
|
||||
};
|
||||
const runtime = { manager, settings: scope };
|
||||
const runtime = { manager: {}, settings: scope };
|
||||
|
||||
// 1. tabs 端点
|
||||
console.log("\n[1] tabs 端点");
|
||||
const strat1 = await handleStrategy("strategies", {}, runtime);
|
||||
assert(Array.isArray(strat1) && strat1.length === 2, "strategies 端点恒 2 项(内置)");
|
||||
const tabs1 = await handleStrategy("tabs", {}, runtime);
|
||||
assert(Array.isArray(tabs1) && tabs1.length === 2, "tabs 返回统一数组");
|
||||
assert(tabs1[0].kind === "builtin" && tabs1[1].kind === "strategy", "内置 + 策略混排");
|
||||
assert(Array.isArray(tabs1) && tabs1.length === 5, "tabs 恒 5 条(3 内置 + 2 策略)");
|
||||
assert(tabs1.filter((t) => t.kind === "builtin").length === 3 && tabs1.filter((t) => t.kind === "strategy").length === 2, "内置 3 + 策略 2 混排");
|
||||
assert(tabs1.some((t) => t.refId === "grid-supermarket") && tabs1.some((t) => t.refId === "manual-t"), "策略 tab 恒在(不可增删)");
|
||||
|
||||
// 2. tabs/update 整表更新
|
||||
console.log("\n[2] tabs/update 整表更新");
|
||||
const afterUpdate = await handleStrategy("tabs/update", { tabs: [
|
||||
{ id: "tab-g", kind: "strategy", refId: "grid-supermarket", name: "网格超市", visible: false, order: 0 },
|
||||
{ id: "tab-all", kind: "builtin", refKey: "allPositions", name: "全部持仓", visible: true, order: 1 },
|
||||
{ id: "tab-strategy-manual-t", kind: "strategy", refId: "manual-t", name: "手动做T", visible: false, order: 0 },
|
||||
{ id: "tab-all-positions", kind: "builtin", refKey: "allPositions", name: "全部持仓", visible: true, order: 1 },
|
||||
{ id: "tab-watchlist", kind: "builtin", refKey: "watchlist", name: "关注列表", visible: true, order: 2 },
|
||||
{ id: "tab-strategy-grid-supermarket", kind: "strategy", refId: "grid-supermarket", name: "网格超市", visible: true, order: 3 },
|
||||
{ id: "tab-trade-records", kind: "builtin", refKey: "tradeRecords", name: "交易记录", visible: true, order: 4 },
|
||||
] }, runtime);
|
||||
assert(afterUpdate[0].id === "tab-g" && afterUpdate[0].visible === false, "策略 tab 排到前面且隐藏");
|
||||
assert(afterUpdate[1].id === "tab-all", "内置 tab 排后");
|
||||
assert(afterUpdate[0].id === "tab-strategy-manual-t" && afterUpdate[0].visible === false, "手动做T 排最前且隐藏(顺序+显隐生效)");
|
||||
assert(afterUpdate[1].id === "tab-all-positions", "内置 tab 排第二位");
|
||||
assert(afterUpdate.length === 5, "整表更新后恒 5 条");
|
||||
|
||||
// 3. strategies/add 联动追加 tab
|
||||
console.log("\n[3] strategies/add 联动追加 tab");
|
||||
const added = await handleStrategy("strategies/add", { name: "长线持有" }, runtime);
|
||||
const tabsAfterAdd = await handleStrategy("tabs", {}, runtime);
|
||||
assert(tabsAfterAdd.length === 3, "add 后 tabs 联动新增一条");
|
||||
assert(tabsAfterAdd[2].kind === "strategy" && tabsAfterAdd[2].refId === added.id, "新策略 tab 追加末尾");
|
||||
assert(tabsAfterAdd[2].name === "长线持有", "新策略 tab name 正确");
|
||||
// 3. strategies/add 已退役
|
||||
console.log("\n[3] strategies/add 退役");
|
||||
await assertRetired("strategies/add", { name: "长线持有" }, runtime);
|
||||
|
||||
// 4. strategies/remove 联动删除 tab + 清份额
|
||||
console.log("\n[4] strategies/remove 联动删除 tab");
|
||||
await handleStrategy("strategies/remove", { strategyId: "grid-supermarket" }, runtime);
|
||||
const tabsAfterRm = await handleStrategy("tabs", {}, runtime);
|
||||
assert(!tabsAfterRm.some((t) => t.refId === "grid-supermarket"), "删除策略后对应 tab 条目消失");
|
||||
assert(tabsAfterRm.some((t) => t.refId === added.id), "其余策略 tab 保留");
|
||||
assert(cleared.includes("grid-supermarket"), "manager.clearStrategyShares 被调用(份额回未分配)");
|
||||
// 4. strategies/remove 已退役
|
||||
console.log("\n[4] strategies/remove 退役");
|
||||
await assertRetired("strategies/remove", { strategyId: "grid-supermarket" }, runtime);
|
||||
|
||||
// 5. strategies/update 重命名(tabs 行 join 跟随)
|
||||
console.log("\n[5] strategies/update 重命名跟随");
|
||||
await handleStrategy("strategies/update", { strategies: [
|
||||
{ id: added.id, name: "长线持有v2" },
|
||||
] }, runtime);
|
||||
const tabsAfterRename = await handleStrategy("tabs", {}, runtime);
|
||||
assert(tabsAfterRename.find((t) => t.refId === added.id).name === "长线持有v2", "重命名后 tabs 行 name join 跟随(Q4)");
|
||||
// 5. strategies/update 已退役
|
||||
console.log("\n[5] strategies/update 退役");
|
||||
await assertRetired("strategies/update", { strategies: [] }, runtime);
|
||||
|
||||
// 6. 端点表不含 strategies/move
|
||||
console.log("\n[6] strategies/move 已废弃");
|
||||
// 6. strategies/schema-update 单策略写入(R-027 唯一写路径)
|
||||
console.log("\n[6] strategies/schema-update");
|
||||
const saved = await handleStrategy("strategies/schema-update", {
|
||||
strategyId: "grid-supermarket",
|
||||
configSchema: [{ key: "gridGap", label: "网格间距", type: "text", def: "3%" }],
|
||||
}, runtime);
|
||||
assert(saved.configSchema[0].label === "网格间距", "schema-update 返回更新后的策略对象");
|
||||
const stratAfter = await handleStrategy("strategies", {}, runtime);
|
||||
assert(stratAfter.find((s) => s.id === "grid-supermarket").configSchema[0].label === "网格间距", "读回一致(写入 strategyFields)");
|
||||
assert(stratAfter.find((s) => s.id === "manual-t").configSchema.length === 0, "只影响目标策略(manual-t 无定义)");
|
||||
|
||||
// 7. 端点表不含 strategies/move
|
||||
console.log("\n[7] strategies/move 已废弃");
|
||||
assert(!STRATEGY_METHODS.has("strategies/move"), "STRATEGY_METHODS 不含 strategies/move");
|
||||
|
||||
console.log("\n结果: " + pass + " 通过 / " + fail + " 失败");
|
||||
|
||||
+42
-66
@@ -1,18 +1,16 @@
|
||||
/**
|
||||
* R-011 回归测试:tabs 统一管理数据层
|
||||
* R-011 回归测试:tabs 统一管理数据层(R-027 策略 tab 静态化)
|
||||
* 独立内存 mock scope(settings 读写),验证:
|
||||
* 1. 归一化迁移:旧布尔对象 tabs → 有序数组(内置保留原显隐,策略接续追加)
|
||||
* 2. 迁移:旧隐藏策略 → visible=true(Q3)
|
||||
* 3. getTabs:策略行 name join strategies(Q4 自动跟随改名)
|
||||
* 3. getTabs:策略行 name 由内置常量派生(改名场景已退役)
|
||||
* 4. updateTabs:整表更新(顺序 + 显隐)
|
||||
* 5. addStrategy:联动追加 tab 条目(末尾,Q2)
|
||||
* 6. removeStrategy:联动删除对应 tab 条目(D3)
|
||||
* 5. 策略 tab 静态化(R-027):getStrategies 恒 2 项;normalizeTabs 恒 5 条;
|
||||
* 存量自建策略 tab 条目被丢弃;addStrategy/removeStrategy/renameStrategy 等退役导出不存在
|
||||
* 纯内存,不触真实数据(技术约束-011)
|
||||
*/
|
||||
import {
|
||||
getTabs, updateTabs, normalizeTabs,
|
||||
addStrategy, removeStrategy, renameStrategy, getStrategies,
|
||||
} from '../src/settings.js';
|
||||
import { getTabs, updateTabs, normalizeTabs, getStrategies } from '../src/settings.js';
|
||||
import * as settingsMod from '../src/settings.js';
|
||||
|
||||
let pass = 0, fail = 0;
|
||||
function assert(cond, msg) {
|
||||
@@ -46,93 +44,71 @@ assert(oldTabs[1].refKey === 'tradeRecords' && oldTabs[1].visible === false, '
|
||||
assert(oldTabs[2].refKey === 'watchlist' && oldTabs[2].visible === true, '内置 watchlist 保留旧显隐=true');
|
||||
const manualTab = oldTabs.find((t) => t.kind === 'strategy' && t.refId === 'manual-t');
|
||||
assert(manualTab && manualTab.visible === true, "旧隐藏策略 manual-t 迁移后 visible=true(Q3)");
|
||||
assert(manualTab && manualTab.name === '手动做T', '策略行 name 来自 strategies');
|
||||
assert(manualTab && manualTab.name === '手动做T', '策略行 name 来自内置常量');
|
||||
assert(oldTabs[3].order === 3 && oldTabs[4].order === 4, '策略按 order 接续(order 连续)');
|
||||
|
||||
// ---------- 2. 已归一化数组直接返回 ----------
|
||||
console.log("\n[2] 已归一化数组直接返回");
|
||||
const arrScope = makeScope({ tabs: [
|
||||
{ id: 'tab-a', kind: 'builtin', refKey: 'allPositions', name: '全部持仓', visible: true, order: 1 },
|
||||
{ id: 'tab-b', kind: 'builtin', refKey: 'watchlist', name: '关注列表', visible: true, order: 0 },
|
||||
{ id: 'tab-all-positions', kind: 'builtin', refKey: 'allPositions', name: '全部持仓', visible: true, order: 1 },
|
||||
{ id: 'tab-watchlist', kind: 'builtin', refKey: 'watchlist', name: '关注列表', visible: true, order: 0 },
|
||||
] });
|
||||
const arrTabs = normalizeTabs(arrScope);
|
||||
assert(arrTabs[0].id === 'tab-b' && arrTabs[1].id === 'tab-a', '数组按 order 排序');
|
||||
assert(arrTabs[0].id === 'tab-watchlist' && arrTabs[1].id === 'tab-all-positions', '数组按 order 排序');
|
||||
assert(arrTabs.length === 5, '缺失条目补齐(恒 5 条)');
|
||||
|
||||
// ---------- 3. 策略行 name join(改名自动跟随,Q4) ----------
|
||||
console.log("\n[3] getTabs 策略名 join");
|
||||
// ---------- 3. getTabs:策略行 name 由常量派生(改名已退役) ----------
|
||||
console.log("\n[3] getTabs 策略行 name");
|
||||
const joinScope = makeScope({
|
||||
strategies: [{ id: 'manual-t', name: '手动做T新名' }],
|
||||
strategies: [{ id: 'manual-t', name: '手动做T新名' }], // 存量改名残留:不再参与
|
||||
tabs: [
|
||||
{ id: 'tab-all', kind: 'builtin', refKey: 'allPositions', name: '全部持仓', visible: true, order: 0 },
|
||||
{ id: 'tab-s', kind: 'strategy', refId: 'manual-t', name: '旧名', visible: true, order: 1 },
|
||||
{ id: 'tab-all-positions', kind: 'builtin', refKey: 'allPositions', name: '全部持仓', visible: true, order: 0 },
|
||||
{ id: 'tab-strategy-manual-t', kind: 'strategy', refId: 'manual-t', name: '旧名', visible: true, order: 1 },
|
||||
],
|
||||
});
|
||||
const joined = getTabs(joinScope);
|
||||
assert(joined.find((t) => t.id === 'tab-s').name === '手动做T新名', '策略改名后 tabs 行 name 自动跟随(Q4)');
|
||||
assert(joined.find((t) => t.id === 'tab-strategy-manual-t').name === '手动做T', '策略行 name 恒为内置常量(存量行 name 被覆盖)');
|
||||
|
||||
// ---------- 4. updateTabs 整表更新 ----------
|
||||
console.log("\n[4] updateTabs 整表更新");
|
||||
const updScope = makeScope({ tabs: [
|
||||
{ id: 'tab-a', kind: 'builtin', refKey: 'allPositions', name: '全部持仓', visible: true, order: 0 },
|
||||
{ id: 'tab-b', kind: 'builtin', refKey: 'watchlist', name: '关注列表', visible: true, order: 1 },
|
||||
{ id: 'tab-all-positions', kind: 'builtin', refKey: 'allPositions', name: '全部持仓', visible: true, order: 0 },
|
||||
{ id: 'tab-watchlist', kind: 'builtin', refKey: 'watchlist', name: '关注列表', visible: true, order: 1 },
|
||||
] });
|
||||
await updateTabs(updScope, [
|
||||
{ id: 'tab-b', kind: 'builtin', refKey: 'watchlist', name: '关注列表', visible: false, order: 0 },
|
||||
{ id: 'tab-a', kind: 'builtin', refKey: 'allPositions', name: '全部持仓', visible: true, order: 1 },
|
||||
{ id: 'tab-watchlist', kind: 'builtin', refKey: 'watchlist', name: '关注列表', visible: false, order: 0 },
|
||||
{ id: 'tab-all-positions', kind: 'builtin', refKey: 'allPositions', name: '全部持仓', visible: true, order: 1 },
|
||||
]);
|
||||
const updTabs = getTabs(updScope);
|
||||
assert(updTabs[0].id === 'tab-b' && updTabs[0].visible === false, '整表更新:顺序与显隐生效');
|
||||
assert(updTabs.length === 2, '整表更新后条目数不变');
|
||||
assert(updTabs[0].id === 'tab-watchlist' && updTabs[0].visible === false, '整表更新:顺序与显隐生效');
|
||||
assert(updTabs.length === 5, '归一化后恒 5 条(缺失策略行补齐)');
|
||||
|
||||
// ---------- 5. addStrategy 联动追加 tab(末尾,Q2) ----------
|
||||
console.log("\n[5] addStrategy 联动追加 tab");
|
||||
const addScope = makeScope({
|
||||
strategies: [{ id: 'grid-supermarket', name: '网格超市' }],
|
||||
tabs: [
|
||||
{ id: 'tab-all', kind: 'builtin', refKey: 'allPositions', name: '全部持仓', visible: true, order: 0 },
|
||||
{ id: 'tab-s', kind: 'strategy', refId: 'grid-supermarket', name: '网格超市', visible: true, order: 1 },
|
||||
],
|
||||
});
|
||||
const added = await addStrategy(addScope, '长线持有');
|
||||
assert(getStrategies(addScope).length === 2, '策略定义表新增');
|
||||
const addTabs = getTabs(addScope);
|
||||
assert(addTabs.length === 3, 'tabs 联动新增一条');
|
||||
assert(addTabs[2].kind === 'strategy' && addTabs[2].refId === added.id, '新策略 tab 追加到末尾(Q2)');
|
||||
assert(addTabs[2].name === '长线持有', '新策略 tab name 正确');
|
||||
assert(addTabs[2].visible === true, '新策略 tab 默认显示');
|
||||
assert(addTabs[2].order === 2, 'order 连续');
|
||||
|
||||
// ---------- 6. removeStrategy 联动删除 tab(D3) ----------
|
||||
console.log("\n[6] removeStrategy 联动删除 tab");
|
||||
const rmScope = makeScope({
|
||||
// ---------- 5. 策略 tab 静态化(R-027) ----------
|
||||
console.log("\n[5] 策略 tab 静态化(R-027)");
|
||||
const statScope = makeScope({
|
||||
strategies: [
|
||||
{ id: 'grid-supermarket', name: '网格超市' },
|
||||
{ id: 'manual-t', name: '手动做T' },
|
||||
{ id: 'long-term', name: '长线持有' }, // R-027 前可增:存量自建策略
|
||||
],
|
||||
tabs: [
|
||||
{ id: 'tab-all', kind: 'builtin', refKey: 'allPositions', name: '全部持仓', visible: true, order: 0 },
|
||||
{ id: 'tab-g', kind: 'strategy', refId: 'grid-supermarket', name: '网格超市', visible: true, order: 1 },
|
||||
{ id: 'tab-m', kind: 'strategy', refId: 'manual-t', name: '手动做T', visible: true, order: 2 },
|
||||
{ id: 'tab-all-positions', kind: 'builtin', refKey: 'allPositions', name: '全部持仓', visible: true, order: 1 },
|
||||
{ id: 'tab-watchlist', kind: 'builtin', refKey: 'watchlist', name: '关注列表', visible: true, order: 2 },
|
||||
{ id: 'tab-strategy-long-term', kind: 'strategy', refId: 'long-term', name: '长线持有', visible: true, order: 0 }, // 存量自建策略 tab
|
||||
],
|
||||
});
|
||||
await removeStrategy(rmScope, 'grid-supermarket');
|
||||
const rmTabs = getTabs(rmScope);
|
||||
assert(rmTabs.length === 2, '删除策略后 tabs 联动删除对应条目');
|
||||
assert(!rmTabs.some((t) => t.refId === 'grid-supermarket'), 'grid-supermarket tab 条目已删除');
|
||||
assert(rmTabs.some((t) => t.refId === 'manual-t'), 'manual-t tab 条目保留');
|
||||
assert(getStrategies(rmScope).length === 1, '策略定义表删除');
|
||||
|
||||
// ---------- 7. renameStrategy 只改名(tabs 行 join 跟随,不影响定义) ----------
|
||||
console.log("\n[7] renameStrategy");
|
||||
const rnScope = makeScope({
|
||||
strategies: [{ id: 'manual-t', name: '手动做T' }],
|
||||
tabs: [
|
||||
{ id: 'tab-m', kind: 'strategy', refId: 'manual-t', name: '手动做T', visible: true, order: 0 },
|
||||
],
|
||||
});
|
||||
await renameStrategy(rnScope, 'manual-t', '手动T+');
|
||||
assert(getStrategies(rnScope)[0].name === '手动T+', '策略定义名更新');
|
||||
assert(getTabs(rnScope)[0].name === '手动T+', 'tabs 行 name join 跟随(Q4)');
|
||||
const strat = getStrategies(statScope);
|
||||
assert(strat.length === 2, 'getStrategies 恒 2 项(自建策略被忽略)');
|
||||
assert(!strat.some((s) => s.id === 'long-term'), '自建策略不出现在读取结果');
|
||||
const statTabs = normalizeTabs(statScope);
|
||||
assert(statTabs.length === 5, 'normalizeTabs 恒 5 条(3 内置 + 2 策略)');
|
||||
assert(!statTabs.some((t) => t.refId === 'long-term'), '存量自建策略 tab 条目被丢弃');
|
||||
assert(statTabs.filter((t) => t.kind === 'builtin').length === 3 && statTabs.filter((t) => t.kind === 'strategy').length === 2, '条目构成 = 3 内置 + 2 策略');
|
||||
assert(!('addStrategy' in settingsMod), '退役导出 addStrategy 不存在');
|
||||
assert(!('removeStrategy' in settingsMod), '退役导出 removeStrategy 不存在');
|
||||
assert(!('renameStrategy' in settingsMod), '退役导出 renameStrategy 不存在');
|
||||
assert(!('updateStrategies' in settingsMod), '退役导出 updateStrategies 不存在');
|
||||
assert(!('appendStrategyTab' in settingsMod) && !('removeStrategyTab' in settingsMod), '退役导出 append/removeStrategyTab 不存在');
|
||||
|
||||
// ---------- 汇总 ----------
|
||||
console.log("\n结果: " + pass + " 通过 / " + fail + " 失败");
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* R-013 列显隐配置回归(迭代 11 扩展,2026-09-02)
|
||||
* R-013 列显隐配置回归(迭代 11 扩展,2026-09-02;期望值随 R-015/迭代13 行情列与 R-027/迭代22 真相源更新)
|
||||
* 覆盖(纯内存 mock scope,技术约束-011):
|
||||
* 1. normalizeStrategyColumns 默认:基础列全显默认序 + configSchema 字段列全显
|
||||
* 2. 覆盖生效:visible=false 隐藏、排序调整
|
||||
@@ -41,15 +41,19 @@ const scope = makeScope({
|
||||
],
|
||||
});
|
||||
const gridCols = normalizeStrategyColumns(scope, 'grid-supermarket');
|
||||
const baseKeys = ['lastPrice','pctChange','lastClose','avgPrice','lastTradePrice','shares'];
|
||||
assert(gridCols.length === 8, '默认 8 列(6 基础 + 2 字段)');
|
||||
// 基础列目录(COLUMN_META,迭代 13/R-015 扩展:4 个行情列默认隐藏)
|
||||
const baseKeys = ['lastPrice','pctChange','lastClose','avgPrice','lastTradePrice','shares',
|
||||
'upStopPrice','downStopPrice','open','high'];
|
||||
const hiddenByDefault = ['upStopPrice','downStopPrice','open','high'];
|
||||
assert(gridCols.length === 12, '默认 12 列(10 基础 + 2 字段)');
|
||||
assert(baseKeys.every((k) => gridCols.some((c) => c.key === k)), '含全部基础列');
|
||||
assert(gridCols[6].key === 'gridGap' && gridCols[7].key === 'riskLevel', '字段列按 configSchema 序追加末尾');
|
||||
assert(gridCols.every((c) => c.visible !== false), '默认全部可见');
|
||||
assert(gridCols[10].key === 'gridGap' && gridCols[11].key === 'riskLevel', '字段列按 configSchema 序追加末尾');
|
||||
assert(gridCols.slice(0, 6).every((c) => c.visible !== false), '前 6 基础列默认可见');
|
||||
assert(gridCols.filter((c) => hiddenByDefault.includes(c.key)).every((c) => c.visible === false), '4 个行情列默认隐藏(R-015)');
|
||||
assert(gridCols[0].key === 'lastPrice', '默认序:现价在前');
|
||||
|
||||
const manualCols = normalizeStrategyColumns(scope, 'manual-t');
|
||||
assert(manualCols.length === 6, '旧策略(无 configSchema)仅 6 基础列');
|
||||
assert(manualCols.length === 10, '无字段定义策略仅 10 基础列');
|
||||
|
||||
// ---------- 2. 覆盖生效 ----------
|
||||
section('2. 覆盖(隐藏 + 排序)');
|
||||
@@ -64,7 +68,7 @@ await updateStrategyColumns(scope, 'grid-supermarket', [
|
||||
{ key: 'lastTradePrice', visible: true },
|
||||
]);
|
||||
const after = getStrategyColumns(scope, 'grid-supermarket');
|
||||
assert(after.length === 8, '读回 8 列(含不可见,保位)');
|
||||
assert(after.length === 12, '读回 12 列(含不可见,保位)');
|
||||
assert(after[0].key === 'riskLevel', '排序覆盖:riskLevel 提到最前');
|
||||
assert(after.find((c) => c.key === 'gridGap').visible === false, 'gridGap 已隐藏');
|
||||
assert(after.find((c) => c.key === 'lastPrice').visible === false, 'lastPrice 已隐藏');
|
||||
@@ -88,6 +92,15 @@ const afterDel = normalizeStrategyColumns(scope, 'grid-supermarket');
|
||||
assert(!afterDel.some((c) => c.key === 'riskLevel'), '删除字段 riskLevel 列自动移除');
|
||||
assert(afterDel.some((c) => c.key === 'gridGap'), '其余列保留');
|
||||
|
||||
// ---------- 4. R-027:字段定义真相源 strategyFields ----------
|
||||
section('4. 字段定义真相源(strategyFields 优先,旧 strategies 兜底)');
|
||||
const newScope = makeScope({
|
||||
strategies: [{ id: 'grid-supermarket', name: '网格超市', configSchema: [{ key: 'old', label: '旧字段', type: 'text' }] }],
|
||||
strategyFields: { 'grid-supermarket': gridSchema },
|
||||
});
|
||||
const newCols = normalizeStrategyColumns(newScope, 'grid-supermarket');
|
||||
assert(newCols.some((c) => c.key === 'gridGap') && !newCols.some((c) => c.key === 'old'), 'strategyFields 优先于旧 configSchema');
|
||||
|
||||
console.log("");
|
||||
console.log('结果: ' + pass + ' 通过, ' + fail + ' 失败');
|
||||
process.exit(fail > 0 ? 1 : 0);
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* R-013 回归测试:策略自定义字段(定义随策略 configSchema + 值落 strategy_holdings.values)
|
||||
* 覆盖(独立数据目录,技术约束-011):
|
||||
* 1. settings.configSchema:schema 归一化(旧策略无 configSchema → []);updateStrategies 保留定义
|
||||
* 1. settings.configSchema:schema 归一化(旧策略无 configSchema → []);updateStrategyFields 单策略写入
|
||||
* 2. SqliteStore:_ensureHoldingValuesColumn 幂等补列;openHolding 后 values=null;readValues/writeValues 读写
|
||||
* 3. PositionManager.updateHoldingValues:定位持仓 → 按所属策略 configSchema 校验(number/enum/boolean)
|
||||
* → 写回;空值放行;非法值拒绝(field-validation);额外键放行(可扩展);null 清除
|
||||
@@ -14,7 +14,7 @@ import { join } from 'node:path';
|
||||
import { SqliteStore } from '../src/storage/SqliteStore.js';
|
||||
import { DataStore } from '../src/storage/DataStore.js';
|
||||
import { PositionManager } from '../src/position/PositionManager.js';
|
||||
import { getStrategies, updateStrategies, addStrategy } from '../src/settings.js';
|
||||
import { getStrategies, getStrategyFields, updateStrategyFields } from '../src/settings.js';
|
||||
|
||||
let pass = 0, fail = 0;
|
||||
function assert(cond, msg) {
|
||||
@@ -56,9 +56,16 @@ assert(grid && Array.isArray(grid.configSchema) && grid.configSchema.length ===
|
||||
const manual = all.find((s) => s.id === 'manual-t');
|
||||
assert(manual && Array.isArray(manual.configSchema) && manual.configSchema.length === 0, '旧策略 manual-t configSchema 归一化为 [](Q4)');
|
||||
|
||||
// updateStrategies 整表更新保留定义
|
||||
await updateStrategies(scope, [{ id: 'manual-t', name: '手动做T' }, { id: 'grid-supermarket', name: '网格超市', configSchema: gridSchema }]);
|
||||
assert(getStrategies(scope).find((s) => s.id === 'grid-supermarket').configSchema.length === 4, 'updateStrategies 整表保留 configSchema');
|
||||
// updateStrategyFields 单策略写入(R-027 唯一写路径)
|
||||
await updateStrategyFields(scope, 'grid-supermarket', gridSchema);
|
||||
assert(getStrategyFields(scope, 'grid-supermarket').length === 4, 'updateStrategyFields 写入后读回 4 字段');
|
||||
assert(getStrategyFields(scope, 'manual-t').length === 0, '写入只影响目标策略(manual-t 仍无定义)');
|
||||
try {
|
||||
await updateStrategyFields(scope, 'long-term', gridSchema);
|
||||
assert(false, '未知 strategyId 应抛错');
|
||||
} catch (e) {
|
||||
assert(e.code === 'strategy-not-found', '未知 strategyId 抛 strategy-not-found');
|
||||
}
|
||||
|
||||
// ---------- 2. SqliteStore values 列 ----------
|
||||
section('2. SqliteStore values 列');
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* R-017 / 迭代 15 回归 → R-018 / 迭代 16 语义修订:归属候选退役 7 天清仓窗口
|
||||
* R-017 / 迭代 15 回归 → R-018 / 迭代 16 语义修订 → R-027 迭代 22:策略 id 改用内置(manual-t / grid-supermarket):归属候选退役 7 天清仓窗口
|
||||
* 新语义(R-018 T2 老师拍板):候选回归「当前活动持仓 + 未关联」;正式候选为策略级 attribution-targets。
|
||||
* 覆盖:当前活动持仓候选 / 清仓/作废行不进候选 / 多策略 / 空 code / targets 端点。
|
||||
*/
|
||||
@@ -20,25 +20,25 @@ const tmp = mkdtempSync(join(tmpdir(), 'odl-r017-'));
|
||||
const storage = new DataStore({ dataDir: tmp });
|
||||
const runtime = {
|
||||
storage,
|
||||
settings: { get: () => ({ strategies: [{ id: 's-t', name: '做T' }, { id: 's-g', name: '网格' }] }) },
|
||||
settings: { get: () => ({}) }, // R-027:策略恒为内置两项(不再从 settings 读取)
|
||||
};
|
||||
|
||||
try {
|
||||
console.log('\n[1] 当前活动持仓候选(closed=false)');
|
||||
await storage.openHolding('s-t', '600719.SH', 1000);
|
||||
await storage.openHolding('s-g', '600719.SH', 800); // 同码第二策略
|
||||
await storage.openHolding('manual-t', '600719.SH', 1000);
|
||||
await storage.openHolding('grid-supermarket', '600719.SH', 800); // 同码第二策略
|
||||
let cands = await storage.getTradeAttributionCandidates('600719.SH');
|
||||
assert(cands.length === 2, '同码两策略当前持仓都在候选');
|
||||
assert(cands.every((c) => c.closed === false && c.closedAt === null), '当前持仓 closed=false / closedAt=null');
|
||||
assert(cands[0].shares >= cands[1].shares, '当前持仓按 shares DESC');
|
||||
|
||||
console.log('\n[2] 清仓/作废行不进候选(R-018 退役 7 天窗口)');
|
||||
const hc = await storage.openHolding('s-t', '000001.SZ', 500);
|
||||
await storage.closeHolding('s-t', '000001.SZ'); // 刚清仓(R-017 时代会入选,R-018 起不再)
|
||||
const hc = await storage.openHolding('manual-t', '000001.SZ', 500);
|
||||
await storage.closeHolding('manual-t', '000001.SZ'); // 刚清仓(R-017 时代会入选,R-018 起不再)
|
||||
cands = await storage.getTradeAttributionCandidates('000001.SZ');
|
||||
assert(cands.length === 0, '刚清仓行不进候选(7 天窗口退役)');
|
||||
const hv = await storage.openHolding('s-t', '600000.SH', 300);
|
||||
storage.sqlite.voidHolding('s-t', '600000.SH');
|
||||
const hv = await storage.openHolding('manual-t', '600000.SH', 300);
|
||||
storage.sqlite.voidHolding('manual-t', '600000.SH');
|
||||
cands = await storage.getTradeAttributionCandidates('600000.SH');
|
||||
assert(cands.length === 0, '作废行不进候选');
|
||||
|
||||
@@ -47,18 +47,18 @@ try {
|
||||
assert((await storage.getTradeAttributionCandidates('999999.SH')).length === 0, '无持仓 code → []');
|
||||
|
||||
console.log('\n[4] R-018 attribution-targets(策略级候选端点)');
|
||||
const hc2 = await storage.openHolding('s-t', '000002.SZ', 500);
|
||||
const hc2 = await storage.openHolding('manual-t', '000002.SZ', 500);
|
||||
const tgt = await handleTrade('orders/attribution-targets', { code: '000002.SZ' }, runtime);
|
||||
assert(Array.isArray(tgt), 'targets 端点返回数组');
|
||||
assert(tgt.every((x) => x.strategyId && x.activityShares != null), 'targets 每项含 strategyId + activityShares');
|
||||
const rowT = tgt.find((x) => x.strategyId === 's-t');
|
||||
const rowT = tgt.find((x) => x.strategyId === 'manual-t');
|
||||
assert(rowT && rowT.activityShares === 500 && rowT.hasActiveHolding === true, '有仓策略 activityShares=500 / hasActiveHolding=true');
|
||||
const rowG = tgt.find((x) => x.strategyId === 's-g');
|
||||
const rowG = tgt.find((x) => x.strategyId === 'grid-supermarket');
|
||||
assert(rowG && rowG.activityShares === 0 && rowG.hasActiveHolding === false, '无仓策略 activityShares=0 / hasActiveHolding=false');
|
||||
// 关闭的持仓不占 hasActiveHolding
|
||||
await storage.closeHolding('s-t', '000002.SZ');
|
||||
await storage.closeHolding('manual-t', '000002.SZ');
|
||||
const tgt2 = await handleTrade('orders/attribution-targets', { code: '000002.SZ' }, runtime);
|
||||
assert(tgt2.find((x) => x.strategyId === 's-t').hasActiveHolding === false, '清仓后该策略 hasActiveHolding=false');
|
||||
assert(tgt2.find((x) => x.strategyId === 'manual-t').hasActiveHolding === false, '清仓后该策略 hasActiveHolding=false');
|
||||
} finally {
|
||||
storage.close();
|
||||
try { rmSync(tmp, { recursive: true, force: true }); } catch { /* ignore */ }
|
||||
|
||||
@@ -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', '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);
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user