Files
kyugao dd98b4d45b 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)
2026-09-01 18:04:28 +08:00

133 lines
9.6 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 技术实现方案: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. 验收 + 复盘。