diff --git a/docs/04-迭代记录/11-策略自定义字段配置/技术实现方案.md b/docs/04-迭代记录/11-策略自定义字段配置/技术实现方案.md
index 77874ec..1dfdc0c 100644
--- a/docs/04-迭代记录/11-策略自定义字段配置/技术实现方案.md
+++ b/docs/04-迭代记录/11-策略自定义字段配置/技术实现方案.md
@@ -17,6 +17,7 @@ strategies: z.array(z.object({
type: z.union([z.const('text'), z.const('number'), z.const('boolean'), z.const('enum')]).required(),
enum: z.array(z.string()), // 仅 type=enum 时使用
def: z.any(), // 默认值(text=string / number=number / boolean=boolean / enum=枚举项)
+ unit: z.string().default(''), // 单位(2026-09-02 加:可为空;如 % / 元 / 手;展示时拼在值后)
})).default([]), // 缺省 []
})).default(DEFAULT_STRATEGIES),
```
@@ -71,11 +72,12 @@ case 'holdings/values-update':
## 4. 策略持仓 tab UI(src/client/views/StrategyTab.jsx)
-- 持仓行展开区(R-010 展开逻辑基础上)增加「自定义字段」区块(configSchema 非空才渲染,空则无此区块);
-- 块内按 configSchema 渲染控件:text=输入框 / number=数字输入 / boolean=开关(复用 Switch 组件)/ enum=下拉;
-- 值来源:strategy-positions 返回的 values;控件初值 = values[key] ?? def;
-- 编辑即保存:单个字段变更(或区块「保存」按钮)→ 调 one-divine-lot/holdings/values-update;成功 Toast;校验失败(服务端返回)Toast 错误并回滚输入;
-- 与交易记录展开区块并存(展开区纵向分区:交易记录 / 自定义字段)。
+> 2026-09-02 演进(老师选 A + 列化):自定义字段**不再放展开区**,直接作为表格列展示;值编辑 = **点击字段单元格内联编辑**;展开区(R-010)仅保留交易明细。
+
+- 自定义字段列按列配置(§7)渲染,取值 = values[key] ?? def(boolean→开/关,空→—);
+- 点击字段单元格(有 holding 锚点行)进入内联编辑:text/number=输入框(Enter 保存 / Esc 取消 / blur 保存)、boolean=开关(点击即提交切换)、enum=下拉(选择即提交);
+- 保存调 one-divine-lot/holdings/values-update(按该行现有 values 合并,仅改本字段;空值删键回退 def);成功刷新,失败 Toast;
+- 单元格点击 stopPropagation,不与行展开(tr onClick)冲突;无 holding 锚点(未分配)的字段列不可编辑(虚线标识可点);
## 5. 回归脚本(scripts/test-r013-custom-fields.mjs)
@@ -83,6 +85,55 @@ case 'holdings/values-update':
- 用例:addStrategy 带 configSchema(四类型)→ openHolding ×2 同策略不同 code → writeValues 各自值 → 读回校验 → 校验拦截(数字非法 / 枚举越界)→ 旧策略无 configSchema 持仓行 readValues 为 NULL;
- 断言存储与读取一致、校验拒绝。
+## 7. 表格列显隐配置(迭代 11 范围扩展,2026-09-02 老师确认)
+
+> 背景:R-013 落地后老师要求——策略持仓表的**列可显示/隐藏配置**:不限于自定义字段列,还包含持仓表原有基础数据列;除 代码/名称/操作(+展开箭头)外全部支持;**每策略独立配置**;入口 = 策略持仓 tab 顶部「列设置」;支持**列排序**。并入本次迭代。
+
+### 7.1 数据模型(定稿)
+
+```js
+// settings 新增 strategyColumns(仅存与默认不同的覆盖;读取时归一化)
+strategyColumns: {
+ [strategyId]: [
+ { key: 'lastPrice', visible: true }, // 基础数据列(可配置区)
+ { key: 'pctChange', visible: true },
+ { key: 'lastClose', visible: false },
+ { key: 'avgPrice', visible: true },
+ { key: 'lastTradePrice', visible: true },
+ { key: 'shares', visible: true },
+ { key: 'gridGap', visible: true }, // 自定义字段列(key = configSchema.key)
+ { key: 'gridAmount', visible: true },
+ ],
+}
+```
+
+**列分类**:
+- **固定列**:展开箭头 | 代码 | 名称(恒显、锁最前)| 操作(锁最后,含 全部移入/移出)—— 不可配置;
+- **可配置区**:基础数据列(现价/涨幅/昨收/成本价/最后一笔成交价/份额)+ 该策略全部自定义字段列(key=configSchema.key)—— 显隐 + 顺序可配置;
+- 自定义字段列**存在性**仍由 configSchema 决定(策略未配该字段则无此列);configSchema 增删字段 → 列配置读取时自动跟随(新增字段默认追加末尾、删除字段自然移除)。
+
+**显隐单一数据源**(老师确认 2026-09-02):visible **只存在 strategyColumns**,configSchema **不加 visible**;展开区编辑区恒显示该策略全部字段(编辑入口需全量),表格列显隐由列配置单独管——避免双显隐源打架。
+
+**默认值/兼容**:未配置 strategyColumns 的策略 → 归一化为 基础列全显(默认序)+ configSchema 字段列全显(configSchema 序);老数据缺字段 → 补默认。
+
+### 7.2 涉及改动
+
+1. **src/settings.js**:基础列目录常量 COLUMN_META(key/label);strategySchema + strategyColumns;读取归一化 normalizeStrategyColumns(基础列 + 该策略 configSchema 字段合成全列 → 应用覆盖 → 返回有序可见列);更新 API;
+2. **src/api/strategies.js**:新增 strategy-columns(读归一化结果)/ strategy-columns/update {strategyId, columns};
+3. **src/client/views/StrategyTab.jsx**:表格动态列化——表头/行按列配置渲染(固定列 + 可见可配置列),行数据映射每列取值;
+4. **新组件 ColumnSettingsPopover**(views/):tab 顶部「列设置」按钮 → 弹层:可配置列列表(标签 + 显隐勾选 + 拖动排序)+ 立即持久化 strategy-columns/update;
+5. 展开区(§4)不再显示自定义字段(已列化),仅保留交易明细;字段值编辑 = 表格字段单元格点击内联编辑(§4 演进);
+6. 回归脚本 + typecheck/build。
+
+### 7.3 列取值(StrategyTab 渲染参考)
+
+| 列 key | 取值 |
+|---|---|
+| lastPrice / lastClose / pctChange | getPrice(code) 行情 |
+| avgPrice / lastTradePrice | p 的字段(QMT 成本价 / R-010 最后一笔成交价) |
+| shares | p.shares |
+| 自定义字段 key | p.values?.[key] ?? def(展示同展开区:boolean→开/关,空→—) |
+
## 6. 验证
- build + typecheck 通过;
diff --git a/docs/04-迭代记录/11-策略自定义字段配置/迭代目标.md b/docs/04-迭代记录/11-策略自定义字段配置/迭代目标.md
index b2111bf..66d00ea 100644
--- a/docs/04-迭代记录/11-策略自定义字段配置/迭代目标.md
+++ b/docs/04-迭代记录/11-策略自定义字段配置/迭代目标.md
@@ -5,17 +5,17 @@
## 目标描述
-给策略增加**可自定义、可扩展**的字段能力:每个策略在设置页「策略分组」子 tab 配置自定义字段定义(configSchema:key/label/type/enum/默认值,随策略定义存 settings 不落库);该策略下每个持仓(strategy_holdings 行)按所属策略的定义存取一份键值对值(新增 values JSON 列落库);策略持仓 tab 持仓行展开区按定义渲染并编辑值。旧策略(无定义)行为与现状完全一致。
+给策略增加**可自定义、可扩展**的字段能力:每个策略在设置页「策略分组」子 tab 配置自定义字段定义(configSchema:key/label/type/enum/默认值,随策略定义存 settings 不落库);该策略下每个持仓(strategy_holdings 行)按所属策略的定义存取一份键值对值(新增 values JSON 列落库);自定义字段作为策略持仓表**列**展示,点击单元格内联编辑值(列显隐与顺序每策略独立配置)。旧策略(无定义)行为与现状完全一致。
## 目标分解
1. 数据层:settings.strategies 扩展 configSchema(schemastery,type union text|number|boolean|enum),读取归一化(旧项缺省 []);strategy_holdings 新增 values TEXT 列(幂等 ALTER)+ readValues/writeValues;
2. API 层:strategy-positions 每行附 values;新增 holdings/values-update {holdingId, values} 写回(服务端按 configSchema 校验:数字/枚举/布尔);
3. 设置页 UI:「策略分组」子 tab 策略行可展开 → 字段列表 + 添加/编辑/删除字段表单 + 保存;
-4. 策略持仓 UI:持仓行展开区(R-010 基础上)增加「自定义字段」区块,按定义渲染四种类型控件,编辑即保存;
+4. 策略持仓 UI:自定义字段列表格列展示 + 单元格点击内联编辑(四类型);列显隐/顺序每策略独立配置(顶部「列设置」);
5. 回归脚本(独立数据目录,技术约束-011)+ 验证 + 验收复核。
## 对老师的配合需求
- 验收:设置页配置字段定义(四种类型各一 + 枚举)→ 策略持仓 tab 持仓行展开编辑值 → 重启验证持久化;
-- 提供:是否接受「旧策略无定义」行为样(默认接受,Q4 已确认)。
+- 提供:是否接受「旧策略无定义」行为样(默认接受,Q4 已确认)。
\ No newline at end of file
diff --git a/docs/04-迭代记录/11-策略自定义字段配置/验收标准.md b/docs/04-迭代记录/11-策略自定义字段配置/验收标准.md
index 20a2f09..7590110 100644
--- a/docs/04-迭代记录/11-策略自定义字段配置/验收标准.md
+++ b/docs/04-迭代记录/11-策略自定义字段配置/验收标准.md
@@ -5,7 +5,7 @@
## 验收标准线
1. 设置页「策略分组」:策略行可展开,添加 / 编辑 / 删除字段(四种类型:文本/数字/布尔/枚举,含枚举选项与默认值)保存后生效,重启后定义仍在;
-2. 策略持仓 tab:持仓行展开区出现「自定义字段」区块(仅该策略配置了字段时),四种类型控件按定义渲染(文本=输入框、数字=数字输入、布尔=开关、枚举=下拉),编辑值保存后刷新仍在(已落库);
+2. 策略持仓 tab:自定义字段以**列**形式展示(仅该策略配置了字段时),点击字段单元格内联编辑(文本/数字=输入框、布尔=开关、枚举=下拉),Enter/选择/开关即保存,刷新后仍在(已落库);展开区仅交易明细,不再含自定义字段;
3. 同策略多行各有各的值(600719 与 300057 互不影响);不同策略字段集互不影响;
4. 旧策略(无 configSchema):持仓行不渲染字段区,现有操作(加仓/减仓/清仓/展开交易记录)与现状完全一致;
5. 服务端校验生效:数字填非数字、枚举填选项外 → 拒绝保存并 Toast 提示;空值/缺省允许;
@@ -19,4 +19,4 @@
## 验收目标
-- 7 条验收线通过,迭代 11 标记「验收通过」,R-013 更新实现状态(已实现),归档。
+- 7 条验收线通过,迭代 11 标记「验收通过」,R-013 更新实现状态(已实现),归档。
\ No newline at end of file
diff --git a/docs/05-需求池/R-013.md b/docs/05-需求池/R-013.md
index e140384..c6a84ad 100644
--- a/docs/05-需求池/R-013.md
+++ b/docs/05-需求池/R-013.md
@@ -25,7 +25,7 @@
- **D3**(Q2/Q3)字段**类型化**:文本 / 数字 / 布尔 / **枚举**(预设选项下拉,有用,采纳)。定义含:key / label / type / enum 选项 / 默认值。
- **D4**(第三轮)配置入口 = **设置页「策略分组」子 tab**:策略行做成**可展开**,展开区添加 / 配置字段(名称 + 类型 + 枚举选项 + 默认值)。
- **D5**(Q4)兼容:旧策略无 configSchema(缺省空数组)→ 持仓行不展示自定义字段区,行为与现在完全一致;旧行 values 列缺省 NULL(空);无迁移历史值。
-- **D6**(2026-09-02 老师确认)值编辑入口 = **策略持仓 tab 持仓行展开区**(沿 R-010 展开能力),展开后按 configSchema 渲染各字段输入控件,编辑即调 API 写回该行 values。
+- **D6**(2026-09-02 老师确认,2026-09-02 迭代 11 演进为列化)值编辑入口原定为**持仓行展开区**;随「自定义字段列化 + 单元格点击编辑」演进(老师选 A):字段直接作为策略持仓表**列**展示,点击字段单元格进入内联编辑(text/number 输入框、boolean 开关、enum 下拉),编辑即调 API 写回该行 values。展开区(交易明细)不再显示自定义字段。
## 目标数据模型(定稿)
@@ -144,19 +144,18 @@ call('one-divine-lot/holdings/values-update', { args: { holdingId: 1, values: {
[▸] 长线持有 ...(目标价 / 持仓备注)
```
-**策略持仓 tab → 持仓行展开区(编辑值,R-010 展开能力之上)**:
+**策略持仓 tab(迭代 11 演进:自定义字段直接为表格列,点击单元格内联编辑)**:
```
-601117.SH 中国交建 600股 现价 7.20 +0.56% [展开]
- └ 交易记录(R-010 已有)
- └ 自定义字段:
- 网格间距 [ 4% ]
- 单格金额 [ 10000 ]
- 设置止损 [ ✓ ]
- 风险等级 [ 高 ▼ ]
- [保存]
+代码 名称 现价 涨幅 网格间距 单格金额 设置止损 风险等级 策略份额 操作
+601117.SH 中国交建 7.20 +0.56% 4% 10000 ✓ 高 600 [移出]
+300057.SZ 万顺新材 5.10 -1.2% 5% 8000 ✗ 低 1000 [移出]
+ (点击「网格间距/单格金额/风险等级」单元格 → 输入框/下拉编辑,Enter 保存;
+ 点击「设置止损」单元格 → 开关直接切换;空值显示 — / 默认值 def)
```
+> 表格顶部另有「列设置」按钮:每策略独立配置列显隐与顺序(基础数据列 + 自定义字段列;代码/名称/操作固定)。
+
### 5. 渲染 / 校验逻辑参考
```js
@@ -189,6 +188,6 @@ function validateField(f, v) {
- **Q2** 字段加类型(文本 / 数字 / 布尔 / 枚举)→ 确认。
- **Q3** 支持枚举字段(预设选项下拉)→ 确认(有用)。
- **Q4** 旧策略无自定义字段:沿用现有字段定义(不迁移、不补默认),值列缺省空 → 确认。
-- **D6(值编辑入口)**:持仓行展开区编辑 —— 老师确认(2026-09-02)。
+- **D6(值编辑入口)**:持仓行展开区编辑(老师确认 2026-09-02)→ 迭代 11 演进为**表格列 + 单元格点击编辑**(老师选 A,2026-09-02)。
> 三要素满足(边界清楚 / 核心逻辑明确 / 老师确认 Q1-Q4),**已定稿**,可进入计划范围。
\ No newline at end of file
diff --git a/scripts/test-r013-columns.mjs b/scripts/test-r013-columns.mjs
new file mode 100644
index 0000000..766446a
--- /dev/null
+++ b/scripts/test-r013-columns.mjs
@@ -0,0 +1,93 @@
+/**
+ * R-013 列显隐配置回归(迭代 11 扩展,2026-09-02)
+ * 覆盖(纯内存 mock scope,技术约束-011):
+ * 1. normalizeStrategyColumns 默认:基础列全显默认序 + configSchema 字段列全显
+ * 2. 覆盖生效:visible=false 隐藏、排序调整
+ * 3. configSchema 增删字段自动跟随(新增字段追加、删除字段移除)
+ * 4. updateStrategyColumns 整表覆盖 + getStrategyColumns 读回
+ */
+import {
+ normalizeStrategyColumns, getStrategyColumns, updateStrategyColumns,
+} 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 scope */
+function makeScope(initial) {
+ const store = structuredClone(initial ?? {});
+ return {
+ get: () => structuredClone(store),
+ update: async (patch) => { Object.assign(store, structuredClone(patch)); },
+ _store: store,
+ };
+}
+
+const gridSchema = [
+ { key: 'gridGap', label: '网格间距', type: 'text', def: '3%' },
+ { key: 'riskLevel', label: '风险等级', type: 'enum', enum: ['低','中','高'], def: '中' },
+];
+
+// ---------- 1. 默认归一化 ----------
+section('1. 默认列配置(无覆盖)');
+const scope = makeScope({
+ strategies: [
+ { id: 'grid-supermarket', name: '网格超市', configSchema: gridSchema },
+ { id: 'manual-t', name: '手动做T' },
+ ],
+});
+const gridCols = normalizeStrategyColumns(scope, 'grid-supermarket');
+const baseKeys = ['lastPrice','pctChange','lastClose','avgPrice','lastTradePrice','shares'];
+assert(gridCols.length === 8, '默认 8 列(6 基础 + 2 字段)');
+assert(baseKeys.every((k) => gridCols.some((c) => c.key === k)), '含全部基础列');
+assert(gridCols[6].key === 'gridGap' && gridCols[7].key === 'riskLevel', '字段列按 configSchema 序追加末尾');
+assert(gridCols.every((c) => c.visible !== false), '默认全部可见');
+assert(gridCols[0].key === 'lastPrice', '默认序:现价在前');
+
+const manualCols = normalizeStrategyColumns(scope, 'manual-t');
+assert(manualCols.length === 6, '旧策略(无 configSchema)仅 6 基础列');
+
+// ---------- 2. 覆盖生效 ----------
+section('2. 覆盖(隐藏 + 排序)');
+await updateStrategyColumns(scope, 'grid-supermarket', [
+ { key: 'riskLevel', visible: true },
+ { key: 'shares', visible: true },
+ { key: 'gridGap', visible: false }, // 隐藏 gridGap
+ { key: 'lastPrice', visible: false }, // 隐藏现价
+ { key: 'pctChange', visible: true },
+ { key: 'lastClose', visible: true },
+ { key: 'avgPrice', visible: true },
+ { key: 'lastTradePrice', visible: true },
+]);
+const after = getStrategyColumns(scope, 'grid-supermarket');
+assert(after.length === 8, '读回 8 列(含不可见,保位)');
+assert(after[0].key === 'riskLevel', '排序覆盖:riskLevel 提到最前');
+assert(after.find((c) => c.key === 'gridGap').visible === false, 'gridGap 已隐藏');
+assert(after.find((c) => c.key === 'lastPrice').visible === false, 'lastPrice 已隐藏');
+assert(after[1].key === 'shares', '排序覆盖:shares 第二');
+
+// ---------- 3. configSchema 增删跟随 ----------
+section('3. configSchema 增删字段自动跟随');
+await updateStrategyColumns(scope, 'grid-supermarket', after); // 已存覆盖
+// 模拟新增字段 hasStop
+scope._store.strategies = scope._store.strategies.map((s) => s.id === 'grid-supermarket'
+ ? { ...s, configSchema: [...gridSchema, { key: 'hasStop', label: '设置止损', type: 'boolean', def: false }] } : s);
+const afterAdd = normalizeStrategyColumns(scope, 'grid-supermarket');
+assert(afterAdd.some((c) => c.key === 'hasStop'), '新增字段 hasStop 自动加入列配置');
+assert(afterAdd.find((c) => c.key === 'hasStop').visible !== false, '新字段默认可见');
+assert(afterAdd.find((c) => c.key === 'gridGap').visible === false, '原隐藏 gridGap 保留隐藏');
+
+// 模拟删除字段 riskLevel
+scope._store.strategies = scope._store.strategies.map((s) => s.id === 'grid-supermarket'
+ ? { ...s, configSchema: gridSchema.filter((f) => f.key !== 'riskLevel') } : s);
+const afterDel = normalizeStrategyColumns(scope, 'grid-supermarket');
+assert(!afterDel.some((c) => c.key === 'riskLevel'), '删除字段 riskLevel 列自动移除');
+assert(afterDel.some((c) => c.key === 'gridGap'), '其余列保留');
+
+console.log("");
+console.log('结果: ' + pass + ' 通过, ' + fail + ' 失败');
+process.exit(fail > 0 ? 1 : 0);
\ No newline at end of file
diff --git a/scripts/test-r013-custom-fields.mjs b/scripts/test-r013-custom-fields.mjs
new file mode 100644
index 0000000..dea7bba
--- /dev/null
+++ b/scripts/test-r013-custom-fields.mjs
@@ -0,0 +1,172 @@
+/**
+ * R-013 回归测试:策略自定义字段(定义随策略 configSchema + 值落 strategy_holdings.values)
+ * 覆盖(独立数据目录,技术约束-011):
+ * 1. settings.configSchema:schema 归一化(旧策略无 configSchema → []);updateStrategies 保留定义
+ * 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, updateStrategies, addStrategy } 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)');
+
+// updateStrategies 整表更新保留定义
+await updateStrategies(scope, [{ id: 'manual-t', name: '手动做T' }, { id: 'grid-supermarket', name: '网格超市', configSchema: gridSchema }]);
+assert(getStrategies(scope).find((s) => s.id === 'grid-supermarket').configSchema.length === 4, 'updateStrategies 整表保留 configSchema');
+
+// ---------- 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);
\ No newline at end of file
diff --git a/src/api/strategies.js b/src/api/strategies.js
index 181bc35..8f18346 100644
--- a/src/api/strategies.js
+++ b/src/api/strategies.js
@@ -11,6 +11,7 @@
import {
getStrategies, updateStrategies, addStrategy, removeStrategy,
getTabs, updateTabs,
+ getStrategyColumns, updateStrategyColumns,
} from '../settings.js';
/** 策略/份额/tabs 端点方法表 */
@@ -27,6 +28,9 @@ export const STRATEGY_METHODS = new Set([
'remove-shares',
'tabs',
'tabs/update',
+ 'holdings/values-update', // R-013:写某持仓行的自定义字段值
+ 'strategy-columns', // 迭代11列显隐:读某策略列配置(归一化)
+ 'strategy-columns/update', // 迭代11列显隐:写某策略列配置
]);
/**
@@ -66,7 +70,15 @@ export async function handleStrategy(method, args, { manager, settings }) {
return await manager.moveAllUnallocatedToStrategy(args.code, args.strategyId);
case 'remove-shares':
return await manager.removeFromStrategy(args.code, args.strategyId, args.shares);
+ case 'holdings/values-update':
+ // R-013:写持仓行自定义字段值(服务端按 configSchema 校验在 manager 内完成)
+ return await manager.updateHoldingValues(args.holdingId, args.values);
+ case 'strategy-columns':
+ return getStrategyColumns(settings, args.strategyId);
+ case 'strategy-columns/update':
+ await updateStrategyColumns(settings, args.strategyId, args.columns);
+ return getStrategyColumns(settings, args.strategyId);
default:
throw Object.assign(new Error('unknown strategy method: ' + method), { code: 'not-found' });
}
-}
+}
\ No newline at end of file
diff --git a/src/client/views/ColumnSettingsPopover.jsx b/src/client/views/ColumnSettingsPopover.jsx
new file mode 100644
index 0000000..29fd724
--- /dev/null
+++ b/src/client/views/ColumnSettingsPopover.jsx
@@ -0,0 +1,94 @@
+/**
+ * 策略持仓表列设置弹层(迭代 11 列显隐扩展,2026-09-02)
+ * 管理可配置列(基础数据列 + 自定义字段列):显隐勾选 + ↑↓ 排序 + 立即持久化
+ * 固定列(代码/名称/操作/展开箭头)不在列表内,恒显示
+ */
+import React, { useState } from 'react';
+import { useRpc } from './connection.jsx';
+import { useToast } from './Toast.jsx';
+
+/**
+ * @param {object} props
+ * @param {Array} props.columns 可配置列 [{key,label,kind,visible}](归一化结果,含不可见列)
+ * @param {string} props.strategyId
+ * @param {Function} props.onClose 关闭
+ * @param {Function} props.onSaved 保存成功回调
+ */
+export function ColumnSettingsPopover({ columns, strategyId, onClose, onSaved }) {
+ const call = useRpc();
+ const toast = useToast();
+ const [list, setList] = useState(() => (Array.isArray(columns) ? columns.map((c) => ({ ...c })) : []));
+ const [saving, setSaving] = useState(false);
+ const [dragKey, setDragKey] = useState(null);
+
+ const toggle = (key) => setList((ls) => ls.map((c) => (c.key === key ? { ...c, visible: !c.visible } : c)));
+
+ /** ↑↓ 排序(同 Tab 设置简化版:用箭头替代 DnD,零依赖稳妥) */
+ const move = (index, dir) => {
+ setList((ls) => {
+ const next = ls.slice();
+ const to = index + dir;
+ if (to < 0 || to >= next.length) return ls;
+ const [item] = next.splice(index, 1);
+ next.splice(to, 0, item);
+ return next;
+ });
+ };
+
+ const save = async () => {
+ setSaving(true);
+ try {
+ // 提交整列配置(含不可见列,保位置)
+ const payload = list.map((c) => ({ key: c.key, visible: c.visible }));
+ const res = await call('one-divine-lot/strategy-columns/update', { args: { strategyId, columns: payload } });
+ if (res && res.ok) {
+ toast.success('列设置已保存');
+ onSaved?.();
+ onClose?.();
+ } else {
+ toast.error((res && res.error && res.error.message) || '保存失败');
+ }
+ } catch (e) {
+ toast.error(e.message);
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ const rowBtn = { padding: '0 5px', cursor: 'pointer', border: '1px solid var(--dsw-alias-border-l2, #ccc)', borderRadius: 3, background: 'var(--dsw-alias-bg-layer-1, #fff)', fontSize: 11, lineHeight: '16px' };
+ const kindTag = (kind) => ({
+ fontSize: 10, borderRadius: 6, padding: '0 4px', marginRight: 4,
+ color: kind === 'base' ? 'var(--dsw-alias-state-business-primary, #1565c0)' : 'var(--dsw-alias-state-warn-primary, #6a1b9a)',
+ border: '1px solid var(--dsw-alias-border-l3, #90caf9)',
+ });
+
+ return (
+
+
列设置
+
勾选显示列,↑↓ 调整顺序(代码/名称/操作固定)
+
+ {list.map((c, i) => (
+
+
+
+
+
+ toggle(c.key)} />
+ {c.kind === 'base' ? '数据' : '字段'}
+ {c.label}
+
+ ))}
+
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/client/views/FieldCellEditor.jsx b/src/client/views/FieldCellEditor.jsx
new file mode 100644
index 0000000..3f0fc2a
--- /dev/null
+++ b/src/client/views/FieldCellEditor.jsx
@@ -0,0 +1,151 @@
+/**
+ * 自定义字段单元格内联编辑器(迭代 11,2026-09-02 老师选 A:点击单元格即编辑)
+ * 按字段类型渲染:text=输入框 / number=数字输入 / boolean=开关点击切换 / enum=下拉
+ * 保存调 holdings/values-update(按该行 values 合并——读当前值做基底,只改本字段)
+ */
+import React, { useState } from 'react';
+import { useRpc } from './connection.jsx';
+import { useToast } from './Toast.jsx';
+
+const inputStyle = {
+ padding: '2px 4px', border: '1px solid var(--dsw-alias-state-business-primary, #1565c0)',
+ borderRadius: 3, fontSize: 12, background: 'var(--dsw-alias-bg-layer-1, #fff)', color: 'inherit',
+ outline: 'none',
+ boxSizing: 'border-box',
+ // 宽度由内容决定(ch 随文本长度),切换编辑时列宽不跳动
+ width: 'auto',
+ minWidth: '3ch',
+ maxWidth: '160px',
+};
+
+/** 编辑态确认按钮(✓):紧凑 inline,紧贴输入框,宽度增量小(列宽变化轻微) */
+const actionBtn = {
+ padding: '0 3px', cursor: 'pointer', border: 'none', background: 'none',
+ fontSize: 13, lineHeight: '15px', marginLeft: 1, verticalAlign: 'middle',
+ flex: 'none',
+};
+const okBtn = { ...actionBtn, color: 'var(--dsw-alias-state-success-primary, #2e7d32)' };
+const cancelBtn = { ...actionBtn, color: 'var(--dsw-alias-label-tertiary, #999)' };
+
+/**
+ * @param {object} props
+ * @param {object} props.fieldDef configSchema 字段定义 {key,label,type,enum,def}
+ * @param {object|null} props.values 该行当前全部字段值(合并基底)
+ * @param {number} props.holdingId
+ * @param {Function} props.onDone 保存/取消后回调(刷新父数据)
+ */
+export function FieldCellEditor({ fieldDef, values, holdingId, onDone }) {
+ const call = useRpc();
+ const toast = useToast();
+ const has = values && Object.prototype.hasOwnProperty.call(values, fieldDef.key);
+ const cur = has ? values[fieldDef.key] : fieldDef.def;
+ const [saving, setSaving] = useState(false);
+ // 文本/数字草稿;布尔/枚举直接由控件 onChange 触发提交
+ const [text, setText] = useState(() => (fieldDef.type === 'number' ? (cur == null ? '' : String(cur)) : (cur == null ? '' : String(cur))));
+
+ /** 提交新值(payload 为整行 values:读当前做基底合并本字段) */
+ const commit = async (newVal, hasNewVal) => {
+ setSaving(true);
+ try {
+ const base = (values && typeof values === 'object') ? { ...values } : {};
+ if (hasNewVal) base[fieldDef.key] = newVal;
+ else delete base[fieldDef.key]; // 空值 → 删键(缺省回退 def)
+ const res = await call('one-divine-lot/holdings/values-update', { args: { holdingId, values: base } });
+ if (res && res.ok) {
+ onDone?.();
+ } else {
+ toast.error((res && res.error && res.error.message) || '保存失败');
+ setSaving(false);
+ }
+ } catch (e) {
+ toast.error(e.message);
+ setSaving(false);
+ }
+ };
+
+ /** 行内键盘:Enter 保存 / Esc 取消 */
+ const onKey = (e) => {
+ if (e.key === 'Enter') saveText();
+ else if (e.key === 'Escape') onDone?.();
+ };
+
+ const saveText = () => {
+ const s = String(text ?? '').trim();
+ if (fieldDef.type === 'number') {
+ if (s === '') return commit(null, false); // 清空
+ const n = Number(s);
+ if (!Number.isFinite(n)) { toast.error('需为数字'); return; }
+ return commit(n, true);
+ }
+ // text:空串 → 删键,否则存值
+ return commit(s === '' ? null : s, s !== '');
+ };
+
+ if (fieldDef.type === 'boolean') {
+ // 布尔:点击单元格进入后,这里是开关 —— 但开关点击应提交,需阻止再进入。直接渲染开关提交。
+ return (
+
+
+
+ );
+ }
+
+ if (fieldDef.type === 'enum') {
+ return (
+
+ );
+ }
+
+ // text / number:输入框 + Enter 保存 / Esc 取消 + ✓ 确认 / ✕ 取消按钮
+ const inputRef = React.useRef(null);
+ return (
+
+ setText(e.target.value)}
+ onKeyDown={onKey}
+ onBlur={() => { if (!saving) saveText(); }}
+ />
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/client/views/SettingsSection.jsx b/src/client/views/SettingsSection.jsx
index 0921dea..71c1701 100644
--- a/src/client/views/SettingsSection.jsx
+++ b/src/client/views/SettingsSection.jsx
@@ -9,9 +9,10 @@
* - QMT 连接配置:R-004 卡片形式(迭代 03)
*/
-import { useState, useEffect, useCallback } from 'react';
+import { useState, useEffect, useCallback, Fragment } from 'react';
import { useRpc } from './connection.jsx';
import { ToastProvider, useToast } from './Toast.jsx';
+import { StrategyFieldsEditor } from './StrategyFieldsEditor.jsx'; // R-013:策略自定义字段编辑器
/** 确认弹窗 */
function ConfirmDialog({ title, message, onConfirm, onCancel }) {
@@ -183,6 +184,9 @@ function StrategyGroupSettings() {
const [editingName, setEditingName] = useState('');
const [confirmDelete, setConfirmDelete] = useState(null);
const [changedMsg, setChangedMsg] = useState(null);
+ // R-013:当前展开配置字段的策略 id(null=无展开)
+ const [expandedId, setExpandedId] = useState(null);
+ const [fieldSaving, setFieldSaving] = useState(false);
const call = useRpc();
const toast = useToast();
@@ -244,6 +248,25 @@ function StrategyGroupSettings() {
}
};
+ /** R-013:保存某策略的自定义字段定义(整表 strategies/update) */
+ const handleSaveConfig = async (id, configSchema) => {
+ setFieldSaving(true);
+ try {
+ const res = await call('one-divine-lot/strategies/update', {
+ args: { strategies: strategies.map((s) => (s.id === id ? { ...s, configSchema } : s)) },
+ });
+ if (res && res.ok) {
+ handleChanged('策略自定义字段已保存');
+ } else {
+ toast.error((res && res.error && res.error.message) || '保存失败');
+ }
+ } catch (e) {
+ toast.error(e.message);
+ } finally {
+ setFieldSaving(false);
+ }
+ };
+
const handleDelete = async (id) => {
setSaving(true);
try {
@@ -310,7 +333,8 @@ function StrategyGroupSettings() {
{strategies.map((s) => (
-
+
+
|
{editingId === s.id ? (
@@ -325,11 +349,22 @@ function StrategyGroupSettings() {
) : (
- s.name
+
+
+ {s.name}
+
)}
|
{s.id} |
+
|
+ {expandedId === s.id && (
+
+ |
+ handleSaveConfig(s.id, fields)}
+ onClose={() => setExpandedId(null)}
+ />
+ |
+
+ )}
+
))}
@@ -722,4 +770,4 @@ function QmtConnectionsSettings() {
)}
);
-}
+}
\ No newline at end of file
diff --git a/src/client/views/StrategyFieldsEditor.jsx b/src/client/views/StrategyFieldsEditor.jsx
new file mode 100644
index 0000000..a0a472c
--- /dev/null
+++ b/src/client/views/StrategyFieldsEditor.jsx
@@ -0,0 +1,190 @@
+/**
+ * 策略自定义字段编辑器(R-013 迭代 11)
+ * 管理某策略的 configSchema:字段列表 + 添加/编辑/删除 + 类型选择(文本/数字/布尔/枚举)
+ * 保存 = 调父组件 onSave(fields)(父组件整表 strategies/update)
+ */
+import React, { useState } from 'react';
+import { useToast } from './Toast.jsx';
+
+const TYPE_LABEL = { text: '文本', number: '数字', boolean: '布尔', enum: '枚举' };
+
+/** 样式常量 */
+const inputStyle = { padding: '4px 6px', border: '1px solid var(--dsw-alias-border-l2, #ccc)', borderRadius: 3, marginRight: 6, fontSize: 12, background: 'var(--dsw-alias-bg-layer-1, #fff)' };
+const btnStyle = { padding: '3px 10px', cursor: 'pointer', border: '1px solid var(--dsw-alias-border-l2, #ccc)', borderRadius: 3, background: 'var(--dsw-alias-bg-layer-1, #fff)', marginRight: 4, fontSize: 12 };
+const solidBtn = (bg) => ({ ...btnStyle, border: 'none', background: bg, color: 'var(--dsw-alias-button-contrast-fill, #fff)' });
+const dangerBtn = { ...btnStyle, borderColor: 'var(--dsw-alias-state-error-primary, #c62828)', color: 'var(--dsw-alias-state-error-primary, #c62828)' };
+
+/**
+ * 字段编辑器
+ * @param {object} props
+ * @param {object} props.strategy 策略(含 configSchema)
+ * @param {Function} props.onSave 保存回调 (fields) => Promise
+ * @param {Function} props.onClose 收起回调
+ * @param {boolean} props.saving 保存中
+ */
+export function StrategyFieldsEditor({ strategy, onSave, onClose, saving }) {
+ const toast = useToast();
+ const [fields, setFields] = useState(() => (Array.isArray(strategy?.configSchema) ? strategy.configSchema.map((x) => ({ ...x })) : []));
+ // 表单草稿:null=关闭;{ index:-1=新增 | 编辑下标, ... }
+ const [draft, setDraft] = useState(null);
+
+ const emptyDraft = () => ({ index: -1, key: '', label: '', type: 'text', unit: '', enumText: '', defText: '', defBool: false, defEnum: '' });
+
+ /** 由 label 生成 key(转小写 ascii slug;冲突去重) */
+ const makeKey = (label, usedKeys) => {
+ const base = String(label ?? '').trim().toLowerCase()
+ .replace(/\s+/g, '-')
+ .replace(/[^a-z0-9-]/g, '')
+ .replace(/-+/g, '-')
+ .replace(/^-|-$/g, '') || 'field';
+ let candidate = base;
+ let i = 2;
+ while (usedKeys.includes(candidate)) { candidate = base + '-' + i; i++; }
+ return candidate;
+ };
+
+ const startAdd = () => setDraft(emptyDraft());
+ const startEdit = (index) => {
+ const fld = fields[index];
+ setDraft({
+ index,
+ key: fld.key,
+ label: fld.label ?? '',
+ type: fld.type ?? 'text',
+ unit: fld.unit ?? '',
+ enumText: Array.isArray(fld.enum) ? fld.enum.join(',') : '',
+ defText: fld.type === 'boolean' ? '' : (fld.def != null ? String(fld.def) : ''),
+ defBool: fld.type === 'boolean' ? !!fld.def : false,
+ defEnum: fld.type === 'enum' ? (fld.def ?? '') : '',
+ });
+ };
+
+ const removeField = (index) => {
+ setFields(fields.filter((_, i) => i !== index));
+ if (draft?.index === index) setDraft(null);
+ };
+
+ const saveDraft = () => {
+ if (!draft) return;
+ const label = draft.label.trim();
+ if (!label) { toast.error('字段名称不能为空'); return; }
+ if (!['text', 'number', 'boolean', 'enum'].includes(draft.type)) { toast.error('字段类型无效'); return; }
+ let enumOptions = [];
+ if (draft.type === 'enum') {
+ enumOptions = draft.enumText.split(/[,,]/).map((s) => s.trim()).filter(Boolean);
+ if (enumOptions.length === 0) { toast.error('枚举类型需填写选项(逗号分隔)'); return; }
+ }
+ const usedKeys = fields.filter((_, i) => i !== draft.index).map((x) => x.key);
+ const key = draft.key.trim() || makeKey(label, usedKeys);
+ if (usedKeys.includes(key)) { toast.error('字段标识已存在: ' + key); return; }
+ let defVal;
+ if (draft.type === 'text') defVal = draft.defText;
+ else if (draft.type === 'number') defVal = draft.defText.trim() === '' ? null : Number(draft.defText);
+ else if (draft.type === 'boolean') defVal = draft.defBool;
+ else defVal = draft.defEnum || null;
+ const field = { key, label, type: draft.type, unit: String(draft.unit ?? '').trim() };
+ if (draft.type === 'enum') field.enum = enumOptions;
+ if (defVal !== null && defVal !== undefined && defVal !== '') field.def = defVal;
+ if (draft.index === -1) setFields([...fields, field]);
+ else setFields(fields.map((f2, i) => (i === draft.index ? field : f2)));
+ setDraft(null);
+ };
+
+ const saveAll = async () => {
+ if (draft) { toast.error('请先完成正在编辑的字段'); return; }
+ await onSave(fields);
+ };
+
+ return (
+
+
+ 自定义字段({fields.length})
+
+
+
+
+
+
+
+ {fields.length === 0 && !draft && (
+
尚未配置字段 —— 点击「+ 添加字段」为本策略添加自定义字段(该策略下每个持仓将按此定义填写值)。
+ )}
+
+ {fields.length > 0 && (
+
+
+
+ | 展示名 |
+ key |
+ 类型 |
+ 选项/默认 |
+ 操作 |
+
+
+
+ {fields.map((fld, i) => (
+
+ | {fld.label || fld.key} |
+ {fld.key} |
+ {TYPE_LABEL[fld.type] || fld.type} |
+
+ {fld.type === 'enum' ? (Array.isArray(fld.enum) ? fld.enum.join(' / ') : '')
+ : fld.type === 'boolean' ? (fld.def ? '默认开' : '默认关')
+ : ((fld.def != null ? '默认 ' + fld.def + (fld.unit ? ' ' + fld.unit : '') : '') + (fld.unit && fld.def == null ? '单位 ' + fld.unit : ''))}
+ |
+
+
+
+ |
+
+ ))}
+
+
+ )}
+
+ {draft && (
+
+
+ setDraft({ ...draft, label: e.target.value })} />
+ setDraft({ ...draft, key: e.target.value })} />
+
+ setDraft({ ...draft, unit: e.target.value })} />
+
+ {draft.type === 'enum' && (
+
+ setDraft({ ...draft, enumText: e.target.value })} />
+
+
+ )}
+ {draft.type === 'text' && (
+
+ setDraft({ ...draft, defText: e.target.value })} />
+
+ )}
+ {draft.type === 'number' && (
+
+ setDraft({ ...draft, defText: e.target.value })} />
+
+ )}
+ {draft.type === 'boolean' && (
+
+ )}
+
+
+
+
+
+ )}
+
+ );
+}
\ No newline at end of file
diff --git a/src/client/views/StrategyTab.jsx b/src/client/views/StrategyTab.jsx
index a9d2b81..3e269d4 100644
--- a/src/client/views/StrategyTab.jsx
+++ b/src/client/views/StrategyTab.jsx
@@ -21,6 +21,8 @@ import { LoadState } from './LoadState.jsx';
import { ToastProvider, useToast } from './Toast.jsx';
import { useMarket } from '../market/MarketDataProvider.jsx';
import { PriceCell } from './PriceCell.jsx';
+import { ColumnSettingsPopover } from './ColumnSettingsPopover.jsx'; // 迭代11:列显隐设置
+import { FieldCellEditor } from './FieldCellEditor.jsx'; // 迭代11:自定义字段单元格点击编辑
/** 价格格式化(2 位小数) */
function fmtPrice(v) {
@@ -121,12 +123,40 @@ const ADD_FORM_CSS = `
}
`;
+/** 可编辑自定义字段单元格样式(迭代11:明显的可点击记号:悬停高亮 + 铅笔图标) */
+const EDITABLE_CELL_CSS = `
+ .odl-cell-editable {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ cursor: pointer;
+ padding: 1px 5px;
+ border-radius: 4px;
+ border: 1px dashed transparent;
+ transition: background .12s ease, border-color .12s ease;
+ max-width: 100%;
+ }
+ .odl-cell-editable .odl-edit-ico {
+ font-size: 11px;
+ opacity: .55;
+ flex: none;
+ color: var(--dsw-alias-state-business-primary, #1565c0);
+ }
+ .odl-cell-editable:hover {
+ background: var(--dsw-alias-interactive-bg-hover, #eef4fb);
+ border-color: var(--dsw-alias-state-business-primary, #1565c0);
+ }
+ .odl-cell-editable:hover .odl-edit-ico {
+ opacity: 1;
+ }
+`;
+
/** 注入样式到文档(只注入一次) */
let styleInjected = false;
function ensureAddFormCss() {
if (styleInjected || typeof document === 'undefined') return;
const el = document.createElement('style');
- el.textContent = ADD_FORM_CSS;
+ el.textContent = ADD_FORM_CSS + '\n' + EDITABLE_CELL_CSS;
document.head.appendChild(el);
styleInjected = true;
}
@@ -147,6 +177,13 @@ function StrategyTabInner({ strategyId, strategyName }) {
// 涨幅排序(2026-09-02):sortKey='pctChange' 时按涨幅排序;null=默认顺序
const [sortKey, setSortKey] = useState(null); // 'pctChange' | null
const [sortDir, setSortDir] = useState('desc'); // 'asc' | 'desc'
+ // R-013:本策略自定义字段定义(configSchema;从 strategies 端点取)
+ const [strategySchema, setStrategySchema] = useState(null);
+ // 迭代11列显隐:列配置(归一化有序数组 [{key,label,kind,visible}])+ 列设置弹层开关
+ const [columns, setColumns] = useState(null);
+ const [columnOpen, setColumnOpen] = useState(false);
+ // 迭代11:正在单元格内编辑的自定义字段({code, key} | null)
+ const [editingCell, setEditingCell] = useState(null);
const call = useRpc();
const toast = useToast();
const { getPrice, registerCodes } = useMarket();
@@ -155,10 +192,19 @@ function StrategyTabInner({ strategyId, strategyName }) {
setLoading(true);
setError(null);
try {
- const [strategyRes, allRes] = await Promise.all([
+ const [strategyRes, allRes, strategiesRes, columnsRes] = await Promise.all([
call('one-divine-lot/strategy-positions', { args: { strategyId } }),
call('one-divine-lot/unallocated', {}),
+ call('one-divine-lot/strategies', {}),
+ call('one-divine-lot/strategy-columns', { args: { strategyId } }),
]);
+ // 迭代11列显隐:列配置(含不可见列,保序;渲染时过滤 visible)
+ if (columnsRes && columnsRes.ok) setColumns(columnsRes.value);
+ // R-013:取本策略 configSchema(字段定义,决定展开区渲染哪些字段)
+ if (strategiesRes && strategiesRes.ok) {
+ const st = (Array.isArray(strategiesRes.value) ? strategiesRes.value : []).find((x) => x.id === strategyId);
+ setStrategySchema(Array.isArray(st?.configSchema) ? st.configSchema : []);
+ }
if (strategyRes && strategyRes.ok) {
setPositions(strategyRes.value);
// 上报策略持仓 code 给行情 Provider(去重:不再由 Provider 自拉 positions)
@@ -279,6 +325,82 @@ function StrategyTabInner({ strategyId, strategyName }) {
return rows;
}, [positions, sortKey, sortDir, getPrice]);
+ // 迭代11列显隐:可见列(表头/行渲染依据;未加载配置时=null,回退旧固定列)
+ const visibleColumns = useMemo(() => {
+ if (!Array.isArray(columns)) return null;
+ return columns.filter((c) => c.visible !== false);
+ }, [columns]);
+
+ /** 渲染某数据列单元格内容(col: {key,label,kind})—— 返回 React 节点 */
+ const renderDataCell = (col, p) => {
+ if (col.kind === 'field') {
+ // 自定义字段列:p.values[key] ?? def;点击单元格进入内联编辑(老师选 A)
+ const fieldDef = (strategySchema ?? []).find((f2) => f2.key === col.key);
+ if (!fieldDef) return null; // configSchema 已删该字段(列配置滞后,读时已跟随,双保险)
+ const isEditing = editingCell && editingCell.code === p.code && editingCell.key === col.key;
+ if (isEditing) {
+ return (
+ { setEditingCell(null); load(); }}
+ />
+ );
+ }
+ // 展示态:可点击(进入编辑)
+ const has = p.values && Object.prototype.hasOwnProperty.call(p.values, col.key);
+ const v = has ? p.values[col.key] : fieldDef.def;
+ let shown;
+ if (fieldDef.type === 'boolean') shown = v ? '开' : '关';
+ else if (v === undefined || v === null || v === '') shown = '—';
+ else shown = String(v) + (fieldDef.unit ? fieldDef.unit : ''); // 值后拼单位(可空)
+ const clickable = p.holdingId != null; // 无 holding 锚点(未分配)不可编辑
+ if (!clickable) {
+ return {shown};
+ }
+ return (
+ { e.stopPropagation(); setEditingCell({ code: p.code, key: col.key }); }}
+ >
+ {shown}
+ ✎
+
+ );
+ }
+ // 基础数据列
+ switch (col.key) {
+ case 'lastPrice':
+ return ;
+ case 'pctChange':
+ return {fmtPct(p.pctChange)};
+ case 'lastClose':
+ return {fmtPrice(getPrice(p.code)?.lastClose)};
+ case 'avgPrice':
+ return {fmtPrice(p.avgPrice)};
+ case 'lastTradePrice':
+ return {fmtPrice(p.lastTradePrice)};
+ case 'shares':
+ return {p.shares};
+ default:
+ return null;
+ }
+ };
+
+ /** 表头列 label(排序箭头仅涨幅支持) */
+ const headerCell = (col) => {
+ if (col.key === 'pctChange') {
+ return (
+
+ {col.label}{sortKey === 'pctChange' ? (sortDir === 'asc' ? ' ↑' : ' ↓') : ''}
+ |
+ );
+ }
+ return {col.label} | ;
+ };
+
const pctArrow = sortKey === 'pctChange' ? (sortDir === 'desc' ? ' ▼' : ' ▲') : '';
return (
@@ -292,6 +414,22 @@ function StrategyTabInner({ strategyId, strategyName }) {
>
{showAdd ? '取消' : '+ 添加持仓'}
+
+
+ {columnOpen && columns && (
+ setColumnOpen(false)}
+ onSaved={load}
+ />
+ )}
+
@@ -339,12 +477,7 @@ function StrategyTabInner({ strategyId, strategyName }) {
|
代码 |
名称 |
- 现价 |
- 涨幅{pctArrow} |
- 昨收 |
- 成本价 |
- 最后一笔成交价 |
- 策略份额 |
+ {(visibleColumns ?? []).map((col) => headerCell(col))}
操作 |
@@ -393,12 +526,9 @@ function StrategyTabInner({ strategyId, strategyName }) {
{p.code} |
{p.name} |
-
- {fmtPct(p.pctChange)} |
- {fmtPrice(getPrice(p.code)?.lastClose)} |
- {fmtPrice(p.avgPrice)} |
- {fmtPrice(p.lastTradePrice)} |
- {p.shares} |
+ {(visibleColumns ?? []).map((col) => (
+ {renderDataCell(col, p)} |
+ ))}
e.stopPropagation()}>
{removeTarget && removeTarget.code === p.code ? (
@@ -438,7 +568,7 @@ function StrategyTabInner({ strategyId, strategyName }) {
{isExpanded && (
- |
+ |
{isLoading ? (
交易记录加载中...
) : !trades || trades.length === 0 ? (
diff --git a/src/index.js b/src/index.js
index 701e80f..e6df9fb 100644
--- a/src/index.js
+++ b/src/index.js
@@ -14,7 +14,7 @@
*/
import { QmtBridgeRestDataSource } from './data-source/QmtBridgeRestDataSource.js';
-import { registerSettings, resolveStartupConnection } from './settings.js';
+import { registerSettings, resolveStartupConnection, getStrategies } from './settings.js';
import { DataStore } from './storage/DataStore.js';
import { PositionManager } from './position/PositionManager.js';
import { registerApi } from './api/index.js';
@@ -70,7 +70,12 @@ async function apply(ctx, config) {
qmtHealthMonitor.start();
// S5: 分仓逻辑(份额分配/查询)
- const manager = new PositionManager({ dataSource, storage });
+ // R-013:注入策略自定义字段定义读取回调(updateHoldingValues 校验用;settings.getStrategies 已归一化 configSchema)
+ const manager = new PositionManager({
+ dataSource,
+ storage,
+ getStrategySchema: (strategyId) => getStrategies(settings).find((s) => s.id === strategyId)?.configSchema ?? [],
+ });
// R-009:交易记录本地同步(启动预热 + 60s 定时 UPSERT 落库)
const tradeSync = new TradeSync({ runtime: { dataSource, storage }, logger });
diff --git a/src/position/PositionManager.js b/src/position/PositionManager.js
index f332510..2371b7d 100644
--- a/src/position/PositionManager.js
+++ b/src/position/PositionManager.js
@@ -27,10 +27,15 @@ export class PositionManager {
* @param {object} opts
* @param {import('../data-source/data-source-types.js').DataSource} opts.dataSource 数据源(QMT REST)
* @param {DataStore} opts.storage 数据集存储(R-006 DataStore)
+ * @param {Function} [opts.getStrategySchema] 读策略自定义字段定义的回调 (strategyId) => configSchema[]
+ * (R-013:由 index.js 注入,内部经 settings.getStrategies 取;缺省返回 [])
*/
- constructor({ dataSource, storage }) {
+ constructor({ dataSource, storage, getStrategySchema }) {
this.dataSource = dataSource;
this.storage = storage;
+ this.getStrategySchema = typeof getStrategySchema === 'function'
+ ? getStrategySchema
+ : () => [];
}
/** 全量持仓(QMT 真实数据) */
@@ -77,7 +82,8 @@ export class PositionManager {
this.storage.getCurrentHoldings(strategyId), // 含 holding_id(同策略同 code 当前持仓唯一)
]);
const shareMap = new Map(dataset.map((d) => [d.code, d.shares]));
- const holdingMap = new Map(holdings.map((h) => [h.code, h.holdingId]));
+ const holdingMap = new Map(holdings.map((h) => [h.code, h.holdingId])); // code → holdingId
+ const valuesMap = new Map(holdings.map((h) => [h.code, h.values ?? null])); // R-013:code → 自定义字段值
// R-010 Q4:批量取各 holding 的关联委托(查最后一笔成交价)
const holdingIds = [...new Set(holdings.map((h) => h.holdingId).filter((x) => x != null))];
const tradesByHolding = new Map();
@@ -102,7 +108,7 @@ export class PositionManager {
// getOrdersByHolding 已按 insert_ts DESC,第一条即最新
lastTradePrice = +filled[0].tradedPrice;
}
- return { ...p, shares, holdingId, avgPrice, lastTradePrice };
+ return { ...p, shares, holdingId, avgPrice, lastTradePrice, values: valuesMap.get(p.code) ?? null }; // R-013
})
.filter(Boolean);
}
@@ -269,4 +275,69 @@ export class PositionManager {
}
return before.map((h) => h.code);
}
+
+ // ===== 持仓自定义字段值(R-013 迭代 11)=====
+
+ /**
+ * 校验字段值是否符合该策略 configSchema 定义(服务端校验,写回前调用)
+ * 规则(技术约束-015):
+ * - 空值/缺省允许(undefined/null/'' → 放行,UI 回退显示 def);
+ * - number → Number(v) 必须有限数(空串已在空值分支放行);
+ * - enum → 值必须 ∈ enum 选项;
+ * - boolean → typeof 必须为 boolean;
+ * - 未定义 key 的额外键放行(可扩展,Q1 老师确认)。
+ * @param {Array} schema configSchema(该策略定义)
+ * @param {object} values 待校验的键值对
+ * @returns {string[]} 错误信息数组(空 = 通过)
+ */
+ validateHoldingValues(schema, values) {
+ const errors = [];
+ const dict = schema && Array.isArray(schema) ? schema : [];
+ for (const f of dict) {
+ const v = values && typeof values === 'object' ? values[f.key] : undefined;
+ if (v === undefined || v === null || v === '') continue; // 空值放行
+ if (f.type === 'number') {
+ if (!Number.isFinite(Number(v))) errors.push((f.label || f.key) + ' 需为数字');
+ } else if (f.type === 'enum') {
+ const options = Array.isArray(f.enum) ? f.enum : [];
+ if (!options.includes(v)) errors.push((f.label || f.key) + ' 需在选项内: ' + options.join('/'));
+ } else if (f.type === 'boolean') {
+ if (typeof v !== 'boolean') errors.push((f.label || f.key) + ' 需为布尔');
+ }
+ // text:任意字符串放行
+ }
+ return errors;
+ }
+
+ /**
+ * 更新某持仓行的自定义字段值(R-013)
+ * 定位当前持仓(closed_at IS NULL)→ 按所属策略 configSchema 校验 → 整体写回该行 values。
+ * 额外键(未在 configSchema 定义)放行:values 保持用户提交的全量(可扩展)。
+ * @param {number|string} holdingId 持仓 ID
+ * @param {object} values 字段值键值对(key 对齐 configSchema;null/空对象 → 清除整行值)
+ * @returns {Promise<{holdingId: number, values: object|null}>}
+ */
+ async updateHoldingValues(holdingId, values) {
+ const hid = Number(holdingId);
+ const current = await this.storage.getCurrentHoldings();
+ const holding = current.find((h) => h.holdingId === hid);
+ if (!holding) {
+ throw Object.assign(new Error('持仓不存在或已清仓: ' + holdingId), { code: 'holding-not-found' });
+ }
+ const schema = this.getStrategySchema(holding.strategyId);
+ // null/空对象 → 清除该行值
+ if (values == null || (typeof values === 'object' && Object.keys(values).length === 0)) {
+ await this.storage.writeValues(hid, null);
+ return { holdingId: hid, values: null };
+ }
+ if (typeof values !== 'object' || Array.isArray(values)) {
+ throw Object.assign(new Error('字段值格式无效'), { code: 'field-validation' });
+ }
+ const errors = this.validateHoldingValues(schema, values);
+ if (errors.length > 0) {
+ throw Object.assign(new Error('字段校验失败: ' + errors.join(';')), { code: 'field-validation', errors });
+ }
+ await this.storage.writeValues(hid, values);
+ return { holdingId: hid, values };
+ }
}
\ No newline at end of file
diff --git a/src/settings.js b/src/settings.js
index e10f69f..8a0df9a 100644
--- a/src/settings.js
+++ b/src/settings.js
@@ -33,16 +33,46 @@ export const DEFAULT_TABS = BUILTIN_TABS.map((t, i) => ({
/** 默认策略(首次注册时的初始值) */
export const DEFAULT_STRATEGIES = [
- { id: 'grid-supermarket', name: '网格超市' },
- { id: 'manual-t', name: '手动做T' },
+ { id: 'grid-supermarket', name: '网格超市', configSchema: [] },
+ { id: 'manual-t', name: '手动做T', configSchema: [] },
+];
+
+/**
+ * 策略持仓表基础数据列目录(迭代 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 },
];
/** 策略设置 schema(schemastery 定义,settings.register 要求 schema 对象) */
+/** @type {import('@deepseek-ai/schemastery').Schema} 策略设置 schema(显式注解:含 z.dict,规避 dts 推断 cosmokit Dict 引用) */
export const strategySchema = z.object({
- // 策略定义表(R-011 收窄:仅 id + name,visible/order 归 tabs 统一管理)
+ // 策略定义表(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(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),
// 统一 tab 列表(R-011:唯一顺序与显隐来源;内置 + 策略混排)
// union 兼容存量:旧格式为布尔对象(迭代 02),新格式为有序数组;normalizeTabs 读取时归一化
@@ -73,6 +103,14 @@ export const strategySchema = z.object({
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({}),
});
@@ -195,21 +233,29 @@ export function registerSettings(ctx, config) {
}
/**
- * 读取策略定义表(R-011 收窄:仅 id + name,无 visible/order)
+ * 读取策略定义表(R-011 收窄 + R-013 扩展:{id, name, configSchema})
+ * 归一化:旧存量数据缺 configSchema(R-013 前)→ 补空数组(Q4 兼容)。
* @param {ReturnType} scope
*/
export function getStrategies(scope) {
const value = scope?.get() ?? {};
- return Array.isArray(value.strategies) ? value.strategies : DEFAULT_STRATEGIES;
+ const list = Array.isArray(value.strategies) ? value.strategies : DEFAULT_STRATEGIES;
+ return list.map((s) => ({ ...s, configSchema: Array.isArray(s.configSchema) ? s.configSchema : [] }));
}
/**
- * 更新策略(整表更新)
+ * 更新策略(整表更新;R-013:configSchema 随整表携带)
+ * 写入归一化:项缺 configSchema → 补 [](防客户端旧格式整表提交丢定义)。
* @param {ReturnType} scope
* @param {Array} strategies 完整策略列表
*/
export async function updateStrategies(scope, strategies) {
- await scope.update({ strategies });
+ const next = (Array.isArray(strategies) ? strategies : []).map((s) => ({
+ id: s.id,
+ name: s.name,
+ configSchema: Array.isArray(s.configSchema) ? s.configSchema : [],
+ }));
+ await scope.update({ strategies: next });
}
/**
@@ -224,6 +270,7 @@ export async function addStrategy(scope, name) {
const strategy = {
id,
name: String(name ?? '').trim(),
+ configSchema: [], // R-013:新策略默认无自定义字段(定义在设置页配置)
};
await updateStrategies(scope, [...list, strategy]);
// R-011:联动在 tabs 列表末尾追加策略 tab 条目(Q2 追加末尾)
@@ -369,6 +416,75 @@ export async function updateTabConfig(scope, tabs) {
await updateTabs(scope, next);
}
+/**
+ * ============================================================================
+ * 策略持仓表列配置(迭代 11 列显隐扩展;产品约束-011、技术约束-017 待落)
+ * 数据:settings.strategyColumns = { [strategyId]: [{key, visible}] }(仅存覆盖)
+ * 归一化:基础列(COLUMN_META 默认序全显)+ 该策略 configSchema 字段列(configSchema 序全显)
+ * → 应用 strategyColumns 覆盖(visible + 顺序)→ 返回有序列数组 [{key, label, fixed, visible}]
+ * 固定列不在此配置:展开箭头/代码/名称(最前)、操作(最后)。
+ */
+
+/**
+ * 归一化某策略的列配置(基础列 + 自定义字段列,应用覆盖)
+ * @param {ReturnType} scope
+ * @param {string} strategyId
+ * @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 raw = (value.strategyColumns && value.strategyColumns[strategyId]) || [];
+
+ // 1. 默认全列(基础列全显默认序 + 字段列全显 configSchema 序)
+ const baseCols = COLUMN_META.map((c) => ({ key: c.key, label: c.label, fixed: false, kind: 'base' }));
+ const fieldCols = configSchema.map((f) => ({ key: f.key, label: f.label || f.key, fixed: false, kind: 'field' }));
+ 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)) {
+ ordered.push({ ...d, visible: visibleMap.has(d.key) ? visibleMap.get(d.key) : true });
+ }
+ }
+ return ordered;
+}
+
+/** 读取某策略列配置(归一化结果) */
+export function getStrategyColumns(scope, strategyId) {
+ return normalizeStrategyColumns(scope, strategyId);
+}
+
+/**
+ * 更新某策略列配置(整表覆盖该策略的 columns)
+ * @param {ReturnType} scope
+ * @param {string} strategyId
+ * @param {Array} columns 完整列数组 [{key, visible}](含不可见列——保留位置,visible 控制显示)
+ */
+export async function updateStrategyColumns(scope, strategyId, columns) {
+ const current = scope?.get() ?? {};
+ 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)
@@ -517,4 +633,4 @@ export async function setDefaultQmtConnection(scope, id) {
const value = scope?.get() ?? {};
await scope.update({ ...value, qmtConnections: { ...state, defaultId: id } });
return { ...state, defaultId: id };
-}
+}
\ No newline at end of file
diff --git a/src/storage/DataStore.js b/src/storage/DataStore.js
index 05f7a05..aff31d2 100644
--- a/src/storage/DataStore.js
+++ b/src/storage/DataStore.js
@@ -87,6 +87,16 @@ export class DataStore {
return this.sqlite.getHoldingHistory(code);
}
+ /** 读取持仓自定义字段值(R-013:委托 SqliteStore) */
+ readValues(holdingId) {
+ return this.sqlite.readValues(holdingId);
+ }
+
+ /** 写入持仓自定义字段值(R-013:整体替换该行 values JSON) */
+ writeValues(holdingId, values) {
+ return this.sqlite.writeValues(holdingId, values);
+ }
+
// ===== 兼容 API(旧调用;PositionManager 迁移后不再使用 setDataset/removeDataset)=====
/** 读取某策略数据集([{code, shares}],兼容旧调用) */
diff --git a/src/storage/SqliteStore.js b/src/storage/SqliteStore.js
index f2e7109..d627353 100644
--- a/src/storage/SqliteStore.js
+++ b/src/storage/SqliteStore.js
@@ -98,6 +98,17 @@ CREATE INDEX IF NOT EXISTS idx_trade_fills_order ON trade_fills (order_id);
CREATE INDEX IF NOT EXISTS idx_trade_fills_date ON trade_fills (trade_date);
`;
+/** 解析 strategy_holdings."values" JSON 列(容错:空/脏返回 null;模块级,供 .map(this._mapHolding) 裸调用) */
+function parseValues(raw) {
+ if (raw == null) return null;
+ try {
+ const v = JSON.parse(raw);
+ return v && typeof v === 'object' ? v : null;
+ } catch {
+ return null;
+ }
+}
+
export class SqliteStore {
/**
* @param {object} [options]
@@ -124,6 +135,8 @@ export class SqliteStore {
this.db.exec(SCHEMA_SQL);
// 存量库迁移:trade_orders 补手动归属列(R-009 二次定稿;幂等)
this._ensureTradeAttributionColumns();
+ // 存量库迁移:strategy_holdings 补自定义字段值列(R-013 迭代 11;幂等)
+ this._ensureHoldingValuesColumn();
return this.db;
}
@@ -151,6 +164,57 @@ export class SqliteStore {
}
}
+ /**
+ * 确保 strategy_holdings 存在 values 列(旧库 ALTER 补列,幂等;R-013 迭代 11)。
+ * values = 该持仓行按所属策略 configSchema 存的自定义字段值(JSON 键值对,可含扩展键)。
+ * 只读连接(外部脚本持锁时)抛 readonly → 容忍,真实迁移由插件进程执行。
+ */
+ _ensureHoldingValuesColumn() {
+ try {
+ const cols = new Set(this.db.prepare('PRAGMA table_info(strategy_holdings)').all().map((c) => c.name));
+ if (!cols.has('values')) {
+ this.db.exec('ALTER TABLE strategy_holdings ADD COLUMN "values" TEXT');
+ }
+ } catch (err) {
+ if (String(err?.message ?? '').includes('readonly')) {
+ console.warn('[one-divine-lot] strategy_holdings values 列迁移跳过(只读连接,插件持锁中)');
+ } else {
+ throw err;
+ }
+ }
+ }
+
+ // ===== 持仓自定义字段值(R-013 迭代 11)=====
+
+ /**
+ * 读取某持仓行的自定义字段值(JSON 键值对)
+ * @param {number} holdingId 持仓 ID
+ * @returns {object|null} values 对象(未配置为 null)
+ */
+ readValues(holdingId) {
+ this.init();
+ const row = this.db.prepare('SELECT "values" FROM strategy_holdings WHERE holding_id=?').get(holdingId);
+ if (!row || row.values == null) return null;
+ try {
+ return JSON.parse(row.values);
+ } catch {
+ return null; // 脏数据容忍:解析失败视为无值
+ }
+ }
+
+ /**
+ * 写入某持仓行的自定义字段值(整体替换该行 values JSON;可含扩展键)
+ * @param {number} holdingId 持仓 ID
+ * @param {object} values 字段值键值对(key 对齐 configSchema)
+ * @returns {boolean} 是否更新成功
+ */
+ writeValues(holdingId, values) {
+ this.init();
+ const payload = values && typeof values === 'object' ? JSON.stringify(values) : null;
+ const rres = this.db.prepare('UPDATE strategy_holdings SET "values"=? WHERE holding_id=?').run(payload, holdingId);
+ return rres.changes > 0;
+ }
+
/** 关闭连接 */
close() {
if (this.db) {
@@ -285,7 +349,7 @@ export class SqliteStore {
const r = this.db.prepare(
'INSERT INTO strategy_holdings (strategy_id, code, shares, created_at, closed_at) VALUES (?,?,?,?,NULL)'
).run(strategyId, code, Number(shares), now);
- return { holdingId: Number(r.lastInsertRowid), strategyId, code, shares: Number(shares), createdAt: now, closedAt: null };
+ return { holdingId: Number(r.lastInsertRowid), strategyId, code, shares: Number(shares), createdAt: now, closedAt: null, values: null }; // R-013:新持仓无自定义字段值
}
/** 加仓:当前持仓份额累加 */
@@ -324,7 +388,7 @@ export class SqliteStore {
_getActive(strategyId, code) {
this.init();
const row = this.db.prepare(
- 'SELECT holding_id, strategy_id, code, shares, created_at, closed_at FROM strategy_holdings WHERE strategy_id=? AND code=? AND closed_at IS NULL'
+ 'SELECT holding_id, strategy_id, code, shares, created_at, closed_at, "values" FROM strategy_holdings WHERE strategy_id=? AND code=? AND closed_at IS NULL'
).get(strategyId, code);
return row ? this._mapHolding(row) : null;
}
@@ -334,11 +398,11 @@ export class SqliteStore {
this.init();
if (strategyId) {
return this.db.prepare(
- 'SELECT holding_id, strategy_id, code, shares, created_at, closed_at FROM strategy_holdings WHERE strategy_id=? AND closed_at IS NULL ORDER BY holding_id'
+ 'SELECT holding_id, strategy_id, code, shares, created_at, closed_at, "values" FROM strategy_holdings WHERE strategy_id=? AND closed_at IS NULL ORDER BY holding_id'
).all(strategyId).map(this._mapHolding);
}
return this.db.prepare(
- 'SELECT holding_id, strategy_id, code, shares, created_at, closed_at FROM strategy_holdings WHERE closed_at IS NULL ORDER BY strategy_id, holding_id'
+ 'SELECT holding_id, strategy_id, code, shares, created_at, closed_at, "values" FROM strategy_holdings WHERE closed_at IS NULL ORDER BY strategy_id, holding_id'
).all().map(this._mapHolding);
}
@@ -347,11 +411,11 @@ export class SqliteStore {
this.init();
if (code) {
return this.db.prepare(
- 'SELECT holding_id, strategy_id, code, shares, created_at, closed_at FROM strategy_holdings WHERE code=? ORDER BY holding_id'
+ 'SELECT holding_id, strategy_id, code, shares, created_at, closed_at, "values" FROM strategy_holdings WHERE code=? ORDER BY holding_id'
).all(code).map(this._mapHolding);
}
return this.db.prepare(
- 'SELECT holding_id, strategy_id, code, shares, created_at, closed_at FROM strategy_holdings ORDER BY holding_id'
+ 'SELECT holding_id, strategy_id, code, shares, created_at, closed_at, "values" FROM strategy_holdings ORDER BY holding_id'
).all().map(this._mapHolding);
}
@@ -363,9 +427,12 @@ export class SqliteStore {
shares: row.shares,
createdAt: row.created_at,
closedAt: row.closed_at,
+ values: parseValues(row.values), // R-013:自定义字段值(未配置 null;模块级函数,.map 裸传不依赖 this)
};
}
+
+
// ===== 行情读写(market_quotes_cache)=====
/** 单码行情 */
@@ -552,7 +619,7 @@ export class SqliteStore {
if (codes.length === 0) return out;
const placeholders = codes.map(() => '?').join(',');
const rows = this.db.prepare(
- 'SELECT holding_id, strategy_id, code, shares, created_at, closed_at FROM strategy_holdings WHERE code IN (' + placeholders + ')'
+ 'SELECT holding_id, strategy_id, code, shares, created_at, closed_at, "values" FROM strategy_holdings WHERE code IN (' + placeholders + ')'
).all(...codes);
const holdingsByCode = new Map();
for (const h of rows) {
| |