245fcad95c
- PositionSync: 内存快照 + 10s 定时全量同步 + 失败保留旧快照 + 空快照双重确认 (/health + getAsset 账户身份)+ 幽灵持仓自动清仓(连续3轮消失 + 账户身份守卫防误清) - PositionManager.getAllPositions 改读快照,空则读穿透兜底;未注入 sync 时兼容旧穿透 - 回归 test-position-sync.mjs 35 项(纯内存 mock,含 kept 分支刷新 syncedAt 防灯灰) - 文档链: R-014 + PLAN-013 + 迭代三件套 + 数据存储设计 §11(内存不落库决策与三问标准) 需求: R-014(老师拍板: 内存不落库/读穿透兜底/幽灵自动清仓/不显示同步时间)
390 lines
22 KiB
Markdown
390 lines
22 KiB
Markdown
# 数据存储设计(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.json(code 为中心)→ 新 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);不建 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/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-009(Q1-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, -- 交易日 YYYYMMDD(m_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_ts(insert_date+insert_time 合成毫秒) | join 持仓生命周期窗口用(created_at ≤ t < closed_at) |
|
||
| 策略/持仓归属 | **用户手动设置**(交易记录 tab 下拉:候选 = 该 code 当前持仓策略 + 未关联);持久化到 trade_orders.strategy_id + holding_id;UPSERT 不覆盖归属列;可随时改以最终为准 | 一票多策略无法算法区分(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 时保持旧行为(兼容) |
|
||
| 幽灵持仓自动清仓 | QMT 快照连续 3 轮(约 30s)消失的 code,本地全部策略当前持仓 closeHolding 转历史;**账户身份守卫**:accountId 未知当轮跳过、账户切换当轮重置跳过(防误清);部分减持不触发(负数未分配由 UI 暴露) | 老师选定;清仓转历史不物理删除,历史保留语义不变 |
|
||
| 前端 | 零改动 | 三个接口数据源自动切换 |
|
||
|
||
### 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. 约束条目(引用)
|
||
|
||
- 技术约束-012:数据存储遵循本文件(SQLite 存储设计,含交易记录表 §10);
|
||
- 技术约束-011:测试/回归脚本禁止在真实数据上执行写操作(独立数据目录);
|
||
- 本文件为新领域设计约束,后续数据存储变更以本文件为最终依据。 |