Files
one_divine_lot/scripts/test-r013-custom-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

179 lines
8.1 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-013 回归测试:策略自定义字段(定义随策略 configSchema + 值落 strategy_holdings.values
* 覆盖(独立数据目录,技术约束-011):
* 1. settings.configSchemaschema 归一化(旧策略无 configSchema → []);updateStrategyFields 单策略写入
* 2. SqliteStore_ensureHoldingValuesColumn 幂等补列;openHolding 后 values=nullreadValues/writeValues 读写
* 3. PositionManager.updateHoldingValues:定位持仓 → 按所属策略 configSchema 校验(number/enum/boolean
* → 写回;空值放行;非法值拒绝(field-validation);额外键放行(可扩展);null 清除
* 4. strategy-positions:返回项附 values(该策略持仓行)
* 纯内存/临时目录,不触真实数据(技术约束-011)
*/
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
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, getStrategyFields, updateStrategyFields } from '../src/settings.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 + "]"); }
/** 内存 mock settings scopeget/update */
function makeScope(initial) {
const store = structuredClone(initial ?? {});
return {
get: () => structuredClone(store),
update: async (patch) => { Object.assign(store, structuredClone(patch)); },
_store: store,
};
}
// 网格超市 configSchema(四类型)
const gridSchema = [
{ key: 'gridGap', label: '网格间距', type: 'text', def: '3%' },
{ key: 'gridAmount', label: '单格金额', type: 'number', def: 10000 },
{ key: 'hasStop', label: '设置止损', type: 'boolean', def: false },
{ key: 'riskLevel', label: '风险等级', type: 'enum', enum: ['低', '中', '高'], def: '中' },
];
const scope = makeScope({
strategies: [
{ id: 'grid-supermarket', name: '网格超市', configSchema: gridSchema },
{ id: 'manual-t', name: '手动做T' }, // 旧策略:无 configSchema
],
});
// ---------- 1. settings configSchema 归一化 ----------
section('1. settings.configSchema');
const all = getStrategies(scope);
const grid = all.find((s) => s.id === 'grid-supermarket');
assert(grid && Array.isArray(grid.configSchema) && grid.configSchema.length === 4, 'grid-supermarket configSchema 4 字段');
const manual = all.find((s) => s.id === 'manual-t');
assert(manual && Array.isArray(manual.configSchema) && manual.configSchema.length === 0, '旧策略 manual-t configSchema 归一化为 []Q4');
// 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 列');
const dataDir = mkdtempSync(join(tmpdir(), 'odl-r013-'));
const sqlite = new SqliteStore({ dataDir });
const store = new DataStore({ dataDir });
const pm = new PositionManager({
dataSource: { getPositions: async () => [] }, // 测试不拉真实持仓
storage: store,
getStrategySchema: (id) => getStrategies(scope).find((s) => s.id === id)?.configSchema ?? [],
});
const h1 = await store.openHolding('grid-supermarket', '601117.SH', 600);
const h2 = await store.openHolding('grid-supermarket', '300057.SZ', 1000);
const h3 = await store.openHolding('manual-t', '600719.SH', 1000);
assert(store.readValues(h1.holdingId) === null, '新建持仓 readValues = null');
assert(typeof h1.holdingId === 'number' && h1.values === null, 'openHolding 返回 values=null');
// 幂等补列(重复 init 不报错)
sqlite.init(); sqlite.init();
assert(true, 'init 幂等(_ensureHoldingValuesColumn 重复执行不报错)');
// ---------- 3. updateHoldingValues 校验与写回 ----------
section('3. updateHoldingValues');
// 正常写入(全量)
const r1 = await pm.updateHoldingValues(h1.holdingId, { gridGap: '3%', gridAmount: 10000, hasStop: true, riskLevel: '高' });
assert(r1.values.gridGap === '3%' && r1.values.gridAmount === 10000, '合法值写入成功');
assert(store.readValues(h1.holdingId).riskLevel === '高', '读回与写入一致(落库)');
// number 非法拒绝
try {
await pm.updateHoldingValues(h2.holdingId, { gridAmount: 'abc' });
assert(false, 'number 填非数字应拒绝');
} catch (e) {
assert(e.code === 'field-validation' && e.message.includes('单格金额'), 'number 非法值拒绝: ' + e.message);
}
// enum 越界拒绝
try {
await pm.updateHoldingValues(h2.holdingId, { riskLevel: '超高' });
assert(false, 'enum 越界应拒绝');
} catch (e) {
assert(e.code === 'field-validation' && e.message.includes('风险等级'), 'enum 越界拒绝: ' + e.message);
}
// boolean 类型错误拒绝
try {
await pm.updateHoldingValues(h2.holdingId, { hasStop: 'yes' });
assert(false, 'boolean 非布尔应拒绝');
} catch (e) {
assert(e.code === 'field-validation', 'boolean 非法值拒绝');
}
// 空值放行
await pm.updateHoldingValues(h2.holdingId, { gridAmount: null, riskLevel: '' });
assert(true, '空值/缺省放行(允许清空)');
// 额外键放行(可扩展)
const rExtra = await pm.updateHoldingValues(h2.holdingId, { gridAmount: 5000, customNote: '扩展键' });
assert(rExtra.values.customNote === '扩展键', '未定义 key 额外键放行(可扩展)');
// 不存在的持仓
try {
await pm.updateHoldingValues(99999, { gridGap: '1%' });
assert(false, '不存在持仓应拒绝');
} catch (e) {
assert(e.code === 'holding-not-found', '不存在持仓拒绝: ' + e.message);
}
// 清仓后不可写
await store.closeHolding('manual-t', '600719.SH');
try {
await pm.updateHoldingValues(h3.holdingId, { a: 1 });
assert(false, '已清仓持仓应拒绝');
} catch (e) {
assert(e.code === 'holding-not-found', '已清仓持仓拒绝');
}
// null 清除
await pm.updateHoldingValues(h2.holdingId, null);
assert(store.readValues(h2.holdingId) === null, 'null 清除该行值');
// ---------- 4. 旧策略(无 configSchema)任意键放行 ----------
section('4. 旧策略持仓(无 configSchema');
const h3b = await store.openHolding('manual-t', '600719.SH', 800);
const rOld2 = await pm.updateHoldingValues(h3b.holdingId, { note: '自由备注' });
assert(rOld2.values.note === '自由备注', '旧策略(schema=[])任意键放行');
// ---------- 5. strategy-positions 附 values(经 getStrategyPositions 需 dataSource 返回持仓) ----------
section('5. strategy-positions 附 values');
const pmReal = new PositionManager({
dataSource: {
getPositions: async () => [
{ code: '601117.SH', name: '中国交建', volume: 600, available: 600, avgPrice: 7.0, price: 7.2, marketValue: 4320 },
{ code: '300057.SZ', name: '万顺新材', volume: 1000, available: 1000, avgPrice: 5.0, price: 5.1, marketValue: 5100 },
],
},
storage: store,
getStrategySchema: (id) => getStrategies(scope).find((s) => s.id === id)?.configSchema ?? [],
});
const list = await pmReal.getStrategyPositions('grid-supermarket');
assert(list.length === 2, 'grid-supermarket 持仓 2 行');
const row1 = list.find((p) => p.code === '601117.SH');
assert(row1 && row1.holdingId === h1.holdingId, '返回含 holdingId');
assert(row1 && row1.values && row1.values.riskLevel === '高', '返回行附 values601117: riskLevel=高)');
assert(list.find((p) => p.code === '300057.SZ').values === null, '清值行 values=null');
console.log("");
console.log('结果: ' + pass + ' 通过, ' + fail + ' 失败');
rmSync(dataDir, { recursive: true, force: true });
process.exit(fail > 0 ? 1 : 0);