迭代15: 委托归属候选纳入近期清仓持仓(R-017)

- 交易记录 tab 归属候选只回当前持仓 → 清仓后当日委托失去下拉锚点无法补关联
  (2026-09-07 大连热电实际发生: 盘中归属后被清为未关联,下拉已无做T候选)
- SqliteStore.getAttributionCandidates: 候选 = 该 code 当前持仓 + 近 7 天清仓的持仓
  (closed 标记 + closedAt,排序 当前持仓在前 → 清仓行 closed_at DESC)
- AttributionSelect: 下拉 value 改 holdingId 键控(同策略多轮持仓不撞值)、
  清仓选项「(已清仓 MM-DD)」后缀、已归属但候选缺失显示「当前归属」占位
- 回归: test-r017-candidates 12/12 + 存量回归全绿
This commit is contained in:
2026-09-07 18:15:20 +08:00
parent 51c70c48fb
commit 22ec732607
10 changed files with 278 additions and 17 deletions
@@ -0,0 +1,27 @@
# 计划:委托归属候选纳入近期清仓持仓(阶段航点)
> 编号:PLAN-016 | 粒度:阶段航点 创建:2026-09-07 | 状态:**已实施(待验收)**
> 派生自终极目标:目标-008(真实交易系统接入)
> 依据需求:**R-017(已定稿,2026-09-07,老师指令确认方向)** —— 符合入范围门槛
> 设计约束:技术约束-013 语义延伸(候选窗口 + holdingId 键控);不新增约束条目
## 目标
清仓后当日委托可补关联:归属候选 = 该 code 当前持仓 + 近 7 天清仓持仓(closed 标记),下拉锚点改 holdingId 键控。
## 范围
**做**getAttributionCandidates 扩展(closed_at IS NULL OR ≥ 7 天前;closed/closedAt 字段;当前在前/清仓 closed_at DESC+ AttributionSelect holdingId 键控 + 「(已清仓 MM-DD)」后缀 + 「当前归属」占位选项 + 回归 test-r017-candidates.mjs。
**不做**:候选窗口可配置;自动归属;其他候选消费方调整;历史数据修复工具(一次性修复走 set-attribution)。
## 涉及文件
src/storage/SqliteStore.jsgetAttributionCandidates)、src/client/views/TradeRecordsTab.jsxAttributionSelect)、scripts/test-r017-candidates.mjs(新增)。
## 实现步骤
1. R-017 + 本计划 + 三件套;2. 存储扩展;3. UI 重构;4. 回归 + typecheck + build + 存量回归;5. 复盘。
## 验收要点
`docs/04-迭代记录/15-归属候选纳入清仓持仓/验收标准.md`
@@ -0,0 +1,29 @@
# 技术实现方案:15-归属候选纳入清仓持仓
> 迭代编号:15 依据:PLAN-016 + R-017
## 1. 存储层(getAttributionCandidates 扩展,门面透传不变)
```sql
SELECT holding_id, strategy_id, shares, closed_at FROM strategy_holdings
WHERE code=? AND (closed_at IS NULL OR closed_at >= ?) -- ? = Date.now() - 7d
ORDER BY (closed_at IS NULL) DESC, shares DESC, closed_at DESC
```
- 返回行新增 closed: boolean / closedAt: number|null(消费方:api strategies.js 同方法直接受益——注意 attribution-candidates 端点在 trades.js,调 DataStore.getTradeAttributionCandidates 透传,零改动);
- 窗口常量 7 * 86400000(与 R-016 默认范围一致)。
## 2. UITradeRecordsTab.jsx AttributionSelect 重构)
- 锚点键:currentKey = order.holdingId != null ? String(order.holdingId) : ''holdingId 是 R-009 定稿的归属锚点;strategyId 在同策略多轮持仓时会撞值);
- handleSelect:按 String(holdingId) 找候选 → setAttribution(orderId, code, cand.strategyId, cand.holdingId)__unassigned__ → 双 null
- 占位选项:已归属但候选缺失(清仓超 7 天)→ 「当前归属({strategyId})」,value=currentKey,防 select 悬空显示第一项误导;
- 清仓候选文本后缀「(已清仓 MM-DD)」(fmtClosed 本地 helper);select maxWidth 120→150。
## 3. 数据修复记录(一次性,非功能)
2026-09-07 大连热电卖出委托 645001623 经 live set-attribution 通道回填 strategy_id=manual-t / holding_id=15curl 实测 ok:trueorders/by-holding 回读确认)。当日归属曾被清为未关联的原因:候选下拉无做T项时老师误触「未关联」入口——本迭代修复后此路径消除。
## 4. 验证链
test-r017-candidates.mjs 12 断言 + typecheck + build + 存量回归(r016 19 / position-sync 35 / r013 21 / quote-sync 27)。
@@ -0,0 +1,34 @@
# 迭代复盘:15-归属候选纳入清仓持仓
> 复盘日期:2026-09-07 | 迭代状态:**已实施,待老师人工验收**
> 关联需求:R-017 关联计划:PLAN-016
## 结果
迭代 15 达成:归属候选 = 当前持仓 + 近 7 天清仓持仓(closed 标记 + 「已清仓 MM-DD」后缀),下拉锚点改 holdingId 键控(同策略多轮持仓不撞值)+ 「当前归属」占位防悬空;清仓后当日委托可补关联。另:当日大连热电卖出委托(645001623)的归属已经 set-attribution 通道修复回 manual-t/holding 15(一次性数据修复,非功能)。
## 过程事实
1. **数据取证先行**:老师报告「无法关联」时,DB 真相 = 委托归属已被清为 null/null(非从未设置)+ 候选接口只剩网格超市——「归属丢失」与「候选缺失」两个问题一次取证分清;
2. **当日修复走既有通道**set-attribution 本就无候选限制(Q4 可随时改语义),curl 修复 + 回读验证,不新造工具;
3. **实现**SqliteStore.getAttributionCandidates WHERE/ORDER 扩展 + closed/closedAt 字段(门面/api 零改动透传);AttributionSelect 从 strategyId 键控重构为 holdingId 键控 + 占位 + 后缀;
4. **验证**test-r017 12/12typecheck 通过;存量回归 r016 19/19、position-sync 35/35、r013 21/21、quote-sync 27/27build 通过。
## 经验教训(复盘沉淀)
### 1. 「关联」的锚点键要在多轮持仓出现时重新审视
- R-009 时代下拉 value 用 strategyId(一码一策略一行时无碍);R-016 历史行引入后同码可同时存在「当前行 + 历史行」、同策略可有多轮清仓——strategyId 键撞值成为真实 bug 面,holdingId 才是归属锚点(R-009 表结构早已如此,UI 层滞后);
- 沉淀:外键语义(表里存什么)和 UI 键(下拉传什么)要对齐审查,表结构升级时 UI 不自动跟上。
### 2. 「数据问题」与「能力缺失」要分层处置
- 老师当下要的是「这笔委托关联回去」(数据问题)——走既有 API 通道立即修复,不等迭代;「以后不再发生」(能力缺失)——立项 R-017 走流程。两层都做但互不阻塞。
### 3. 候选缺失时的 UI 应该显示「当前归属」而不是悬空
- select value 不在任何 option 里时浏览器表现不可控(显示空白或第一项),第一项恰是「未关联」时极易诱导误操作(本次归属被清的可能路径);
- 沉淀:受控 select 的 value 必须保证总有对应 option——数据缺口用占位 option 补,不让浏览器自作主张。
## 遗留/后续
1. **候选窗口(7 天)与 R-016 范围档是两套口径**:当前均满足场景;若老师后续要统一(候选也用 5 档范围),另议小改;
2. **自动归属推导**_resolveStrategyAttribution 时间窗)仍在(技术约束-013),但 Q3 全手动原则下仅为内部能力,未暴露 UI;维持现状;
3. R-015 / 迭代 13、R-016 / 迭代 14 的人工验收仍待老师确认。
@@ -0,0 +1,19 @@
# 迭代目标:15-归属候选纳入清仓持仓
> 迭代编号:15 | 创建:2026-09-07 | 状态:已实施,待验收
> 依据计划:PLAN-016 需求:R-017(已定稿,2026-09-07
## 目标描述
交易记录 tab 的归属候选(orders/attribution-candidates)纳入近 7 天清仓的持仓(closed 标记),下拉锚点改 holdingId 键控——修复「清仓后当日委托无法补关联」(2026-09-07 大连热电做T实际发生:盘中归属被清后下拉已无手动做T候选)。
## 目标分解
1. **存储**SqliteStore.getAttributionCandidates):WHERE 扩展为 closed_at IS NULL OR closed_at ≥ 7 天前;返回行附 closed/closedAt;排序 当前持仓(shares DESC) 在前 → 清仓行 closed_at DESC
2. **UI**AttributionSelect):value 改 String(holdingId);点选按 holdingId 找候选取 strategyId+holdingId 写入;清仓选项加「(已清仓 MM-DD)」后缀;已归属但候选缺失时显示「当前归属」占位(防 select 值悬空);title 提示更新;
3. **回归**scripts/test-r017-candidates.mjs12 断言:候选构成/标记/窗口边界/排序/隔离/端点透传)。
## 对老师/主理人的配合需求
- 重启 DSH web 进程(服务端代码更新)+ 刷新页面后人工验收;
- 验收通过后迭代 15 标记「验收通过」,R-017 归档。
@@ -0,0 +1,20 @@
# 验收标准:15-归属候选纳入清仓持仓
> 迭代编号:15 | 依据:PLAN-016 验收要点 + R-017
## 验收标准线
1. **自动化**typecheck + build 通过;test-r017-candidates.mjs 12/12;存量回归 r016 / position-sync / r013 / quote-sync 全绿;
2. **候选构成**(真实环境):交易记录 tab 大连热电今日卖出委托的归属下拉 = 网格超市(当前持仓)+ 手动做T(已清仓 09-07)两项;
3. **补关联**:点选「手动做T(已清仓 09-07)」→ 保存成功提示 → 行内归属显示手动做T且下拉保持选中;做T策略 tab 历史持仓行展开可见该笔卖出(holding 15 关联);
4. **可逆**:切回「未关联」→ 双 null 持久化;再切回做T → 恢复;
5. **占位**:归属存在但候选缺失的场景显示「当前归属(…)」占位(可用超 7 天清仓行验证,或观察现有数据)。
## 验收方法
- 自动化项 AI 执行出具结果;2~5 老师重启 DSH web + 刷新页面人工验收;
- 全部通过后迭代 15 标记「验收通过」,R-017 归档 已完成/。
## 验收目标
- 5 条验收线通过,迭代 15 标记「验收通过」,R-017 更新实现状态并归档。
+30
View File
@@ -0,0 +1,30 @@
# 需求:R-017 委托归属候选纳入近期清仓持仓
> 登记:2026-09-07 来源:老师指令(2026-09-07「处理一下,今天大连热电的交易记录,我没办法关联到手动做T持仓的大连热电上」)| 状态:**已定稿**
> 归属:迭代 15 | 计划:PLAN-016 实现状态:**已实现(2026-09-07,待老师人工验收)**
> 前置:R-016「关联观察」所列问题的正式立项(清仓后当日委托无法通过下拉关联到对应 holding,2026-09-07 大连热电实际发生——盘中手动归属后又被清为未关联,下拉已无手动做T候选)。
## 需求描述
交易记录 tab 的归属候选(orders/attribution-candidates)只回**当前持仓**——清仓后(幽灵清仓最快约 30s 转历史)当日委托在下拉中失去对应策略/持仓锚点,无法补关联。本轮:**候选 = 该 code 的当前持仓 + 近 7 天清仓的持仓**,清仓行带标记供 UI 区分展示。
## 讨论记录(2026-09-07
| # | 决策点 | 结论 |
|---|---|---|
| 1 | 立项与方向 | 老师指令确认(对 R-016 关联观察修复方案的采纳) |
| 2 | 候选窗口 | 近 7 天清仓(与 R-016 默认范围一致,AI 定;覆盖「当日做T当日盘后补关联」场景足够) |
| 3 | 锚点键 | 下拉 value 改用 **holdingId**(同策略可有多轮持仓行,strategyId 会撞值;holding_id 本就是归属锚点,R-009 定稿语义) |
| 4 | 标记展示 | 清仓候选在选项文本加「(已清仓 MM-DD)」后缀;已归属但候选列表缺失的委托显示「当前归属」占位选项(避免 select 空白) |
| 5 | 排序 | 当前持仓在前,清仓行按 closed_at DESC 追加 |
| 6 | 归属写入 | 不变:orders/set-attribution 无候选限制(本次数据修复即经此通道完成) |
## 边界
**做**getAttributionCandidates 纳入近 7 天清仓行(附 closed/closedAt);AttributionSelect 改 holdingId 键控 + 已清仓后缀 + 当前归属占位;回归脚本。
**不做**:候选窗口可配置;自动归属(Q3 手动原则不变);交易记录 tab 之外的候选消费方调整;历史数据修复工具(本次一次性修复经 set-attribution 完成,不固化成功能)。
## 验收
`docs/04-迭代记录/15-归属候选纳入清仓持仓/验收标准.md`
+1
View File
@@ -28,6 +28,7 @@
| 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-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历史持仓展示 | 已实现(待验收) | | 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历史持仓展示 | 已实现(待验收) |
| R-017 | 委托归属候选纳入近期清仓持仓 | 交易记录 tab 归属候选只回当前持仓——清仓后当日委托失去下拉锚点无法补关联(R-016 关联观察立项,2026-09-07 大连热电实际发生:盘中归属后被清为未关联,下拉已无做T候选)。**2026-09-07 定稿(老师指令确认方向)**:候选 = 当前持仓 + 近 7 天清仓持仓(closed 标记,选项文本「已清仓」后缀),下拉 value 改 holdingId 键控(同策略多轮不撞值),排序当前在前/清仓按 closed_at DESC;当日数据修复经 set-attribution 通道完成(非固化功能)。详见 R-017.md | 老师指令(2026-09-07 | P1 | 已定稿 | 2026-09-07 | 15-归属候选纳入清仓持仓 | 已实现(待验收) |
## 渐进明细规划素材 ## 渐进明细规划素材
+72
View File
@@ -0,0 +1,72 @@
/**
* R-017 / 迭代 15 回归:归属候选纳入近期清仓持仓
* 独立临时数据目录(技术约束-011)。覆盖:当前持仓在前 / 近 7 天清仓行入选(closed 标记)/
* 超 7 天清仓行被滤 / 策略隔离(同 code 多轮多策略)/ 空 code / 端点透传。
*/
import { DataStore } from '../src/storage/DataStore.js';
import { handleTrade } from '../src/api/trades.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-r017-'));
const storage = new DataStore({ dataDir: tmp });
const runtime = { storage, settings: { get: () => ({}) } }; // 策略名缺省 → fallback strategyId
try {
console.log('\n[1] 当前持仓候选(closed=false');
const h1 = await storage.openHolding('s-t', '600719.SH', 1000);
await storage.openHolding('s-g', '600719.SH', 800); // 同码第二策略
let cands = await storage.getTradeAttributionCandidates('600719.SH');
assert(cands.length === 2, '同码两策略当前持仓都在候选');
assert(cands.every((c) => c.closed === false && c.closedAt === null), '当前持仓 closed=false / closedAt=null');
assert(cands[0].shares >= cands[1].shares, '当前持仓按 shares DESC');
console.log('\n[2] 近 7 天清仓行入选(closed=true),当前在前');
await storage.openHolding('s-t', '000001.SZ', 500);
await storage.closeHolding('s-t', '000001.SZ'); // 刚清仓(做T场景)
cands = await storage.getTradeAttributionCandidates('000001.SZ');
assert(cands.length === 1 && cands[0].closed === true, '刚清仓行进入候选');
assert(cands[0].holdingId != null && cands[0].closedAt > 0, '清仓行带 holdingId + closedAt');
assert(cands[0].strategyId === 's-t', '清仓行策略正确');
console.log('\n[3] 超 7 天清仓行被滤除');
const hOld = await storage.openHolding('s-t', '600000.SH', 300);
await storage.closeHolding('s-t', '600000.SH');
storage.sqlite.db.prepare('UPDATE strategy_holdings SET closed_at=? WHERE holding_id=?')
.run(Date.now() - 8 * DAY, hOld.holdingId);
cands = await storage.getTradeAttributionCandidates('600000.SH');
assert(cands.length === 0, '8 天前清仓行不在候选');
console.log('\n[4] 排序:当前持仓在前,清仓行 closed_at DESC');
await storage.openHolding('s-g', '300750.SZ', 100);
await storage.closeHolding('s-g', '300750.SZ');
const hOlder = await storage.openHolding('s-g', '300750.SZ', 200); // 复用同码再建仓再清
await storage.closeHolding('s-g', '300750.SZ');
storage.sqlite.db.prepare('UPDATE strategy_holdings SET closed_at=? WHERE holding_id=?')
.run(Date.now() - 3 * DAY, hOlder.holdingId);
cands = await storage.getTradeAttributionCandidates('300750.SZ');
assert(cands.length === 2 && cands.every((c) => c.closed), '同码两轮清仓都入选');
assert(cands[0].closedAt >= cands[1].closedAt, '清仓行按 closed_at DESC');
console.log('\n[5] 空 code / 无持仓 code');
assert((await storage.getTradeAttributionCandidates('')).length === 0, '空 code → []');
assert((await storage.getTradeAttributionCandidates('999999.SH')).length === 0, '无持仓 code → []');
console.log('\n[6] API 端点透传(含 strategyName fallback');
const api = await handleTrade('orders/attribution-candidates', { code: '000001.SZ' }, runtime);
assert(api.length === 1 && api[0].strategyName === 's-t', '端点返回候选且策略名 fallback 到 id');
} finally {
storage.close();
try { rmSync(tmp, { recursive: true, force: true }); } catch { /* ignore */ }
}
console.log('\n=== 结果: ' + pass + ' 通过 / ' + fail + ' 失败 ===');
process.exitCode = fail > 0 ? 1 : 0;
+32 -13
View File
@@ -473,47 +473,66 @@ function OrderRows({ order, expanded, onToggle, attributionCandidates, onLoadCan
} }
/** 委托归属选择下拉(R-009,Q3 全手动选;Q4 可随时改) /** 委托归属选择下拉(R-009,Q3 全手动选;Q4 可随时改)
* 候选 = 该 code 当前持仓策略(orders/attribution-candidates); * 候选 = 该 code 当前持仓 + 近 7 天清仓持仓(R-017orders/attribution-candidates);
* 锚点键 = holdingId(同策略可有多轮持仓行,strategyId 会撞值,R-017 定稿);
* 未设置显示「未关联」,用户点选后持久化(orders/set-attribution)。 * 未设置显示「未关联」,用户点选后持久化(orders/set-attribution)。
*/ */
function AttributionSelect({ order, candidates, onLoadCandidates, setAttribution }) { function AttributionSelect({ order, candidates, onLoadCandidates, setAttribution }) {
const current = order.strategyId || ''; const currentKey = order.holdingId != null ? String(order.holdingId) : '';
const handleSelect = (ev) => { const handleSelect = (ev) => {
const val = ev.target.value; const val = ev.target.value;
if (val === current) return; if (val === currentKey) return;
if (val === '__unassigned__') { if (val === '__unassigned__') {
setAttribution(order.orderId, order.code, null, null); setAttribution(order.orderId, order.code, null, null);
return; return;
} }
const cand = (candidates ?? []).find((c) => c.strategyId === val); const cand = (candidates ?? []).find((c) => String(c.holdingId) === val);
setAttribution(order.orderId, order.code, val, cand ? cand.holdingId : null); setAttribution(order.orderId, order.code, cand ? cand.strategyId : null, cand ? cand.holdingId : null);
}; };
// 已归属但候选列表缺失(如清仓超 7 天):显示「当前归属」占位,避免 select 值悬空
const candByKey = new Map((candidates ?? []).map((c) => [String(c.holdingId), c]));
const hasCurrent = currentKey && candByKey.has(currentKey);
const closedSuffix = (c) => (c.closed ? '(已清仓 ' + fmtClosed(c.closedAt) + '' : '');
return ( return (
<select <select
value={current || '__unassigned__'} value={hasCurrent || !currentKey ? (currentKey || '__unassigned__') : currentKey}
onChange={handleSelect} onChange={handleSelect}
style={{ style={{
fontSize: 12, fontSize: 12,
padding: '2px 4px', padding: '2px 4px',
border: '1px solid ' + (current ? 'var(--dsw-alias-state-success-primary, #2e7d32)' : 'var(--dsw-alias-border-l2, #ccc)'), border: '1px solid ' + (currentKey ? 'var(--dsw-alias-state-success-primary, #2e7d32)' : 'var(--dsw-alias-border-l2, #ccc)'),
borderRadius: 4, borderRadius: 4,
background: current ? 'color-mix(in srgb, var(--dsw-alias-state-success-primary, #2e7d32) 12%, var(--dsw-alias-bg-layer-1, #fff))' : 'var(--dsw-alias-bg-layer-1, #fff)', background: currentKey ? 'color-mix(in srgb, var(--dsw-alias-state-success-primary, #2e7d32) 12%, var(--dsw-alias-bg-layer-1, #fff))' : 'var(--dsw-alias-bg-layer-1, #fff)',
color: current ? 'var(--dsw-alias-state-success-primary, #1b5e20)' : 'var(--dsw-alias-label-tertiary, #999)', color: currentKey ? 'var(--dsw-alias-state-success-primary, #1b5e20)' : 'var(--dsw-alias-label-tertiary, #999)',
maxWidth: 120, maxWidth: 150,
}} }}
title="设置该委托的策略归属(可随时修改)" title="设置该委托的策略/持仓归属(可随时修改;清仓 7 天内可补关联"
> >
<option value="__unassigned__">未关联</option> <option value="__unassigned__">未关联</option>
{!hasCurrent && currentKey && (
<option value={currentKey}>当前归属{order.strategyId || '未知策略'}</option>
)}
{(candidates ?? []).map((c) => ( {(candidates ?? []).map((c) => (
<option key={c.holdingId} value={c.strategyId}>{c.strategyName || c.strategyId}</option> <option key={c.holdingId} value={String(c.holdingId)}>
{c.strategyName || c.strategyId}{closedSuffix(c)}
</option>
))} ))}
</select> </select>
); );
} }
/** 清仓日期短格式(MM-DD */
function fmtClosed(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.getMonth() + 1) + '-' + p2(d.getDate());
}
const thStyle = { padding: '8px 6px', lineHeight: '24px', whiteSpace: 'nowrap' }; const thStyle = { padding: '8px 6px', lineHeight: '24px', whiteSpace: 'nowrap' };
const thClickStyle = { ...thStyle, cursor: 'pointer', userSelect: 'none' }; const thClickStyle = { ...thStyle, cursor: 'pointer', userSelect: 'none' };
const tdStyle = { padding: '8px 6px', lineHeight: '24px', whiteSpace: 'nowrap', fontVariantNumeric: 'tabular-nums' }; const tdStyle = { padding: '8px 6px', lineHeight: '24px', whiteSpace: 'nowrap', fontVariantNumeric: 'tabular-nums' };
+14 -4
View File
@@ -683,22 +683,32 @@ 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 全手动选) * 委托归属候选列表(用户手动设置用;Q3 全手动选)
* R-017 迭代 15:候选 = 该 code 当前持仓 + 近 7 天清仓的持仓(closed 标记)——
* 清仓后(幽灵清仓最快约 30s 转历史)当日委托仍可补关联到对应 holding。
* @param {string} code 证券代码(含后缀) * @param {string} code 证券代码(含后缀)
* @returns {Array<{strategyId: string, holdingId: number, shares: number}>} 候选列表(可空) * @returns {Array<{strategyId: string, holdingId: number, shares: number, closed: boolean, closedAt: number|null}>} 候选列表(可空)
*/ */
getAttributionCandidates(code) { getAttributionCandidates(code) {
this.init(); this.init();
if (!code) return []; if (!code) return [];
const weekAgo = Date.now() - 7 * 86400000;
return this.db.prepare( return this.db.prepare(
'SELECT holding_id, strategy_id, shares FROM strategy_holdings WHERE code=? AND closed_at IS NULL ORDER BY shares DESC' `SELECT holding_id, strategy_id, shares, closed_at FROM strategy_holdings
).all(code).map((h) => ({ WHERE code=? AND (closed_at IS NULL OR closed_at >= ?)
ORDER BY (closed_at IS NULL) DESC, shares DESC, closed_at DESC`
).all(code, weekAgo).map((h) => ({
strategyId: h.strategy_id, strategyId: h.strategy_id,
holdingId: h.holding_id, holdingId: h.holding_id,
shares: h.shares, shares: h.shares,
closed: h.closed_at != null,
closedAt: h.closed_at ?? null,
})); }));
} }
_mapOrderRow(r) { _mapOrderRow(r) {
return { return {
orderId: r.order_id, orderId: r.order_id,