/** * R-013 回归测试:策略自定义字段(定义随策略 configSchema + 值落 strategy_holdings.values) * 覆盖(独立数据目录,技术约束-011): * 1. settings.configSchema:schema 归一化(旧策略无 configSchema → []);updateStrategyFields 单策略写入 * 2. SqliteStore:_ensureHoldingValuesColumn 幂等补列;openHolding 后 values=null;readValues/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 scope(get/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 === '高', '返回行附 values(601117: 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);