Files
one_divine_lot/docs/03-设计约束/数据存储设计.md
T
kyugao 6f74ed4a79 docs(结构约束同步): 活动文档路径对齐新目录结构
归类重构(技术约束-016)落地后同步活动文档旧路径:
- 数据存储设计.md: 三处对应实现表 12 处 src/component/* → storage/position/market/trades
- R-013 / PLAN-012 / 迭代11技术实现方案: SqliteStore/PositionManager 路径更新
- 迭代11程序结构设计: 补后续动作记录(历史档案按追加式原则不改写)
2026-09-02 15:43:26 +08:00

358 lines
19 KiB
Markdown
Raw 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.
# 数据存储设计(JSON Data Store Schema
> 策略数据集 + 行情缓存的 JSON data store 设计(R-006 及其扩展,2026-08-31 / 2026-09-01 定)
> 本文件是数据存储领域的**设计约束文档**,后续数据存储相关迭代以此为依据。
## 1. 设计目标
1. **数据快速展现**:DataStore 的核心目标是「数据快速展现」——份额分配、行情价格持久化,重启/刷新后首屏即有数据,不依赖每次实盘查询。
2. **标准化描述**:为每个策略定义标准化的字段描述(schema),在描述之上挂数据集。
3. **统一 JSON 操作**:数据量很少(无需数据库/Circe),用 JSON 文件 + 类 JSON Schema 统一读写与校验。
## 2. 总体架构
```
~/.dsh/one-divine-lot/
├── store.schema.json # 数据集 schema(类 JSON Schema,随代码发布,自动生成)
├── store.json # 份额分配数据(策略为中心,原子写)
└── store.market.json # 行情快照缓存(code → 最新行情,原子写)
```
**分层**
- **策略定义**id/name/visible/order):存 DSH settings~/.dsh/settings.yaml),设置页交互不变
- **份额分配**(策略 → 股票 → 股数):存 store.json(策略为中心)
- **行情快照**code → lastPrice 等):存 store.market.json(持久化缓存)
## 3. 数据文件结构与 Schema
### 3.1 store.schema.json(数据集 schema
```json
{
"version": 1,
"kind": "one-divine-lot-data-store",
"strategies": {
"description": "策略数据集:每个策略一个 dataset(份额分配)",
"type": "array",
"items": {
"strategyId": { "type": "string", "required": true, "description": "策略 id(与 settings 中的策略一致)" },
"dataset": {
"type": "array",
"description": "该策略的持仓数据集(份额分配)",
"items": {
"code": { "type": "string", "required": true, "description": "证券代码(含后缀,如 600719.SH" },
"shares": { "type": "number", "required": true, "description": "份额(股数,>0" }
}
}
}
}
}
```
### 3.2 store.json(份额分配,策略为中心)
```json
{
"version": 1,
"strategies": [
{
"strategyId": "grid-supermarket",
"dataset": [
{ "code": "600719.SH", "shares": 800 },
{ "code": "300057.SZ", "shares": 1000 }
]
},
{
"strategyId": "manual-t",
"dataset": [
{ "code": "600719.SH", "shares": 1000 }
]
}
]
}
```
**数据视角**:按策略查持仓(策略为中心),比旧 code 为中心(allocations.json)更贴合业务。
### 3.3 store.market.json(行情快照缓存)
```json
{
"version": 1,
"savedAt": 1788193491108,
"quotes": {
"600719.SH": {
"time": 1788159604000,
"timetag": "20260831 15:00:04",
"lastPrice": 7.16,
"open": 7.19,
"high": 7.24,
"low": 7.02,
"lastClose": 7.22,
"volume": 63523,
"amount": 45341300
}
}
}
```
**用途**:宿主重启后首屏快速展示上次价格(不依赖实盘订阅);实盘推送/轮询做增量更新并定期写回。
## 4. 数据流
```
启动时:
DataStore.load() → 读 store.json(份额分配)
DataStore.loadMarket() → 读 store.market.json(行情缓存,首屏有价)
MarketFeed._primePositions() → 主动拉持仓盘口 → 写缓存(服务端启动即预热最新价)
实盘中:
MarketFeed WS 推送 / REST 定时刷新 → MarketDataHub.ingest() → 内存缓存
MarketDataHub 防抖 3s → DataStore.setMarketQuotes() → store.market.json
查询时(market-snapshot):
MarketDataHub.getByCodes() → 内存 → 磁盘 → REST 补拉(三级命中)
```
## 5. 关键设计决策
| 决策 | 结论 | 理由 |
|---|---|---|
| 存储介质 | JSON 文件(不引入数据库) | 数据量很少,JSON + schema 足够 |
| 策略定义位置 | DSH settings(不迁 store | 设置页交互不变,查询简单 |
| 份额分配结构 | 策略为中心(strategies[].dataset | 贴合业务视角,schema 统一描述 |
| dataset 格式 | [{code, shares}] 数组 | 可扩展字段(未来加成本价/备注) |
| 行情缓存 | 持久化(store.market.json | DataStore 为数据快速展现存在,重启不丢价 |
| 写入 | 原子写(临时文件 + rename) | 防损坏,多次写安全 |
| 测试隔离 | ODL_TEST_DATA_DIR 环境变量或 dataDir 参数 | 防误删真实数据(技术约束-011) |
## 6. 迁移
- 旧 allocations.jsoncode 为中心)→ 新 store.json(策略为中心):**启动时自动迁移**,旧文件备份为 allocations.json.bak
- 迁移逻辑在 DataStore._migrateFromLegacy()code 为中心 → 策略为中心聚合
## 7. 对应实现
| 模块 | 职责 | 文件 |
|---|---|---|
| DataStore | 份额分配 + 行情持久化读写、迁移、schema | src/storage/DataStore.js |
| PositionManager | 份额业务逻辑(依赖 DataStore | src/position/PositionManager.js |
| MarketDataHub | 行情缓存(内存 + 磁盘持久化 + 查询) | src/market/MarketDataHub.js |
| MarketFeed | 行情获取(WS + REST 定时刷新 + 启动 prime | src/market/MarketFeed.js |
## 8. 约束条目(引用)
- 技术约束-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);不建 strategiesJSON 列压扁)+ 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_holdingsdataset 平铺为行,created_at=迁移时间戳,closed_at=NULL);store.market.json → market_quotes_cache(抽取 lastPrice/lastClose 两列);allocations.json → strategy_holdingscode 为中心聚合转换);迁移前 JSON 备份为 *.json.bak
- 幂等:SQLite 有数据即跳过;迁移失败不破坏原 JSON;
- 一次性脚本:scripts/migrate-json-to-sqlite.mjs(与启动自动迁移共用逻辑)。
### 9.6 关键设计决策(更新)
| 决策 | 结论 | 理由 |
|---|---|---|
| 存储介质 | **SQLitenode:sqlite** | 复杂查询 + 未来多数据集统一存储(R-008 D1) |
| 驱动 | node:sqliteNode ≥22.5 内置) | 零依赖分发,experimental 风险由 SqliteStore 封装隔离(D2 |
| 持仓表 | **strategy_holdings**(持仓生命周期:holding_id 自增 + created_at/closed_at | 关系模型正确、无 JSON 压扁、支持按 code 反查;为交易记录关联铺路(2026-09-01 讨论演进) |
| 行情表 | market_quotes_cachecode → 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:sqliteinit/迁移/持仓生命周期/行情读写/close | src/storage/SqliteStore.js |
| DataStore | 持仓 + 行情持久化(委托 SqliteStore,对外 API 升级为生命周期语义) | src/storage/DataStore.js |
| PositionManager | 份额业务(适配单票生命周期:openHolding/addShares/reduceShares/closeHolding | src/position/PositionManager.js |
| MarketDataHub | 行情缓存(内存 + SQLite 持久化 + 查询) | src/market/MarketDataHub.js |
| MarketFeed | 行情获取(WS + REST 定时刷新 + 启动 prime | src/market/MarketFeed.js |
| migrate 脚本 | 一次性迁移脚本 | scripts/migrate-json-to-sqlite.mjs |
## 10. 交易记录存储设计(R-009 落实,2026-09-01 迭代 07 实施)
> 依据:R-009Q1-Q8 定稿,2026-09-01)。在 SQLite 新增交易记录两表(trade_orders + trade_fills),QMT 当日交易数据本地持久化(跨日积累成历史库),支持按策略过滤 / 复盘交易。
### 10.1 表结构
```sql
-- 交易委托(委托主行;order_id 唯一,UPSERT 幂等;零冗余 strategy_id/holding_id
CREATE TABLE IF NOT EXISTS trade_orders (
order_id TEXT PRIMARY KEY, -- m_strOrderSysID
trade_date TEXT NOT NULL, -- 交易日 YYYYMMDDm_strInsertDate
code TEXT NOT NULL,
name TEXT NOT NULL DEFAULT '',
exchange TEXT NOT NULL DEFAULT '',
direction TEXT NOT NULL DEFAULT '', -- buy / sell
direction_code INTEGER,
opt_name TEXT NOT NULL DEFAULT '',
status INTEGER,
order_volume REAL NOT NULL DEFAULT 0,
traded_volume REAL NOT NULL DEFAULT 0,
limit_price REAL NOT NULL DEFAULT 0,
traded_price REAL NOT NULL DEFAULT 0,
amount REAL NOT NULL DEFAULT 0,
insert_date TEXT NOT NULL DEFAULT '',
insert_time TEXT NOT NULL DEFAULT '',
insert_ts INTEGER NOT NULL, -- 派生:insert_date+insert_time 合成毫秒时间戳
cancel_info TEXT NOT NULL DEFAULT '',
error_msg TEXT NOT NULL DEFAULT '',
strategy_id TEXT, -- 用户手动设置的策略归属(冗余,Q1 确认)
holding_id INTEGER, -- 用户手动设置的持仓归属(冗余,Q1 确认)
fetched_at INTEGER NOT NULL -- 同步时间戳
);
-- 交易成交(trade_id 唯一,order_id 外键关联委托;零冗余 strategy_id/holding_id
CREATE TABLE IF NOT EXISTS trade_fills (
trade_id TEXT PRIMARY KEY, -- m_strTradeID
order_id TEXT NOT NULL, -- → trade_orders.order_id
trade_date TEXT NOT NULL,
code TEXT NOT NULL,
name TEXT NOT NULL DEFAULT '',
exchange TEXT NOT NULL DEFAULT '',
direction TEXT NOT NULL DEFAULT '',
direction_code INTEGER,
opt_name TEXT NOT NULL DEFAULT '',
price REAL NOT NULL DEFAULT 0,
volume REAL NOT NULL DEFAULT 0,
amount REAL NOT NULL DEFAULT 0,
commission_rate_wan REAL NOT NULL DEFAULT 0,
trade_time TEXT NOT NULL DEFAULT '',
fetched_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_trade_orders_date ON trade_orders (trade_date);
CREATE INDEX IF NOT EXISTS idx_trade_orders_ts ON trade_orders (insert_ts);
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);
```
### 10.2 关键设计决策(R-009 Q1-Q8 定稿)
| 决策 | 结论 | 理由 |
|---|---|---|
| 表范围 | trade_orders + trade_fills 两表 | 委托 1:N 成交,忠实 R-007 单表合并模型;保留无成交委托(待报/已撤/废单);避免 JSON 压扁 |
| 冗余 | **trade_orders 冗余 strategy_id + holding_id**(用户手动设置) | 老师二次定稿(Q3 修正):手动设置归属,便于过滤/展示/复盘;trade_fills 仍零冗余(跟随委托) |
| 委托时间派生列 | insert_tsinsert_date+insert_time 合成毫秒) | join 持仓生命周期窗口用(created_at ≤ t < closed_at |
| 策略/持仓归属 | **用户手动设置**(交易记录 tab 下拉:候选 = 该 code 当前持仓策略 + 未关联);持久化到 trade_orders.strategy_id + holding_idUPSERT 不覆盖归属列;可随时改以最终为准 | 一票多策略无法算法区分(300057.SZ 分属 grid/manual),人为确认符合「人机合一」(2026-09-01 老师二次定稿) |
| 同步 | 服务端 TradeSync:启动预热 + 60s 定时 UPSERT(幂等);前端今日轮询写穿 | 不依赖前端开 tab 也持续积累 |
| 代码归一化 | QMT 委托/成交 code 无后缀(001330),持仓带后缀(001330.SZ)——数据源映射层统一 `normalizeInstrumentCode`(补交易所后缀);委托交易日 = insertDate(无独立 tradeDate 字段),tradeDate 兜底 insertDate | 保证 FK 链 join 匹配 + 历史按时间段过滤正确(迭代 07 真实数据发现) |
| 同步范围 | 只同步当日(QMT 无历史接口) | 数据逐日积累 = 本地历史库 |
| 历史查询 | trades/history 端点(时间段/code/策略/方向过滤) | 复盘 + 按策略过滤 |
| 数据清理 | 不做自动清理(复盘需要历史) | 导出/清理后续迭代 |
### 10.3 数据流
```
启动时:TradeSync 启动预热一次(拉今日 orders+trades → UPSERT 落库,不覆盖归属列)
实盘中:TradeSync 60s 定时同步(UPSERT 幂等,状态覆盖更新,归属列保留)
前端今日轮询命中 orders/trades 端点 → 写穿本地(机会式)
用户: 交易记录 tab 手动设置每笔委托归属(strategy_id + holding_id 落库,可随时改)
查询时:今日 → QMT 实时(+ 本地归属);历史范围 → trades/history 查本地 SQLite(策略过滤 = 用户设置的归属)
```
### 10.4 对应实现(更新)
| 模块 | 职责 | 文件 |
|---|---|---|
| SqliteStore | +trade_orders/trade_fills 建表 + 交易 UPSERT/历史查询 + 策略归属推导 | src/storage/SqliteStore.js |
| DataStore | +交易记录方法(upsertTradeOrders/upsertTradeFills/queryTradeHistory | src/storage/DataStore.js |
| TradeSync | 服务端定时同步(启动预热 + 60s + UPSERT 幂等) | src/trades/TradeSync.js |
| api/trades.js | +trades/history 端点 | src/api/trades.js |
## 11. 约束条目(引用)
- 技术约束-012:数据存储遵循本文件(SQLite 存储设计,含交易记录表 §10);
- 技术约束-011:测试/回归脚本禁止在真实数据上执行写操作(独立数据目录);
- 本文件为新领域设计约束,后续数据存储变更以本文件为最终依据。