迭代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:
@@ -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.js(getAttributionCandidates)、src/client/views/TradeRecordsTab.jsx(AttributionSelect)、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. UI(TradeRecordsTab.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=15(curl 实测 ok:true,orders/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/12;typecheck 通过;存量回归 r016 19/19、position-sync 35/35、r013 21/21、quote-sync 27/27;build 通过。
|
||||||
|
|
||||||
|
## 经验教训(复盘沉淀)
|
||||||
|
|
||||||
|
### 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.mjs(12 断言:候选构成/标记/窗口边界/排序/隔离/端点透传)。
|
||||||
|
|
||||||
|
## 对老师/主理人的配合需求
|
||||||
|
|
||||||
|
- 重启 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 更新实现状态并归档。
|
||||||
@@ -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`。
|
||||||
@@ -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-归属候选纳入清仓持仓 | 已实现(待验收) |
|
||||||
|
|
||||||
## 渐进明细规划素材
|
## 渐进明细规划素材
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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-017,orders/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' };
|
||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user