docs(迭代06): 数据存储SQLite迭代文档 + 需求归档(R-008)

- 新增 02-计划/计划-数据存储SQLite.md(PLAN-007)
- 新增 04-迭代记录/06-数据存储SQLite/ 五份文档(迭代目标/技术实现方案/验收标准/UI交互调用分析/迭代复盘)
- 归档 R-008 至 已完成/(含归档头 + 讨论记录索引),索引更新为已实现(已归档)
- 设计约束更新:技术方案约束-012 标注 SQLite 变更、数据存储设计.md 第 9 节变更预告
- 需求池说明.md 新增「归档与转正规范」;迭代记录说明.md 新增「实现经验沉淀」
- 验收标准修正 db 文件名笔误(one-divine-lot.db → store.db)
This commit is contained in:
2026-09-01 18:04:28 +08:00
parent 9b719f13ba
commit dd98b4d45b
12 changed files with 782 additions and 3 deletions
@@ -0,0 +1,247 @@
# UI 交互调用分析:06-数据存储SQLite
> 分析日期:2026-09-01 | 依据:技术实现方案.md(接口定义)
> 目的:基于 SQLite 存储层接口(strategy_holdings / market_quotes_cache + 持仓生命周期方法集),梳理**前端各操作 → 服务端 → 存储层**的完整调用链,确认对外 API 不变的前提下,存储层升级对调用流程的影响。
## 调用链路总览(升级后不变的部分)
```
前端组件(React
│ fetch POST /odl/api/<method> body={ args }
connection.jsx useRpc() ── 标准化剥离 one-divine-lot/ 前缀
服务端 src/api/*.js 领域分发(positions / strategies / qmt-connections / market / trades
PositionManager / MarketDataHub / Settings(业务逻辑)
DataStore(改造)── 委托 ──▶ SqliteStorenode:sqlite,新增)
│ ├─ strategy_holdings 表(持仓生命周期)
│ └─ market_quotes_cache 表(行情快照)
```
**关键结论**:前端组件与 /odl/api/* 端点**零改动**;改动集中在服务端业务层(PositionManager 适配单票生命周期)+ 存储层(DataStore→SqliteStore)。以下逐操作分析。
---
## 操作 1:策略持仓 tab 加载(读)
**触发**:进入策略 tab → `StrategyTab.load()`
**前端调用**`StrategyTab.jsx`):
```js
Promise.all([
call('one-divine-lot/strategy-positions', { args: { strategyId } }), // 策略持仓
call('one-divine-lot/unallocated', {}), // 未分配(添加下拉候选)
]);
```
**服务端**`api/strategies.js`)→ `PositionManager.getStrategyPositions(strategyId)`
- 升级前:`getAllPositions()`QMT REST+ `storage.getDataset(strategyId)`(整策略读 store.json
- 升级后:`getAllPositions()` + `SqliteStore.getCurrentHoldings(strategyId)``SELECT ... WHERE strategy_id=? AND closed_at IS NULL`
**存储层**
```sql
SELECT code, shares, created_at FROM strategy_holdings
WHERE strategy_id = ? AND closed_at IS NULL
```
**返回**`[{ code, name, volume, shares, ... }]`(对外结构不变,上层 API 无感知)
---
## 操作 2:添加持仓(建仓/加仓)
**触发**:策略 tab 添加表单 → 选标的 + 填份额 → 确认添加 → `handleAdd()`
**前端调用**`StrategyTab.jsx`):
```js
call('one-divine-lot/add-shares', { args: { code, strategyId, shares: Number(addShares) } });
```
**服务端**`PositionManager.addToStrategy(code, strategyId, shares)`
- 升级前:读 code 份额 map → 校验超限 → `_setShares(code, next)`**整策略 dataset 重写**
- 升级后:校验逻辑不变(total / 已分配 + 新增 ≤ 总持仓),写入改为**单票生命周期**:
- 该策略该 code **无当前持仓**`SqliteStore.openHolding(strategyId, code, shares)`INSERTcreated_at=nowclosed_at=NULL
- **已有当前持仓** → `SqliteStore.addShares(strategyId, code, shares)`UPDATE shares += ? WHERE closed_at IS NULL
**存储层**
```sql
-- openHolding(建仓)
INSERT INTO strategy_holdings (strategy_id, code, shares, created_at, closed_at)
VALUES (?, ?, ?, ?, NULL);
-- addShares(加仓)
UPDATE strategy_holdings SET shares = shares + ?
WHERE strategy_id = ? AND code = ? AND closed_at IS NULL;
```
**返回**`getShares(code)``{ strategyId: shares }`,结构不变)→ 前端 `toast.success` + `load()` 刷新
---
## 操作 3:移出份额(减仓/清仓)
**触发**:策略 tab 行内「移出」→ 填数量 → 确认 → `handleRemove()`
**前端调用**`StrategyTab.jsx`):
```js
call('one-divine-lot/remove-shares', { args: { code, strategyId, shares: Number(removeTarget.shares) } });
```
**服务端**`PositionManager.removeFromStrategy(code, strategyId, shares)`
- 升级前:读份额 → 校验不超移 → `_setShares`(整策略重写;减到 0 则删除该 code 条目)
- 升级后:校验不变,写入分两种:
- 减后 **> 0** → `SqliteStore.reduceShares(strategyId, code, shares)`UPDATE shares -= ? WHERE closed_at IS NULL
- 减到 **= 0** → `SqliteStore.closeHolding(strategyId, code)`UPDATE closed_at=now, shares=0**转历史,不物理删除**)
**存储层**
```sql
-- reduceShares(减仓,仍持仓)
UPDATE strategy_holdings SET shares = shares - ?
WHERE strategy_id = ? AND code = ? AND closed_at IS NULL;
-- closeHolding(清仓,转历史)
UPDATE strategy_holdings SET shares = 0, closed_at = ?
WHERE strategy_id = ? AND code = ? AND closed_at IS NULL;
```
**行为变化**:清仓数据**保留为历史**closed_at 非 NULL),不再物理删除 —— 供未来交易记录/复盘关联(R-007)。
---
## 操作 4:全部移入(单票级)
**触发**:策略 tab 行内「全部移入」→ `handleMoveAllIn()`
**前端调用**`StrategyTab.jsx`):
```js
call('one-divine-lot/move-all-shares', { args: { code, strategyId } });
```
**服务端**`PositionManager.moveAllUnallocatedToStrategy(code, strategyId)`
- 升级前:计算未分配份额 → `_setShares`(整策略重写,`current[strategyId] + unallocated`
- 升级后:等价于「把未分配份额并入该策略当前持仓」→ 走生命周期:
- 无当前持仓 → `openHolding(strategyId, code, unallocated)`
- 有当前持仓 → `addShares(strategyId, code, unallocated)`
**存储层**:同操作 2 的 openHolding / addSharesshares = 原份额 + 未分配份额)
**返回**`getShares(code)``toast.success` + `load()`
---
## 操作 5:一键清零(策略级)
**触发**:策略 tab 顶部「一键清零」→ 弹窗确认 → `handleClearAll()`
**前端调用**`StrategyTab.jsx`):
```js
call('one-divine-lot/remove-all-shares', { args: { strategyId } });
```
**服务端**`PositionManager.clearStrategyShares(strategyId)`
- 升级前:`storage.getDataset(strategyId)`(读整策略)→ `storage.removeDataset(strategyId)`**物理删除整策略 dataset**
- 升级后:**批量 closeHolding** —— 该策略所有当前持仓转历史(不物理删除):
-`getCurrentHoldings(strategyId)` 得到受影响 code 列表
- 逐笔 `closeHolding(strategyId, code)`
- 返回受影响 code 列表(对外结构不变)
**存储层**
```sql
UPDATE strategy_holdings SET shares = 0, closed_at = ?
WHERE strategy_id = ? AND closed_at IS NULL;
```
**返回**`string[]`(受影响标的 code 列表)→ 前端提示「已清空策略份额(N 只标的回到未分配)」+ `load()`
---
## 操作 6:删除策略(设置页联动清份额)
**触发**:设置页「策略分组」→ 删除策略 → 弹窗确认 → `handleDelete()`
**前端调用**`SettingsSection.jsx`):
```js
call('one-divine-lot/strategies/remove', { args: { strategyId: id } });
```
**服务端**`api/strategies.js`):
```js
await removeStrategy(settings, args.strategyId); // settings 删除策略定义
await manager.clearStrategyShares(args.strategyId); // 联动清份额
return getStrategies(settings);
```
**升级后**`clearStrategyShares` 同样走**批量 closeHolding** —— 该策略持仓全部转历史(closed_at=now),**历史保留**(供交易记录关联),settings 中策略定义删除(策略 id 从配置消失,但 SQLite 中历史行仍带 strategy_id,可追溯)。
**存储层**:同操作 5 的批量 closeHolding。
---
## 操作 7:行情现价展示(读缓存)
**触发**:任意持仓表格渲染现价列 → `useMarket().getPrice(code)`
**前端调用**`MarketDataProvider` 轮询):
```js
// 表格上报 code → Provider 调行情接口(去重,不再自拉 positions)
registerCodes(codes);
// 周期轮询
call('one-divine-lot/market-snapshot', { args: { codes } });
```
**服务端**`api/market.js`)→ `MarketDataHub.getByCodes()`
- 内存缓存 → 磁盘缓存 → REST 补拉(三级命中)
- 升级后:磁盘缓存由 `store.market.json` 改为 `SqliteStore.getMarketQuotes(codes)`(读 market_quotes_cache 表)
**存储层**
```sql
SELECT code, last_price, last_close FROM market_quotes_cache WHERE code IN (...);
```
**返回**`{ code: { lastPrice, lastClose } }` → PriceCell 渲染现价 + 红涨绿跌着色
---
## 操作 8:行情写回缓存(服务端后台)
**触发**MarketDataHub.ingest() 防抖 3s 写回(非用户直接操作)
**前端**:无直接调用(服务端后台行为)
**服务端**`MarketDataHub` → 升级后 `SqliteStore.setMarketQuotes(quotes)`
```sql
INSERT INTO market_quotes_cache (code, last_price, last_close, updated_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(code) DO UPDATE SET
last_price = excluded.last_price,
last_close = excluded.last_close,
updated_at = excluded.updated_at;
```
**说明**:只落盘 lastPrice/lastClose 两列(UI 消费的现价 + 涨跌基准);盘口五档等仅内存缓存,不落盘(方案明确)。
---
## 存储层接口映射总表
| 前端操作 | /odl/api 端点 | PositionManager 方法 | SqliteStore 方法(升级) | 原 DataStore 方法(废弃) |
|---|---|---|---|---|
| 策略 tab 加载 | strategy-positions | getStrategyPositions | **getCurrentHoldings** | getDataset |
| 添加(无持仓) | add-shares | addToStrategy | **openHolding** | setDataset(整策略重写) |
| 添加(有持仓) | add-shares | addToStrategy | **addShares** | setDataset |
| 移出(>0 | remove-shares | removeFromStrategy | **reduceShares** | setDataset |
| 移出(=0 | remove-shares | removeFromStrategy | **closeHolding** | setDataset(删条目) |
| 全部移入 | move-all-shares | moveAllUnallocatedToStrategy | openHolding / addShares | setDataset |
| 一键清零 | remove-all-shares | clearStrategyShares | **批量 closeHolding** | removeDataset(物理删) |
| 删除策略 | strategies/remove | removeStrategy + clearStrategyShares | 批量 closeHolding | removeDataset |
| 行情读取 | market-snapshot | MarketDataHub.getByCodes | **getMarketQuotes** | store.market.json 读 |
| 行情写回 | (后台) | MarketDataHub.ingest | **setMarketQuotes** | store.market.json 写 |
## 对前端的结论
1. **前端零改动**:所有操作仍走 /odl/api/* 端点,`useRpc` 封装、请求/响应结构不变;
2. **行为增强(前端无感)**:清仓/一键清零/删除策略后数据**转历史保留**(closed_at),而非物理删除 —— 为 R-007 交易记录关联与复盘铺路;
3. **行情落盘降维**:只落 lastPrice/lastClose 两列(响应无需关心,服务端投影);
4. **唯一可见差异**:历史持仓数据存在(未来若做「持仓历史」UI,getHoldingHistory 可直接支撑)。
@@ -0,0 +1,132 @@
# 技术实现方案:06-数据存储SQLiteJSON → SQLite
> 依据:PLAN-007 | 需求:R-008 | 设计约束:技术约束-012(变更)、数据存储设计.md(第 9 节变更落实)、技术约束-011(测试隔离)
## 技术选型
- **存储引擎**node:sqliteNode ≥22.5 内置,DatabaseSync 同步 API)——零依赖分发(技术约束-005 不受影响),experimental 风险由存储层封装隔离(D2);
- **封装**:新增 SqliteStore 模块(init/事务/读写/close),对外暴露与 DataStore 兼容的行为,未来可切换;
- **迁移**:一次性迁移脚本(scripts/migrate-json-to-sqlite.mjs+ DataStore 启动检测自动迁移(旧 JSON 存在且 SQLite 空 → 自动迁移,幂等);
- **表结构**strategy_holdings + market_quotes_cache 两表(R-008 讨论修正:① 策略持仓为**持仓生命周期表**(自增 holding_id + created_at/closed_at),不用 JSON 列压扁;② 行情快照只存 last_price/last_close 两个具体列,表名 market_quotes_cache 明确缓存语义;不建 trades,R-007 时再建)。
## 表结构设计(落实数据存储设计.md 第 9 节)
```sql
-- 策略持仓生命周期(一笔 = 一次「建仓→清仓」的完整持仓;历史保留,供交易记录关联)
CREATE TABLE IF NOT EXISTS strategy_holdings (
holding_id INTEGER PRIMARY KEY AUTOINCREMENT, -- 自增持仓编号(交易记录关联锚点;实测删除不复用)
strategy_id TEXT NOT NULL, -- 对应 settings 中策略的 id(如 grid-supermarket,非 name
code TEXT NOT NULL, -- 证券代码(含后缀,如 600719.SH)
shares REAL NOT NULL, -- 当前份额(股数,>0
created_at INTEGER NOT NULL, -- 建仓时间(毫秒时间戳)
closed_at INTEGER, -- 清仓时间(NULL=当前持仓;非 NULL=已清仓历史)
PRIMARY KEY (holding_id)
);
-- 业务约束:同一策略同一股票只允许一笔「当前持仓」(closed_at IS NULL 唯一)
CREATE UNIQUE INDEX IF NOT EXISTS idx_active_holding
ON strategy_holdings (strategy_id, code) WHERE closed_at IS NULL;
-- 行情快照缓存(code 维度一行一码;只存 UI 消费的现价 + 昨收,无 JSON 列;cache 语义=重启首屏有价)
CREATE TABLE IF NOT EXISTS market_quotes_cache (
code TEXT PRIMARY KEY, -- 证券代码(含后缀,如 600719.SH)
last_price REAL NOT NULL, -- 最新价(UI 现价)
last_close REAL NOT NULL, -- 昨收(UI 涨跌着色对比基准)
updated_at INTEGER NOT NULL DEFAULT 0 -- 写入时间戳(毫秒)
);
```
**数据示例**strategy_holdings,真实数据迁移后):
| holding_id | strategy_id | code | shares | created_at | closed_at |
|---|---|---|---|---|---|
| 1 | grid-supermarket | 601117.SH | 600 | 迁移时间戳 | NULL |
| 2 | grid-supermarket | 002129.SZ | 200 | 迁移时间戳 | NULL |
| 3 | grid-supermarket | 300057.SZ | 1000 | 迁移时间戳 | NULL |
| ... | ... | ... | ... | ... | ... |
| 8 | manual-t | 600719.SH | 1000 | 迁移时间戳 | NULL |
| 9 | manual-t | 300426.SZ | 800 | 迁移时间戳 | NULL |
| 10 | manual-t | 600719.SH | 0 | 迁移时间戳 | 1788xxx(清仓后) |
**schema 语义对齐**store.schema.json → SQLite):
- holding_id 自增(SQLite AUTOINCREMENT 实测删除不复用),稳定唯一,作为未来交易记录(trades)的关联外键;
- strategy_holdings.strategy_id = settings 策略的 id(英文 slug,稳定唯一标识;改名只改 name 不影响数据);
- created_at=建仓时间;closed_at=清仓时间(NULL=当前持仓);部分唯一索引保证同策略同股票仅一笔当前持仓;
- market_quotes_cache 对齐 store.market.json 的 quotes 对象,但只抽取 lastPrice/lastClose 两个具体列入库(无 JSON 列);盘口五档等实时字段仅存内存缓存,不落盘。
## 架构设计
```
SqliteStore(新增,node:sqlite 封装)
├─ init() : 建库建表(CREATE TABLE IF NOT EXISTS
├─ migrateJson() : 旧 JSON → SQLite(迁移前备份,幂等)
├─ 持仓生命周期 : openHolding/addShares/reduceShares/closeHolding
├─ 持仓查询 : getCurrentHoldings/getHoldingHistory
├─ 行情读写 : getMarketQuote/getMarketQuotes/setMarketQuotes
└─ close() : 关闭连接(插件释放时)
DataStore(改造,对外 API 升级为持仓生命周期语义)
└─ 内部委托 SqliteStorePositionManager 按单票操作,不再整策略重写)
MarketDataHub(改造,持久化路径不变)
└─ setMarketQuotes → SqliteStore.setMarketQuotes(防抖写回)
```
**存储层方法集(替代原 getDataset/setDataset/removeDataset/getAllDatasets 整体读写)**
| 方法 | 是什么 | 什么时候用 | SQL 要点 |
|---|---|---|---|
| openHolding(strategyId, code, shares) | 建仓:创建一笔新持仓 | 某策略首次分配某股票(原「从 0 添加」) | INSERTholding_id 自增,created_at=nowclosed_at=NULL |
| addShares(strategyId, code, shares) | 加仓:当前持仓份额累加 | 原 addToStrategy 的「该策略已有该票」分支 | UPDATE shares=shares+? WHERE closed_at IS NULL |
| reduceShares(strategyId, code, shares) | 减仓:当前持仓份额减少 | 原 removeFromStrategy | UPDATE shares=shares-? WHERE closed_at IS NULL |
| closeHolding(strategyId, code) | 清仓:份额归零,closed_at=now 转历史 | 原 removeFromStrategy 减到 0 / 份额清零 / 删除策略联动 | UPDATE closed_at=now WHERE closed_at IS NULLshares 置 0 |
| getCurrentHoldings(strategyId?) | 查当前持仓(含 code/shares/created_at | 策略 tab 渲染 / 汇总 / 单票查询 | SELECT ... WHERE closed_at IS NULL(可加 strategy_id 过滤) |
| getHoldingHistory(code?) | 查历史(含 closed_at,当前+历史) | 未来交易记录关联 / 复盘 | SELECT ...(可加 code 过滤) |
**不再保留**removeDataset(物理删除整策略)、setDataset(整策略整体重写)——删除策略/一键清零改为 closeHolding 批量软关闭(历史保留,可关联交易记录)。
**PositionManager 适配**
- _setShares(内部整策略重写)改为按单票调 openHolding/addShares/reduceShares/closeHolding
- addToStrategy:无当前持仓 → openHolding;有 → addShares
- removeFromStrategy:减后>0 → reduceShares;减到0 → closeHolding
- clearStrategyShares / strategies/remove:批量 closeHolding(转历史,不物理删除);
- getDataset/getAllDatasets 调用点改 getCurrentHoldings(对外返回 [{code, shares}] 结构不变,上层 API 无感知)。
## 迁移策略(D6
1. **启动检测**DataStore.load()/loadMarket() 时检测 —— 旧 JSON 文件存在 且 SQLite 空(strategy_holdings / market_quotes_cache 无数据)→ 触发自动迁移;
2. **迁移动作**
- store.json → strategy_holdings 表(strategies[].dataset 平铺为行:strategy_id + code + sharescreated_at=迁移时间戳,closed_at=NULL);
- store.market.json → market_quotes_cache 表(抽取每条快照的 lastPrice/lastClose → last_price/last_close 两列);
- 旧 allocations.json(若仍存在,R-006 遗留迁移源,code 为中心)→ strategy_holdings 表(聚合转换,created_at=迁移时间戳);
- 迁移前将 JSON 文件备份为 *.json.bakD7);
3. **幂等**:SQLite 有数据即跳过迁移;迁移失败不破坏原 JSON(先读后写,写库失败不删原文件);
4. **一次性脚本**scripts/migrate-json-to-sqlite.mjs(独立执行,供手动/CI 使用,逻辑与启动自动迁移共用)。
## 涉及设计约束
| 约束 | 内容 |
|---|---|
| 技术约束-012 | 数据存储遵循数据存储设计.md:SQLite 升级后更新为遵循 SQLite 存储设计(本迭代执行变更) |
| 技术约束-011 | 测试/回归脚本禁止在真实数据上执行写操作:ODL_TEST_DATA_DIR 独立数据目录 |
| 技术约束-005 | 客户端 bundle 不受影响(node:sqlite 为 Node 内置,服务端使用) |
## 变更记录(实现期间)
- **2026-09-01 移除「一键清零」按钮**(老师决定):
- 前端:策略 tab 顶部「一键清零」按钮 + 确认弹窗 + handleClearAll 移除(StrategyTab.jsx);
- 后端:`remove-all-shares` 端点移除(api/strategies.js、api/index.js 注释);
- 保留:`clearStrategyShares` 方法(`strategies/remove` 删除策略仍联动清份额);
- 理由:该操作粒度尴尬(误触风险高、单票移出+删除策略已覆盖需求),实际使用价值低;
- 影响:前端 UI 精简;API 面收缩(旧客户端若调用 remove-all-shares 将 404,本次同版本更新无兼容问题)。
## 实现步骤
1. 文档骨架:PLAN-007 + 迭代 06(本步);
2. 约束变更:技术约束-012 更新 + 数据存储设计.md 第 9 节落实;
3. SqliteStorenode:sqlite 封装(init/迁移/持仓生命周期/行情读写/close);
4. DataStore 改造:持仓读写切换 SqliteStoreopenHolding/addShares/reduceShares/closeHolding/getCurrentHoldings/getHoldingHistory),保留迁移检测;
5. PositionManager 适配:整策略重写 → 单票生命周期操作;
6. 迁移脚本:一次性迁移脚本 + 启动自动迁移(幂等);
7. MarketDataHub 适配:行情持久化切至 market_quotes_cache 表(落盘只投影 last_price/last_close 两列);
8. 构建测试:pnpm run build + typecheck + 独立数据目录回归测试(技术约束-011);
9. 验收 + 复盘。
@@ -0,0 +1,42 @@
# 迭代复盘:06-数据存储SQLiteJSON → SQLite
> 复盘日期:2026-09-01 | 迭代状态:**已完成(验收通过)**
> 关联需求:R-008(数据存储管理 JSON → SQLite,已定稿)
> 关联计划:PLAN-007(数据存储 SQLite 化)
## 结果
迭代 06 达成:数据存储从 JSON data storestore.json / store.market.json / store.schema.json)升级为 SQLitestore.dbnode:sqlite),仅存储引擎替换、对外行为不变。真实数据迁移完成(15 条持仓 + 13 条行情),JSON 清理废弃,份额/行情/设置功能全部不回归。
## 过程事实
1. **技术选型验证**node:sqliteNode 22.23.1 内置,DatabaseSyncSQLite 3.51.3)实测可用;better-sqlite3 需原生编译 → 选 node:sqlite(零依赖分发,experimental 风险由 SqliteStore 封装隔离);
2. **表结构**strategy_holdings(持仓生命周期表:holding_id 自增 + strategy_id/code/shares/created_at/closed_at + 部分唯一索引)+ market_quotes_cachecode 主键 + last_price/last_close/updated_at 两列);不建 tradesR-007 再建);
3. **SqliteStore 封装**:init/迁移/持仓生命周期(openHolding/addShares/reduceShares/closeHolding/行情读写/close
4. **DataStore 改造**:门面委托 SqliteStore,保留兼容 APIgetDataset/getAllDatasets/行情读写),启动检测自动迁移(幂等);
5. **PositionManager 适配**:整策略 dataset 重写(_setShares)→ 单票生命周期(_applySharesForStrategy 绝对目标语义);
6. **迁移脚本**scripts/migrate-json-to-sqlite.mjs + pnpm migrate 命令(一次性 + 启动自动迁移共用逻辑);
7. **真实迁移**15 持仓 + 13 行情迁入 SQLite,与迁移前逐条一致;JSON 备份 .bak 后清理删除(仅留 store.db);
8. **移除「一键清零」按钮**(老师决定):前端按钮/弹窗 + 后端 remove-all-shares 端点移除;clearStrategyShares 保留(删除策略联动)。
## 经验教训(复盘沉淀)
### 1. 语义迁移必须逐方法核对(重要)
- **教训**:改造 PositionManager 时,把 addToStrategy 的「新增量」误当「绝对目标份额」传给生命周期方法,导致加仓 100 变 100 —— 真实 API 验证时发现(600719.SH 手动做T 少了 100 股),已修复并回滚数据;
- **沉淀**:方法语义变更(增量 vs 绝对量)必须显式命名(_applySharesForStrategy 接收 target 绝对目标),调用方逐处核对;改造后必须用真实 API 走一遍 CRUD 回归,不能只靠隔离单测。
### 2. node:sqlite 的锁行为
- 插件进程持有 SQLite 连接时,外部脚本再开连接写库会报 `attempt to write a readonly database`
- **沉淀**:真实数据修正必须走插件自己的 API(或停机后操作),外部脚本只能读;测试写操作用 ODL_TEST_DATA_DIR 隔离(技术约束-011)。
### 3. 关闭连接
- 插件 dispose 时必须 storage.close()(关闭 SQLite 连接),避免资源泄漏。
### 4. 文档与实际必须一致
- 验收标准初稿写 `one-divine-lot.db`,实际实现用 `store.db` —— 归档前已修正。
## 遗留/后续
1. **盘中行情写 SQLite 验证**:本次验收时已收盘,无实时推送;开盘后应确认行情防抖写回 store.db(观察 updated_at / 文件 mtime);
2. **R-007 交易记录关联**strategy_holdings.holding_id 已就绪,可作为 trades 表关联锚点(R-007 实现时建 trades 表);
3. **SqliteStore 迁移逻辑引用旧文件名**migrateJson 仍检查 store.json 等(幂等跳过),保留用于新环境初始化。
@@ -0,0 +1,27 @@
# 迭代目标:06-数据存储SQLiteJSON → SQLite 存储引擎替换)
## 目标
将神之一手的数据存储从 JSON data storestore.json / store.market.json / store.schema.json)升级为 **SQLite 数据库**node:sqlite),**仅存储引擎替换、对外行为不变**:引入 strategy_holdings(持仓生命周期表)+ market_quotes_cache 两表,存储层升级为单票持仓生命周期(openHolding/addShares/reduceShares/closeHolding),策略定义仍存 DSH settings,一次性迁移 + 启动自动迁移(幂等),JSON 迁移后废弃(迁移前自动备份)。
## 目标描述
- 范围:R-008(已定稿 2026-09-01D1-D8 全部确认);不新增其他功能域;
- 要解决的问题:JSON 全量读写无法支撑复杂查询/筛选(按 code/时间/策略),且未来交易记录/复盘等多数据集统一存储需要数据库底座(目标-003/005/006);
- 引用需求:**R-008(已定稿,2026-09-01**
- 实现方式:node:sqliteNode ≥22.5,实测 22.23.1 可用)+ SqliteStore 封装隔离 experimental 风险 + 启动自动迁移(幂等)+ JSON 废弃(D1-D8);
- 关联设计约束:技术约束-012(变更)、数据存储设计.md(第 9 节变更预告落实)。
## 目标讨论过程
- 2026-09-01 老师提出需求:准备上 SQLite,现在基于 JSON 文件(T-008 入池);
- 2026-09-01 第一轮讨论定稿(D1-D8):动机=复杂查询+未来铺路;node:sqlite(实测可用);仅引擎替换;strategy_holdings+market_quotes_cache 两表(持仓生命周期表 + 行情极简两列缓存表);存储层单票生命周期操作;策略仍存 settings;一次性迁移脚本+启动自动迁移;JSON 迁移后废弃;P1;
- 2026-09-01 实测验证:本机 Node v22.23.1node:sqlite 可用(DatabaseSync,内置 SQLite 3.51.3),标记 Experimentalbetter-sqlite3 需编译(倾向稳妥+零依赖分发 → 选 node:sqlite);
- 2026-09-01 三要素核对通过,转正为 R-008,进入迭代 06。
## 对老师的配合需求
- 确认迭代 06 计划与验收标准(PLAN-007);
- 构建安装后重启 DSH 配合验证(首次启动自动迁移真实数据);
- 验证迁移结果:store.json/store.market.json 数据正确落 SQLite(备份文件留存),份额分配/行情显示不回归;
- 验证持仓生命周期:加仓/减仓/清仓操作后当前持仓与历史(holding_id/created_at/closed_at)正确,删除策略/一键清零后历史保留。
@@ -0,0 +1,28 @@
# 验收标准:06-数据存储SQLiteJSON → SQLite
## 验收标准线
1. **存储介质切换**:数据落 SQLitestore.db),不再生成/更新 store.json / store.market.json / store.schema.json(迁移前备份除外);
2. **两表结构**strategy_holdings + market_quotes_cache 两表存在且结构符合技术实现方案(strategy_holdings 持仓生命周期表:holding_id 自增 + strategy_id/code/shares/created_at/closed_atmarket_quotes_cache 仅 last_price/last_close 两列,无 JSON 列);
3. **持仓生命周期正确**openHolding/addShares/reduceShares/closeHolding 操作正确 —— 建仓生成 holding_id+created_at、加/减仓更新当前持仓 shares、清仓 closed_at 置位转历史;同策略同股票仅一笔当前持仓(部分唯一索引);删除策略/一键清零改为软关闭(历史保留);
4. **行情不回归**:行情缓存查询/写回不变 —— 启动首屏有价(从 SQLite 加载)、盘中防抖写回、三级命中(内存→SQLite→REST 补拉)行为一致;
5. **自动迁移正确**:旧 JSONstore.json / store.market.json / 旧 allocations.json 若存在)→ SQLite 数据一致(份额逐条核对、行情快照逐条核对),迁移前备份文件(*.json.bak)留存;
6. **幂等**:重复启动不重复迁移(SQLite 有数据即跳过);迁移失败不破坏原 JSON;
7. **JSON 废弃**:迁移成功后 store.json / store.market.json 不再读写(旧文件保留为备份,新数据只写 SQLite);
8. **策略定义位置**:策略(id/name/visible/order)仍存 DSH settings,设置页交互不变;
9. **不回归**:持仓/策略/行情/连接配置/交易记录等现有功能正常;
10. **测试隔离**:回归测试在独立数据目录(ODL_TEST_DATA_DIR)执行,真实数据目录无写操作(技术约束-011)。
## 验收方法
- 独立数据目录(ODL_TEST_DATA_DIR)构造旧 JSON 数据(store.json / store.market.json)→ 启动插件 → 确认自动迁移完成、SQLite 数据与 JSON 一致、备份文件留存;
- 反复重启确认幂等(不重复迁移、数据不丢);
- 通过 API 验证份额 CRUD 与行情读写行为与改造前一致(对照迭代 04/05 回归);
- 手动核对真实数据目录:迁移后 store.json / store.market.json 不再更新,store.db 为数据源;
- 回归:全部持仓 / 策略 / 行情 / QMT 连接配置 / 交易记录正常。
## 验收目标
- 存储引擎替换闭环:JSON → SQLite(迁移正确 + 幂等 + 备份),对外行为零变化;
- 为复杂查询/多数据集统一存储铺路(strategy_holdings 持仓生命周期表 + market_quotes_cache 就绪,holding_id 可作为 trades 关联锚点,trades 表 R-007 再建);
- 设计约束同步落地:技术约束-012 与数据存储设计.md 更新为 SQLite 设计。
+12
View File
@@ -61,3 +61,15 @@
- [ ] 迭代目标.md 的讨论过程如何记录(详细纪要 vs 结论摘要)
- [ ] 验收标准.md 的写法模板(标准线 / 验收方法 / 验收目标的具体写法与示例)
- [ ] 复盘模板(复盘时如何从记录中提炼结论)
## 实现经验沉淀(2026-09-01 起,随迭代复盘累积)
> 跨迭代复用的实现经验,从各次迭代复盘中提炼,作为后续实现的隐性约束。
### 语义迁移核对(迭代 062026-09-01
改造/迁移方法时,若方法语义发生变化(如「新增量」变「绝对目标量」、存储整体读写变单条生命周期),必须:
1. **显式命名语义**:如 `_applySharesForStrategy(target)` 注释标明参数为绝对目标;
2. **逐调用点核对**:每个调用点确认传入参数含义一致(增量 vs 绝对量);
3. **真实 API CRUD 回归**:不能只靠隔离单测 —— 隔离测试可能掩盖语义混淆(本次 add-shares 加仓 100 变 100 的 Bug 就是真实 API 验证才暴露)。