diff --git a/docs/02-计划/计划-数据存储SQLite.md b/docs/02-计划/计划-数据存储SQLite.md new file mode 100644 index 0000000..4eeca4a --- /dev/null +++ b/docs/02-计划/计划-数据存储SQLite.md @@ -0,0 +1,61 @@ +# 计划:数据存储管理(JSON → SQLite)(阶段航点) + +> 编号:PLAN-007 | 粒度:阶段航点(大粒度) | 创建:2026-09-01 | 状态:**进行中** +> 派生自终极目标:目标-003(按策略监控市场)、目标-005(市场复盘)、目标-006(交易复盘)——复杂查询/多数据集统一存储是复盘与监控的数据底座 +> 依据需求:**R-008(已定稿,2026-09-01,D1-D8 全部确认)** —— 符合入范围门槛 +> 设计约束:技术约束-012(变更中)、数据存储设计.md(第 9 节变更预告) + +## 目标 + +将神之一手的数据存储从 **JSON data store**(store.json / store.market.json / store.schema.json)升级为 **SQLite 数据库**(node:sqlite):**仅存储引擎替换,对外行为不变**。引入 strategies + allocation + market_quotes 三表,策略定义仍存 DSH settings,JSON 迁移后废弃(迁移前自动备份)。 + +## 范围 + +**做**: +1. **存储层封装**:新增 SqliteStore 模块,封装 node:sqlite(DatabaseSync)——隔离 experimental 风险,提供 init/migrate/upsert/query 能力;对外行为与 DataStore 兼容(D2); +2. **表结构**:strategy_holdings(**持仓生命周期表**:自增 holding_id + strategy_id/code/shares/created_at/closed_at,一对多「多」侧单表)+ market_quotes_cache(code → last_price/last_close 两列,行情快照缓存)两表(D4;2026-09-01 讨论修正:不用 strategies JSON 列 + allocation 冗余双表;行情不用 snapshot JSON 列;持仓加生命周期为交易记录关联铺路); +3. **策略定义位置**:仍存 DSH settings,不迁 SQLite(D5); +4. **迁移**:一次性迁移脚本(scripts/)+ 启动检测自动迁移(旧 JSON 存在且 SQLite 空 → 自动迁移,幂等,迁移前自动备份)(D6); +5. **JSON 废弃**:迁移后废弃 JSON 文件(迁移前备份)(D7); +6. **DataStore 改造**:数据读写/迁移/schema 逻辑切换至 SQLite 后端,**存储层 API 升级为持仓生命周期语义**(openHolding/addShares/reduceShares/closeHolding/getCurrentHoldings/getHoldingHistory,废弃整策略重写的 setDataset/removeDataset); +7. **MarketDataHub 适配**:行情持久化走 SQLite market_quotes_cache 表(落盘只投影 last_price/last_close 两列),防抖写回逻辑不变。 + +**不做**: +- 不建 trades 表(R-007 交易记录时再建,D4); +- 不做数据管理界面/导出/清理(D3:本期仅存储引擎替换); +- 不引入新依赖(node:sqlite 为 Node 内置,零依赖分发,技术约束-005 不受影响); +- 不改策略设置交互(D5); +- 不实现复杂查询 API(本期仅保证现有行为不变,查询能力为后续功能铺路)。 + +## 程序结构(改造后) + +``` +src/ +├── component/ +│ ├── SqliteStore.js # 新增:SQLite 存储层封装(node:sqlite) +│ ├── DataStore.js # 改:读写/迁移切换至 SqliteStore(API 升级为持仓生命周期) +│ └── MarketDataHub.js # 改:行情持久化走 SqliteStore.market_quotes_cache +├── index.js # 改:实例化 SqliteStore 注入 DataStore +scripts/ +└── migrate-json-to-sqlite.mjs # 新增:一次性迁移脚本(独立可执行) +``` + +## 实现步骤(建议顺序) + +1. **文档骨架**:PLAN-007 + 迭代 06 子目录(本步); +2. **约束变更**:技术约束-012 更新 + 数据存储设计.md 第 9 节落实(SQLite 表结构设计替代 JSON schema); +3. **SqliteStore**:node:sqlite 封装(init/事务/upsert/query/备份); +4. **DataStore 改造**:load/save/行情读写切换 SqliteStore,存储层 API 升级为持仓生命周期,保留迁移检测; +5. **PositionManager 适配**:整策略重写 → 单票生命周期操作; +6. **迁移脚本**:一次性迁移脚本(JSON → SQLite)+ 启动自动迁移(幂等); +6. **MarketDataHub 适配**:行情持久化切至 market_quotes_cache 表(投影 last_price/last_close); +7. **构建 + 测试**:pnpm run build + typecheck + 独立数据目录回归测试(技术约束-011); +8. **验收**:对照迭代 06 验收标准逐条核验,记录迭代复盘。 + +## 验收要点 + +- 数据落 SQLite(one-divine-lot.db),store.json/store.market.json 迁移后废弃(迁移前备份); +- 对外行为不变:份额分配 CRUD、行情缓存查询/写回、重启后首屏有价; +- 幂等:重复启动不重复迁移;迁移失败不破坏原 JSON; +- 旧 allocations.json 迁移链路仍有效(并入 SQLite 迁移); +- 技术约束-011:回归测试用独立数据目录(ODL_TEST_DATA_DIR)。 diff --git a/docs/03-设计约束/技术方案约束.md b/docs/03-设计约束/技术方案约束.md index d31b403..f3e6626 100644 --- a/docs/03-设计约束/技术方案约束.md +++ b/docs/03-设计约束/技术方案约束.md @@ -17,11 +17,11 @@ | 技术约束-006 | 客户端 slots.register 的 component 必须是第二参数(register({...}, Component));settings schema 必须用 schemastery z.object() 函数式定义 | 2026-08-28 | 生效 | - | 迭代 01 复盘沉淀:component 位置错误致 React #130;普通对象 schema 报 schema is not a function | | 技术约束-007 | 插件安装用 dsh plugin add(自动 reconcile bundles),不直接用 pnpm add;bundle patch 顶层必须是 insert 操作 | 2026-08-28 | 生效 | - | 迭代 01 复盘沉淀:pnpm add 不会更新 dsh.profile.bundles | | 技术约束-008 | QMT 连接配置存储复用 one-divine-lot settings namespace(新增 qmtConnections 字段:list[{id,name,baseUrl,order}] + activeId + defaultId),与策略配置同机制持久化;激活切换 = 更新数据源实例的 baseUrl(数据源按请求读取地址,已核实),立即生效无需重启 DSH;插件启动时激活默认配置(列表为空时回退 cordis 注入的 qmtBaseUrl 兜底,不做自动迁移);测试连接由服务端代理请求 {baseUrl}/health(避免浏览器跨域) | 2026-08-29 | 生效 | - | R-004 定稿(2026-08-29):Q1/Q2/Q4/Q5/Q8 确认(启动自动激活默认、立即切换、不迁移、超时不配置化、复用 settings) | -| 技术约束-012 | 插件数据存储遵循 **docs/03-设计约束/数据存储设计.md**(JSON data store schema):份额分配存 store.json(策略为中心)、行情快照存 store.market.json、schema 存 store.schema.json;原子写(临时文件+rename);旧 allocations.json 启动自动迁移;DataStore 为数据快速展现存在(重启/刷新首屏有数据) | 2026-09-01 | 生效 | - | R-006 及扩展定稿(2026-08-31/09-01):JSON data store schema 标准化 + 行情持久化 | +| 技术约束-012 | 插件数据存储遵循 **docs/03-设计约束/数据存储设计.md**:存储引擎为 **SQLite(node:sqlite)**——strategy_holdings + market_quotes_cache 两表(持仓生命周期表 + 行情极简两列缓存表);存储层单票生命周期操作;策略定义仍存 DSH settings;旧 JSON(store.json / store.market.json / allocations.json)经一次性迁移脚本 + 启动自动迁移(幂等、迁移前自动备份)后废弃;仅存储引擎替换,对外行为不变 | 2026-09-01 | 生效 | - | 变更(2026-09-01 R-008 定稿 + 迭代 06 实施):JSON data store → SQLite(node:sqlite,零依赖分发;strategy_holdings 持仓生命周期表+market_quotes_cache 两表;存储层单票生命周期;自动迁移;JSON 废弃);R-006 原 JSON 设计为上一版本基线 | | 技术约束-011 | 测试/回归脚本**禁止在真实数据上执行写操作**:份额写操作(add/remove/move/clear)必须使用独立数据目录(AllocationStorage 支持 ODL_TEST_DATA_DIR 环境变量或 dataDir 参数指向临时目录),只读端点(positions/summary/strategies/market-snapshot)可直连生产 API | 2026-08-31 | 生效 | - | 2026-08-31 数据误删事故沉淀:回归测试误删大连热电/万顺新材份额分配,老师定「测试用独立数据目录」 | | 技术约束-010 | 行情实时数据由**服务端中转 + 缓存**提供(R-005 演进,2026-08-31 老师改):DSH 服务端做「WS 订阅 + REST 轮询 + 行情缓存」,前端统一轮询 /odl/api/market-snapshot(不做前端直连,无跨域);行情持久化到 store.market.json(重启不丢价,首屏快速展现);QMT Bridge WS 推送是会话级/有状态行为(归属 QMT Bridge 工作空间) | 2026-08-31 | 生效 | - | 变更(2026-08-31):老师由「前端直连 WS」改为「服务端中转 + 缓存」——解决跨域与 WS 语义不稳定问题;2026-09-01 加行情持久化与启动 prime | | 技术约束-009 | 会话头部快捷切换控件挂载 DSH 开放 slot `conversation.session.header.actions`(多实例挂载点,按 order 排序多插件共存):客户端插件以独立 id 并排注册(DSH 内置 PTC 标签 order=-10,本控件 order=-9),不改动 DSH 宿主;控件经 ConnectionProvider 包装复用现有 RPC 通道与 /odl/api/* 端点 | 2026-08-29 | 生效 | - | R-004 定稿(2026-08-29):Q9 确认;宿主代码审查核实 slot 机制与内置插件注册方式 | \ No newline at end of file +--> diff --git a/docs/03-设计约束/数据存储设计.md b/docs/03-设计约束/数据存储设计.md index b064848..d3ed3f3 100644 --- a/docs/03-设计约束/数据存储设计.md +++ b/docs/03-设计约束/数据存储设计.md @@ -144,3 +144,125 @@ - 技术约束-011:测试/回归脚本禁止在真实数据上执行写操作(独立数据目录) - 本文件为新领域设计约束,后续数据存储变更以本文件为最终依据 + +## 9. SQLite 存储设计(R-008 落实,2026-09-01 迭代 06 实施) + +> **变更理由**(R-008 讨论确认):① 需要复杂查询/筛选(按 code/时间/策略,JSON 内存过滤不便);② 为未来功能铺路(交易记录/复盘/消息等统一存储)。 +> **变更范围**:仅存储引擎替换(JSON → SQLite),对外行为不变(数据快速展现目标不变)。本节替代第 3 节的 JSON schema 作为存储设计;第 3 节 JSON 结构保留为上一版本基线(迁移源)。 +> **2026-09-01 迭代 06 演进**:存储层从「数据集整体读写」升级为「持仓生命周期」(自增 holding_id + created_at/closed_at),为交易记录关联铺路。 + +### 9.1 存储介质 + +``` +~/.dsh/one-divine-lot/ +├── one-divine-lot.db # SQLite 数据库(strategy_holdings + market_quotes_cache 两表) +├── store.json.bak # 迁移前备份(原 store.json) +├── store.market.json.bak # 迁移前备份(原 store.market.json) +└── allocations.json.bak # 迁移前备份(原 allocations.json,若存在) +``` + +### 9.2 表结构 + +> 2026-09-01 讨论修正:策略持仓为**一对多的「多」侧单表**(strategy_holdings)+ **持仓生命周期**(自增 holding_id / created_at / closed_at);不建 strategies(JSON 列压扁)+ allocation(冗余)双表;「一」侧=settings 中的策略定义(D5,不迁 SQLite)。 + +```sql +-- 策略持仓生命周期(一笔 = 一次「建仓→清仓」的完整持仓;历史保留,供交易记录关联) +CREATE TABLE IF NOT EXISTS strategy_holdings ( + holding_id INTEGER PRIMARY KEY AUTOINCREMENT, -- 自增持仓编号(交易记录关联锚点;实测删除不复用) + strategy_id TEXT NOT NULL, -- 对应 settings 策略的 id(如 grid-supermarket) + code TEXT NOT NULL, -- 证券代码(含后缀) + 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, + 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 | 300057.SZ | 1000 | 迁移时间戳 | NULL | +| 8 | manual-t | 600719.SH | 1000 | 迁移时间戳 | NULL | +| 9 | manual-t | 600719.SH | 0 | 迁移时间戳 | 1788xxx(清仓后) | + +**语义对齐**(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 列);盘口五档等实时字段仅存内存缓存,不落盘(重启首屏只需「上次价格」)。 + +### 9.3 分层 + +- **策略定义**(id/name/visible/order):仍存 DSH settings(~/.dsh/settings.yaml),设置页交互不变(D5); +- **持仓份额**(策略 → 股票 → 股数 + 生命周期):存 strategy_holdings 表(一对多「多」侧,holding_id 唯一); +- **行情快照**(code → lastPrice/lastClose):存 market_quotes_cache 表(极简两列)。 + +### 9.4 数据流(不变) + +``` +启动时: + DataStore.load() → 读 SQLite strategy_holdings(迁移检测:旧 JSON 存在且库空 → 自动迁移) + DataStore.loadMarket() → 读 SQLite market_quotes_cache(行情缓存,首屏有价) + MarketFeed._primePositions() → 主动拉持仓盘口 → 写缓存(服务端启动即预热最新价) + +实盘中: + MarketFeed WS 推送 / REST 定时刷新 → MarketDataHub.ingest() → 内存缓存 + MarketDataHub 防抖 3s → SqliteStore.setMarketQuotes() → market_quotes_cache 表(只投影 last_price/last_close) + +查询时(market-snapshot): + MarketDataHub.getByCodes() → 内存 → SQLite → REST 补拉(三级命中) +``` + +### 9.5 迁移 + +- 触发:启动检测 —— 旧 JSON 文件(store.json / store.market.json / allocations.json)存在 且 SQLite 空 → 自动迁移; +- 动作:store.json → strategy_holdings(dataset 平铺为行,created_at=迁移时间戳,closed_at=NULL);store.market.json → market_quotes_cache(抽取 lastPrice/lastClose 两列);allocations.json → strategy_holdings(code 为中心聚合转换);迁移前 JSON 备份为 *.json.bak; +- 幂等:SQLite 有数据即跳过;迁移失败不破坏原 JSON; +- 一次性脚本:scripts/migrate-json-to-sqlite.mjs(与启动自动迁移共用逻辑)。 + +### 9.6 关键设计决策(更新) + +| 决策 | 结论 | 理由 | +|---|---|---| +| 存储介质 | **SQLite(node:sqlite)** | 复杂查询 + 未来多数据集统一存储(R-008 D1) | +| 驱动 | node:sqlite(Node ≥22.5 内置) | 零依赖分发,experimental 风险由 SqliteStore 封装隔离(D2) | +| 持仓表 | **strategy_holdings**(持仓生命周期:holding_id 自增 + created_at/closed_at) | 关系模型正确、无 JSON 压扁、支持按 code 反查;为交易记录关联铺路(2026-09-01 讨论演进) | +| 行情表 | market_quotes_cache(code → last_price/last_close 两列) | 只存 UI 消费的现价+昨收;无 JSON 列;盘口深度仅内存(2026-09-01 讨论修正) | +| 不建表 | 不建 trades(R-007 时再建) | 本期最小改动(D4) | +| 策略定义位置 | DSH settings(不迁 SQLite) | 设置页交互不变(D5) | +| 存储层语义 | 单票生命周期(openHolding/addShares/reduceShares/closeHolding),废弃整策略重写(setDataset/removeDataset) | 份额=持仓生命周期,历史保留可关联交易记录(2026-09-01 讨论演进) | +| 迁移 | 一次性脚本 + 启动自动迁移(幂等) | 参照 R-006 迁移模式(D6) | +| JSON 去留 | 迁移后废弃(迁移前自动备份) | 单一数据源(D7) | +| 能力范围 | 仅存储引擎替换,对外行为不变 | 最小改动(D3) | +| 写入 | SQLite 事务(同步 API) | 原子性由数据库保证 | +| 测试隔离 | ODL_TEST_DATA_DIR 环境变量或 dataDir 参数 | 防误删真实数据(技术约束-011) | + +### 9.7 对应实现(更新) + +| 模块 | 职责 | 文件 | +|---|---|---| +| SqliteStore | SQLite 存储层封装(node:sqlite:init/迁移/持仓生命周期/行情读写/close) | src/component/SqliteStore.js | +| DataStore | 持仓 + 行情持久化(委托 SqliteStore,对外 API 升级为生命周期语义) | src/component/DataStore.js | +| PositionManager | 份额业务(适配单票生命周期:openHolding/addShares/reduceShares/closeHolding) | src/component/PositionManager.js | +| MarketDataHub | 行情缓存(内存 + SQLite 持久化 + 查询) | src/component/MarketDataHub.js | +| MarketFeed | 行情获取(WS + REST 定时刷新 + 启动 prime) | src/component/MarketFeed.js | +| migrate 脚本 | 一次性迁移脚本 | scripts/migrate-json-to-sqlite.mjs | + +## 10. 约束条目(引用) + +- 技术约束-012:数据存储遵循本文件(SQLite 存储设计); +- 技术约束-011:测试/回归脚本禁止在真实数据上执行写操作(独立数据目录); +- 本文件为新领域设计约束,后续数据存储变更以本文件为最终依据。 diff --git a/docs/04-迭代记录/06-数据存储SQLite/UI交互调用分析.md b/docs/04-迭代记录/06-数据存储SQLite/UI交互调用分析.md new file mode 100644 index 0000000..c711978 --- /dev/null +++ b/docs/04-迭代记录/06-数据存储SQLite/UI交互调用分析.md @@ -0,0 +1,247 @@ +# UI 交互调用分析:06-数据存储SQLite + +> 分析日期:2026-09-01 | 依据:技术实现方案.md(接口定义) +> 目的:基于 SQLite 存储层接口(strategy_holdings / market_quotes_cache + 持仓生命周期方法集),梳理**前端各操作 → 服务端 → 存储层**的完整调用链,确认对外 API 不变的前提下,存储层升级对调用流程的影响。 + +## 调用链路总览(升级后不变的部分) + +``` +前端组件(React) + │ fetch POST /odl/api/ body={ args } + ▼ +connection.jsx useRpc() ── 标准化剥离 one-divine-lot/ 前缀 + ▼ +服务端 src/api/*.js 领域分发(positions / strategies / qmt-connections / market / trades) + ▼ +PositionManager / MarketDataHub / Settings(业务逻辑) + ▼ +DataStore(改造)── 委托 ──▶ SqliteStore(node: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)`(INSERT,created_at=now,closed_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 / addShares(shares = 原份额 + 未分配份额) + +**返回**:`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 可直接支撑)。 diff --git a/docs/04-迭代记录/06-数据存储SQLite/技术实现方案.md b/docs/04-迭代记录/06-数据存储SQLite/技术实现方案.md new file mode 100644 index 0000000..aa55b90 --- /dev/null +++ b/docs/04-迭代记录/06-数据存储SQLite/技术实现方案.md @@ -0,0 +1,132 @@ +# 技术实现方案:06-数据存储SQLite(JSON → SQLite) + +> 依据:PLAN-007 | 需求:R-008 | 设计约束:技术约束-012(变更)、数据存储设计.md(第 9 节变更落实)、技术约束-011(测试隔离) + +## 技术选型 + +- **存储引擎**:node:sqlite(Node ≥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 升级为持仓生命周期语义) + └─ 内部委托 SqliteStore(PositionManager 按单票操作,不再整策略重写) + +MarketDataHub(改造,持久化路径不变) + └─ setMarketQuotes → SqliteStore.setMarketQuotes(防抖写回) +``` + +**存储层方法集(替代原 getDataset/setDataset/removeDataset/getAllDatasets 整体读写)**: + +| 方法 | 是什么 | 什么时候用 | SQL 要点 | +|---|---|---|---| +| openHolding(strategyId, code, shares) | 建仓:创建一笔新持仓 | 某策略首次分配某股票(原「从 0 添加」) | INSERT(holding_id 自增,created_at=now,closed_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 NULL(shares 置 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 + shares,created_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.bak(D7); +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. SqliteStore:node:sqlite 封装(init/迁移/持仓生命周期/行情读写/close); +4. DataStore 改造:持仓读写切换 SqliteStore(openHolding/addShares/reduceShares/closeHolding/getCurrentHoldings/getHoldingHistory),保留迁移检测; +5. PositionManager 适配:整策略重写 → 单票生命周期操作; +6. 迁移脚本:一次性迁移脚本 + 启动自动迁移(幂等); +7. MarketDataHub 适配:行情持久化切至 market_quotes_cache 表(落盘只投影 last_price/last_close 两列); +8. 构建测试:pnpm run build + typecheck + 独立数据目录回归测试(技术约束-011); +9. 验收 + 复盘。 diff --git a/docs/04-迭代记录/06-数据存储SQLite/迭代复盘.md b/docs/04-迭代记录/06-数据存储SQLite/迭代复盘.md new file mode 100644 index 0000000..3a9ddf6 --- /dev/null +++ b/docs/04-迭代记录/06-数据存储SQLite/迭代复盘.md @@ -0,0 +1,42 @@ +# 迭代复盘:06-数据存储SQLite(JSON → SQLite) + +> 复盘日期:2026-09-01 | 迭代状态:**已完成(验收通过)** +> 关联需求:R-008(数据存储管理 JSON → SQLite,已定稿) +> 关联计划:PLAN-007(数据存储 SQLite 化) + +## 结果 + +迭代 06 达成:数据存储从 JSON data store(store.json / store.market.json / store.schema.json)升级为 SQLite(store.db,node:sqlite),仅存储引擎替换、对外行为不变。真实数据迁移完成(15 条持仓 + 13 条行情),JSON 清理废弃,份额/行情/设置功能全部不回归。 + +## 过程事实 + +1. **技术选型验证**:node:sqlite(Node 22.23.1 内置,DatabaseSync,SQLite 3.51.3)实测可用;better-sqlite3 需原生编译 → 选 node:sqlite(零依赖分发,experimental 风险由 SqliteStore 封装隔离); +2. **表结构**:strategy_holdings(持仓生命周期表:holding_id 自增 + strategy_id/code/shares/created_at/closed_at + 部分唯一索引)+ market_quotes_cache(code 主键 + last_price/last_close/updated_at 两列);不建 trades(R-007 再建); +3. **SqliteStore 封装**:init/迁移/持仓生命周期(openHolding/addShares/reduceShares/closeHolding)/行情读写/close; +4. **DataStore 改造**:门面委托 SqliteStore,保留兼容 API(getDataset/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 等(幂等跳过),保留用于新环境初始化。 diff --git a/docs/04-迭代记录/06-数据存储SQLite/迭代目标.md b/docs/04-迭代记录/06-数据存储SQLite/迭代目标.md new file mode 100644 index 0000000..6e8a180 --- /dev/null +++ b/docs/04-迭代记录/06-数据存储SQLite/迭代目标.md @@ -0,0 +1,27 @@ +# 迭代目标:06-数据存储SQLite(JSON → SQLite 存储引擎替换) + +## 目标 + +将神之一手的数据存储从 JSON data store(store.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-01,D1-D8 全部确认);不新增其他功能域; +- 要解决的问题:JSON 全量读写无法支撑复杂查询/筛选(按 code/时间/策略),且未来交易记录/复盘等多数据集统一存储需要数据库底座(目标-003/005/006); +- 引用需求:**R-008(已定稿,2026-09-01)**; +- 实现方式:node:sqlite(Node ≥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.1,node:sqlite 可用(DatabaseSync,内置 SQLite 3.51.3),标记 Experimental;better-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)正确,删除策略/一键清零后历史保留。 diff --git a/docs/04-迭代记录/06-数据存储SQLite/验收标准.md b/docs/04-迭代记录/06-数据存储SQLite/验收标准.md new file mode 100644 index 0000000..9d5d2c8 --- /dev/null +++ b/docs/04-迭代记录/06-数据存储SQLite/验收标准.md @@ -0,0 +1,28 @@ +# 验收标准:06-数据存储SQLite(JSON → SQLite) + +## 验收标准线 + +1. **存储介质切换**:数据落 SQLite(store.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_at;market_quotes_cache 仅 last_price/last_close 两列,无 JSON 列); +3. **持仓生命周期正确**:openHolding/addShares/reduceShares/closeHolding 操作正确 —— 建仓生成 holding_id+created_at、加/减仓更新当前持仓 shares、清仓 closed_at 置位转历史;同策略同股票仅一笔当前持仓(部分唯一索引);删除策略/一键清零改为软关闭(历史保留); +4. **行情不回归**:行情缓存查询/写回不变 —— 启动首屏有价(从 SQLite 加载)、盘中防抖写回、三级命中(内存→SQLite→REST 补拉)行为一致; +5. **自动迁移正确**:旧 JSON(store.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 设计。 diff --git a/docs/04-迭代记录/说明.md b/docs/04-迭代记录/说明.md index d5c5f3e..3edff80 100644 --- a/docs/04-迭代记录/说明.md +++ b/docs/04-迭代记录/说明.md @@ -61,3 +61,15 @@ - [ ] 迭代目标.md 的讨论过程如何记录(详细纪要 vs 结论摘要) - [ ] 验收标准.md 的写法模板(标准线 / 验收方法 / 验收目标的具体写法与示例) - [ ] 复盘模板(复盘时如何从记录中提炼结论) + +## 实现经验沉淀(2026-09-01 起,随迭代复盘累积) + +> 跨迭代复用的实现经验,从各次迭代复盘中提炼,作为后续实现的隐性约束。 + +### 语义迁移核对(迭代 06,2026-09-01) + +改造/迁移方法时,若方法语义发生变化(如「新增量」变「绝对目标量」、存储整体读写变单条生命周期),必须: + +1. **显式命名语义**:如 `_applySharesForStrategy(target)` 注释标明参数为绝对目标; +2. **逐调用点核对**:每个调用点确认传入参数含义一致(增量 vs 绝对量); +3. **真实 API CRUD 回归**:不能只靠隔离单测 —— 隔离测试可能掩盖语义混淆(本次 add-shares 加仓 100 变 100 的 Bug 就是真实 API 验证才暴露)。 diff --git a/docs/05-需求池/已完成/R-008.md b/docs/05-需求池/已完成/R-008.md new file mode 100644 index 0000000..4c11957 --- /dev/null +++ b/docs/05-需求池/已完成/R-008.md @@ -0,0 +1,84 @@ +# R-008 数据存储管理(JSON → SQLite)· 已完成 + +> 归档日期:2026-09-01 | 需求状态:**已完成** +> 原索引:docs/05-需求池/需求池索引.md(主索引保留 R-008 条目,指向本归档) +> 实现迭代:06-数据存储SQLite(验收通过,迭代复盘见 docs/04-迭代记录/06-数据存储SQLite/迭代复盘.md) +> 讨论记录:需求从 T-008 草稿转正,2026-09-01 定稿(D1-D8);实现过程中的语义迁移 Bug 修复与「一键清零」移除见迭代 06 技术实现方案变更记录 + +--- + +> 状态:**已定稿**(2026-09-01,老师确认)| 登记日期:2026-09-01 +> 来源:T-008 草稿转正(docs/05-需求池/草稿/T-008-数据存储管理SQLite.md) +> 优先级:P1 +> 关联:docs/03-设计约束/数据存储设计.md(将变更)、技术约束-012(将变更) + +## 想法描述 + +将神之一手的数据存储从 **JSON 文件**(store.json / store.market.json / store.schema.json)升级为 **SQLite 数据库**,并引入「数据存储管理」能力。 + +**背景**:当前存储基于 JSON data store(R-006,2026-08-31 定),设计约束明确「数据量很少,无需数据库」。现准备引入 SQLite,意味着数据量/查询复杂度已增长到需要数据库的程度,需重新评估该设计决策。 + +## 现状(代码审查 2026-09-01) + +| 项 | 现状 | +|---|---| +| 存储介质 | JSON 文件:store.schema.json(schema)/ store.json(份额分配,策略为中心)/ store.market.json(行情快照缓存) | +| 存储模块 | src/component/DataStore.js(读写/迁移/schema)、MarketDataHub.js(行情缓存写回)、AllocationStorage.js(遗留旧存储,迁移源) | +| 数据流 | 启动 load → 实盘 ingest → 防抖 3s 写回 store.market.json;原子写(临时文件 + rename) | +| 依赖 | 无数据库依赖(package.json 无 sqlite) | +| 设计约束 | docs/03-设计约束/数据存储设计.md:「数据量很少(无需数据库/Circe),用 JSON 文件 + 类 JSON Schema」 | + +## 动机(为什么要 SQLite) + +(待老师补充确认 —— 初步推测): +- [ ] 数据量增长:行情缓存 / 交易记录 / 复盘记录等数据积累,JSON 全量读写成本变高? +- [ ] 查询需求:按 code / 时间 / 策略做复杂查询(如历史行情、交易复盘筛选),JSON 内存过滤不便? +- [ ] 写入频率:盘中行情防抖写回 + 未来更多高频写入,JSON 原子写压力大? +- [ ] 多数据集统一管理:份额 / 行情 / 交易记录 / 复盘记录统一入一个库? + +## 待讨论点 + +- [ ] **动机确认**:老师确认上 SQLite 的具体原因(上述哪条 / 其他)——决定改造范围; +- [ ] **库选型**:better-sqlite3(同步 API,Node 原生绑定)?node:sqlite(Node 22+ 内置)?其他?(技术约束-001:复用优先,需论证) +- [ ] **schema 设计**:表结构 —— strategies / allocation(份额)/ market_quotes / trades / reviews?如何对齐现有 store.schema.json 语义? +- [ ] **迁移策略**:现有 store.json / store.market.json / 旧 allocations.json 如何迁入 SQLite(一次性迁移?启动自动迁移 + 备份?参照 R-006 迁移模式)? +- [ ] **保留还是废弃 JSON**:SQLite 落地后 JSON 文件是否完全废弃(读兼容 / 写切换 / 双写过渡)? +- [ ] **策略定义位置**:策略(id/name/visible/order)仍存 DSH settings 还是迁入 SQLite?(现有设计决策:存 settings) +- [ ] **数据存储管理能力**:本需求「数据存储管理」具体指什么 —— 数据浏览/导出?备份/恢复?清理(行情缓存膨胀治理)?还是仅存储引擎替换? +- [ ] **设计约束变更**:数据存储设计.md 的「无需数据库」决策需修订 —— 变更理由与记录; +- [ ] **依赖引入**:新增原生依赖(better-sqlite3 需编译)对插件构建/分发的影响(技术约束-005 bundle 格式); +- [ ] **优先级与排期**:机制定稿后确定。 + +> 成熟后按「三要素」(边界清楚 / 核心逻辑明确 / 老师确认)讨论定稿,再转正到根目录形成正式需求。 +## 讨论结论(2026-09-01 第一轮) + +### 已确认决策 + +| # | 决策点 | 结论 | +|---|---|---| +| D1 | 动机 | **需要复杂查询/筛选** + **为未来功能铺路**(多数据集统一管理);非数据量/写入压力问题 | +| D2 | 库选型 | **node:sqlite**(Node ≥22.5,实测本机 22.23.1 可用);存储层封装独立模块隔离 experimental 风险 | +| D3 | 能力范围 | **仅存储引擎替换**(JSON→SQLite),对外行为不变,最小改动;本期不做数据管理界面/导出/清理 | +| D4 | 表范围 | 本期:**strategies + allocation + market_quotes** 三表;**不建 trades 表**(R-007 时再建) | +| D5 | 策略定义位置 | **仍存 DSH settings**(不迁 SQLite,设置页交互不变) | +| D6 | 迁移策略 | **一次性迁移脚本** + **启动检测自动迁移**(检测旧 JSON 存在且 SQLite 空 → 自动迁移,幂等) | +| D7 | JSON 去留 | **迁移后废弃**(迁移前自动备份) | +| D8 | 优先级 | **P1** | + +### 实测验证记录(2026-09-01) + +- 本机 Node v22.23.1,`node:sqlite` 可用(`DatabaseSync`,内置 SQLite 3.51.3),但标记 **Experimental**(`ExperimentalWarning`); +- better-sqlite3 实测 ESM 加载正常,但原生模块需编译;分发需预编译产物; +- 选型考量:本项目倾向稳妥 + 零依赖分发 → node:sqlite;存储层封装(SqliteStore)隔离 experimental 风险,未来可切换。 + +### 设计约束变更(待执行) + +- 技术约束-012(数据存储遵循 JSON data store)需**变更**:SQLite 落地后更新为遵循 SQLite 存储设计; +- docs/03-设计约束/数据存储设计.md 需修订:「无需数据库」决策 → SQLite(记录变更理由:复杂查询 + 未来功能铺路); +- 变更需在定稿时同步执行(本需求转正后)。 + +### 转正条件(三要素核对) + +- [x] 边界清楚:仅引擎替换 + 三表 + 不建 trades + 策略仍存 settings; +- [x] 核心逻辑明确:node:sqlite + 自动迁移 + JSON 废弃(D1-D8 全部确认); +- [ ] **老师确认定稿**(待确认)—— 确认后转正为正式需求 R 编号,进入计划/迭代。 \ No newline at end of file diff --git a/docs/05-需求池/说明.md b/docs/05-需求池/说明.md index d3b4d18..e6983f9 100644 --- a/docs/05-需求池/说明.md +++ b/docs/05-需求池/说明.md @@ -40,6 +40,28 @@ > 实践样本:R-002 从提出到定稿经历 7+ 轮讨论,完整走完三要素后进入迭代 01。 +## 归档与转正规范(2026-09-01 老师确认,源自 R-005/R-008 实践) + +### 需求归档检查清单 + +需求实现完成后归档,按序执行: + +1. **核实完成**:对应迭代复盘存在且标记「验收通过」,索引中实现状态为「已实现」; +2. **补归档头**:归档文件加「归档日期 / 需求状态:已完成 / 实现迭代 / 讨论记录索引」头部; +3. **移入归档**:需求文件从根目录移入 `已完成/`; +4. **更新索引**:主索引保留条目,实现状态改为「已实现(已归档)」,描述标注归档路径; +5. **双向追溯**:迭代记录与需求池互相引用(迭代复盘关联需求编号,需求归档引用迭代复盘路径)。 + +> 归档只追加不改写历史。 + +### 草稿转正清理 + +草稿转正为正式需求(T→R)时: + +- **必须清理草稿残留的头部/模板内容**(如「状态:起草」「登记日期」旧头部),确保正式文件只有一套头部; +- 转正后删除草稿文件; +- 索引同步更新(草稿行状态 → 已转需求 R-xxx)。 + ## 目录结构(2026-08-28 老师确认) ``` diff --git a/docs/05-需求池/需求池索引.md b/docs/05-需求池/需求池索引.md index 1645e7f..3e3937a 100644 --- a/docs/05-需求池/需求池索引.md +++ b/docs/05-需求池/需求池索引.md @@ -19,6 +19,7 @@ | R-005 | WS 盘中价格实时更新 | 3 个监控表格(全部持仓/手动做T/网格超市)盘中现价实时显示:服务端中转 + 行情缓存(store.market.json 持久化)+ 前端轮询读缓存;现价列 + 红涨绿跌 + 变化高亮。**2026-08-31 定稿,2026-09-01 完成(迭代 04 验收通过),已归档至 已完成/R-005.md** | 老师指令(2026-08-31,T-005 转正) | P1 | 已定稿 | 2026-09-01 | 04-WS盘中价格实时更新 | **已实现(已归档)** | | R-004 | QMT 连接配置(多配置管理 + 会话头部快捷切换) | 设置页「QMT 连接配置」子 tab:多配置 CRUD(卡片形式)、单选激活(数据源热切换立即生效)、默认标记(启动自动激活)、测试连接(/health 代理)、删除边界处理;会话头部(PTC 模式标签旁)快捷切换 chip。**2026-08-29 定稿(Q1-Q10)并完成实现与验收(7/7 通过),已归档至 已完成/R-004.md**。决策沉淀:产品约束-005/006、技术约束-003(变更)/008/009、UI约束-001/002 | 老师指令(2026-08-28) | P1 | 已定稿 | 2026-08-29 | 03-QMT连接配置 | **已实现(已归档)** | | R-007 | 交易记录接入(订单与成交数据 + 时间段查询) | 交易记录 tab 接入 QMT Bridge 当日委托(/trade/orders)与当日成交(/trade/trades):**单表合并**(委托主行 + 展开成交明细)+ **时间段查询**(今日/本周/本月 + 手动起止)+ 费用计算(佣金费率+最低5元/印花税/过户费,委托级合并计费)。**2026-09-01 定稿并完成(迭代 05 验收通过),已归档至 已完成/R-007.md** | 老师指令(2026-09-01) | P1 | 已定稿 | 2026-09-01 | 05-交易记录接入 | **已实现(已归档)** | +| R-008 | 数据存储管理(JSON → SQLite) | 数据存储从 JSON data store 升级为 SQLite(node:sqlite):仅存储引擎替换 + strategies/allocation/market_quotes 三表 + 一次性迁移脚本(启动自动迁移)+ JSON 废弃;策略仍存 DSH settings;不建 trades 表(R-007 时再建)。**2026-09-01 定稿(T-008 转正,D1-D8 确认),2026-09-01 完成(迭代 06 验收通过),已归档至 已完成/R-008.md** | 老师指令(2026-09-01,T-008 转正) | P1 | 已定稿 | 2026-09-01 | 06-数据存储SQLite | **已实现(已归档)** | ## 渐进明细规划素材 @@ -32,4 +33,5 @@ | T-004 | 消息/通知工具 | DSH 内消息路由与通知能力,支撑按策略模式管理消息目标 | 起草 | | T-005 | 通过 ws 长连接做市场数据的实时反映 | WebSocket 长连接实现市场数据(持仓/行情)实时推送与展示;源自迭代 01 复盘。**2026-08-31 转正为 R-005(迭代 04 实施中)**,草稿文件:`草稿/通过ws长连接做市场数据的实时反映.md` | 已转需求 R-005 | | T-006 | 数据池中间层 + 策略表格动态字段配置 | 服务端数据池(数据集中间层:按 code 聚合宽表、多源字段映射、填充器架构)+ 策略持仓表格动态字段配置(列配置×池行渲染)。源自 R003 讨论(2026-08-28)拆分独立,草稿文件:`草稿/数据池中间层与策略表格动态字段配置.md` | 起草 | -| T-007 | RSS 订阅管理 | 管理 RSS 订阅源(增删改查)+ 抓取聚合订阅内容,作为资讯/消息来源;支撑 目标-003 市场监控、目标-004 消息管理,与 T-004 同属消息链路。**2026-08-28 老师确认先放草稿**,草稿文件:`草稿/RSS订阅管理.md` | 起草 | \ No newline at end of file +| T-007 | RSS 订阅管理 | 管理 RSS 订阅源(增删改查)+ 抓取聚合订阅内容,作为资讯/消息来源;支撑 目标-003 市场监控、目标-004 消息管理,与 T-004 同属消息链路。**2026-08-28 老师确认先放草稿**,草稿文件:`草稿/RSS订阅管理.md` | 起草 | +| T-008 | 数据存储管理(JSON → SQLite) | 数据存储从 JSON data store 升级为 SQLite(node:sqlite)数据库 + 数据存储管理能力;**变更既有设计约束**(数据存储设计.md「无需数据库」决策)。**2026-09-01 定稿转正为 R-008 并完成(迭代 06)** | 已转需求 R-008 |