迭代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
+6 -6
View File
@@ -12,12 +12,12 @@
*
* 端点(POST /odl/api/<method>body = { args: {...} }):
* positions → 全量持仓
* strategies → 策略列表(含 visible
* strategies/update → 更新策略列表(整表
* strategies/add → 新增策略 { name }
* strategies/remove删除策略 { strategyId },联动清份额
* strategies/move排序 { strategyId, dir }
* strategy-positions → 某策略持仓 { strategyId }
* strategies → 策略列表(R-027:恒为内置两项 + 各自 configSchema
* strategies/schema-update → 写某策略字段定义 { strategyId, configSchema }R-027 唯一写路径
* formula/variables → 公式变量目录 { strategyId? }R-026
* formula/trial 公式试算 { strategyId, formula }R-026
* strategy-positions 某策略持仓 { strategyId }(含计算字段 computed
* R-027 退役:strategies/update、strategies/add、strategies/remove、strategies/move
* strategy-holdings/history → 某策略已清仓历史持仓 { strategyId, range }R-016
* unallocated → 未分配持仓
* summary → 分仓摘要
+94 -18
View File
@@ -2,18 +2,74 @@
* 服务端 API:策略 / 份额 / tabs 域(从 api.js 拆分,2026-08-31
*
* 端点:
* strategies / strategies/update / strategies/add / strategies/remove
* strategies(读;R-027:恒为内置两项)
* strategies/schema-updateR-027:写某策略字段定义,唯一写路径)
* strategy-positions / unallocated / summary
* strategy-holdings/historyR-016 迭代 14:已清仓历史持仓,范围筛选)
* add-shares / move-all-shares / remove-shares
* tabs / tabs/update
* formula/variablesR-026:公式变量目录)/ formula/trialR-026:试算)
* holdings/values-update / strategy-columns / strategy-columns/update
*
* R-027 退役:strategies/update、strategies/add、strategies/remove(策略静态化,不可增删改名)
*/
import {
getStrategies, updateStrategies, addStrategy, removeStrategy,
getStrategies, getStrategyFields, updateStrategyFields, assertBuiltinStrategy,
getTabs, updateTabs,
getStrategyColumns, updateStrategyColumns,
} from '../settings.js';
import { validateFormula, compileFormula, evaluateFormula, inferResultKind } from '../formula/evaluator.js';
import { allowedVarNames, buildContext, catalogFor, BUILTIN_VAR_NAMES } from '../formula/variables.js';
import { computeRows } from '../formula/FormulaService.js';
/** 字段类型白名单(R-026 含计算类型) */
const FIELD_TYPE_WHITELIST = new Set(['text', 'number', 'boolean', 'enum', 'formula']);
/** 字段校验失败(前端 Toast + 公式框描红) */
function fieldError(message) {
return Object.assign(new Error(message), { code: 'field-validation' });
}
/**
* 字段定义校验(R-027/R-026):结构 + 唯一性 + 名称冲突 + 公式(语法/变量/仅非公式字段)
* @param {Array} fieldDefs 客户端提交的 configSchema
*/
function validateConfigSchema(fieldDefs) {
const fields = Array.isArray(fieldDefs) ? fieldDefs : [];
const keys = new Set();
const labels = new Set();
fields.forEach((f, i) => {
const label = String(f?.label ?? '').trim();
const key = String(f?.key ?? '').trim();
if (!label) throw fieldError(`${i + 1} 个字段:展示名不能为空`);
if (!key) throw fieldError(`字段『${label}』:标识(key)不能为空`);
if (!FIELD_TYPE_WHITELIST.has(f?.type)) throw fieldError(`字段『${label}』:类型无效`);
if (keys.has(key)) throw fieldError(`字段标识重复: ${key}`);
if (labels.has(label)) throw fieldError(`字段展示名重复: ${label}`);
if (BUILTIN_VAR_NAMES.has(label)) throw fieldError(`变量名『${label}』与内置变量冲突,请修改字段展示名`);
if (f.type === 'enum' && !(Array.isArray(f.enum) && f.enum.some((x) => String(x).trim()))) {
throw fieldError(`字段『${label}』:枚举类型需填写选项`);
}
keys.add(key);
labels.add(label);
});
// 公式校验:变量 = 内置名 ∪ 本策略非 formula 字段展示名(零循环依赖)
const vars = allowedVarNames(fields);
for (const f of fields) {
if (f?.type !== 'formula') continue;
const res = validateFormula(f.formula, vars);
if (!res.ok) throw fieldError(`字段『${f.label}』公式错误:${res.error.message}`);
// R-028:结果类型声明必须与公式形态一致(判定 = 顶层比较/and/or;数字 = 其余)
const declared = f.resultKind === 'boolean' ? 'boolean' : 'number';
const inferred = inferResultKind(compileFormula(f.formula).ast);
if (declared !== inferred) {
throw fieldError(declared === 'boolean'
? `字段『${f.label}』:结果类型选了「判定」,但公式没有比较运算(如 涨停价 > 基准值 + 1)`
: `字段『${f.label}』:公式结果是判定,请把「结果类型」改为「判定」`);
}
}
}
/** R-016 范围档 → 回溯毫秒(自然日近似,展示用途足够;技术约束-019)。
* range='today' 不走回溯毫秒——二轮补充(2026-09-07 老师定):本地自然日 00:00 起清仓的持仓
@@ -36,9 +92,9 @@ function localDayStartMs() {
/** 策略/份额/tabs 端点方法表 */
export const STRATEGY_METHODS = new Set([
'strategies',
'strategies/update',
'strategies/add',
'strategies/remove',
'strategies/schema-update', // R-027:写某策略字段定义(唯一写路径)
'formula/variables', // R-026:公式变量目录
'formula/trial', // R-026:公式试算(取一行真实持仓)
'strategy-positions',
'unallocated',
'summary',
@@ -59,27 +115,47 @@ export const STRATEGY_METHODS = new Set([
* @param {object} args
* @param {object} runtime { manager, settings, storage }
*/
export async function handleStrategy(method, args, { manager, settings, storage }) {
export async function handleStrategy(method, args, { manager, settings, storage, marketHub }) {
switch (method) {
case 'strategies':
return getStrategies(settings);
case 'strategies/update':
await updateStrategies(settings, args.strategies);
return getStrategies(settings);
case 'strategies/add':
return await addStrategy(settings, args.name);
case 'strategies/remove':
// 删除策略 + 联动清空该策略下所有份额(份额回未分配
await removeStrategy(settings, args.strategyId);
await manager.clearStrategyShares(args.strategyId);
return getStrategies(settings);
case 'strategies/schema-update':
// R-027:单策略字段定义写入(结构/唯一性/公式校验 → strategyFields
assertBuiltinStrategy(args.strategyId);
validateConfigSchema(args.configSchema);
return await updateStrategyFields(settings, args.strategyId, args.configSchema);
case 'formula/variables':
// R-026:公式变量目录(内置分组 + 本策略非 formula 字段
if (args.strategyId != null) assertBuiltinStrategy(args.strategyId);
return catalogFor(getStrategyFields(settings, args.strategyId));
case 'formula/trial': {
// R-026:试算(用该策略一行真实持仓数据求值;无持仓返回 reason)
assertBuiltinStrategy(args.strategyId);
const fieldDefs = getStrategyFields(settings, args.strategyId);
const check = validateFormula(args.formula, allowedVarNames(fieldDefs));
if (!check.ok) throw fieldError(check.error.message);
const rows = await manager.getStrategyPositions(args.strategyId);
const row = rows.find((r) => (r.shares ?? 0) > 0) ?? rows[0] ?? null;
if (!row) return { code: null, name: null, value: null, reason: 'no-holding' };
const ctx = buildContext({
row,
quote: marketHub?.quotes?.get(row.code) ?? null,
instrument: marketHub?.instruments?.get(row.code) ?? null,
fieldDefs,
});
const value = evaluateFormula(args.formula, ctx);
return { code: row.code, name: row.name ?? null, value, reason: value === null ? 'no-data' : null };
}
case 'tabs':
return getTabs(settings);
case 'tabs/update':
await updateTabs(settings, args.tabs);
return getTabs(settings);
case 'strategy-positions':
return await manager.getStrategyPositions(args.strategyId);
case 'strategy-positions': {
// R-026:含计算字段的策略,逐行现算(只读内存缓存;无 formula 字段零开销)
const rows = await manager.getStrategyPositions(args.strategyId);
return computeRows({ rows, fieldDefs: getStrategyFields(settings, args.strategyId), hub: marketHub });
}
case 'unallocated':
return await manager.getUnallocatedPositions();
case 'summary':