迭代14: 策略tab历史持仓展示(R-016)+ 今日已清仓默认层
- R-016 Q1-Q7: 两个策略 tab title 旁「历史持仓」开关 + 清仓范围筛选(近一周默认/1月/3月/半年/1年) - SqliteStore.getHoldingsHistory(strategy_id + closed_at 非空 + sinceMs,closed_at DESC,只读) - DataStore 门面透传 + strategy-holdings/history 端点(name 兜底 + 参数校验) - 前端: 开关/范围下拉/历史行灰显+「已清仓」徽标/展开复用 R-010(rowKey 唯一化) - R-016 二轮补充(Q8-Q9 老师拍板): 今日已清仓默认层 - range=today = 本地自然日 00:00 起(特判零点,不落回溯毫秒档) - 当天已清仓的持仓默认恒显示、不受「历史持仓」开关控制(开关只控今天之前) - rowsToRender 分层合成 + holdingId 去重;标题计数不含已清仓行;零写路径变更 - 回归: test-r016-history 24/24 + 存量回归全绿
This commit is contained in:
@@ -0,0 +1,49 @@
|
|||||||
|
# 计划:策略 tab 历史持仓展示(显示/隐藏已清仓持仓 + 清仓时间范围筛选)(阶段航点)
|
||||||
|
|
||||||
|
> 编号:PLAN-015 | 粒度:阶段航点 | 创建:2026-09-07 | 状态:**已实施(待验收)**
|
||||||
|
> 派生自终极目标:目标-003(按策略监控市场)、目标-008(真实交易系统接入)
|
||||||
|
> 依据需求:**R-016(已定稿,2026-09-07,Q1-Q7 老师拍板)** —— 符合入范围门槛
|
||||||
|
> 设计约束:新增 技术约束-019(历史持仓查询走本地库,当前持仓快照语义不掺历史)
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
两个策略 tab(做T / 网格超市)title 旁提供「历史持仓」开关 + 清仓时间范围筛选(默认近一周,另有近 1 个月 / 近 3 个月 / 近半年 / 近 1 年);历史持仓行可展开查看关联交易记录(复用 R-010),补上「清仓即失联」的操作追溯缺口。
|
||||||
|
|
||||||
|
## 范围
|
||||||
|
|
||||||
|
**做**:
|
||||||
|
1. **存储层**(SqliteStore):新增 getHoldingsHistory(strategyId, { sinceMs }) —— closed_at 非空 + closed_at ≥ sinceMs 过滤 + 按 closed_at DESC 排序(读只读,零写路径变更,不改 closeHolding 语义);
|
||||||
|
2. **门面层**(DataStore):getHoldingsHistory 透传(await _ensure 后委托);
|
||||||
|
3. **API**:新增 strategy-holdings/history 端点(args: strategyId 必填 + range ∈ week|month|quarter|halfYear|year,服务端换算 sinceMs;缺失/非法参数报参数错误);strategy-positions 等现有端点零改动;
|
||||||
|
4. **前端 StrategyTab**:
|
||||||
|
- 头部按钮区(+ 添加持仓 / 列设置 旁)新增「历史持仓」开关按钮 + 范围下拉(仅开关开启时显示;默认 week);
|
||||||
|
- 开启时懒加载历史数据(切换范围重新拉取;关闭即隐藏不销毁缓存);进 tab 默认关(会话级,不持久化);
|
||||||
|
- 历史行渲染在当前持仓行之后:代码 / 名称(关联委托 name 兜底,无则 —)/ 份额(显示 DB 原值 0,Q2)/ 清仓时间 / 持有天数;实时行情列(现价/涨幅/涨停跌停/今开/最高)显示 —;
|
||||||
|
- 历史行灰显 + 「已清仓」徽标(含清仓日期);标题计数(N 只)只数当前持仓;
|
||||||
|
- 历史行行展开复用 R-010(trades/by-holding,按 holdingId 懒加载);
|
||||||
|
- 自定义字段列历史行只读展示(values 随行返回,无编辑入口);
|
||||||
|
5. **回归脚本** scripts/test-r016-history.mjs(独立临时数据目录,技术约束-011)。
|
||||||
|
|
||||||
|
**不做**:全部持仓 tab;盈亏统计;清仓方式标记;历史行实时行情;成本价/收益推导;closeHolding 语义与任何存储写入路径变更;范围偏好持久化;「全部历史」档位。
|
||||||
|
|
||||||
|
## 涉及文件
|
||||||
|
|
||||||
|
- 修改:src/storage/SqliteStore.js、src/storage/DataStore.js、src/api/strategies.js、src/client/views/StrategyTab.jsx
|
||||||
|
- 新增:scripts/test-r016-history.mjs
|
||||||
|
|
||||||
|
## 实现步骤
|
||||||
|
|
||||||
|
1. 本文档链(PLAN + 迭代三件套 + 技术约束-019);2. SqliteStore.getHoldingsHistory + DataStore 门面;3. API 端点;4. StrategyTab 开关/范围/历史行渲染/展开复用;5. 回归脚本 + typecheck + build + 存量回归(test-position-sync / test-r013 / test-quote-sync);6. 复盘。
|
||||||
|
|
||||||
|
## 验收要点
|
||||||
|
|
||||||
|
见 `docs/04-迭代记录/14-策略tab历史持仓展示/验收标准.md`。
|
||||||
|
|
||||||
|
## 二轮补充(2026-09-07 Q8-Q9 老师拍板:今日已清仓默认层)
|
||||||
|
|
||||||
|
- **补充范围**:已清仓行分两层——L1「今日已清仓」(range='today',本地自然日 00:00 起)默认恒显示、不受「历史持仓」开关控制;L2「历史范围层」(今天之前的 week/month/quarter/halfYear/year)仍由开关控制(默认关)。前端按 holdingId 去重两层(开启范围不重复出两行);标题计数不含已清仓行维持;零写路径变更维持。
|
||||||
|
- **不做(维持首轮)**:不做统计 / 不改 closeHolding / 范围偏好不持久化 / 全部持仓 tab 不加 / 不新增「全部历史」档。
|
||||||
|
|
||||||
|
## 验收要点(二轮)
|
||||||
|
|
||||||
|
见验收标准「二轮补充验收线 7-10」;自动化已复跑:test-r016 24/24 + 存量回归全绿。
|
||||||
@@ -28,6 +28,8 @@
|
|||||||
| 技术约束-016 | src 目录按功能域归类(2026-09-02 结构优化):服务端代码**禁止平铺**,按职责域分目录 —— src/data-source/(QmtBridgeRestDataSource + data-source-types + QmtHealthMonitor,数据源与连接健康)、src/storage/(SqliteStore + DataStore,存储层)、src/position/(PositionManager,分仓逻辑)、src/market/(MarketDataHub + MarketFeed,行情)、src/trades/(TradeSync,交易同步);api/ 按领域拆分子文件(positions/strategies/qmt-connections/market/trades),client/ 仅放 UI(views/ 组件 + market/ provider);文件命名 = 类名(PascalCase)+ .js/.jsx;新增服务端模块必须先落对应域目录,无合适域时先讨论补域,不得回退平铺 | 2026-09-02 | 生效 | - | 新增(2026-09-02 结构审查 + 优化落地):component/ 平铺还原为语义分域,删除死代码 AllocationStorage、DataStore.setDataset/removeDataset |
|
| 技术约束-016 | src 目录按功能域归类(2026-09-02 结构优化):服务端代码**禁止平铺**,按职责域分目录 —— src/data-source/(QmtBridgeRestDataSource + data-source-types + QmtHealthMonitor,数据源与连接健康)、src/storage/(SqliteStore + DataStore,存储层)、src/position/(PositionManager,分仓逻辑)、src/market/(MarketDataHub + MarketFeed,行情)、src/trades/(TradeSync,交易同步);api/ 按领域拆分子文件(positions/strategies/qmt-connections/market/trades),client/ 仅放 UI(views/ 组件 + market/ provider);文件命名 = 类名(PascalCase)+ .js/.jsx;新增服务端模块必须先落对应域目录,无合适域时先讨论补域,不得回退平铺 | 2026-09-02 | 生效 | - | 新增(2026-09-02 结构审查 + 优化落地):component/ 平铺还原为语义分域,删除死代码 AllocationStorage、DataStore.setDataset/removeDataset |
|
||||||
| 技术约束-017 | 持仓内存快照(R-014,2026-09-02):全量实盘持仓由服务端 PositionSync **进程内内存快照**管理(启动预热 + 10s 定时全量拉 /trade/positions → 校验 → 整体替换),**不落库**(无缓存表、不复用 strategy_holdings——账本与对账单分离,holding_id 交易锚点不掺易变快照;判断标准:外部可一次调用重取全的实时投影不落库);PositionManager.getAllPositions 以快照为准(strategy-positions / unallocated / summary 三接口不再请求时穿透 QMT),快照为空读穿透兜底(当场拉一次并回填);同步失败保留上次快照(不清空不报错);空快照双重确认(/health 可用 + getAsset 账户身份可识别)才接受为真清仓;幽灵清仓:快照连续 3 轮(约 30s)消失的 code 该码全部策略当前持仓自动 closeHolding 转历史(不物理删除),账户身份守卫防误清(accountId 未知当轮跳过、切换当轮重置跳过);syncNow 允许手动调用(mounted 只管定时循环,不拦手动同步) | 2026-09-02 | 生效 | - | 新增(2026-09-02 R-014 定稿 + 迭代 12 实施):AI 初版建缓存表方案经老师质疑反转为内存方案(落库三问:不可再生?重启首屏依赖?读放大/复杂查询?全否 → 内存) |
|
| 技术约束-017 | 持仓内存快照(R-014,2026-09-02):全量实盘持仓由服务端 PositionSync **进程内内存快照**管理(启动预热 + 10s 定时全量拉 /trade/positions → 校验 → 整体替换),**不落库**(无缓存表、不复用 strategy_holdings——账本与对账单分离,holding_id 交易锚点不掺易变快照;判断标准:外部可一次调用重取全的实时投影不落库);PositionManager.getAllPositions 以快照为准(strategy-positions / unallocated / summary 三接口不再请求时穿透 QMT),快照为空读穿透兜底(当场拉一次并回填);同步失败保留上次快照(不清空不报错);空快照双重确认(/health 可用 + getAsset 账户身份可识别)才接受为真清仓;幽灵清仓:快照连续 3 轮(约 30s)消失的 code 该码全部策略当前持仓自动 closeHolding 转历史(不物理删除),账户身份守卫防误清(accountId 未知当轮跳过、切换当轮重置跳过);syncNow 允许手动调用(mounted 只管定时循环,不拦手动同步) | 2026-09-02 | 生效 | - | 新增(2026-09-02 R-014 定稿 + 迭代 12 实施):AI 初版建缓存表方案经老师质疑反转为内存方案(落库三问:不可再生?重启首屏依赖?读放大/复杂查询?全否 → 内存) |
|
||||||
| 技术约束-018 | 盘口内存快照(R-015,2026-09-02):行情数据由 QuoteSync(取数:启动 prime + 5s REST 定时刷 watch 集合 + 涨停跌停经 /data/instrument 按**交易日**内存缓存)与 QuoteHub(存查:内存快照 Map + watchCodes Set + 读穿透走 dataSource.getTicks + getQuote/getByCodes 对外)管理,**替换并删除 MarketFeed/MarketDataHub**(方案 A,不留兼容壳);**WS 数据通路移除**(ingest 入口带 source 标签留回归口子);**market_quotes_cache 表退役 DROP**(幂等),价格单一入口 = QuoteHub,DataStore 行情方法(loadMarket/getMarketQuote(s)/setMarketQuotes)删除;同步失败保留内存旧值;watchCodes 维持只进不出无上限;涨停/跌停/昨收不落库 | 2026-09-02 | 生效 | - | 新增(2026-09-02 R-015 定稿 + 迭代 13 实施):老师五拍板(替换/WS 移除/纯内存 DROP/watch 现状/指示灯);warmup bug 复现(loadMarket return this 残迹)为不落库关键证据 |
|
| 技术约束-018 | 盘口内存快照(R-015,2026-09-02):行情数据由 QuoteSync(取数:启动 prime + 5s REST 定时刷 watch 集合 + 涨停跌停经 /data/instrument 按**交易日**内存缓存)与 QuoteHub(存查:内存快照 Map + watchCodes Set + 读穿透走 dataSource.getTicks + getQuote/getByCodes 对外)管理,**替换并删除 MarketFeed/MarketDataHub**(方案 A,不留兼容壳);**WS 数据通路移除**(ingest 入口带 source 标签留回归口子);**market_quotes_cache 表退役 DROP**(幂等),价格单一入口 = QuoteHub,DataStore 行情方法(loadMarket/getMarketQuote(s)/setMarketQuotes)删除;同步失败保留内存旧值;watchCodes 维持只进不出无上限;涨停/跌停/昨收不落库 | 2026-09-02 | 生效 | - | 新增(2026-09-02 R-015 定稿 + 迭代 13 实施):老师五拍板(替换/WS 移除/纯内存 DROP/watch 现状/指示灯);warmup bug 复现(loadMarket return this 残迹)为不落库关键证据 |
|
||||||
|
|
||||||
|
| 技术约束-019 | 历史持仓查询(R-016,2026-09-07):策略 tab 历史持仓展示走**本地库只读查询**(SqliteStore.getHoldingsHistory:strategy_id + closed_at IS NOT NULL + closed_at ≥ sinceMs,closed_at DESC),经独立端点 strategy-holdings/history 暴露;**当前持仓路径(strategy-positions / PositionSync 快照语义)不掺历史数据**(两份结果前端合并渲染);closeHolding 置 shares=0 语义维持不变(Q2 老师拍板:历史行份额显示 0,重点在追溯该持仓的历史操作而非清仓时份额);范围换算服务端做(week=7d/month=30d/quarter=90d/halfYear=182d/year=365d 自然日近似)——**变更 1(2026-09-07 二轮补充 Q8-Q9 老师拍板)**:range 新增 'today' = **本地自然日 00:00 起**(特判零点,不落回溯毫秒档),供前端「今日已清仓默认层」(恒显示、不受历史持仓开关控制);历史范围层语义收窄为**今天之前**;其余不变 | 2026-09-07 | 生效 | - | 变更 1(2026-09-07 R-016 二轮补充 Q8-Q9 老师拍板):+range=today 自然日边界,历史开关只控今天之前;首轮新增(Q1-Q7):历史行=追溯操作锚点,不动存储写路径 |
|
||||||
<!-- 示例条目(确认格式后删除):
|
<!-- 示例条目(确认格式后删除):
|
||||||
| 技术约束-001 | 示例:技术栈以 Node.js / TypeScript 为准,不引入未讨论的新框架 | 2026-08-26 | 生效 | - | 讨论确认:优先复用 DSH 既有能力,新框架需论证 |
|
| 技术约束-001 | 示例:技术栈以 Node.js / TypeScript 为准,不引入未讨论的新框架 | 2026-08-26 | 生效 | - | 讨论确认:优先复用 DSH 既有能力,新框架需论证 |
|
||||||
-->
|
-->
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
# 技术实现方案:14-策略tab历史持仓展示
|
||||||
|
|
||||||
|
> 迭代编号:14 | 依据:PLAN-015 + R-016(老师拍板:Q2 显示 0 / 其余按建议)
|
||||||
|
|
||||||
|
## 1. 存储层(SqliteStore.getHoldingsHistory)
|
||||||
|
|
||||||
|
```js
|
||||||
|
/** 某策略已清仓持仓(closed_at 非空),closed_at ≥ sinceMs,按 closed_at DESC */
|
||||||
|
getHoldingsHistory(strategyId, { sinceMs } = {}) {
|
||||||
|
// SELECT holding_id, strategy_id, code, shares, created_at, closed_at, "values"
|
||||||
|
// FROM strategy_holdings
|
||||||
|
// WHERE strategy_id=? AND closed_at IS NOT NULL [AND closed_at >= ?]
|
||||||
|
// ORDER BY closed_at DESC
|
||||||
|
// 返回 _mapHolding 同构(holdingId/strategyId/code/shares/createdAt/closedAt/values)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- 只读新增;**零写路径变更**(closeHolding 维持 shares=0 + closed_at,Q2 定稿);
|
||||||
|
- sinceMs 缺省 = 不过滤(内部能力预留;API 层始终传值);
|
||||||
|
- 与 getHoldingHistory(code) 并存:后者是全表按 code 查(R-008 遗留),不满足本需求维度,不复用不改动。
|
||||||
|
|
||||||
|
## 2. 门面层(DataStore)
|
||||||
|
|
||||||
|
```js
|
||||||
|
async getHoldingsHistory(strategyId, opts) { await this._ensure(); return this.sqlite.getHoldingsHistory(strategyId, opts); }
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. API(src/api/strategies.js 新 method)
|
||||||
|
|
||||||
|
- method:`strategy-holdings/history`;
|
||||||
|
- args:`{ strategyId: string, range: 'week'|'month'|'quarter'|'halfYear'|'year' }`;
|
||||||
|
- range → sinceMs 换算(服务端):week=7d / month=30d / quarter=90d / halfYear=182d / year=365d(自然日近似,回溯窗口展示用途足够,精确日历边界无业务含义);
|
||||||
|
- 缺 strategyId 或 range 非法 → 参数错误(code: 'bad-request');
|
||||||
|
- 返回行附加 name 兜底:按 holdingId 查 trade_orders 取最新一笔的 name(SQL 单查,无则 null)——前端直接展示,免二次请求。
|
||||||
|
|
||||||
|
## 4. 前端(StrategyTab.jsx)
|
||||||
|
|
||||||
|
- 状态:`showHistory`(默认 false)、`historyRange`(默认 'week')、`historyRows`、`historyLoading`;
|
||||||
|
- 头部按钮区:「历史持仓」toggle 按钮 + 开启时显示范围下拉(近一周/近1个月/近3个月/近半年/近1年);
|
||||||
|
- 数据流:开启或切范围 → call('one-divine-lot/strategy-holdings/history', { strategyId, range });缓存按 range 键保留,关闭仅隐藏;
|
||||||
|
- 渲染:rowsForRender = 当前持仓行 +(showHistory ? 历史行 : []);历史行标识 `isHistory: true`;
|
||||||
|
- 行样式:opacity/灰色调(--dsw-alias-label-tertiary)+ 徽标「已清仓 YYYY-MM-DD」;
|
||||||
|
- 单元格:代码/名称/清仓时间(YYYY-MM-DD HH:mm)/持有天数(ceil((closedAt-createdAt)/86400000),同日=1);shares 列显示 DB 原值(=0,Q2);行情类列(lastPrice/pctChange/lastClose/avgPrice/lastTradePrice/upStop/downStop/open/high)一律 —(不注册行情、不取价);自定义字段列:p.values 只读展示,禁点击编辑(isHistory 不进入 FieldCellEditor 分支);
|
||||||
|
- 展开:复用 toggleExpand(code, holdingId) → trades/by-holding(历史行同样有效);
|
||||||
|
- 排序:当前行维持现状(涨幅排序仅作用于当前行集合);历史行固定清仓时间 DESC 追加在后;
|
||||||
|
- 计数:标题(N 只)= 当前持仓数,不含历史行。
|
||||||
|
|
||||||
|
## 5. 回归脚本(scripts/test-r016-history.mjs)
|
||||||
|
|
||||||
|
- ODL_TEST_DATA_DIR 临时目录(技术约束-011,不碰真实数据);
|
||||||
|
- 覆盖:建仓→清仓→history 可查 / 范围过滤边界(7d/30d…)/ 排序 / 策略隔离 / 未清仓行不出现 / API 参数校验 / name 兜底。
|
||||||
|
|
||||||
|
## 6. 验证链
|
||||||
|
|
||||||
|
typecheck → build → test-r016 → 存量回归(test-position-sync 34 / test-r013 21 / test-quote-sync)→ 人工验收清单。
|
||||||
|
|
||||||
|
## 7. 二轮补充实现:今日已清仓默认层(2026-09-07 老师拍板 Q8-Q9)
|
||||||
|
|
||||||
|
> 在首轮(§1-6)基础上追加;首轮实现保持可追溯,新增改动如下。
|
||||||
|
|
||||||
|
### 7.1 服务端(src/api/strategies.js)
|
||||||
|
|
||||||
|
- range 新增 today:**特判本地自然日零点**(localDayStartMs,setHours(0,0,0,0)),sinceMs = 本地今天 00:00;不落入 HISTORY_RANGE_MS 回溯毫秒档(那 5 档语义 = now − 范围,today 语义 = 自然日边界,两类不可混);
|
||||||
|
- 参数校验错误信息扩为 today|week|month|quarter|halfYear|year;其余逻辑(strategy_id 过滤 + name 兜底 + DESC 排序)与 5 档完全复用——**SqliteStore/DataStore 零改动**(sinceMs 本就通用)。
|
||||||
|
|
||||||
|
### 7.2 前端(src/client/views/StrategyTab.jsx)
|
||||||
|
|
||||||
|
- 状态:historyRows 缓存键新增 today(与 5 档并存互不干扰);
|
||||||
|
- **数据流**:loadHistory 定义提前到 load 之前;load() 的 finally 里调 loadHistory('today')——进 tab 即拉 + 每次持仓重载(移出/加仓/编辑后 load())都刷新今日已清仓层,当天清仓转历史后 tab 内不因此少行;
|
||||||
|
- **渲染合成(rowsToRender)**分两层:
|
||||||
|
- L1 = histRowsFor('today') —— **恒显示**(showHistory 关也显示);当前持仓行后追加,清仓时间 DESC;
|
||||||
|
- L2 = showHistory 开时追加 histRowsFor(historyRange),**按 holdingId 去重**(剔除已含于 L1 的行,避免今天清仓 + 开启近一周出现两行);
|
||||||
|
- 交互文案:开关 title 更新为「显示/隐藏今天之前已清仓的历史持仓(当天已清仓的持仓始终显示)」;文件头注释同步;
|
||||||
|
- 标题计数(N 只)仍只算当前持仓,两层已清仓行不计入。
|
||||||
|
|
||||||
|
### 7.3 回归(scripts/test-r016-history.mjs)
|
||||||
|
|
||||||
|
- 新增 [4b] range=today:昨天 23:59:59 清仓不出现 / 今天 00:00:01 清仓出现 / 不含 40 天前行 / today 行 name 兜底同样生效 / range 非法校验不受 today 引入影响;
|
||||||
|
- 验证链复跑:test-r016 24/24 + 存量回归全绿(见迭代复盘)。
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# 迭代复盘:14-策略tab历史持仓展示
|
||||||
|
|
||||||
|
> 复盘日期:2026-09-07 | 迭代状态:**已实施,待老师人工验收**
|
||||||
|
> 关联需求:R-016 | 关联计划:PLAN-015
|
||||||
|
|
||||||
|
## 结果
|
||||||
|
|
||||||
|
迭代 14 达成:两个策略 tab(做T / 网格超市)title 旁新增「历史持仓」开关 + 清仓时间范围筛选(近一周默认 / 近 1 个月 / 近 3 个月 / 近半年 / 近 1 年);已清仓持仓行灰显 + 「已清仓」徽标(含清仓日期)追加在当前持仓行后(closed_at DESC),展开复用 R-010 查看关联交易——补上「清仓即失联」的操作追溯缺口。当前持仓快照路径(R-014 语义)与所有写路径(closeHolding 置 shares=0,Q2 拍板)零改动。
|
||||||
|
|
||||||
|
## 过程事实
|
||||||
|
|
||||||
|
1. **讨论驱动定稿**(2026-09-07,Q1-Q7 老师逐项拍板):Q2 为本轮唯一改判——AI 建议改 closeHolding 保留清仓时份额,老师否决:「显示 0 就好了,即使显示了清仓时的 shares 也没多少意义,主要目标是追溯这次持仓相关的历史操作」——需求核心从「数据完整性」校正为「操作追溯锚点」,方案随之收敛为零写路径变更;其余六问均按 AI 建议定稿;
|
||||||
|
2. **实现**(严格按 PLAN-015 步骤):SqliteStore.getHoldingsHistory(strategy_id + closed_at IS NOT NULL + sinceMs 过滤 + DESC,只读新增)→ DataStore 门面透传 → strategy-holdings/history 端点(range 五档服务端换算自然日近似 + name 关联委托兜底 + 参数校验 bad-request)→ StrategyTab 开关/范围下拉/历史行渲染;
|
||||||
|
3. **实现中发现并修复一个真实隐患**:R-010 展开状态与交易缓存原以 code 为键——同码「当前行 + 历史行」并存(清仓后重新买入)时两行会同时展开、缓存串行;本轮把展开状态/缓存/行键统一收敛为唯一 rowKey(当前行 = code,历史行 = 'h-' + holdingId),toggleExpand 签名同步变更(当前调用方仅 tbody 一处);
|
||||||
|
4. **验证**:test-r016-history.mjs 19/19(清仓转历史可查 / 5 档范围边界 / 排序 / 策略隔离 / 未清仓不出现 / 参数校验 / name 兜底 / 端点分发零破坏);typecheck 通过;存量回归 test-position-sync 35/35、test-r013 21/21、test-quote-sync 27/27;build 通过(client bundle 164KB wrapped);
|
||||||
|
5. **文档链**:R-016 定稿 + PLAN-015 + 迭代三件套 + 技术约束-019 + 需求池索引(R-016 登记;顺带补登遗漏的 R-015 行);
|
||||||
|
6. **部署态排查(老师首验无数据,2026-09-07)**:实锤定位 = **运行中的 DSH 服务端进程仍是旧代码**——同进程 /odl/api/summary 正常应答、新端点返回 unknown method(curl 实测);客户端 bundle 因插件 symlink 直连源码 lib/ 而被刷新(按钮已出现),服务端模块注册表却停留在进程启动时;处置 = ① loadHistory 失败从静默吞空改为 Toast 可见化(静默兜底=排障盲区,本轮教训)并重新构建(test-r016 19/19 复跑通过);② 服务端生效依赖 DSH web 进程重启(插件 symlink 方式安装,无需重装,重启即取新 lib)。
|
||||||
|
|
||||||
|
## 经验教训(复盘沉淀)
|
||||||
|
|
||||||
|
### 1. 需求的「核心目标」要听老师的定调,不要替老师拔高
|
||||||
|
- AI 在 Q2 提议改 closeHolding 语义以「保留清仓时份额」,出发点是账本完整性;老师一句话把需求核心定调为「追溯历史操作」——份额数字本身没有追溯价值,holding_id 锚点 + 关联委托才有;
|
||||||
|
- 沉淀:讨论中 AI 的方案建议要标明「我认定的价值假设」,让老师有机会否定假设本身,而不只是选项。
|
||||||
|
|
||||||
|
### 2. 复用既有交互时,键的唯一性要先审后用
|
||||||
|
- 展开状态按 code 键控在「一码一行」时代是隐含正确的;历史行引入打破了「一码一行」不变量——同类隐含假设(缓存 Map、React key、loading 标记)都要在数据形态变化时重新过一遍;
|
||||||
|
- 沉淀:向既有列表引入第二类行时,先列出所有「按 X 键控」的状态,逐个判断是否需要升级为行唯一键。
|
||||||
|
|
||||||
|
### 3. 只读需求的实现应当「零写路径」可断言
|
||||||
|
- 本轮全程未触碰任何 INSERT/UPDATE 语句与语义(closeHolding 分毫未动),回归脚本专门加了「strategy-positions 分发不变」断言——历史查询与当前查询的隔离在端点层就已成立(独立 method),不需要靠约定;
|
||||||
|
- 沉淀:「只读功能」的范围声明落成验收断言(分发不变 + 写路径 diff 为空),比口头承诺可靠。
|
||||||
|
|
||||||
|
## 遗留/后续
|
||||||
|
|
||||||
|
1. **归属候选不纳入清仓持仓**(R-016 关联观察,2026-09-07 大连热电实际遇到):清仓后当日委托无法再通过下拉关联到对应 holding——是否立独立需求(候选纳入近期清仓持仓)待老师定;
|
||||||
|
2. **历史行成本价/收益推导**:本期不做(边界定稿);若后续要做,数据源 = 关联委托成交记录,属展示层推导,不动存储;
|
||||||
|
3. **清仓方式标记**(手动 vs 幽灵自动):数据未区分,本期不做;若要做需 closeHolding 增加来源标记(涉及写路径,另立需求);
|
||||||
|
4. **R-015 / 迭代 13 人工验收**仍待老师确认(与本迭代无依赖)。
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# 迭代目标:14-策略tab历史持仓展示
|
||||||
|
|
||||||
|
> 迭代编号:14 | 创建:2026-09-07 | 状态:已实施,待验收
|
||||||
|
> 依据计划:PLAN-015 | 需求:R-016(已定稿,2026-09-07,Q1-Q7 老师拍板)
|
||||||
|
|
||||||
|
## 目标描述
|
||||||
|
|
||||||
|
两个策略 tab(做T / 网格超市)title 旁添加「历史持仓」功能:① 显示/隐藏已清仓历史持仓的开关;② 显示历史时默认展示近一周清仓的持仓,并提供 近 1 个月 / 近 3 个月 / 近半年 / 近 1 年 范围选项。历史持仓行复用 R-010 行展开查看关联交易记录——补上「清仓即失联」的操作追溯缺口(核心目标:追溯该持仓相关的历史操作,老师 2026-09-07 定调)。
|
||||||
|
|
||||||
|
## 目标分解
|
||||||
|
|
||||||
|
1. **存储查询**(SqliteStore.getHoldingsHistory):closed_at 非空 + closed_at ≥ sinceMs + strategy_id 过滤,按 closed_at DESC;只读新增,不动任何写路径(closeHolding 置 shares=0 语义维持,Q2 老师拍板「显示 0 就好」);
|
||||||
|
2. **门面透传**(DataStore.getHoldingsHistory);
|
||||||
|
3. **API 端点**(strategy-holdings/history):strategyId 必填 + range ∈ week|month|quarter|halfYear|year(服务端换算 sinceMs;week 为前端默认但服务端不设隐式默认,参数缺失报错);
|
||||||
|
4. **前端**(StrategyTab):头部按钮区加「历史持仓」开关 + 范围下拉(开启时显示,默认 week);懒加载 + 范围切换重拉 + 关闭隐藏(缓存保留);历史行追加在当前持仓行后(清仓时间降序)、灰显 + 「已清仓」徽标、标题计数不含历史行;历史行字段 = 代码/名称(关联委托 name 兜底)/份额(0)/清仓时间/持有天数,行情列 —,自定义字段列只读;行展开复用 R-010;
|
||||||
|
5. **回归**:scripts/test-r016-history.mjs(ODL_TEST_DATA_DIR 临时目录)+ typecheck + build + 存量回归三脚本。
|
||||||
|
|
||||||
|
## 二轮补充(2026-09-07 验收前追加:今日已清仓默认层,Q8-Q9 老师拍板)
|
||||||
|
|
||||||
|
- **补充诉求**:当天(本地自然日 00:00 起)已清仓的持仓**默认显示**;「历史持仓」开关只控制**今天之前**的历史——历史持仓关 ≠ 当天已清仓也被藏起(首轮「进 tab 默认只看当前」使当天清仓行当场不可见,补上该缺口);
|
||||||
|
- **实现增量**:① 服务端 range='today' 特判本地自然日零点(不落回溯毫秒档);② 前端 rowsToRender 分两层——L1 今日已清仓恒显示(缓存键 'today',随每次持仓重载刷新)+ L2 历史范围层受开关控制,层间按 holdingId 去重;③ 视觉/计数维持首轮(灰显+徽标、计数不含已清仓行);
|
||||||
|
- 影响面:只动只读查询 + 前端渲染合成;**零写路径变更**(closeHolding 语义仍不动);
|
||||||
|
- 回归:test-r016 追加 range=today 自然日边界用例(24/24)。
|
||||||
|
|
||||||
|
## 对老师/主理人的配合需求
|
||||||
|
|
||||||
|
- 实现完成后需老师真实环境人工验收(开关/范围切换/历史行展示/展开复盘/与真实清仓数据对照);
|
||||||
|
- 二轮补充后重点验收:**「历史持仓」关时当天清仓的持仓仍显示**、「历史持仓」开时今天之前的行按范围显示、层间无重复行;
|
||||||
|
- 验收通过后本迭代标记「验收通过」,R-016 进入归档流程。
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# 验收标准:14-策略tab历史持仓展示
|
||||||
|
|
||||||
|
> 迭代编号:14 | 依据:PLAN-015 验收要点 + R-016
|
||||||
|
|
||||||
|
## 验收标准线
|
||||||
|
|
||||||
|
1. **自动化**:typecheck + build 通过;test-r016-history.mjs 全绿(清仓转历史可查 / 5 档范围过滤与边界 / closed_at DESC 排序 / 策略隔离 / 未清仓行不出现 / 参数校验 / name 兜底);存量回归 test-position-sync、test-r013、test-quote-sync 全绿;
|
||||||
|
2. **入口与默认态**:做T / 网格超市两个 tab title 旁出现「历史持仓」开关;默认关闭 = 与现状完全一致(进 tab 无历史行);开启后默认范围 = 近一周;**全部持仓 tab 无此功能**;
|
||||||
|
3. **范围筛选**:5 档(近一周/近1个月/近3个月/近半年/近1年)切换即重拉;用 2026-09-07 大连热电真实数据验证——holding_id=15(做T,当日 09:32:35 幽灵清仓)在近一周内可见、切近 1 个月仍可见;
|
||||||
|
4. **历史行展示**:灰显 + 「已清仓 YYYY-MM-DD」徽标;清仓时间/持有天数正确;份额显示 0(Q2);行情列(现价/涨幅/涨停跌停/今开/最高/成本/最后成交价)显示 —;名称有则显示、无则 —;标题计数(N 只)不含历史行;当前持仓在前、历史行按清仓时间降序在后;
|
||||||
|
5. **展开复盘**:历史行展开可见该 holding 关联委托(大连热电 09:31:26 卖出 1000 股 @7.42 应出现);自定义字段历史行只读(无编辑入口);
|
||||||
|
6. **零破坏**:strategy-positions / unallocated / summary 等现有端点行为不变;当前持仓行展示与现状一致;无任何写路径变更(closeHolding 语义不变)。
|
||||||
|
|
||||||
|
## 验收方法
|
||||||
|
|
||||||
|
- 自动化项 AI 执行出具结果;
|
||||||
|
- 2~5 老师真实环境人工验收:重载插件 → 两个策略 tab 开关历史持仓 → 切换范围 → 对照大连热电当日清仓数据 → 展开历史行核对关联委托;
|
||||||
|
- 全部通过后:迭代 14 标记「验收通过」,R-016 归档 已完成/。
|
||||||
|
|
||||||
|
## 验收目标
|
||||||
|
|
||||||
|
- 6 条验收线通过,迭代 14 标记「验收通过」,R-016 更新实现状态(已实现)并归档。
|
||||||
|
|
||||||
|
## 二轮补充验收线(2026-09-07 Q8-Q9 追加:今日已清仓默认层)
|
||||||
|
|
||||||
|
7. **今日已清仓默认显示(核心)**:「历史持仓」**关**时,策略 tab 仍显示 当前持仓 + 今天(本地自然日 00:00 起)已清仓的持仓行;当天卖出/幽灵清仓的持仓行不因开关关闭而消失;
|
||||||
|
8. **开关只控更早历史**:「历史持仓」开时,追加显示今天之前各范围(近一周/近1个月/近3个月/近半年/近1年)已清仓行——近一周范围**不含**今天已显示的行(层间按 holdingId 去重,无重复行);
|
||||||
|
9. **边界与展示**:昨天 23:59:59 清仓的行在开关关时不可见(属更早历史);今日行沿用灰显 + 已清仓徽标;标题计数(N 只)仍不含已清仓行;行情列 — / 份额 0 / 只读字段 / 行展开复用 R-010 均维持首轮;
|
||||||
|
10. **自动化**:test-r016 新增 range=today 用例全绿(24/24);typecheck + build + 存量回归(test-position-sync / test-r013 / test-quote-sync)全绿。
|
||||||
|
|
||||||
|
## 验收方法(二轮补充)
|
||||||
|
|
||||||
|
- 自动化项 AI 执行出具结果(已执行:test-r016 24/24、position-sync 35、r013 21、quote-sync 27、typecheck 0、r017 12);
|
||||||
|
- 老师真实环境人工验收:刷新页面 → 在不开启「历史持仓」的情况下核对当天已清仓持仓仍显示 → 开启开关核对今天之前的行按范围出现且与今日行无重复 → 对照大连热电当日清仓数据。
|
||||||
|
|
||||||
|
## 验收目标
|
||||||
|
|
||||||
|
- 首轮 6 条 + 二轮 4 条验收线通过后,迭代 14 标记「验收通过」,R-016 归档 已完成/。
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# 需求:R-016 策略 tab 历史持仓展示(显示/隐藏已清仓持仓 + 清仓时间范围筛选)
|
||||||
|
|
||||||
|
> 登记:2026-09-07 | 来源:老师指令(优化功能需求讨论)| 状态:**已定稿(2026-09-07,Q1-Q7 老师拍板)**
|
||||||
|
> 归属:迭代 14 | 计划:PLAN-015 | 实现状态:**已实现(2026-09-07,待老师人工验收)**
|
||||||
|
|
||||||
|
## 需求描述(老师原始诉求)
|
||||||
|
|
||||||
|
**两个策略 tab**(做T / 网格超市)**title 旁边**添加以下功能:
|
||||||
|
|
||||||
|
1. **显示/隐藏已清仓的历史持仓**(开关);
|
||||||
|
2. 显示历史持仓时,**默认显示近一周清仓的持仓**,同时提供 **近 1 个月 / 近 3 个月 / 近半年 / 近 1 年** 几个范围选项。
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
- 策略持仓行 = QMT 快照驱动(R-014),卖出清仓后 QMT 无该持仓 → 幽灵清仓把 strategy_holdings 行转历史(closed_at 非空)→ 策略 tab 当场少一行;
|
||||||
|
- 典型场景(2026-09-07 实际发生):早盘卖出大连热电 1000 股(做T),约 70 秒后该持仓行从做T tab 消失——当日交易记录还在(R-009 已入库、归属 holding_id=15 正确),但 tab 上锚点不可见;
|
||||||
|
- 历史数据一直在库(strategy_holdings 全量保留),只是**没有展示入口**;
|
||||||
|
- R-010 持仓行展开(关联委托汇总)以 holding_id 为锚,历史持仓行天然可复用——历史行展开 = 该轮做T/清仓的完整交易复盘(**本需求的核心目标:追溯该持仓相关的历史操作**,老师 2026-09-07 定调)。
|
||||||
|
|
||||||
|
## 讨论记录(2026-09-07,Q1-Q7 老师拍板)
|
||||||
|
|
||||||
|
| # | 问题 | 结论(老师拍板) |
|
||||||
|
|---|---|---|
|
||||||
|
| Q1 | 历史行展示哪些字段? | **按建议**:代码 / 名称(取该 holding 最近关联委托的 name 兜底,无则 —)/ 清仓时间 / 持有天数;实时行情列(现价/涨幅/涨停等)历史行显示 **—**(不参与行情订阅);行展开沿用 R-010 看关联委托;成本价从委托推导本期不做 |
|
||||||
|
| Q2 | 清仓时份额怎么显示?(closeHolding 现把 shares 置 0) | **显示 0 就好了**(老师改判,否决 AI 的「保留 shares」方案):即使显示清仓时的份额也没多少意义,主要目标是追溯该持仓相关的历史操作;**不改 closeHolding 语义**,历史行份额直接显示 DB 原值(0) |
|
||||||
|
| Q3 | 排序与视觉区分? | **按建议**:当前持仓在前(维持现状排序),历史行追加在后按清仓时间降序;历史行灰显 + 「已清仓」徽标(含清仓日期);标题计数(N 只)只数当前持仓 |
|
||||||
|
| Q4 | 开关与范围选择要不要记住偏好? | **按建议**:会话级即可(默认关,进 tab 重置为「只看当前」)——查看历史是临时行为,「策略 tab = 当前持仓」是主语义 |
|
||||||
|
| Q5 | 范围选项集合? | **按建议**:近一周(默认)/ 近 1 个月 / 近 3 个月 / 近半年 / 近 1 年,共 5 档;语义 = closed_at ∈ [now − 范围, now];不加「全部历史」 |
|
||||||
|
| Q6 | 自定义字段值(R-013 values)在历史行怎么处理? | **按建议**:values 数据随行保留,历史行**只读展示**(无编辑入口;服务端本就拒绝已清仓行写入) |
|
||||||
|
| Q7 | 接口形态? | **按建议**:新增独立只读端点(strategy-holdings/history,args: strategyId + range),当前持仓路径(strategy-positions / R-014 快照语义)零改动;前端两份结果合并渲染 |
|
||||||
|
|
||||||
|
## 边界(定稿)
|
||||||
|
|
||||||
|
**做**:两个策略 tab 的历史持仓开关 + 范围筛选(title 旁按钮区);历史行展示(Q1 字段集 + Q2 份额显示 0)+ 行展开复用 R-010;配套后端历史查询端点(SqliteStore 范围查询 + DataStore 门面 + API)。
|
||||||
|
|
||||||
|
**不做**:全部持仓 tab 不加此功能(老师点名两个策略 tab);历史行盈亏统计(R-002 Q4 不做统计原则);清仓方式标记(手动清仓 vs 幽灵自动清仓,数据未区分);历史行实时行情;成本价/收益推导;**不改 closeHolding 存储语义**(shares 置 0 维持,Q2);范围记忆/偏好持久化(Q4 会话级);「全部历史」档位。
|
||||||
|
|
||||||
|
## 关联观察(上一轮会话遗留,不在本需求范围)
|
||||||
|
|
||||||
|
- 交易记录 tab 的归属候选(orders/attribution-candidates)只回**当前持仓**——清仓后当日委托无法再通过下拉关联到对应 holding(2026-09-07 大连热电实际遇到);是否立独立需求(如候选纳入近期清仓持仓)待老师定。
|
||||||
|
|
||||||
|
## 二轮补充(2026-09-07,Q8-Q9 老师拍板:今日已清仓默认层)
|
||||||
|
|
||||||
|
> 追加定稿记录(不改写 Q1-Q7 首轮定稿;本轮在首轮验收前补充,老师 2026-09-07 人工验收前的诉求收窄「历史持仓关 = 当天已清仓也看不见」的体验缺口)。
|
||||||
|
|
||||||
|
**老师原始补充诉求**:两个策略 tab 显示历史持仓的交互需要小的逻辑调整——**当前已清仓的持仓默认也显示**;「历史持仓」开关**不控制当天已清仓的持仓**(历史持仓关 ≠ 当天已清仓也被藏起)。
|
||||||
|
|
||||||
|
| # | 问题 | 结论(老师拍板 2026-09-07) |
|
||||||
|
|---|---|---|
|
||||||
|
| Q8 | 「当天」的边界口径? | **本地自然日 00:00 起**(今天清仓的始终显示;跨零点后自动归入更早历史,由「历史持仓」开关控制) |
|
||||||
|
| Q9 | 今日已清仓行的视觉与计数? | **沿用历史行灰显 + 「已清仓」徽标**;标题计数(N 只)仍只算当前持仓,不含已清仓行 |
|
||||||
|
|
||||||
|
**定稿语义(相对首轮 Q4「默认关 = 只看当前」的修订)**:
|
||||||
|
- 已清仓行分两层:**L1 今日已清仓**(range='today',closed_at ≥ 本地自然日 00:00)**默认恒显示**,进 tab 即出现、随持仓重载刷新;**L2 历史范围层**(week/month/quarter/halfYear/year,仅今天之前)仍由「历史持仓」开关控制(默认关);
|
||||||
|
- 交互文案同步收窄:「历史持仓」开关 = 显示/隐藏**今天之前**已清仓的历史持仓(title/提示更新);当天清仓的持仓始终显示;
|
||||||
|
- 展示(灰显/徽标/行情列 —/份额 0/只读字段/行展开)与计数(不含已清仓行)维持 Q1/Q2/Q3/Q6 不变,两层视觉无差异;
|
||||||
|
- 实现:range='today' 特判本地自然日零点(不走回溯毫秒档);前端 rowsToRender 在开关关时也追加 L1(按 holdingId 去重 L1∩L2,开启历史范围不重复出两行);零写路径变更维持。
|
||||||
|
|
||||||
|
**边界修订**:Q5 档位集语义微调为「**今天之前的**回溯范围」;「开关默认关 = 只看当前」修订为「开关默认关 = 当前持仓 + 今日已清仓」;其余边界(不做全部持仓 tab / 不做统计 / 不改 closeHolding / 会话级不持久化等)不变。
|
||||||
|
|
||||||
|
## 验收
|
||||||
|
|
||||||
|
见 `docs/04-迭代记录/14-策略tab历史持仓展示/验收标准.md`。
|
||||||
@@ -26,6 +26,8 @@
|
|||||||
| R-012 | UI 适配 DSH 主题(浅色 / 深色 / 跟随系统) | 神之一手 UI 适配 DSH 浅色/深色/跟随系统主题:141 处硬编码色值替换为宿主 `--dsw-*` token,随主题自动切换;不自行维护主题偏好(**暂定跟随系统**);涨跌红涨绿跌 → 宿主 state-error/success;仅色值 token 化不动布局。**2026-09-02 定稿(暂定跟随系统)并完成(迭代 10),已归档至 已完成/R-012.md** | 老师指令(2026-09-02) | P1 | 已定稿 | 2026-09-02 | 10-UI主题适配 | **已实现(已归档)** |
|
| R-012 | UI 适配 DSH 主题(浅色 / 深色 / 跟随系统) | 神之一手 UI 适配 DSH 浅色/深色/跟随系统主题:141 处硬编码色值替换为宿主 `--dsw-*` token,随主题自动切换;不自行维护主题偏好(**暂定跟随系统**);涨跌红涨绿跌 → 宿主 state-error/success;仅色值 token 化不动布局。**2026-09-02 定稿(暂定跟随系统)并完成(迭代 10),已归档至 已完成/R-012.md** | 老师指令(2026-09-02) | P1 | 已定稿 | 2026-09-02 | 10-UI主题适配 | **已实现(已归档)** |
|
||||||
| R-013 | 策略自定义字段配置(定义随策略,值落库) | 策略可自定义、可扩展:每个策略在设置页「策略分组」子 tab 配置自定义字段(字段名/类型文本·数字·布尔·枚举/枚举项/单位 unit/默认值,configSchema 随策略定义存 settings);每个持仓行按所属策略的定义存一份键值对值(strategy_holdings 新增 values TEXT(JSON) 列);自定义字段作为持仓表列展示,列显隐/排序每策略独立配置(strategyColumns),单元格点击内联编辑。**2026-09-02 定稿(Q1-Q4 + D6)并完成(迭代 11 验收通过),已归档至 已完成/R-013.md** | 老师指令(2026-09-02) | P1 | 已定稿 | 2026-09-02 | 11-策略自定义字段配置 | **已实现(已归档)** |
|
| R-013 | 策略自定义字段配置(定义随策略,值落库) | 策略可自定义、可扩展:每个策略在设置页「策略分组」子 tab 配置自定义字段(字段名/类型文本·数字·布尔·枚举/枚举项/单位 unit/默认值,configSchema 随策略定义存 settings);每个持仓行按所属策略的定义存一份键值对值(strategy_holdings 新增 values TEXT(JSON) 列);自定义字段作为持仓表列展示,列显隐/排序每策略独立配置(strategyColumns),单元格点击内联编辑。**2026-09-02 定稿(Q1-Q4 + D6)并完成(迭代 11 验收通过),已归档至 已完成/R-013.md** | 老师指令(2026-09-02) | P1 | 已定稿 | 2026-09-02 | 11-策略自定义字段配置 | **已实现(已归档)** |
|
||||||
| R-014 | 持仓内存快照(服务端 10s 定时同步,请求不再穿透 QMT) | 服务端建全量持仓**内存快照**(不落库):PositionSync 启动预热 + 每 10s 全量拉 QMT → 校验 → 整体替换;同步失败保留上次快照(QMT 抖动不再白屏);空快照双重确认(/health + getAsset 账户身份)才接受为真清仓;快照为空读穿透兜底(当场拉一次并回填);**幽灵持仓自动清仓**:QMT 连续 3 轮(约 30s)消失的 code,本地全部策略当前持仓自动转历史(账户身份守卫:accountId 未知跳过、切换重置跳过;部分减持不触发);strategy-positions / unallocated / summary 三个接口改读快照,前端零改动。**2026-09-02 定稿(内存不落库 / 读穿透兜底 / 幽灵自动清仓 / 同步时间不显示,老师逐项拍板),2026-09-02 完成(回归 34/34 + r013 回归 21/21 + typecheck + build 通过),迭代 12 验收待老师人工确认** | 架构梳理讨论引出 + 老师指令(2026-09-02) | P1 | 已定稿 | 2026-09-02 | 12-持仓内存快照 | 已实现(待验收) |
|
| R-014 | 持仓内存快照(服务端 10s 定时同步,请求不再穿透 QMT) | 服务端建全量持仓**内存快照**(不落库):PositionSync 启动预热 + 每 10s 全量拉 QMT → 校验 → 整体替换;同步失败保留上次快照(QMT 抖动不再白屏);空快照双重确认(/health + getAsset 账户身份)才接受为真清仓;快照为空读穿透兜底(当场拉一次并回填);**幽灵持仓自动清仓**:QMT 连续 3 轮(约 30s)消失的 code,本地全部策略当前持仓自动转历史(账户身份守卫:accountId 未知跳过、切换重置跳过;部分减持不触发);strategy-positions / unallocated / summary 三个接口改读快照,前端零改动。**2026-09-02 定稿(内存不落库 / 读穿透兜底 / 幽灵自动清仓 / 同步时间不显示,老师逐项拍板),2026-09-02 完成(回归 34/34 + r013 回归 21/21 + typecheck + build 通过),迭代 12 验收待老师人工确认** | 架构梳理讨论引出 + 老师指令(2026-09-02) | P1 | 已定稿 | 2026-09-02 | 12-持仓内存快照 | 已实现(待验收) |
|
||||||
|
| R-015 | 盘口数据内存化(QuoteSync/QuoteHub 统一管理)+ 数据同步指示灯 | QuoteSync(取数:prime + 5s REST 刷新 + 涨停跌停按交易日缓存)/ QuoteHub(存查:内存快照 + watch 集合 + 读穿透 + 价格单一入口)替换 MarketFeed/MarketDataHub(方案 A 替换不包壳);WS 通路移除;market_quotes_cache 表退役 DROP;涨停价/跌停价字段(/data/instrument 按交易日缓存不落库);会话头部新增持仓/盘口同步指示灯(绿/黄/灰 + 悬停详情 + 点击即 syncNow)+ sync-status 端点(吸收临时诊断 market-stats);策略持仓表行情列扩展(涨停/跌停/今开/最高 4 列,默认隐藏)。**2026-09-02 定稿(五项老师逐项拍板),2026-09-02 完成(test-quote-sync 全绿 + 存量回归 34/34、21/21 + typecheck + build),迭代 13 验收待老师人工确认** | 架构演进讨论引出 + 老师指令(2026-09-02) | P1 | 已定稿 | 2026-09-02 | 13-盘口内存快照 | 已实现(待验收) |
|
||||||
|
| R-016 | 策略 tab 历史持仓展示(显示/隐藏已清仓持仓 + 清仓时间范围筛选) | 两个策略 tab(做T/网格超市)title 旁添加:① 显示/隐藏已清仓历史持仓的开关;② 显示历史时默认展示近一周清仓的持仓,并提供 近1个月/近3个月/近半年/近1年 范围选项。背景:卖出清仓后幽灵清仓(R-014)把持仓行转历史,策略 tab 当场少行(2026-09-07 大连热电做T卖出实际发生);历史数据一直在库(strategy_holdings closed_at 非空),只缺展示入口;历史行可复用 R-010 行展开做交易复盘。**2026-09-07 定稿(Q1-Q7 老师拍板:Q2 改判「份额显示 0 就好,不改 closeHolding 语义」,其余按 AI 建议;核心目标=追溯该持仓相关的历史操作)**;历史行复用 R-010 展开做交易复盘;新增 strategy-holdings/history 端点,当前持仓路径零改动。**2026-09-07 二轮补充(Q8-Q9 老师拍板):当天(本地自然日 00:00 起)已清仓的持仓默认显示(range='today' 恒显示层),「历史持仓」开关只控制今天之前的历史(历史持仓关≠当天清仓被藏起);层间按 holdingId 去重,计数不含已清仓行维持;零写路径变更维持**。详见 R-016.md | 老师指令(2026-09-07 优化功能需求讨论) | P1 | 已定稿 | 2026-09-07 | 14-策略tab历史持仓展示 | 已实现(待验收) |
|
||||||
|
|
||||||
## 渐进明细规划素材
|
## 渐进明细规划素材
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
/**
|
||||||
|
* R-016 / 迭代 14 回归:策略已清仓历史持仓查询 + API 端点
|
||||||
|
* 独立临时数据目录(技术约束-011:不碰真实数据)。
|
||||||
|
* 覆盖:清仓转历史可查 / 范围过滤边界(5 档 + range=today 自然日边界)/ closed_at DESC 排序 / 策略隔离 /
|
||||||
|
* 未清仓行不出现 / API 参数校验 / name 兜底 / 只读端点默认范围。
|
||||||
|
*/
|
||||||
|
import { SqliteStore } from '../src/storage/SqliteStore.js';
|
||||||
|
import { DataStore } from '../src/storage/DataStore.js';
|
||||||
|
import { handleStrategy } from '../src/api/strategies.js';
|
||||||
|
import { mkdtempSync, rmSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
let pass = 0, fail = 0;
|
||||||
|
function assert(cond, msg) {
|
||||||
|
if (cond) { pass++; console.log(' ✅ ' + msg); }
|
||||||
|
else { fail++; console.log(' ❌ ' + msg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
const DAY = 86400000;
|
||||||
|
const tmp = mkdtempSync(join(tmpdir(), 'odl-r016-'));
|
||||||
|
const storage = new DataStore({ dataDir: tmp });
|
||||||
|
const runtime = { storage }; // handleStrategy 的 strategy-holdings/history 仅消费 storage
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('\n[1] 建仓 → 清仓 → 历史可查');
|
||||||
|
await storage.openHolding('s1', '600719.SH', 1000);
|
||||||
|
const h1 = await storage.getCurrentHoldings('s1');
|
||||||
|
assert(h1.length === 1 && h1[0].shares === 1000, '建仓后当前持仓 1000');
|
||||||
|
await storage.closeHolding('s1', '600719.SH');
|
||||||
|
const cur = await storage.getCurrentHoldings('s1');
|
||||||
|
assert(cur.length === 0, '清仓后当前持仓为空');
|
||||||
|
const hist1 = await storage.getHoldingsHistory('s1', {});
|
||||||
|
assert(hist1.length === 1, '历史查询可查到 1 行');
|
||||||
|
assert(hist1[0].shares === 0 && hist1[0].closedAt > 0, 'shares=0(Q2:closeHolding 语义不变)且 closed_at 有值');
|
||||||
|
assert(hist1[0].holdingId === h1[0].holdingId, 'holding_id 锚点保留');
|
||||||
|
|
||||||
|
console.log('\n[2] 多行 + 排序(closed_at DESC)+ 策略隔离 + 未清仓不出现');
|
||||||
|
await storage.openHolding('s1', '000001.SZ', 500);
|
||||||
|
await storage.closeHolding('s1', '000001.SZ');
|
||||||
|
await storage.openHolding('s1', '600000.SH', 300); // 未清仓
|
||||||
|
await storage.openHolding('s2', '600719.SH', 100);
|
||||||
|
await storage.closeHolding('s2', '600719.SH');
|
||||||
|
const all = await storage.getHoldingsHistory('s1', {});
|
||||||
|
assert(all.length === 2, 's1 历史 2 行(不含未清仓 600000.SH、不含 s2)');
|
||||||
|
assert(all[0].closedAt >= all[1].closedAt, '按 closed_at DESC 排序');
|
||||||
|
const s2 = await storage.getHoldingsHistory('s2', {});
|
||||||
|
assert(s2.length === 1 && s2[0].code === '600719.SH', '策略隔离:s2 只见自己');
|
||||||
|
|
||||||
|
console.log('\n[3] 范围过滤(sinceMs 边界)');
|
||||||
|
// 人为回溯:000001.SZ 清仓时间改到 40 天前(临时测试库,允许直改)
|
||||||
|
const db = storage.sqlite.db;
|
||||||
|
const row40d = all.find((r) => r.code === '000001.SZ');
|
||||||
|
db.prepare('UPDATE strategy_holdings SET closed_at=? WHERE holding_id=?').run(Date.now() - 40 * DAY, row40d.holdingId);
|
||||||
|
const week = await storage.getHoldingsHistory('s1', { sinceMs: Date.now() - 7 * DAY });
|
||||||
|
assert(week.length === 1 && week[0].code === '600719.SH', '近一周:只有 600719.SH');
|
||||||
|
const month = await storage.getHoldingsHistory('s1', { sinceMs: Date.now() - 30 * DAY });
|
||||||
|
assert(month.length === 1, '近 1 个月:仍只有 600719.SH(40 天前行被滤掉)');
|
||||||
|
const quarter = await storage.getHoldingsHistory('s1', { sinceMs: Date.now() - 90 * DAY });
|
||||||
|
assert(quarter.length === 2 && quarter[0].code === '600719.SH', '近 3 个月:2 行,40 天前行回归且 DESC 在前');
|
||||||
|
const year = await storage.getHoldingsHistory('s1', { sinceMs: Date.now() - 365 * DAY });
|
||||||
|
assert(year.length === 2, '近 1 年:2 行');
|
||||||
|
|
||||||
|
console.log('\n[4] API 端点(含 name 兜底 + 参数校验)');
|
||||||
|
// 关联委托(含 name)
|
||||||
|
db.prepare(
|
||||||
|
"INSERT INTO trade_orders (order_id, trade_date, code, name, direction, insert_ts, fetched_at) VALUES (?,?,?,?,?,?,?)"
|
||||||
|
).run('T-001', '20260907', '600719.SH', '大连热电', 'sell', Date.now(), Date.now());
|
||||||
|
// 归属到 s1 的历史行
|
||||||
|
db.prepare('UPDATE trade_orders SET holding_id=? WHERE order_id=?').run(week[0].holdingId, 'T-001');
|
||||||
|
const apiWeek = await handleStrategy('strategy-holdings/history', { strategyId: 's1', range: 'week' }, runtime);
|
||||||
|
assert(apiWeek.length === 1 && apiWeek[0].code === '600719.SH', '端点近一周返回 1 行');
|
||||||
|
assert(apiWeek[0].name === '大连热电', 'name 兜底取关联委托 name');
|
||||||
|
assert(apiWeek[0].values === null, 'values 未配置为 null');
|
||||||
|
const apiDefault = await handleStrategy('strategy-holdings/history', { strategyId: 's1' }, runtime);
|
||||||
|
assert(apiDefault.length === 1, '缺省 range = week');
|
||||||
|
let threw = null;
|
||||||
|
try { await handleStrategy('strategy-holdings/history', { range: 'week' }, runtime); } catch (e) { threw = e; }
|
||||||
|
assert(threw && threw.code === 'bad-request', '缺 strategyId → bad-request');
|
||||||
|
threw = null;
|
||||||
|
try { await handleStrategy('strategy-holdings/history', { strategyId: 's1', range: 'all' }, runtime); } catch (e) { threw = e; }
|
||||||
|
assert(threw && threw.code === 'bad-request', '非法 range → bad-request');
|
||||||
|
|
||||||
|
console.log('\n[4b] range=today(本地自然日 00:00 边界;R-016 二轮补充:今日已清仓默认层)');
|
||||||
|
const rowToday = all.find((r) => r.code === '600719.SH');
|
||||||
|
const todayStart = new Date(); todayStart.setHours(0, 0, 0, 0);
|
||||||
|
// 清仓时间挪到「昨天 23:59:59」→ today 应滤掉(昨天归历史范围层,不属默认层)
|
||||||
|
db.prepare('UPDATE strategy_holdings SET closed_at=? WHERE holding_id=?').run(todayStart.getTime() - 1000, rowToday.holdingId);
|
||||||
|
const apiTodayMiss = await handleStrategy('strategy-holdings/history', { strategyId: 's1', range: 'today' }, runtime);
|
||||||
|
assert(apiTodayMiss.length === 0, 'today:昨天 23:59:59 清仓 → 不出现(自然日边界下沿)');
|
||||||
|
// 挪回「今天 00:00:01」→ today 可见(00:00:00 含,00:00:01 > 00:00:00)
|
||||||
|
db.prepare('UPDATE strategy_holdings SET closed_at=? WHERE holding_id=?').run(todayStart.getTime() + 1000, rowToday.holdingId);
|
||||||
|
const apiTodayHit = await handleStrategy('strategy-holdings/history', { strategyId: 's1', range: 'today' }, runtime);
|
||||||
|
assert(apiTodayHit.length === 1 && apiTodayHit[0].code === '600719.SH', 'today:今天 00:00:01 清仓 → 出现');
|
||||||
|
// 40 天前的 000001.SZ 不应出现在 today
|
||||||
|
assert(!apiTodayHit.some((r) => r.code === '000001.SZ'), 'today:不含 40 天前清仓的 000001.SZ');
|
||||||
|
// name 兜底对 today 同样生效
|
||||||
|
assert(apiTodayHit[0].name === '大连热电', 'today 行 name 兜底同样生效');
|
||||||
|
// 非法校验:today 之外仍拒绝未知档(回归:'all' 仍非法)
|
||||||
|
threw = null;
|
||||||
|
try { await handleStrategy('strategy-holdings/history', { strategyId: 's1', range: 'all' }, runtime); } catch (e) { threw = e; }
|
||||||
|
assert(threw && threw.code === 'bad-request', 'range 非法(all)→ bad-request 不受 today 引入影响');
|
||||||
|
|
||||||
|
console.log('\n[5] 现有端点零破坏(strategy-positions 快照路径不受影响)');
|
||||||
|
const rt2 = { manager: { async getStrategyPositions() { return [{ code: '600719.SH', shares: 100 }]; } }, settings: {} };
|
||||||
|
const pos = await handleStrategy('strategy-positions', { strategyId: 's1' }, rt2);
|
||||||
|
assert(pos.length === 1 && pos[0].shares === 100, 'strategy-positions 行为不变(mock 验证分发未变)');
|
||||||
|
} finally {
|
||||||
|
storage.close();
|
||||||
|
try { rmSync(tmp, { recursive: true, force: true }); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n=== 结果: ' + pass + ' 通过 / ' + fail + ' 失败 ===');
|
||||||
|
process.exitCode = fail > 0 ? 1 : 0;
|
||||||
@@ -18,6 +18,7 @@
|
|||||||
* strategies/remove → 删除策略 { strategyId },联动清份额
|
* strategies/remove → 删除策略 { strategyId },联动清份额
|
||||||
* strategies/move → 排序 { strategyId, dir }
|
* strategies/move → 排序 { strategyId, dir }
|
||||||
* strategy-positions → 某策略持仓 { strategyId }
|
* strategy-positions → 某策略持仓 { strategyId }
|
||||||
|
* strategy-holdings/history → 某策略已清仓历史持仓 { strategyId, range }(R-016)
|
||||||
* unallocated → 未分配持仓
|
* unallocated → 未分配持仓
|
||||||
* summary → 分仓摘要
|
* summary → 分仓摘要
|
||||||
* add-shares → 添加份额 { code, strategyId, shares }
|
* add-shares → 添加份额 { code, strategyId, shares }
|
||||||
|
|||||||
+53
-2
@@ -4,6 +4,7 @@
|
|||||||
* 端点:
|
* 端点:
|
||||||
* strategies / strategies/update / strategies/add / strategies/remove
|
* strategies / strategies/update / strategies/add / strategies/remove
|
||||||
* strategy-positions / unallocated / summary
|
* strategy-positions / unallocated / summary
|
||||||
|
* strategy-holdings/history(R-016 迭代 14:已清仓历史持仓,范围筛选)
|
||||||
* add-shares / move-all-shares / remove-shares
|
* add-shares / move-all-shares / remove-shares
|
||||||
* tabs / tabs/update
|
* tabs / tabs/update
|
||||||
*/
|
*/
|
||||||
@@ -14,6 +15,24 @@ import {
|
|||||||
getStrategyColumns, updateStrategyColumns,
|
getStrategyColumns, updateStrategyColumns,
|
||||||
} from '../settings.js';
|
} from '../settings.js';
|
||||||
|
|
||||||
|
/** R-016 范围档 → 回溯毫秒(自然日近似,展示用途足够;技术约束-019)。
|
||||||
|
* range='today' 不走回溯毫秒——二轮补充(2026-09-07 老师定):本地自然日 00:00 起清仓的持仓
|
||||||
|
* 作为「今日已清仓」默认层(开关「历史持仓」不控制它,当天清仓默认显示),sinceMs 在 handler 内特判。 */
|
||||||
|
const HISTORY_RANGE_MS = {
|
||||||
|
week: 7 * 86400000,
|
||||||
|
month: 30 * 86400000,
|
||||||
|
quarter: 90 * 86400000,
|
||||||
|
halfYear: 182 * 86400000,
|
||||||
|
year: 365 * 86400000,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 本地自然日零点毫秒(range='today' 用:今天 00:00 起 = 当日清仓的持仓) */
|
||||||
|
function localDayStartMs() {
|
||||||
|
const d = new Date();
|
||||||
|
d.setHours(0, 0, 0, 0);
|
||||||
|
return d.getTime();
|
||||||
|
}
|
||||||
|
|
||||||
/** 策略/份额/tabs 端点方法表 */
|
/** 策略/份额/tabs 端点方法表 */
|
||||||
export const STRATEGY_METHODS = new Set([
|
export const STRATEGY_METHODS = new Set([
|
||||||
'strategies',
|
'strategies',
|
||||||
@@ -31,15 +50,16 @@ export const STRATEGY_METHODS = new Set([
|
|||||||
'holdings/values-update', // R-013:写某持仓行的自定义字段值
|
'holdings/values-update', // R-013:写某持仓行的自定义字段值
|
||||||
'strategy-columns', // 迭代11列显隐:读某策略列配置(归一化)
|
'strategy-columns', // 迭代11列显隐:读某策略列配置(归一化)
|
||||||
'strategy-columns/update', // 迭代11列显隐:写某策略列配置
|
'strategy-columns/update', // 迭代11列显隐:写某策略列配置
|
||||||
|
'strategy-holdings/history', // R-016 迭代 14:某策略已清仓历史持仓(范围筛选,只读)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 处理策略/份额/tabs 端点
|
* 处理策略/份额/tabs 端点
|
||||||
* @param {string} method
|
* @param {string} method
|
||||||
* @param {object} args
|
* @param {object} args
|
||||||
* @param {object} runtime { manager, settings }
|
* @param {object} runtime { manager, settings, storage }
|
||||||
*/
|
*/
|
||||||
export async function handleStrategy(method, args, { manager, settings }) {
|
export async function handleStrategy(method, args, { manager, settings, storage }) {
|
||||||
switch (method) {
|
switch (method) {
|
||||||
case 'strategies':
|
case 'strategies':
|
||||||
return getStrategies(settings);
|
return getStrategies(settings);
|
||||||
@@ -78,6 +98,37 @@ export async function handleStrategy(method, args, { manager, settings }) {
|
|||||||
case 'strategy-columns/update':
|
case 'strategy-columns/update':
|
||||||
await updateStrategyColumns(settings, args.strategyId, args.columns);
|
await updateStrategyColumns(settings, args.strategyId, args.columns);
|
||||||
return getStrategyColumns(settings, args.strategyId);
|
return getStrategyColumns(settings, args.strategyId);
|
||||||
|
case 'strategy-holdings/history': {
|
||||||
|
// R-016 迭代 14:某策略已清仓历史持仓(当前持仓快照路径零改动,技术约束-019)
|
||||||
|
if (!args.strategyId) {
|
||||||
|
throw Object.assign(new Error('缺少 strategyId 参数'), { code: 'bad-request' });
|
||||||
|
}
|
||||||
|
const range = args.range ?? 'week';
|
||||||
|
// R-016 二轮补充(2026-09-07 老师定):range='today' = 本地自然日 00:00 起清仓的持仓
|
||||||
|
// (前端默认层——当天清仓默认显示,与「历史持仓」开关解耦);其余 5 档维持回溯毫秒语义
|
||||||
|
let sinceMs;
|
||||||
|
if (range === 'today') {
|
||||||
|
sinceMs = localDayStartMs();
|
||||||
|
} else {
|
||||||
|
const ms = HISTORY_RANGE_MS[range];
|
||||||
|
if (!ms) {
|
||||||
|
throw Object.assign(new Error('range 非法: ' + range + '(today|week|month|quarter|halfYear|year)'), { code: 'bad-request' });
|
||||||
|
}
|
||||||
|
sinceMs = Date.now() - ms;
|
||||||
|
}
|
||||||
|
const rows = await storage.getHoldingsHistory(args.strategyId, { sinceMs });
|
||||||
|
// name 兜底:该 holding 最近一笔关联委托的 name(前端免二次请求)
|
||||||
|
const ids = rows.map((r) => r.holdingId);
|
||||||
|
const nameMap = new Map();
|
||||||
|
if (ids.length > 0) {
|
||||||
|
const ph = ids.map(() => '?').join(',');
|
||||||
|
const orows = storage.sqlite.db.prepare(
|
||||||
|
"SELECT holding_id, name FROM trade_orders WHERE holding_id IN (" + ph + ") AND name != '' ORDER BY insert_ts DESC"
|
||||||
|
).all(...ids);
|
||||||
|
for (const o of orows) if (!nameMap.has(o.holding_id)) nameMap.set(o.holding_id, o.name);
|
||||||
|
}
|
||||||
|
return rows.map((r) => ({ ...r, name: nameMap.get(r.holdingId) ?? null }));
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
throw Object.assign(new Error('unknown strategy method: ' + method), { code: 'not-found' });
|
throw Object.assign(new Error('unknown strategy method: ' + method), { code: 'not-found' });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,10 @@
|
|||||||
* - 全部移入(O6:行内按钮,该票全部未分配份额移入当前策略)
|
* - 全部移入(O6:行内按钮,该票全部未分配份额移入当前策略)
|
||||||
* - 操作反馈:Toast 轻提示
|
* - 操作反馈:Toast 轻提示
|
||||||
* - 加载失败兜底:LoadState 三态
|
* - 加载失败兜底:LoadState 三态
|
||||||
|
* - 历史持仓展示(R-016 迭代 14):title 旁开关 + 清仓时间范围筛选(近一周默认/1个月/3个月/半年/1年),
|
||||||
|
* 历史行灰显 + 「已清仓」徽标,展开复用 R-010 看关联交易(追溯该持仓的历史操作)
|
||||||
|
* - 今日已清仓默认层(R-016 二轮补充,2026-09-07 老师定):当天(本地自然日 00:00 起)已清仓的持仓
|
||||||
|
* **默认显示**、不受「历史持仓」开关控制——清仓转历史后 tab 不因此少行;开关只控制今天之前的历史
|
||||||
*
|
*
|
||||||
* 添加持仓区域为响应式布局(内联 <style> + 媒体查询):
|
* 添加持仓区域为响应式布局(内联 <style> + 媒体查询):
|
||||||
* - 宽屏:三元素一行(选择标的 | 份额 | 确认按钮)
|
* - 宽屏:三元素一行(选择标的 | 份额 | 确认按钮)
|
||||||
@@ -185,10 +189,38 @@ function StrategyTabInner({ strategyId, strategyName }) {
|
|||||||
const [columnOpen, setColumnOpen] = useState(false);
|
const [columnOpen, setColumnOpen] = useState(false);
|
||||||
// 迭代11:正在单元格内编辑的自定义字段({code, key} | null)
|
// 迭代11:正在单元格内编辑的自定义字段({code, key} | null)
|
||||||
const [editingCell, setEditingCell] = useState(null);
|
const [editingCell, setEditingCell] = useState(null);
|
||||||
|
// R-016 迭代 14:历史持仓展示(title 旁开关 + 清仓范围筛选;会话级,不持久化,Q4)
|
||||||
|
// R-016 二轮补充(2026-09-07 老师定):「今日已清仓」是**默认层**(range='today',恒显示),
|
||||||
|
// 开关只控制今天之前的历史范围层(week/month/quarter/halfYear/year)——开关关≠当天清仓也被藏起
|
||||||
|
const [showHistory, setShowHistory] = useState(false);
|
||||||
|
const [historyRange, setHistoryRange] = useState('week'); // week|month|quarter|halfYear|year
|
||||||
|
const [historyRows, setHistoryRows] = useState({}); // range → [已清仓持仓行](含 'today' 键)
|
||||||
|
const [historyLoading, setHistoryLoading] = useState(false);
|
||||||
const call = useRpc();
|
const call = useRpc();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const { getPrice, registerCodes } = useMarket();
|
const { getPrice, registerCodes } = useMarket();
|
||||||
|
|
||||||
|
/** R-016:拉某范围已清仓历史持仓(独立只读端点;当前持仓快照路径零改动)。
|
||||||
|
* range='today' = 本地自然日 00:00 起清仓(R-016 二轮补充:今日已清仓默认层) */
|
||||||
|
const loadHistory = useCallback(async (range) => {
|
||||||
|
setHistoryLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await call('one-divine-lot/strategy-holdings/history', { args: { strategyId, range } });
|
||||||
|
if (res && res.ok && Array.isArray(res.value)) {
|
||||||
|
setHistoryRows((prev) => ({ ...(prev ?? {}), [range]: res.value }));
|
||||||
|
} else {
|
||||||
|
// 失败可见化(迭代 14 复盘:静默兜底=排障盲区)
|
||||||
|
toast.error('历史持仓加载失败: ' + ((res && res.error && res.error.message) || 'unknown'));
|
||||||
|
setHistoryRows((prev) => ({ ...(prev ?? {}), [range]: [] }));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
toast.error('历史持仓加载失败: ' + (err?.message ?? String(err)));
|
||||||
|
setHistoryRows((prev) => ({ ...(prev ?? {}), [range]: [] }));
|
||||||
|
} finally {
|
||||||
|
setHistoryLoading(false);
|
||||||
|
}
|
||||||
|
}, [call, strategyId, toast]);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -220,14 +252,31 @@ function StrategyTabInner({ strategyId, strategyName }) {
|
|||||||
setError(e.message);
|
setError(e.message);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
// R-016 二轮补充:今日已清仓层随每次持仓重载刷新(进 tab / 移出等操作后,
|
||||||
|
// 当天清仓的行立即转入默认层,不依赖「历史持仓」开关;loadHistory 自容错不外抛)
|
||||||
|
loadHistory('today');
|
||||||
}
|
}
|
||||||
}, [call, strategyId]);
|
}, [call, strategyId, loadHistory, registerCodes]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
ensureAddFormCss();
|
ensureAddFormCss();
|
||||||
load();
|
load();
|
||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
||||||
|
/** 开关:开启即取最新(关闭仅隐藏,缓存保留;进 tab 默认关)。
|
||||||
|
* R-016 二轮补充:开关只控制「今天之前的历史范围层」;range='today'(当日清仓)恒显示、不受本开关控制 */
|
||||||
|
const toggleHistory = () => {
|
||||||
|
const next = !showHistory;
|
||||||
|
setShowHistory(next);
|
||||||
|
if (next) loadHistory(historyRange);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 切换范围:未缓存才拉取 */
|
||||||
|
const changeHistoryRange = (r) => {
|
||||||
|
setHistoryRange(r);
|
||||||
|
if (!historyRows[r]) loadHistory(r);
|
||||||
|
};
|
||||||
|
|
||||||
const handleAdd = async () => {
|
const handleAdd = async () => {
|
||||||
if (!selectedCode || !addShares) return;
|
if (!selectedCode || !addShares) return;
|
||||||
const res = await call('one-divine-lot/add-shares', {
|
const res = await call('one-divine-lot/add-shares', {
|
||||||
@@ -271,30 +320,30 @@ function StrategyTabInner({ strategyId, strategyName }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/** R-010:展开某持仓行 → 懒加载该 holding 的委托汇总 */
|
/** R-010:展开某持仓行 → 懒加载该 holding 的委托汇总(R-016:键改唯一 rowKey,同码多行不串) */
|
||||||
const toggleExpand = async (code, holdingId) => {
|
const toggleExpand = async (rowKey, holdingId) => {
|
||||||
if (expandedCode === code) {
|
if (expandedCode === rowKey) {
|
||||||
// 折叠
|
// 折叠
|
||||||
setExpandedCode(null);
|
setExpandedCode(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setExpandedCode(code);
|
setExpandedCode(rowKey);
|
||||||
if (holdingId == null) {
|
if (holdingId == null) {
|
||||||
setHoldingTrades((prev) => ({ ...(prev ?? {}), [code]: [] }));
|
setHoldingTrades((prev) => ({ ...(prev ?? {}), [rowKey]: [] }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// 懒加载:有缓存直接用,否则请求
|
// 懒加载:有缓存直接用,否则请求
|
||||||
if (holdingTrades && holdingTrades[code]) return;
|
if (holdingTrades && holdingTrades[rowKey]) return;
|
||||||
setHoldingTradesLoading(code);
|
setHoldingTradesLoading(rowKey);
|
||||||
try {
|
try {
|
||||||
const res = await call('one-divine-lot/trades/by-holding', { args: { holdingId } });
|
const res = await call('one-divine-lot/trades/by-holding', { args: { holdingId } });
|
||||||
if (res?.ok && Array.isArray(res.value)) {
|
if (res?.ok && Array.isArray(res.value)) {
|
||||||
setHoldingTrades((prev) => ({ ...(prev ?? {}), [code]: res.value }));
|
setHoldingTrades((prev) => ({ ...(prev ?? {}), [rowKey]: res.value }));
|
||||||
} else {
|
} else {
|
||||||
setHoldingTrades((prev) => ({ ...(prev ?? {}), [code]: [] }));
|
setHoldingTrades((prev) => ({ ...(prev ?? {}), [rowKey]: [] }));
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
setHoldingTrades((prev) => ({ ...(prev ?? {}), [code]: [] }));
|
setHoldingTrades((prev) => ({ ...(prev ?? {}), [rowKey]: [] }));
|
||||||
} finally {
|
} finally {
|
||||||
setHoldingTradesLoading(null);
|
setHoldingTradesLoading(null);
|
||||||
}
|
}
|
||||||
@@ -325,6 +374,7 @@ function StrategyTabInner({ strategyId, strategyName }) {
|
|||||||
}
|
}
|
||||||
return rows;
|
return rows;
|
||||||
}, [positions, sortKey, sortDir, getPrice]);
|
}, [positions, sortKey, sortDir, getPrice]);
|
||||||
|
//(R-016:rowsToRender 在此下方合成,历史行固定追加、不参与涨幅排序)
|
||||||
|
|
||||||
// 迭代11列显隐:可见列(表头/行渲染依据;未加载配置时=null,回退旧固定列)
|
// 迭代11列显隐:可见列(表头/行渲染依据;未加载配置时=null,回退旧固定列)
|
||||||
const visibleColumns = useMemo(() => {
|
const visibleColumns = useMemo(() => {
|
||||||
@@ -332,8 +382,62 @@ function StrategyTabInner({ strategyId, strategyName }) {
|
|||||||
return columns.filter((c) => c.visible !== false);
|
return columns.filter((c) => c.visible !== false);
|
||||||
}, [columns]);
|
}, [columns]);
|
||||||
|
|
||||||
|
// R-016 迭代 14:渲染行 = 当前持仓行 + 已清仓行。
|
||||||
|
// 二轮补充(2026-09-07 老师定):已清仓行分两层——
|
||||||
|
// L1「今日已清仓」(range='today',本地自然日 00:00 起清仓):**默认恒显示**,不受「历史持仓」开关控制
|
||||||
|
// L2「历史范围层」(week/month/...):仅开关开启时显示(用户主动查看更早的历史)
|
||||||
|
// 层间按 holdingId 去重('today' 含近 7 天清仓时,开启 week 不重复出两行);标题计数 (N 只) 只算当前持仓
|
||||||
|
const histRowsFor = (range) =>
|
||||||
|
(historyRows[range] ?? []).map((h) => ({
|
||||||
|
...h,
|
||||||
|
isHistory: true, // 历史行标识:灰显/只读/行情列 —
|
||||||
|
name: h.name ?? null, // name 兜底(接口已按关联委托补,无则 null → 显示 —)
|
||||||
|
}));
|
||||||
|
|
||||||
|
const rowsToRender = useMemo(() => {
|
||||||
|
const current = rowsForRender;
|
||||||
|
const today = histRowsFor('today'); // L1:今天已清仓 —— 恒定显示
|
||||||
|
if (!showHistory) return [...current, ...today];
|
||||||
|
// L2:历史范围层(含 today 在内同一端点返回)——去重:today 已显示的不重复追加
|
||||||
|
const todayIds = new Set(today.map((h) => h.holdingId));
|
||||||
|
const rest = histRowsFor(historyRange).filter((h) => !todayIds.has(h.holdingId));
|
||||||
|
return [...current, ...today, ...rest];
|
||||||
|
}, [rowsForRender, showHistory, historyRows, historyRange]);
|
||||||
|
|
||||||
|
/** 历史行徽标/时间格式化(清仓时间 YYYY-MM-DD HH:mm) */
|
||||||
|
const fmtClosedAt = (ts) => {
|
||||||
|
if (!ts) return '—';
|
||||||
|
const d = new Date(Number(ts));
|
||||||
|
if (!isFinite(d.getTime())) return '—';
|
||||||
|
const p2 = (n) => String(n).padStart(2, '0');
|
||||||
|
return d.getFullYear() + '-' + p2(d.getMonth() + 1) + '-' + p2(d.getDate()) + ' ' + p2(d.getHours()) + ':' + p2(d.getMinutes());
|
||||||
|
};
|
||||||
|
/** 持有天数(同日 = 1,向上取整) */
|
||||||
|
const heldDays = (created, closed) => {
|
||||||
|
if (!created || !closed) return null;
|
||||||
|
const ms = Number(closed) - Number(created);
|
||||||
|
if (!isFinite(ms) || ms < 0) return null;
|
||||||
|
return Math.max(1, Math.ceil(ms / 86400000));
|
||||||
|
};
|
||||||
|
|
||||||
/** 渲染某数据列单元格内容(col: {key,label,kind})—— 返回 React 节点 */
|
/** 渲染某数据列单元格内容(col: {key,label,kind})—— 返回 React 节点 */
|
||||||
const renderDataCell = (col, p) => {
|
const renderDataCell = (col, p) => {
|
||||||
|
// R-016:历史行——行情类列一律 —(不订阅行情);shares 显示 DB 原值(Q2:0 就好);自定义字段只读
|
||||||
|
if (p.isHistory) {
|
||||||
|
if (col.kind === 'field') {
|
||||||
|
const fieldDef0 = (strategySchema ?? []).find((f0) => f0.key === col.key);
|
||||||
|
if (!fieldDef0) return null;
|
||||||
|
const has0 = p.values && Object.prototype.hasOwnProperty.call(p.values, col.key);
|
||||||
|
const v0 = has0 ? p.values[col.key] : fieldDef0.def;
|
||||||
|
let shown0;
|
||||||
|
if (fieldDef0.type === 'boolean') shown0 = v0 ? '开' : '关';
|
||||||
|
else if (v0 === undefined || v0 === null || v0 === '') shown0 = '—';
|
||||||
|
else shown0 = String(v0) + (fieldDef0.unit ? fieldDef0.unit : '');
|
||||||
|
return <span style={{ color: 'var(--dsw-alias-label-tertiary, #999)' }}>{shown0}</span>;
|
||||||
|
}
|
||||||
|
if (col.key === 'shares') return <span style={{ color: 'var(--dsw-alias-label-tertiary, #999)' }}>{p.shares}</span>;
|
||||||
|
return <span style={{ color: 'var(--dsw-alias-label-tertiary, #999)' }}>—</span>;
|
||||||
|
}
|
||||||
if (col.kind === 'field') {
|
if (col.kind === 'field') {
|
||||||
// 自定义字段列:p.values[key] ?? def;点击单元格进入内联编辑(老师选 A)
|
// 自定义字段列:p.values[key] ?? def;点击单元格进入内联编辑(老师选 A)
|
||||||
const fieldDef = (strategySchema ?? []).find((f2) => f2.key === col.key);
|
const fieldDef = (strategySchema ?? []).find((f2) => f2.key === col.key);
|
||||||
@@ -417,13 +521,44 @@ function StrategyTabInner({ strategyId, strategyName }) {
|
|||||||
<div className="odl-root" style={{ padding: 16, fontFamily: 'system-ui' }}>
|
<div className="odl-root" style={{ padding: 16, fontFamily: 'system-ui' }}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12, flexWrap: 'wrap', gap: 8 }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12, flexWrap: 'wrap', gap: 8 }}>
|
||||||
<h3 style={{ margin: 0 }}>{name}({(positions ?? []).length} 只)</h3>
|
<h3 style={{ margin: 0 }}>{name}({(positions ?? []).length} 只)</h3>
|
||||||
<div style={{ display: 'flex', gap: 8 }}>
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowAdd(!showAdd)}
|
onClick={() => setShowAdd(!showAdd)}
|
||||||
style={{ padding: '6px 12px', cursor: 'pointer', border: '1px solid var(--dsw-alias-border-l2, #ccc)', borderRadius: 4, background: 'var(--dsw-alias-interactive-bg-hover, #f5f5f5)' }}
|
style={{ padding: '6px 12px', cursor: 'pointer', border: '1px solid var(--dsw-alias-border-l2, #ccc)', borderRadius: 4, background: 'var(--dsw-alias-interactive-bg-hover, #f5f5f5)' }}
|
||||||
>
|
>
|
||||||
{showAdd ? '取消' : '+ 添加持仓'}
|
{showAdd ? '取消' : '+ 添加持仓'}
|
||||||
</button>
|
</button>
|
||||||
|
{/* R-016 迭代 14:历史持仓开关 + 范围筛选(title 旁)。
|
||||||
|
R-016 二轮补充:今天已清仓的持仓默认显示(不受此开关控制),此开关只开关更早的历史(今天之前) */}
|
||||||
|
<button
|
||||||
|
onClick={toggleHistory}
|
||||||
|
title="显示/隐藏今天之前已清仓的历史持仓(当天已清仓的持仓始终显示)"
|
||||||
|
style={{
|
||||||
|
padding: '6px 12px', cursor: 'pointer', borderRadius: 4, fontSize: 13,
|
||||||
|
border: '1px solid ' + (showHistory ? 'var(--dsw-alias-state-business-primary, #1565c0)' : 'var(--dsw-alias-border-l2, #ccc)'),
|
||||||
|
background: showHistory ? 'color-mix(in srgb, var(--dsw-alias-state-business-primary, #1565c0) 12%, var(--dsw-alias-bg-layer-1, #fff))' : 'var(--dsw-alias-bg-layer-1, #fff)',
|
||||||
|
color: showHistory ? 'var(--dsw-alias-state-business-primary, #1565c0)' : 'var(--dsw-alias-label-primary, #333)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{showHistory ? '历史持仓 开' : '历史持仓 关'}
|
||||||
|
</button>
|
||||||
|
{showHistory && (
|
||||||
|
<select
|
||||||
|
value={historyRange}
|
||||||
|
onChange={(e2) => changeHistoryRange(e2.target.value)}
|
||||||
|
title="已清仓时间范围(按清仓时间回溯)"
|
||||||
|
style={{ padding: '5px 6px', fontSize: 13, border: '1px solid var(--dsw-alias-border-l2, #ccc)', borderRadius: 4, background: 'var(--dsw-alias-bg-layer-1, #fff)' }}
|
||||||
|
>
|
||||||
|
<option value="week">近一周</option>
|
||||||
|
<option value="month">近 1 个月</option>
|
||||||
|
<option value="quarter">近 3 个月</option>
|
||||||
|
<option value="halfYear">近半年</option>
|
||||||
|
<option value="year">近 1 年</option>
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
{showHistory && historyLoading && (
|
||||||
|
<span style={{ fontSize: 12, color: 'var(--dsw-alias-label-tertiary, #999)' }}>历史加载中...</span>
|
||||||
|
)}
|
||||||
<div style={{ position: 'relative' }}>
|
<div style={{ position: 'relative' }}>
|
||||||
<button
|
<button
|
||||||
onClick={() => setColumnOpen(!columnOpen)}
|
onClick={() => setColumnOpen(!columnOpen)}
|
||||||
@@ -476,7 +611,7 @@ function StrategyTabInner({ strategyId, strategyName }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{positions && positions.length === 0 ? (
|
{rowsToRender.length === 0 ? (
|
||||||
<div style={{ padding: 24, textAlign: 'center', color: 'var(--dsw-alias-label-tertiary, #999)' }}>
|
<div style={{ padding: 24, textAlign: 'center', color: 'var(--dsw-alias-label-tertiary, #999)' }}>
|
||||||
暂无持仓,点击「+ 添加持仓」从全部持仓中添加
|
暂无持仓,点击「+ 添加持仓」从全部持仓中添加
|
||||||
</div>
|
</div>
|
||||||
@@ -492,19 +627,24 @@ function StrategyTabInner({ strategyId, strategyName }) {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{rowsForRender.map((p) => {
|
{rowsToRender.map((p) => {
|
||||||
const isExpanded = expandedCode === p.code;
|
const rowKey = p.isHistory ? 'h-' + p.holdingId : p.code; // 唯一行键:同码「当前行 + 历史行」不串展开与缓存
|
||||||
const trades = (holdingTrades ?? {})[p.code];
|
const isExpanded = expandedCode === rowKey;
|
||||||
const isLoading = holdingTradesLoading === p.code;
|
const trades = (holdingTrades ?? {})[rowKey];
|
||||||
|
const isLoading = holdingTradesLoading === rowKey;
|
||||||
const hasHolding = p.holdingId != null;
|
const hasHolding = p.holdingId != null;
|
||||||
|
const histStyle = p.isHistory
|
||||||
|
? { opacity: 0.72, color: 'var(--dsw-alias-label-tertiary, #999)' }
|
||||||
|
: null;
|
||||||
return (
|
return (
|
||||||
<React.Fragment key={p.code}>
|
<React.Fragment key={rowKey}>
|
||||||
<tr
|
<tr
|
||||||
onClick={() => toggleExpand(p.code, p.holdingId)}
|
onClick={() => toggleExpand(rowKey, p.holdingId)}
|
||||||
style={{
|
style={{
|
||||||
borderBottom: '1px solid var(--dsw-alias-border-l1, #eee)',
|
borderBottom: '1px solid var(--dsw-alias-border-l1, #eee)',
|
||||||
cursor: hasHolding ? 'pointer' : 'default',
|
cursor: hasHolding ? 'pointer' : 'default',
|
||||||
background: isExpanded ? 'var(--dsw-alias-interactive-bg-hover, #f5f5f5)' : 'transparent',
|
background: isExpanded ? 'var(--dsw-alias-interactive-bg-hover, #f5f5f5)' : 'transparent',
|
||||||
|
...(histStyle ?? {}),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<td style={{ padding: '8px 6px', textAlign: 'center', width: 28 }}>
|
<td style={{ padding: '8px 6px', textAlign: 'center', width: 28 }}>
|
||||||
@@ -514,13 +654,30 @@ function StrategyTabInner({ strategyId, strategyName }) {
|
|||||||
<span style={{ color: 'var(--dsw-alias-border-l3, #ccc)', fontSize: 10 }}>·</span>
|
<span style={{ color: 'var(--dsw-alias-border-l3, #ccc)', fontSize: 10 }}>·</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td style={{ padding: '8px 6px' }}>{p.code}</td>
|
<td style={{ padding: '8px 6px', whiteSpace: 'nowrap' }}>
|
||||||
<td style={{ padding: '8px 6px' }}>{p.name}</td>
|
{p.code}
|
||||||
|
{p.isHistory && (
|
||||||
|
<span
|
||||||
|
title={'清仓时间 ' + fmtClosedAt(p.closedAt)}
|
||||||
|
style={{
|
||||||
|
marginLeft: 6, fontSize: 11, padding: '1px 6px', borderRadius: 3,
|
||||||
|
border: '1px solid var(--dsw-alias-border-l2, #ccc)',
|
||||||
|
color: 'var(--dsw-alias-label-tertiary, #999)',
|
||||||
|
background: 'var(--dsw-alias-bg-layer-2, #f5f5f5)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
已清仓 {fmtClosedAt(p.closedAt).slice(0, 10)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '8px 6px' }}>{p.isHistory ? (p.name ?? '—') : p.name}</td>
|
||||||
{(visibleColumns ?? []).map((col) => (
|
{(visibleColumns ?? []).map((col) => (
|
||||||
<td key={col.key} style={{ padding: '8px 6px', whiteSpace: 'nowrap' }}>{renderDataCell(col, p)}</td>
|
<td key={col.key} style={{ padding: '8px 6px', whiteSpace: 'nowrap' }}>{renderDataCell(col, p)}</td>
|
||||||
))}
|
))}
|
||||||
<td style={{ padding: '8px 6px', whiteSpace: 'nowrap' }} onClick={(e) => e.stopPropagation()}>
|
<td style={{ padding: '8px 6px', whiteSpace: 'nowrap' }} onClick={(e) => e.stopPropagation()}>
|
||||||
{removeTarget && removeTarget.code === p.code ? (
|
{p.isHistory ? (
|
||||||
|
<span style={{ color: 'var(--dsw-alias-border-l3, #ccc)' }}>—</span>
|
||||||
|
) : removeTarget && removeTarget.code === p.code ? (
|
||||||
<span>
|
<span>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
@@ -559,11 +716,18 @@ function StrategyTabInner({ strategyId, strategyName }) {
|
|||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
<tr style={{ borderBottom: '1px solid var(--dsw-alias-border-l1, #eee)', background: 'var(--dsw-alias-bg-layer-2, #fafafa)' }}>
|
<tr style={{ borderBottom: '1px solid var(--dsw-alias-border-l1, #eee)', background: 'var(--dsw-alias-bg-layer-2, #fafafa)' }}>
|
||||||
<td colSpan={4 + (visibleColumns?.length ?? 6)} style={{ padding: '4px 8px 12px' }}>
|
<td colSpan={4 + (visibleColumns?.length ?? 6)} style={{ padding: '4px 8px 12px' }}>
|
||||||
|
{p.isHistory && (
|
||||||
|
<div style={{ padding: '4px 8px', fontSize: 12, color: 'var(--dsw-alias-label-secondary, #666)' }}>
|
||||||
|
已清仓持仓 · 清仓时间 {fmtClosedAt(p.closedAt)} · 持有 {heldDays(p.createdAt, p.closedAt) ?? '—'} 天 · 份额(清仓后){p.shares}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div style={{ padding: 8, color: 'var(--dsw-alias-label-tertiary, #999)', fontSize: 12 }}>交易记录加载中...</div>
|
<div style={{ padding: 8, color: 'var(--dsw-alias-label-tertiary, #999)', fontSize: 12 }}>交易记录加载中...</div>
|
||||||
) : !trades || trades.length === 0 ? (
|
) : !trades || trades.length === 0 ? (
|
||||||
<div style={{ padding: 8, color: 'var(--dsw-alias-label-tertiary, #999)', fontSize: 12 }}>
|
<div style={{ padding: 8, color: 'var(--dsw-alias-label-tertiary, #999)', fontSize: 12 }}>
|
||||||
{hasHolding ? '该持仓暂无关联交易记录(可在「交易记录」tab 为委托设置归属)' : '该持仓无关联锚点(holding_id)'}
|
{hasHolding
|
||||||
|
? (p.isHistory ? '该历史持仓暂无关联交易记录(归属设置须在清仓当日完成)' : '该持仓暂无关联交易记录(可在「交易记录」tab 为委托设置归属)')
|
||||||
|
: '该持仓无关联锚点(holding_id)'}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<HoldingTradesTable trades={trades} />
|
<HoldingTradesTable trades={trades} />
|
||||||
|
|||||||
@@ -73,12 +73,18 @@ export class DataStore {
|
|||||||
return this.sqlite.getCurrentHoldings(strategyId);
|
return this.sqlite.getCurrentHoldings(strategyId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 历史持仓 */
|
/** 历史持仓(含当前 + 已清仓;全表按 code 查,R-008 遗留) */
|
||||||
async getHoldingHistory(code) {
|
async getHoldingHistory(code) {
|
||||||
await this._ensure();
|
await this._ensure();
|
||||||
return this.sqlite.getHoldingHistory(code);
|
return this.sqlite.getHoldingHistory(code);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 某策略已清仓持仓(closed_at 非空 + 可选 sinceMs 下限;R-016 迭代 14,只读) */
|
||||||
|
async getHoldingsHistory(strategyId, opts) {
|
||||||
|
await this._ensure();
|
||||||
|
return this.sqlite.getHoldingsHistory(strategyId, opts);
|
||||||
|
}
|
||||||
|
|
||||||
/** 读取持仓自定义字段值(R-013:委托 SqliteStore) */
|
/** 读取持仓自定义字段值(R-013:委托 SqliteStore) */
|
||||||
readValues(holdingId) {
|
readValues(holdingId) {
|
||||||
return this.sqlite.readValues(holdingId);
|
return this.sqlite.readValues(holdingId);
|
||||||
|
|||||||
@@ -375,6 +375,25 @@ export class SqliteStore {
|
|||||||
).all().map(this._mapHolding);
|
).all().map(this._mapHolding);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询某策略已清仓持仓(closed_at 非空;R-016 迭代 14)
|
||||||
|
* 只读新增,不动任何写路径(closeHolding 置 shares=0 语义维持——Q2 老师拍板:历史行份额显示 0)。
|
||||||
|
* @param {string} strategyId 策略 ID
|
||||||
|
* @param {{sinceMs?: number}} [opts] sinceMs:closed_at 下限(毫秒,含);缺省不过滤
|
||||||
|
* @returns {Array} 历史持仓行(_mapHolding 同构),按 closed_at DESC(最近清仓在前)
|
||||||
|
*/
|
||||||
|
getHoldingsHistory(strategyId, { sinceMs } = {}) {
|
||||||
|
this.init();
|
||||||
|
const params = [strategyId];
|
||||||
|
let sql = 'SELECT holding_id, strategy_id, code, shares, created_at, closed_at, "values" FROM strategy_holdings WHERE strategy_id=? AND closed_at IS NOT NULL';
|
||||||
|
if (sinceMs != null && Number.isFinite(Number(sinceMs))) {
|
||||||
|
sql += ' AND closed_at >= ?';
|
||||||
|
params.push(Number(sinceMs));
|
||||||
|
}
|
||||||
|
sql += ' ORDER BY closed_at DESC';
|
||||||
|
return this.db.prepare(sql).all(...params).map(this._mapHolding);
|
||||||
|
}
|
||||||
|
|
||||||
/** 查询历史(含当前 + 已清仓;可加 code 过滤) */
|
/** 查询历史(含当前 + 已清仓;可加 code 过滤) */
|
||||||
getHoldingHistory(code) {
|
getHoldingHistory(code) {
|
||||||
this.init();
|
this.init();
|
||||||
@@ -664,8 +683,6 @@ export class SqliteStore {
|
|||||||
return this.db.prepare(
|
return this.db.prepare(
|
||||||
'SELECT * FROM trade_orders WHERE holding_id=? ORDER BY insert_ts DESC'
|
'SELECT * FROM trade_orders WHERE holding_id=? ORDER BY insert_ts DESC'
|
||||||
).all(holdingId).map((r) => this._mapOrderRow(r));
|
).all(holdingId).map((r) => this._mapOrderRow(r));
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 委托归属候选列表(用户手动设置用;候选 = 该 code 当前持仓的策略/持仓,Q3 全手动选)
|
* 委托归属候选列表(用户手动设置用;候选 = 该 code 当前持仓的策略/持仓,Q3 全手动选)
|
||||||
* @param {string} code 证券代码(含后缀)
|
* @param {string} code 证券代码(含后缀)
|
||||||
@@ -682,7 +699,6 @@ export class SqliteStore {
|
|||||||
shares: h.shares,
|
shares: h.shares,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
_mapOrderRow(r) {
|
_mapOrderRow(r) {
|
||||||
return {
|
return {
|
||||||
orderId: r.order_id,
|
orderId: r.order_id,
|
||||||
|
|||||||
Reference in New Issue
Block a user