Files
one_divine_lot/docs/03-设计约束/数据存储设计.md
T

453 lines
27 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. 持仓内存快照(2026-09-02 优化)
> **变更理由**(老师拍板讨论):原实盘持仓在每次请求时穿透 QMT`PositionManager.getAllPositions` 实时拉 `/trade/positions`),带来 ① QMT 抖动 → 策略/全部持仓/未分配三个页面当场空白;② 每次进 tab 都打一次 QMT HTTP。改为服务端内存快照为准,10s 定时同步。
### 11.1 决策
| 决策 | 结论 | 理由 |
|---|---|---|
| 存储介质 | **纯内存**PositionSync 进程内快照),**不落库** | 持仓快照随时可用一次调用重拿全,不满足落库的任一正当条件(不可再生/重启首屏依赖);落库反而引入「过期快照冒充实时的说谎风险」;不复用 strategy_holdings(账本 ≠ 对账单,holding_id 是交易归属锚点,不可掺易变快照) |
| 同步 | PositionSync:启动预热 + 10s 定时全量拉取 → 校验 → 整体替换内存 | 与 TradeSync 同构;闸门校验与页面显示同源同鲜度 |
| 失败策略 | 同步失败**保留上次快照**,不清空不报错 | 读方继续消费旧快照,QMT 抖动不再白屏(强于旧的穿透行为) |
| 空快照 | 双重确认(/health 可用 + getAsset 账户身份可识别)才接受为「真清仓」,否则视为异常保留旧快照 | 防一次异常响应清空缓存 |
| 读路径 | `getAllPositions()` 读内存快照;快照为空 → 读穿透当场拉一次并回填;QMT 也挂 → 抛错(前端 LoadState 重试) | 首启兜底;未注入 positionSync 时保持旧行为(兼容) |
| ~~幽灵持仓自动清仓~~**退役,2026-09-08 R-018 数据域分界** | ~~QMT 快照连续 3 轮消失 → 本地全部策略当前持仓 closeHolding 转历史~~**不再写 strategy_holdings**:同步机制只作用于对账单域(老师拍板:幽灵清仓本就是一个同步机制,不可以让幽灵把爪子伸太长);账本转历史唯一途径 = 卖出单关联减至 0(R-018);对账单码消失 → 快照无此行 + 漏关联软提示(positions/orphan-hints,只读) | R-014 选定时防本地账本残留;R-018 改由交易关联驱动账本生命周期,幽灵自动归档与「账本只由关联交易改」冲突 → 退役(迭代 16 实施,见 §12) |
| 前端 | 零改动 | 三个接口数据源自动切换 |
### 11.2 数据流(持仓部分,替代原「读取时组装」描述)
```
启动:PositionSync 预热一次 → 内存快照
实盘:10s 定时全量同步(失败保留旧快照;空快照双重确认;幽灵清仓判定)
读取:getAllPositions() → 内存快照 →(空)读穿透回填
```
### 11.3 对应实现(追加)
| 模块 | 职责 | 文件 |
|---|---|---|
| PositionSync | 持仓内存快照同步(10s 定时 + 失败保留 + 空快照双重确认 + 幽灵清仓防抖) | src/position/PositionSync.js |
| PositionManager | getAllPositions 改读快照 + 读穿透兜底(未注入 sync 时兼容旧穿透) | src/position/PositionManager.js |
| 回归测试 | 纯内存 mock 34 项(同步/失败保留/空快照/幽灵防抖/账户守卫/读穿透/组装回归) | scripts/test-position-sync.mjs |
## 12. 归属分段存储设计(R-018 落实,2026-09-08 迭代 16 实施)
> **变更理由**(R-018 老师拍板):交易归属从「纯标签」(trade_orders 单组 strategy_id/holding_id,不改数量)升级为**账本写操作**——关联即自动调整 strategy_holdings 份额;支持一笔委托拆多段分配多策略;撤段做逆操作。归属真相源从 trade_orders 两列迁移到**分段表**。
### 12.1 strategy_holdings 状态扩展(作废第三态 + 清仓份额快照)
```sql
-- 幂等 ALTER(沿用 _ensureXxxColumn 模式)
ALTER TABLE strategy_holdings ADD COLUMN void_at INTEGER; -- 作废时间(NULL=有效;非 NULL=建仓被撤=从未成立;不进历史层)
ALTER TABLE strategy_holdings ADD COLUMN closed_shares REAL; -- 清仓前份额快照(closeHolding 写入,供撤卖出段恢复活动用)
-- 活动唯一索引收窄(R-018:活动 = closed_at IS NULL AND void_at IS NULL
DROP INDEX IF EXISTS idx_active_holding;
CREATE UNIQUE INDEX IF NOT EXISTS idx_active_holding
ON strategy_holdings (strategy_id, code) WHERE closed_at IS NULL AND void_at IS NULL;
```
- 生命周期操作语义:closeHolding = shares 置 0 + closed_at + **closed_shares=清仓前份额**shares 展示仍 0R-016 Q2 语义不变);voidHolding(新增)= shares 置 0 + void_at(非清仓:建仓被撤 = 从未成立);
- 作废行不进 R-016 历史「已清仓」层(getHoldingsHistory 过滤 void_at IS NULL),保留数据可追溯。
### 12.2 归属分段表 trade_order_attributions
```sql
CREATE TABLE IF NOT EXISTS trade_order_attributions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_id TEXT NOT NULL, -- → trade_orders.order_id
strategy_id TEXT NOT NULL, -- 段归属策略
holding_id INTEGER NOT NULL, -- 段锚点持仓(R-010 展开用)
code TEXT NOT NULL, -- 冗余
direction TEXT NOT NULL, -- buy/sell(冗余,撤段判向)
volume REAL NOT NULL, -- 该段已成交量(>0;总额 ≤ order.traded_volume
created_at INTEGER NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_attr_order_strategy ON trade_order_attributions (order_id, strategy_id);
CREATE INDEX IF NOT EXISTS idx_attr_holding ON trade_order_attributions (holding_id);
CREATE INDEX IF NOT EXISTS idx_attr_strategy ON trade_order_attributions (strategy_id);
```
- **真相源 = 段表**trade_orders.strategy_id/holding_id 退役为冗余(保留列不删,防外部脚本 break;不再作为查询/过滤源);
- 存量迁移:段表空且 trade_orders 有单组归属 → 迁为第一段(volume = 该单 traded_volume;幂等);
- 查询适配:orders 附加归属、by-holdingR-010)、历史策略过滤(R-009)全部改经段表。
### 12.3 关键设计决策(R-018 迭代 16)
| 决策 | 结论 | 理由 |
|---|---|---|
| 归属真相源 | **分段表 trade_order_attributions**order → 多段 strategyId/holdingId/volume | 一笔委托可拆 N 段关联不同策略(R2/R4);单列两字段无法表达 |
| 数据域分界 | PositionSync/幽灵清仓**只写对账单快照**,不写 strategy_holdings | R-018 老师拍板:同步机制不伸爪子到账本;账本只由交易关联 + 手动份额操作驱动 |
| 关联驱动量 | 段 volume = 该段**已成交量** | Q4:撤单/废单不动账 |
| 撤段逆操作 | 撤 buy 段 → 减回 / 归零作废(void);撤 sell 段 → 加回 / 恢复活动仓(closed_shares | R3/R6/R7:作废 ≠ 清仓(不产生假清仓历史);恢复用 closed_shares 快照 |
| 撤段约束 | 同 holding 内**逆序撤销**(乱序返回 segment-order-conflict);跨 holding 段独立可任意撤 | 账本正确性 > 操作便利(宁可拒绝不写错账) |
| 事务 | setSegments 全量替换在**单事务**内(撤旧段 + 加新段) | 改归属原子性(R3 边界) |
### 12.4 对应实现(追加)
| 模块 | 职责 | 文件 |
|---|---|---|
| AttributionService | 归属服务:apply/revoke/setSegments(动作表判定 + 逆操作 + 事务) | src/trades/AttributionService.js(新增) |
| SqliteStore | 增列/索引重建/voidHolding/close 快照/段表 CRUD/runInTransaction/查询改段表/存量迁移 | src/storage/SqliteStore.js |
| api/trades.js | attribution-targets / attribution-set / attribution-segmentsorders 附加段 | src/api/trades.js |
| PositionSync | 删除幽灵清仓逻辑(只同步对账单快照) | src/position/PositionSync.js |
| 回归测试 | 分界/动作表/撤段/迁移/软提示 | scripts/test-r018-attribution.mjs(新增) |
## 13. 约束条目(引用)
- 技术约束-012:数据存储遵循本文件(SQLite 存储设计,含交易记录表 §10、归属分段存储 §12);
- 技术约束-011:测试/回归脚本禁止在真实数据上执行写操作(独立数据目录);
- 技术约束-017(变更 1,2026-09-08):幽灵自动清仓退役,PositionSync 只同步对账单快照;
- 本文件为新领域设计约束,后续数据存储变更以本文件为最终依据。