ec02a791ee
- 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 通过
650 lines
28 KiB
JavaScript
650 lines
28 KiB
JavaScript
/**
|
||
* 设置管理:策略(标签)配置
|
||
*
|
||
* 设计约束:产品约束-002/003/004(标签体系、标签自定义 + 显示开关、删除份额回未分配)
|
||
* 通过 ctx.settings.register('one-divine-lot', schema) 注册策略配置 namespace。
|
||
*
|
||
* 数据结构:
|
||
* strategies: [{ id, name }](R-011 收窄:visible/order 归 tabs 统一管理)
|
||
* tabs: [{ id, kind: builtin|strategy, refKey|refId, name, visible, order }]
|
||
* 预置:网格超市、手动做T(R-003 O3-5:可删除,彻底自由)
|
||
*
|
||
* R-011 迭代 09(产品约束-009、技术约束-014):
|
||
* - settings.tabs 从布尔对象升级为统一有序数组(内置 + 策略混排)
|
||
* - 旧格式读取时静默归一化迁移(旧隐藏策略迁移后显示,Q3)
|
||
* - addStrategy 联动追加 tab(末尾,Q2);removeStrategy 联动删除对应 tab(D3)
|
||
* - 策略行 name 由 getTabs join strategies(改名自动跟随,Q4)
|
||
* - removeStrategy 的份额清理由上层(api.js 编排 storage)完成
|
||
*
|
||
* R-027(迭代 22 阶段一)策略 tab 静态化:
|
||
* - 策略集合与名称固定为内置两项(BUILTIN_STRATEGIES):不可新增/删除/重命名
|
||
* - tab 列表由 内置 tab + 内置策略 tab 常量派生;存量 tabs 仅保留 visible/order 偏好
|
||
* - 字段定义真相源迁到 strategyFields;strategies[] 收窄为身份表(仅兼容读取旧 configSchema)
|
||
* - 退役 addStrategy/removeStrategy/renameStrategy/updateStrategies/append|removeStrategyTab
|
||
*/
|
||
|
||
import z from '@deepseek-ai/schemastery';
|
||
|
||
/** 内置 tab 元数据(常量,顺序即默认顺序;R-011) */
|
||
export const BUILTIN_TABS = [
|
||
{ id: 'tab-all-positions', refKey: 'allPositions', name: '全部持仓' },
|
||
{ id: 'tab-trade-records', refKey: 'tradeRecords', name: '交易记录' },
|
||
{ id: 'tab-watchlist', refKey: 'watchlist', name: '关注列表' },
|
||
];
|
||
|
||
/** 内置策略(R-027 静态化:集合与名称固定,不可增删改名;字段定义另存 strategyFields) */
|
||
export const BUILTIN_STRATEGIES = [
|
||
{ id: 'grid-supermarket', name: '网格超市' },
|
||
{ id: 'manual-t', name: '手动做T' },
|
||
];
|
||
|
||
/** 内置策略 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,
|
||
})),
|
||
];
|
||
|
||
/**
|
||
* 策略持仓表基础数据列目录(迭代 11 列显隐扩展)
|
||
* 可配置区基础列:key / label / 默认是否显示(data=固定数据列;自定义字段列由 configSchema 动态合成)
|
||
* 固定列(不在此目录,锁死):展开箭头 | 代码 | 名称(最前)| 操作(最后)
|
||
*/
|
||
export const COLUMN_META = [
|
||
{ key: 'lastPrice', label: '现价', fixed: false },
|
||
{ key: 'pctChange', label: '涨幅', fixed: false },
|
||
{ key: 'lastClose', label: '昨收', fixed: false },
|
||
{ key: 'avgPrice', label: '成本价', fixed: false },
|
||
{ key: 'lastTradePrice', label: '最后一笔成交价', fixed: false },
|
||
{ key: 'shares', label: '策略份额', fixed: false },
|
||
// R-015/迭代 13:行情列(盘口快照 + 合约信息;defaultVisible=false 默认隐藏,列设置勾选开启)
|
||
{ key: 'upStopPrice', label: '涨停价', fixed: false, defaultVisible: false },
|
||
{ key: 'downStopPrice', label: '跌停价', fixed: false, defaultVisible: false },
|
||
{ key: 'open', label: '今开', fixed: false, defaultVisible: false },
|
||
{ key: 'high', label: '最高', fixed: false, defaultVisible: false },
|
||
];
|
||
|
||
/**
|
||
* 策略自定义字段定义 schema(R-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)
|
||
});
|
||
|
||
/** 策略设置 schema(schemastery 定义,settings.register 要求 schema 对象) */
|
||
/** @type {import('@deepseek-ai/schemastery').Schema<any>} 策略设置 schema(显式注解:含 z.dict,规避 dts 推断 cosmokit Dict 引用) */
|
||
export const strategySchema = z.object({
|
||
// 策略定义表(R-011 收窄 + R-013 扩展 configSchema:id + name + 自定义字段定义)
|
||
// R-013(技术约束-015):configSchema = 该策略需要的自定义字段定义(key/label/type/enum/def),
|
||
// 随策略定义存 settings 不落库;旧策略缺省 [](兼容,Q4)。
|
||
strategies: z.array(z.object({
|
||
id: z.string().required(),
|
||
name: z.string().required(),
|
||
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([
|
||
z.object({
|
||
allPositions: z.boolean().default(true),
|
||
tradeRecords: z.boolean().default(true),
|
||
watchlist: z.boolean().default(true),
|
||
}),
|
||
z.array(z.object({
|
||
id: z.string().required(),
|
||
kind: z.union([z.const('builtin'), z.const('strategy')]).required(),
|
||
refKey: z.string(), // builtin 专用:allPositions/tradeRecords/watchlist(可空)
|
||
refId: z.string(), // strategy 专用:策略 id(可空)
|
||
name: z.string(), // 展示名(策略行 join 时刷新,Q4 自动跟随改名;可空)
|
||
visible: z.boolean().default(true),
|
||
order: z.number().default(0),
|
||
})),
|
||
]).default(DEFAULT_TABS),
|
||
// QMT 连接配置(R-004,技术约束-008):list + 激活态 + 默认态
|
||
qmtConnections: z.object({
|
||
list: z.array(z.object({
|
||
id: z.string().required(),
|
||
name: z.string().required(),
|
||
baseUrl: z.string().required(),
|
||
order: z.number().default(0),
|
||
})).default([]),
|
||
activeId: z.string().default(''),
|
||
defaultId: z.string().default(''),
|
||
}).default({ list: [], activeId: '', defaultId: '' }),
|
||
// 策略持仓表列配置(迭代 11 扩展:每策略一套,仅存与默认不同的覆盖;读取归一化)
|
||
// strategyColumns: { [strategyId]: [{ key, visible }] } —— key 覆盖 COLUMN_META 基础列 + 该策略 configSchema 字段列
|
||
strategyColumns: z.dict(
|
||
z.array(z.object({
|
||
key: z.string().required(),
|
||
visible: z.boolean().default(true),
|
||
})).default([])
|
||
).default({}),
|
||
});
|
||
|
||
|
||
/** 简易拼音映射表(常用汉字 → 拼音,用于 slug 生成兜底) */
|
||
const PINYIN_MAP = {
|
||
// 完整短语映射(优先级最高)
|
||
'网格超市': 'grid-supermarket',
|
||
'手动做T': 'manual-t',
|
||
'长线持有': 'long-term-hold',
|
||
'短线交易': 'short-term-trade',
|
||
'波段操作': 'swing-trade',
|
||
'价值投资': 'value-invest',
|
||
'趋势跟踪': 'trend-follow',
|
||
'新股申购': 'ipo-subscribe',
|
||
'打板': 'board',
|
||
'低吸': 'dip-buy',
|
||
'高抛': 'high-sell',
|
||
'做T': 't',
|
||
// 词映射
|
||
'长线': 'long-term',
|
||
'短线': 'short-term',
|
||
'持有': 'hold',
|
||
'波段': 'swing',
|
||
'价值': 'value',
|
||
'网格': 'grid',
|
||
'策略': 'strategy',
|
||
'趋势': 'trend',
|
||
'新股': 'new-stock',
|
||
'打新': 'ipo',
|
||
// 单字映射
|
||
'超': 'super',
|
||
'市': 'market',
|
||
'场': 'market',
|
||
'手': 'manual',
|
||
'动': 'action',
|
||
'长': 'long',
|
||
'短': 'short',
|
||
'持': 'hold',
|
||
'波': 'swing',
|
||
'值': 'value',
|
||
'格': 'grid',
|
||
'略': 'strategy',
|
||
'趋': 'trend',
|
||
'势': 'trend',
|
||
};
|
||
|
||
/**
|
||
* 生成策略英文 id(slug)
|
||
* 规则:常见中文策略名映射拼音 → 去空格/转小写/连字符 → 非 ASCII 字符用序号兜底
|
||
* @param {string} name 策略名称
|
||
* @param {string[]} existingIds 已存在的 id(用于去重)
|
||
* @returns {string} 唯一英文 id
|
||
*/
|
||
export function generateStrategyId(name, existingIds = []) {
|
||
const raw = String(name ?? '').trim();
|
||
if (!raw) return '';
|
||
|
||
// 1. 整名映射(常见策略名直接映射,优先级最高)
|
||
const whole = PINYIN_MAP[raw];
|
||
if (whole) return dedupe(whole, existingIds);
|
||
|
||
// 2. 最长子串贪婪匹配(把名字切成已映射的词/字片段)
|
||
let slug = '';
|
||
let i = 0;
|
||
while (i < raw.length) {
|
||
let matched = false;
|
||
for (let len = Math.min(4, raw.length - i); len >= 1; len--) {
|
||
const seg = raw.slice(i, i + len);
|
||
const p = PINYIN_MAP[seg];
|
||
if (p) {
|
||
slug += p + '-';
|
||
i += len;
|
||
matched = true;
|
||
break;
|
||
}
|
||
}
|
||
if (matched) continue;
|
||
const ch = raw[i];
|
||
if (/[a-zA-Z0-9]/.test(ch)) {
|
||
slug += ch.toLowerCase() + '-';
|
||
}
|
||
i++;
|
||
}
|
||
let base = slug.replace(/-+$/, '').replace(/^-+/, '') || 'strategy';
|
||
|
||
// 3. 规范化(去空格、连字符规整、移除非法字符)
|
||
base = base
|
||
.replace(/\s+/g, '-')
|
||
.replace(/[^a-z0-9-]/g, '')
|
||
.replace(/-+/g, '-')
|
||
.replace(/^-|-$/g, '')
|
||
|| 'strategy';
|
||
|
||
return dedupe(base, existingIds);
|
||
}
|
||
|
||
/** 去重:若 id 已存在,追加序号 */
|
||
function dedupe(base, existingIds) {
|
||
let candidate = base;
|
||
let i = 2;
|
||
while (existingIds.includes(candidate)) {
|
||
candidate = `${base}-${i}`;
|
||
i++;
|
||
}
|
||
return candidate;
|
||
}
|
||
|
||
/**
|
||
* 注册策略设置 namespace,返回 SettingsScope
|
||
* @param {import('@deepseek-ai/cordis').Context} ctx
|
||
*/
|
||
export function registerSettings(ctx, config) {
|
||
const ns = config.settingsNamespace ?? 'one-divine-lot';
|
||
const scope = ctx.settings.register(ns, strategySchema, {
|
||
// 组合 base(如需从 composition 注入默认值)
|
||
base: config.strategies ? { strategies: config.strategies } : undefined,
|
||
});
|
||
ctx.logger?.info(`[one-divine-lot] 设置已注册: ${ns}`);
|
||
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-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 = 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) : []),
|
||
),
|
||
}));
|
||
}
|
||
|
||
/** 读取某策略字段定义(未知名 → []) */
|
||
export function getStrategyFields(scope, strategyId) {
|
||
return getStrategies(scope).find((s) => s.id === strategyId)?.configSchema ?? [];
|
||
}
|
||
|
||
/**
|
||
* 写入某策略字段定义(R-027:单策略写入,唯一写路径;不触碰 strategies[] 与份额数据)
|
||
* @param {ReturnType<typeof registerSettings>} scope
|
||
* @param {string} strategyId 内置策略 id
|
||
* @param {Array} configSchema 完整字段定义数组
|
||
* @returns {Promise<object>} 更新后的策略对象
|
||
*/
|
||
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);
|
||
}
|
||
|
||
|
||
/**
|
||
* 归一化 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 raw = scopeValue(scope).tabs;
|
||
|
||
// 旧布尔对象(迭代 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 }));
|
||
|
||
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/R-027):归一化结果(策略行 name 恒为内置名,无改名场景)
|
||
* @param {ReturnType<typeof registerSettings>} scope
|
||
* @returns {Array} tabs 数组(按 order 排序)
|
||
*/
|
||
export function getTabs(scope) {
|
||
return normalizeTabs(scope);
|
||
}
|
||
|
||
/**
|
||
* 更新统一 tab 列表(整表更新:顺序 + 显隐;R-011)
|
||
* 策略行 name 由服务端 join 刷新,客户端可只传 id/order/visible 的数组。
|
||
* @param {ReturnType<typeof registerSettings>} scope
|
||
* @param {Array} tabs 完整 tabs 数组
|
||
*/
|
||
export async function updateTabs(scope, tabs) {
|
||
const current = scope?.get() ?? {};
|
||
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 显隐配置(R-011 迁移期兼容:返回布尔对象,供旧调用点使用)
|
||
* @deprecated 由 getTabs 取代
|
||
*/
|
||
export function getTabConfig(scope) {
|
||
const tabs = getTabs(scope);
|
||
const cfg = { allPositions: true, tradeRecords: true, watchlist: true };
|
||
for (const t of tabs) {
|
||
if (t.kind === 'builtin' && t.refKey in cfg) cfg[t.refKey] = t.visible !== false;
|
||
}
|
||
return cfg;
|
||
}
|
||
|
||
/**
|
||
* 更新通用 tab 显隐配置(R-011 迁移期兼容:写回统一数组)
|
||
* @deprecated 由 updateTabs 取代
|
||
*/
|
||
export async function updateTabConfig(scope, tabs) {
|
||
const currentTabs = getTabs(scope);
|
||
const next = currentTabs.map((t) => (t.kind === 'builtin' && t.refKey in tabs
|
||
? { ...t, visible: tabs[t.refKey] !== false }
|
||
: t));
|
||
await updateTabs(scope, next);
|
||
}
|
||
|
||
/**
|
||
* ============================================================================
|
||
* 策略持仓表列配置(迭代 11 列显隐扩展;产品约束-011、技术约束-017 待落)
|
||
* 数据:settings.strategyColumns = { [strategyId]: [{key, visible}] }(仅存覆盖)
|
||
* 归一化:基础列(COLUMN_META 默认序全显)+ 该策略 configSchema 字段列(configSchema 序全显)
|
||
* → 应用 strategyColumns 覆盖(visible + 顺序)→ 返回有序列数组 [{key, label, fixed, visible}]
|
||
* 固定列不在此配置:展开箭头/代码/名称(最前)、操作(最后)。
|
||
*/
|
||
|
||
/**
|
||
* 归一化某策略的列配置(基础列 + 自定义字段列,应用覆盖)
|
||
* @param {ReturnType<typeof registerSettings>} scope
|
||
* @param {string} strategyId
|
||
* @returns {Array<{key: string, label: string, fixed: boolean, visible: boolean, kind: 'base'|'field'}>}
|
||
*/
|
||
export function normalizeStrategyColumns(scope, strategyId) {
|
||
const value = scopeValue(scope);
|
||
// R-027:字段定义真相源 = strategyFields(缺失时兼容读取旧 strategies[].configSchema)
|
||
const configSchema = getStrategyFields(scope, strategyId);
|
||
const raw = (value.strategyColumns && value.strategyColumns[strategyId]) || [];
|
||
|
||
// 1. 默认全列(基础列默认序 + 字段列 configSchema 序;显隐缺省跟随 defaultVisible,R-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', type: f.type }));
|
||
const defaults = [...baseCols, ...fieldCols];
|
||
|
||
// 2. 应用覆盖:visible + 顺序(未覆盖的列保持默认,追加在默认序后)
|
||
const override = Array.isArray(raw) ? raw : [];
|
||
const visibleMap = new Map();
|
||
for (const o of override) visibleMap.set(o.key, o.visible !== false);
|
||
|
||
// 覆盖中的自定义字段列若 configSchema 已删除 → 忽略(自动跟随删除)
|
||
const activeKeys = new Set(defaults.map((c) => c.key));
|
||
const validOverride = override.filter((o) => activeKeys.has(o.key));
|
||
|
||
// 3. 组装:先按覆盖顺序排,未覆盖列追加默认序
|
||
const ordered = [];
|
||
const placed = new Set();
|
||
for (const o of validOverride) {
|
||
const d = defaults.find((c) => c.key === o.key);
|
||
if (d) { ordered.push({ ...d, visible: o.visible !== false }); placed.add(d.key); }
|
||
}
|
||
for (const d of defaults) {
|
||
if (!placed.has(d.key)) {
|
||
const dv = visibleMap.has(d.key) ? visibleMap.get(d.key) : d.defaultVisible !== false;
|
||
ordered.push({ ...d, visible: dv });
|
||
}
|
||
}
|
||
return ordered;
|
||
}
|
||
|
||
/** 读取某策略列配置(归一化结果) */
|
||
export function getStrategyColumns(scope, strategyId) {
|
||
return normalizeStrategyColumns(scope, strategyId);
|
||
}
|
||
|
||
/**
|
||
* 更新某策略列配置(整表覆盖该策略的 columns)
|
||
* @param {ReturnType<typeof registerSettings>} scope
|
||
* @param {string} strategyId
|
||
* @param {Array} columns 完整列数组 [{key, visible}](含不可见列——保留位置,visible 控制显示)
|
||
*/
|
||
export async function updateStrategyColumns(scope, strategyId, columns) {
|
||
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 });
|
||
}
|
||
|
||
/**
|
||
* ============================================================================
|
||
* QMT 连接配置(R-004 已定稿;产品约束-005、技术约束-008)
|
||
*
|
||
* 数据结构:qmtConnections: { list: [{id,name,baseUrl,order}], activeId, defaultId }
|
||
* - 激活:一次只能激活一个(activeId),激活配置为当前生效连接,切换立即生效(Q2)
|
||
* - 默认:启动时自动激活默认配置(Q1),重启回到默认;运行中手动激活立即生效
|
||
* - 启动兜底:列表为空时回退 cordis 注入的 qmtBaseUrl(Q4 不迁移)
|
||
* - 删除边界(Q6):删激活→自动切默认;删默认→默认转移第一条并激活之;禁删最后一条
|
||
*/
|
||
|
||
/** 归一化并校验 HTTP 地址:trim + 去尾斜杠 + 校验 http(s):// 前缀 */
|
||
export function normalizeBaseUrl(raw) {
|
||
const url = String(raw ?? '').trim().replace(/\/+$/, '');
|
||
if (!/^https?:\/\//i.test(url) || url.length <= 8) {
|
||
throw Object.assign(new Error('HTTP 地址格式无效(需以 http:// 或 https:// 开头)'), { code: 'qmt-invalid-url' });
|
||
}
|
||
return url;
|
||
}
|
||
|
||
/** 生成连接配置英文 id(复用策略拼音 slug;兜底前缀 qmt-conn) */
|
||
export function generateQmtConnectionId(name, existingIds = []) {
|
||
const base = generateStrategyId(name, []);
|
||
const slug = base && base !== 'strategy' ? base : 'qmt-conn';
|
||
return dedupe(slug, existingIds);
|
||
}
|
||
|
||
/** 读取 QMT 连接配置(list 按 order 排序) */
|
||
export function getQmtConnections(scope) {
|
||
const value = scope?.get() ?? {};
|
||
const qc = value.qmtConnections ?? {};
|
||
const list = Array.isArray(qc.list) ? qc.list : [];
|
||
return {
|
||
list: list.slice().sort((a, b) => (a.order ?? 0) - (b.order ?? 0)),
|
||
activeId: typeof qc.activeId === 'string' ? qc.activeId : '',
|
||
defaultId: typeof qc.defaultId === 'string' ? qc.defaultId : '',
|
||
};
|
||
}
|
||
|
||
/** 解析当前生效连接:activeId → defaultId → order 第一条 → null */
|
||
export function getActiveQmtConnection(state) {
|
||
const byId = (id) => (id ? state.list.find((c) => c.id === id) ?? null : null);
|
||
return byId(state.activeId) ?? byId(state.defaultId) ?? state.list[0] ?? null;
|
||
}
|
||
|
||
/**
|
||
* 启动激活连接解析(Q1/Q4):默认 → 上次激活 → 第一条;列表为空回退 cordis qmtBaseUrl。
|
||
* 解析结果与 activeId 不一致时回写(保证运行期状态一致)。
|
||
* @returns {Promise<{connection: object|null, baseUrl: string|null, source: string}>}
|
||
*/
|
||
export async function resolveStartupConnection(scope, config) {
|
||
const state = getQmtConnections(scope);
|
||
if (!state.list.length) {
|
||
const fallback = String(config?.qmtBaseUrl ?? '').trim().replace(/\/+$/, '');
|
||
return { connection: null, baseUrl: fallback || null, source: fallback ? 'cordis-fallback' : 'none' };
|
||
}
|
||
const byId = (id) => (id ? state.list.find((c) => c.id === id) ?? null : null);
|
||
const defaultConn = byId(state.defaultId);
|
||
const activeConn = byId(state.activeId);
|
||
const chosen = defaultConn
|
||
? { conn: defaultConn, why: 'default' }
|
||
: activeConn
|
||
? { conn: activeConn, why: 'last-active' }
|
||
: { conn: state.list[0], why: 'first' };
|
||
if (chosen.conn.id !== state.activeId) {
|
||
await activateQmtConnection(scope, chosen.conn.id);
|
||
}
|
||
return { connection: chosen.conn, baseUrl: chosen.conn.baseUrl, source: 'settings:' + chosen.why };
|
||
}
|
||
|
||
/** 新增连接配置 */
|
||
export async function addQmtConnection(scope, { name, baseUrl } = {}) {
|
||
const trimmedName = String(name ?? '').trim();
|
||
if (!trimmedName) throw Object.assign(new Error('连接名称不能为空'), { code: 'qmt-name-required' });
|
||
const normalizedUrl = normalizeBaseUrl(baseUrl);
|
||
const state = getQmtConnections(scope);
|
||
const id = generateQmtConnectionId(trimmedName, state.list.map((c) => c.id));
|
||
const conn = {
|
||
id,
|
||
name: trimmedName,
|
||
baseUrl: normalizedUrl,
|
||
order: state.list.reduce((max, c) => Math.max(max, c.order ?? 0), -1) + 1,
|
||
};
|
||
const value = scope?.get() ?? {};
|
||
await scope.update({ ...value, qmtConnections: { ...state, list: [...state.list, conn] } });
|
||
return conn;
|
||
}
|
||
|
||
/** 编辑连接配置(名称 / 地址);返回更新后的配置对象 */
|
||
export async function updateQmtConnection(scope, id, patch = {}) {
|
||
const state = getQmtConnections(scope);
|
||
const target = state.list.find((c) => c.id === id);
|
||
if (!target) throw Object.assign(new Error('连接配置不存在: ' + id), { code: 'qmt-connection-not-found' });
|
||
const nextName = patch.name !== undefined ? String(patch.name).trim() : target.name;
|
||
if (!nextName) throw Object.assign(new Error('连接名称不能为空'), { code: 'qmt-name-required' });
|
||
const nextUrl = patch.baseUrl !== undefined ? normalizeBaseUrl(patch.baseUrl) : target.baseUrl;
|
||
const updated = { ...target, name: nextName, baseUrl: nextUrl };
|
||
const value = scope?.get() ?? {};
|
||
await scope.update({
|
||
...value,
|
||
qmtConnections: { ...state, list: state.list.map((c) => (c.id === id ? updated : c)) },
|
||
});
|
||
return updated;
|
||
}
|
||
|
||
/** 删除连接配置(含 Q6 删除边界处理);返回删除后的状态与边界信息 */
|
||
export async function removeQmtConnection(scope, id) {
|
||
const state = getQmtConnections(scope);
|
||
if (!state.list.some((c) => c.id === id)) {
|
||
throw Object.assign(new Error('连接配置不存在: ' + id), { code: 'qmt-connection-not-found' });
|
||
}
|
||
if (state.list.length <= 1) {
|
||
throw Object.assign(new Error('不能删除最后一个连接配置'), { code: 'qmt-last-connection' });
|
||
}
|
||
const removedWasActive = state.activeId === id;
|
||
const removedWasDefault = state.defaultId === id;
|
||
const list = state.list.filter((c) => c.id !== id);
|
||
// 删默认 → 默认标记转移到列表第一条(产品约束-005)
|
||
let defaultId = removedWasDefault ? (list[0]?.id ?? '') : state.defaultId;
|
||
// 删激活 → 自动切换到默认(若默认仍有效),否则第一条
|
||
let activeId = removedWasActive
|
||
? (defaultId && list.some((c) => c.id === defaultId) ? defaultId : (list[0]?.id ?? ''))
|
||
: state.activeId;
|
||
const value = scope?.get() ?? {};
|
||
await scope.update({ ...value, qmtConnections: { list, activeId, defaultId } });
|
||
return { list, activeId, defaultId, removedWasActive, removedWasDefault };
|
||
}
|
||
|
||
/** 激活连接配置(写 activeId;热切换由 api 层编排 setBaseUrl) */
|
||
export async function activateQmtConnection(scope, id) {
|
||
const state = getQmtConnections(scope);
|
||
if (!state.list.some((c) => c.id === id)) {
|
||
throw Object.assign(new Error('连接配置不存在: ' + id), { code: 'qmt-connection-not-found' });
|
||
}
|
||
const value = scope?.get() ?? {};
|
||
await scope.update({ ...value, qmtConnections: { ...state, activeId: id } });
|
||
return { ...state, activeId: id };
|
||
}
|
||
|
||
/** 设为默认配置(写 defaultId) */
|
||
export async function setDefaultQmtConnection(scope, id) {
|
||
const state = getQmtConnections(scope);
|
||
if (!state.list.some((c) => c.id === id)) {
|
||
throw Object.assign(new Error('连接配置不存在: ' + id), { code: 'qmt-connection-not-found' });
|
||
}
|
||
const value = scope?.get() ?? {};
|
||
await scope.update({ ...value, qmtConnections: { ...state, defaultId: id } });
|
||
return { ...state, defaultId: id };
|
||
} |