迭代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
+156 -148
View File
@@ -15,6 +15,12 @@
* - addStrategy 联动追加 tab(末尾,Q2);removeStrategy 联动删除对应 tabD3
* - 策略行 name 由 getTabs join strategies(改名自动跟随,Q4
* - removeStrategy 的份额清理由上层(api.js 编排 storage)完成
*
* R-027(迭代 22 阶段一)策略 tab 静态化:
* - 策略集合与名称固定为内置两项(BUILTIN_STRATEGIES):不可新增/删除/重命名
* - tab 列表由 内置 tab + 内置策略 tab 常量派生;存量 tabs 仅保留 visible/order 偏好
* - 字段定义真相源迁到 strategyFieldsstrategies[] 收窄为身份表(仅兼容读取旧 configSchema
* - 退役 addStrategy/removeStrategy/renameStrategy/updateStrategies/append|removeStrategyTab
*/
import z from '@deepseek-ai/schemastery';
@@ -26,15 +32,31 @@ export const BUILTIN_TABS = [
{ id: 'tab-watchlist', refKey: 'watchlist', name: '关注列表' },
];
/** 默认 tab 列表(首次注册时的初始值;策略行由 DEFAULT_STRATEGIES 派生 */
export const DEFAULT_TABS = BUILTIN_TABS.map((t, i) => ({
id: t.id, kind: 'builtin', refKey: t.refKey, name: t.name, visible: true, order: i,
}));
/** 内置策略(R-027 静态化:集合与名称固定,不可增删改名;字段定义另存 strategyFields */
export const BUILTIN_STRATEGIES = [
{ id: 'grid-supermarket', name: '网格超市' },
{ id: 'manual-t', name: '手动做T' },
];
/** 默认策略(首次注册时的初始值 */
export const DEFAULT_STRATEGIES = [
{ id: 'grid-supermarket', name: '网格超市', configSchema: [] },
{ id: 'manual-t', name: '手动做T', configSchema: [] },
/** 内置策略 id 判定(字段定义写入 / 校验用 */
export function isBuiltinStrategy(strategyId) {
return BUILTIN_STRATEGIES.some((s) => s.id === strategyId);
}
/** 未知策略 id 直接抛错(写路径守卫) */
export function assertBuiltinStrategy(strategyId) {
if (!isBuiltinStrategy(strategyId)) {
throw Object.assign(new Error('未知策略: ' + strategyId), { code: 'strategy-not-found' });
}
}
/** 默认 tab 列表(首次注册时的初始值;R-027:内置 tab + 内置策略 tab */
export const DEFAULT_TABS = [
...BUILTIN_TABS.map((t, i) => ({ id: t.id, kind: 'builtin', refKey: t.refKey, name: t.name, visible: true, order: i })),
...BUILTIN_STRATEGIES.map((s, i) => ({
id: 'tab-strategy-' + s.id, kind: 'strategy', refId: s.id, name: s.name,
visible: true, order: BUILTIN_TABS.length + i,
})),
];
/**
@@ -56,6 +78,28 @@ export const COLUMN_META = [
{ key: 'high', label: '最高', fixed: false, defaultVisible: false },
];
/**
* 策略自定义字段定义 schemaR-013 四类型 + R-026 计算类型)
* - type='formula' 时使用 formula(中文变量表达式串)+ decimals(显示小数位),不使用 def
*/
const fieldSchema = z.object({
key: z.string().required(), // 稳健标识(英文 slug,与字段值 JSON 的 key 对齐)
label: z.string(), // UI 展示名(可中文;计算字段中并作公式变量名)
type: z.union([
z.const('text'),
z.const('number'),
z.const('boolean'),
z.const('enum'),
z.const('formula'), // R-026:计算字段(值由公式实时算出,不落库)
]).required(),
enum: z.array(z.string()).default([]), // 仅 type=enum:预设选项
def: z.any(), // 默认值(text=string / number=number / boolean=boolean / enum=枚举项)
unit: z.string().default(''), // 单位(可为空;如 % / 元 / 手 —— 展示时拼在值后)
formula: z.string().default(''), // 仅 type=formula:表达式串(中文变量)
resultKind: z.union([z.const('number'), z.const('boolean')]).default('number'), // R-028:数字 / 判定
decimals: z.number().default(2), // 仅 type=formula 且 resultKind=number:显示小数位(0-4
});
/** 策略设置 schemaschemastery 定义,settings.register 要求 schema 对象) */
/** @type {import('@deepseek-ai/schemastery').Schema<any>} 策略设置 schema(显式注解:含 z.dict,规避 dts 推断 cosmokit Dict 引用) */
export const strategySchema = z.object({
@@ -65,20 +109,10 @@ export const strategySchema = z.object({
strategies: z.array(z.object({
id: z.string().required(),
name: z.string().required(),
configSchema: z.array(z.object({
key: z.string().required(), // 稳健标识(英文 slug,与字段值 JSON 的 key 对齐)
label: z.string(), // UI 展示名(可中文
type: z.union([
z.const('text'),
z.const('number'),
z.const('boolean'),
z.const('enum'),
]).required(), // 字段类型
enum: z.array(z.string()).default([]), // 仅 type=enum:预设选项
def: z.any(), // 默认值(text=string / number=number / boolean=boolean / enum=枚举项)
unit: z.string().default(''), // 单位(可为空;如 % / 元 / 手 —— 展示时拼在值后)
})).default([]),
})).default(DEFAULT_STRATEGIES),
configSchema: z.array(fieldSchema).default([]),
})).default([]),
// R-027:字段定义真相源(写路径唯一入口;strategies[].configSchema 仅兼容读取旧数据
strategyFields: z.dict(z.array(fieldSchema).default([])).default({}),
// 统一 tab 列表(R-011:唯一顺序与显隐来源;内置 + 策略混排)
// union 兼容存量:旧格式为布尔对象(迭代 02),新格式为有序数组;normalizeTabs 读取时归一化
tabs: z.union([
@@ -237,126 +271,115 @@ export function registerSettings(ctx, config) {
return scope;
}
/** 读取 settings 快照(容错:测试用桩 scope 无 get 时返回空对象) */
function scopeValue(scope) {
return (scope && typeof scope.get === 'function') ? (scope.get() ?? {}) : {};
}
/** 字段类型白名单(含 R-026 计算类型) */
const FIELD_TYPES = new Set(['text', 'number', 'boolean', 'enum', 'formula']);
/**
* 读取策略定义表R-011 收窄 + R-013 扩展:{id, name, configSchema}
* 归一化:旧存量数据缺 configSchemaR-013 前)→ 补空数组(Q4 兼容)。
* 字段定义归一化R-013 + R-026):类型白名单、枚举数组、公式串、小数位夹取 0-4
* @param {Array} fields configSchema 原始数组
* @returns {Array} 归一化后的字段定义数组
*/
export function normalizeFields(fields) {
return (Array.isArray(fields) ? fields : []).map((f) => {
const type = FIELD_TYPES.has(f?.type) ? f.type : 'text';
const out = {
key: String(f?.key ?? ''),
label: String(f?.label ?? ''),
type,
enum: Array.isArray(f?.enum) ? f.enum.map((x) => String(x)) : [],
unit: String(f?.unit ?? ''),
};
if (type === 'formula') {
out.formula = String(f?.formula ?? '').trim();
out.resultKind = f?.resultKind === 'boolean' ? 'boolean' : 'number'; // R-028
const n = Number(f?.decimals);
out.decimals = Number.isFinite(n) ? Math.min(4, Math.max(0, Math.round(n))) : 2;
} else if (f?.def !== undefined) {
out.def = f.def;
}
return out;
});
}
/**
* 读取策略表(R-027 静态化:恒为内置两项;configSchema 取 strategyFields,缺失时兼容读取旧 strategies[]
* @param {ReturnType<typeof registerSettings>} scope
* @returns {Array<{id: string, name: string, configSchema: Array}>}
*/
export function getStrategies(scope) {
const value = scope?.get() ?? {};
const list = Array.isArray(value.strategies) ? value.strategies : DEFAULT_STRATEGIES;
return list.map((s) => ({ ...s, configSchema: Array.isArray(s.configSchema) ? s.configSchema : [] }));
}
/**
* 更新策略(整表更新;R-013configSchema 随整表携带)
* 写入归一化:项缺 configSchema → 补 [](防客户端旧格式整表提交丢定义)。
* @param {ReturnType<typeof registerSettings>} scope
* @param {Array} strategies 完整策略列表
*/
export async function updateStrategies(scope, strategies) {
const next = (Array.isArray(strategies) ? strategies : []).map((s) => ({
id: s.id,
name: s.name,
configSchema: Array.isArray(s.configSchema) ? s.configSchema : [],
const value = scopeValue(scope);
const legacy = new Map((Array.isArray(value.strategies) ? value.strategies : []).map((s) => [s.id, s.configSchema]));
const store = (value.strategyFields && typeof value.strategyFields === 'object') ? value.strategyFields : {};
return BUILTIN_STRATEGIES.map((s) => ({
...s,
configSchema: normalizeFields(
Array.isArray(store[s.id]) ? store[s.id] : (Array.isArray(legacy.get(s.id)) ? legacy.get(s.id) : []),
),
}));
await scope.update({ strategies: next });
}
/** 读取某策略字段定义(未知名 → []) */
export function getStrategyFields(scope, strategyId) {
return getStrategies(scope).find((s) => s.id === strategyId)?.configSchema ?? [];
}
/**
* 新增策略(O3
* 写入某策略字段定义(R-027:单策略写入,唯一写路径;不触碰 strategies[] 与份额数据
* @param {ReturnType<typeof registerSettings>} scope
* @param {string} name 策略名称
* @returns {Promise<object>} 新策略对象
* @param {string} strategyId 内置策略 id
* @param {Array} configSchema 完整字段定义数组
* @returns {Promise<object>} 更新后的策略对象
*/
export async function addStrategy(scope, name) {
const list = getStrategies(scope);
const id = generateStrategyId(name, list.map((s) => s.id));
const strategy = {
id,
name: String(name ?? '').trim(),
configSchema: [], // R-013:新策略默认无自定义字段(定义在设置页配置)
};
await updateStrategies(scope, [...list, strategy]);
// R-011:联动在 tabs 列表末尾追加策略 tab 条目(Q2 追加末尾)
await appendStrategyTab(scope, strategy);
return strategy;
}
/**
* 重命名策略(O3;分配数据挂 id 不挂名称,改名不影响)
* @param {ReturnType<typeof registerSettings>} scope
* @param {string} id 策略 id
* @param {string} name 新名称
* @returns {Promise<boolean>} 是否成功
*/
export async function renameStrategy(scope, id, name) {
const list = getStrategies(scope);
const target = list.find((s) => s.id === id);
if (!target) throw Object.assign(new Error(`策略不存在: ${id}`), { code: 'strategy-not-found' });
const next = list.map((s) => (s.id === id ? { ...s, name: String(name ?? '').trim() || s.name } : s));
await updateStrategies(scope, next);
return true;
export async function updateStrategyFields(scope, strategyId, configSchema) {
assertBuiltinStrategy(strategyId);
const cur = scopeValue(scope);
const store = (cur.strategyFields && typeof cur.strategyFields === 'object') ? cur.strategyFields : {};
await scope.update({ ...cur, strategyFields: { ...store, [strategyId]: normalizeFields(configSchema) } });
return getStrategies(scope).find((s) => s.id === strategyId);
}
/**
* 删除策略(O3;份额清理由上层编排 storage)
* @param {ReturnType<typeof registerSettings>} scope
* @param {string} id 策略 id
* @returns {Promise<boolean>} 是否成功
*/
export async function removeStrategy(scope, id) {
const list = getStrategies(scope);
const target = list.find((s) => s.id === id);
if (!target) throw Object.assign(new Error(`策略不存在: ${id}`), { code: 'strategy-not-found' });
await updateStrategies(scope, list.filter((s) => s.id !== id));
// R-011:联动删除 tabs 列表中对应策略条目(D3)
await removeStrategyTab(scope, id);
return true;
}
/**
* 归一化 tabs 为有序数组(R-011 迁移:旧布尔对象 → 数组;幂等,不重复迁移)
* 旧格式:tabs = { allPositions, tradeRecords, watchlist } + strategies 自带 visible/order
* 新格式:tabs = [{ id, kind, refKey|refId, name, visible, order }]
* 旧隐藏策略迁移后 visible=trueQ3,产品约束-009)。
* 归一化 tabs(R-027 静态化):序列由常量派生(内置 tab + 内置策略 tab),
* 存量 tabs 仅保留 visible/order 偏好;未知条目(自建策略 tab / 已退役 tab)丢弃,缺失条目补齐。
* 旧布尔对象格式(迭代 02)继续兼容:仅取内置显隐。
* @param {ReturnType<typeof registerSettings>} scope
* @returns {Array} 归一化后的 tabs 数组(按 order 排序)
*/
export function normalizeTabs(scope) {
const value = scope?.get() ?? {};
const raw = value.tabs;
// 已归一化(数组)
if (Array.isArray(raw)) return raw.slice().sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
const raw = scopeValue(scope).tabs;
// 旧布尔对象或缺失 → 生成内置条目(保留原显隐值)
const legacy = (typeof raw === 'object' && raw !== null) ? raw : {};
const tabs = BUILTIN_TABS.map((t, i) => ({
id: t.id, kind: 'builtin', refKey: t.refKey, name: t.name,
visible: legacy[t.refKey] !== false, order: i,
}));
// 旧布尔对象(迭代 02):映射到内置 tab 显隐
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
return DEFAULT_TABS.map((t) => (t.kind === 'builtin' && t.refKey in raw
? { ...t, visible: raw[t.refKey] !== false }
: t));
}
if (!Array.isArray(raw)) return DEFAULT_TABS.map((t) => ({ ...t }));
// 策略条目:按旧 order 接续追加;旧隐藏策略迁移后显示(Q3)
const strategies = Array.isArray(value.strategies) ? value.strategies : DEFAULT_STRATEGIES;
const strategyRows = strategies.map((s, i) => ({
id: 'tab-strategy-' + s.id, kind: 'strategy', refId: s.id, name: s.name,
visible: true, order: tabs.length + i,
}));
return [...tabs, ...strategyRows];
const byId = new Map(raw.map((t) => [t.id, t]));
return DEFAULT_TABS
.map((t) => {
const hit = byId.get(t.id);
return hit
? { ...t, visible: hit.visible !== false, order: typeof hit.order === 'number' ? hit.order : t.order }
: { ...t };
})
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
}
/**
* 读取统一 tab 列表(R-011):归一化 + 策略行 name joinQ4 自动跟随改名
* 读取统一 tab 列表(R-011/R-027):归一化结果(策略行 name 恒为内置名,无改名场景
* @param {ReturnType<typeof registerSettings>} scope
* @returns {Array} tabs 数组(按 order 排序,策略行 name 已 join
* @returns {Array} tabs 数组(按 order 排序)
*/
export function getTabs(scope) {
const tabs = normalizeTabs(scope);
const nameMap = new Map(getStrategies(scope).map((s) => [s.id, s.name]));
return tabs.map((t) => (t.kind === 'strategy'
? { ...t, name: nameMap.get(t.refId) ?? t.name ?? t.refId }
: t));
return normalizeTabs(scope);
}
/**
@@ -367,35 +390,21 @@ export function getTabs(scope) {
*/
export async function updateTabs(scope, tabs) {
const current = scope?.get() ?? {};
const normalized = (Array.isArray(tabs) ? tabs : []).map((t, i) => ({
id: t.id,
kind: t.kind === 'strategy' ? 'strategy' : 'builtin',
refKey: t.refKey,
refId: t.refId,
name: t.name,
visible: t.visible !== false,
order: typeof t.order === 'number' ? t.order : i,
}));
const known = new Set(DEFAULT_TABS.map((t) => t.id));
const normalized = (Array.isArray(tabs) ? tabs : [])
.filter((t) => known.has(t?.id)) // R-027:只接受内置条目(策略条目恒存在,不可增删)
.map((t, i) => ({
id: t.id,
kind: t.kind === 'strategy' ? 'strategy' : 'builtin',
refKey: t.refKey,
refId: t.refId,
name: t.name,
visible: t.visible !== false,
order: typeof t.order === 'number' ? t.order : i,
}));
await scope.update({ ...current, tabs: normalized });
}
/** 追加策略 tab 条目到末尾(addStrategy 联动;Q2 追加末尾) */
export async function appendStrategyTab(scope, strategy) {
const tabs = getTabs(scope);
const next = [...tabs, {
id: 'tab-strategy-' + strategy.id, kind: 'strategy', refId: strategy.id,
name: strategy.name, visible: true,
order: tabs.reduce((max, t) => Math.max(max, t.order ?? 0), -1) + 1,
}];
await updateTabs(scope, next);
}
/** 删除策略 tab 条目(removeStrategy 联动;D3 */
export async function removeStrategyTab(scope, strategyId) {
const tabs = getTabs(scope);
await updateTabs(scope, tabs.filter((t) => !(t.kind === 'strategy' && t.refId === strategyId)));
}
/**
* 读取通用 tab 显隐配置(R-011 迁移期兼容:返回布尔对象,供旧调用点使用)
* @deprecated 由 getTabs 取代
@@ -437,15 +446,14 @@ export async function updateTabConfig(scope, tabs) {
* @returns {Array<{key: string, label: string, fixed: boolean, visible: boolean, kind: 'base'|'field'}>}
*/
export function normalizeStrategyColumns(scope, strategyId) {
const value = scope?.get() ?? {};
const strategies = Array.isArray(value.strategies) ? value.strategies : [];
const strategy = strategies.find((s) => s.id === strategyId);
const configSchema = Array.isArray(strategy?.configSchema) ? strategy.configSchema : [];
const value = scopeValue(scope);
// R-027:字段定义真相源 = strategyFields(缺失时兼容读取旧 strategies[].configSchema
const configSchema = getStrategyFields(scope, strategyId);
const raw = (value.strategyColumns && value.strategyColumns[strategyId]) || [];
// 1. 默认全列(基础列默认序 + 字段列 configSchema 序;显隐缺省跟随 defaultVisibleR-015 行情列默认隐藏)
const baseCols = COLUMN_META.map((c) => ({ key: c.key, label: c.label, fixed: false, kind: 'base', defaultVisible: c.defaultVisible !== false }));
const fieldCols = configSchema.map((f) => ({ key: f.key, label: f.label || f.key, fixed: false, kind: 'field' }));
const fieldCols = configSchema.map((f) => ({ key: f.key, label: f.label || f.key, fixed: false, kind: 'field', type: f.type }));
const defaults = [...baseCols, ...fieldCols];
// 2. 应用覆盖:visible + 顺序(未覆盖的列保持默认,追加在默认序后)
@@ -485,7 +493,7 @@ export function getStrategyColumns(scope, strategyId) {
* @param {Array} columns 完整列数组 [{key, visible}](含不可见列——保留位置,visible 控制显示)
*/
export async function updateStrategyColumns(scope, strategyId, columns) {
const current = scope?.get() ?? {};
const current = scopeValue(scope);
const store = current.strategyColumns && typeof current.strategyColumns === 'object' ? current.strategyColumns : {};
const next = { ...store, [strategyId]: (Array.isArray(columns) ? columns : []).map((c) => ({ key: c.key, visible: c.visible !== false })) };
await scope.update({ ...current, strategyColumns: next });