From 22ec7326077df0dcf1790ff42515a9dbb18dee96 Mon Sep 17 00:00:00 2001 From: AdamGao Date: Mon, 7 Sep 2026 18:15:20 +0800 Subject: [PATCH] =?UTF-8?q?=E8=BF=AD=E4=BB=A315:=20=E5=A7=94=E6=89=98?= =?UTF-8?q?=E5=BD=92=E5=B1=9E=E5=80=99=E9=80=89=E7=BA=B3=E5=85=A5=E8=BF=91?= =?UTF-8?q?=E6=9C=9F=E6=B8=85=E4=BB=93=E6=8C=81=E4=BB=93=EF=BC=88R-017?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 交易记录 tab 归属候选只回当前持仓 → 清仓后当日委托失去下拉锚点无法补关联 (2026-09-07 大连热电实际发生: 盘中归属后被清为未关联,下拉已无做T候选) - SqliteStore.getAttributionCandidates: 候选 = 该 code 当前持仓 + 近 7 天清仓的持仓 (closed 标记 + closedAt,排序 当前持仓在前 → 清仓行 closed_at DESC) - AttributionSelect: 下拉 value 改 holdingId 键控(同策略多轮持仓不撞值)、 清仓选项「(已清仓 MM-DD)」后缀、已归属但候选缺失显示「当前归属」占位 - 回归: test-r017-candidates 12/12 + 存量回归全绿 --- docs/02-计划/计划-归属候选纳入清仓持仓.md | 27 +++++++ .../15-归属候选纳入清仓持仓/技术实现方案.md | 29 ++++++++ .../15-归属候选纳入清仓持仓/迭代复盘.md | 34 +++++++++ .../15-归属候选纳入清仓持仓/迭代目标.md | 19 +++++ .../15-归属候选纳入清仓持仓/验收标准.md | 20 ++++++ docs/05-需求池/R-017.md | 30 ++++++++ docs/05-需求池/需求池索引.md | 1 + scripts/test-r017-candidates.mjs | 72 +++++++++++++++++++ src/client/views/TradeRecordsTab.jsx | 45 ++++++++---- src/storage/SqliteStore.js | 18 +++-- 10 files changed, 278 insertions(+), 17 deletions(-) create mode 100644 docs/02-计划/计划-归属候选纳入清仓持仓.md create mode 100644 docs/04-迭代记录/15-归属候选纳入清仓持仓/技术实现方案.md create mode 100644 docs/04-迭代记录/15-归属候选纳入清仓持仓/迭代复盘.md create mode 100644 docs/04-迭代记录/15-归属候选纳入清仓持仓/迭代目标.md create mode 100644 docs/04-迭代记录/15-归属候选纳入清仓持仓/验收标准.md create mode 100644 docs/05-需求池/R-017.md create mode 100644 scripts/test-r017-candidates.mjs diff --git a/docs/02-计划/计划-归属候选纳入清仓持仓.md b/docs/02-计划/计划-归属候选纳入清仓持仓.md new file mode 100644 index 0000000..75a878e --- /dev/null +++ b/docs/02-计划/计划-归属候选纳入清仓持仓.md @@ -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`。 diff --git a/docs/04-迭代记录/15-归属候选纳入清仓持仓/技术实现方案.md b/docs/04-迭代记录/15-归属候选纳入清仓持仓/技术实现方案.md new file mode 100644 index 0000000..1f20b6c --- /dev/null +++ b/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)。 diff --git a/docs/04-迭代记录/15-归属候选纳入清仓持仓/迭代复盘.md b/docs/04-迭代记录/15-归属候选纳入清仓持仓/迭代复盘.md new file mode 100644 index 0000000..f697d41 --- /dev/null +++ b/docs/04-迭代记录/15-归属候选纳入清仓持仓/迭代复盘.md @@ -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 的人工验收仍待老师确认。 diff --git a/docs/04-迭代记录/15-归属候选纳入清仓持仓/迭代目标.md b/docs/04-迭代记录/15-归属候选纳入清仓持仓/迭代目标.md new file mode 100644 index 0000000..501a293 --- /dev/null +++ b/docs/04-迭代记录/15-归属候选纳入清仓持仓/迭代目标.md @@ -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 归档。 diff --git a/docs/04-迭代记录/15-归属候选纳入清仓持仓/验收标准.md b/docs/04-迭代记录/15-归属候选纳入清仓持仓/验收标准.md new file mode 100644 index 0000000..ccb6d3a --- /dev/null +++ b/docs/04-迭代记录/15-归属候选纳入清仓持仓/验收标准.md @@ -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 更新实现状态并归档。 diff --git a/docs/05-需求池/R-017.md b/docs/05-需求池/R-017.md new file mode 100644 index 0000000..ffca701 --- /dev/null +++ b/docs/05-需求池/R-017.md @@ -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`。 diff --git a/docs/05-需求池/需求池索引.md b/docs/05-需求池/需求池索引.md index 473da23..092afc3 100644 --- a/docs/05-需求池/需求池索引.md +++ b/docs/05-需求池/需求池索引.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-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-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-归属候选纳入清仓持仓 | 已实现(待验收) | ## 渐进明细规划素材 diff --git a/scripts/test-r017-candidates.mjs b/scripts/test-r017-candidates.mjs new file mode 100644 index 0000000..33d6532 --- /dev/null +++ b/scripts/test-r017-candidates.mjs @@ -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; diff --git a/src/client/views/TradeRecordsTab.jsx b/src/client/views/TradeRecordsTab.jsx index 94ed655..1e5f027 100644 --- a/src/client/views/TradeRecordsTab.jsx +++ b/src/client/views/TradeRecordsTab.jsx @@ -473,47 +473,66 @@ function OrderRows({ order, expanded, onToggle, attributionCandidates, onLoadCan } /** 委托归属选择下拉(R-009,Q3 全手动选;Q4 可随时改) - * 候选 = 该 code 当前持仓策略(orders/attribution-candidates); + * 候选 = 该 code 当前持仓 + 近 7 天清仓持仓(R-017,orders/attribution-candidates); + * 锚点键 = holdingId(同策略可有多轮持仓行,strategyId 会撞值,R-017 定稿); * 未设置显示「未关联」,用户点选后持久化(orders/set-attribution)。 */ function AttributionSelect({ order, candidates, onLoadCandidates, setAttribution }) { - const current = order.strategyId || ''; + const currentKey = order.holdingId != null ? String(order.holdingId) : ''; const handleSelect = (ev) => { const val = ev.target.value; - if (val === current) return; + if (val === currentKey) return; if (val === '__unassigned__') { setAttribution(order.orderId, order.code, null, null); return; } - const cand = (candidates ?? []).find((c) => c.strategyId === val); - setAttribution(order.orderId, order.code, val, cand ? cand.holdingId : null); + const cand = (candidates ?? []).find((c) => String(c.holdingId) === val); + 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 ( ); } +/** 清仓日期短格式(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 thClickStyle = { ...thStyle, cursor: 'pointer', userSelect: 'none' }; const tdStyle = { padding: '8px 6px', lineHeight: '24px', whiteSpace: 'nowrap', fontVariantNumeric: 'tabular-nums' }; \ No newline at end of file diff --git a/src/storage/SqliteStore.js b/src/storage/SqliteStore.js index 11772cd..e992a10 100644 --- a/src/storage/SqliteStore.js +++ b/src/storage/SqliteStore.js @@ -683,22 +683,32 @@ export class SqliteStore { return this.db.prepare( 'SELECT * FROM trade_orders WHERE holding_id=? ORDER BY insert_ts DESC' ).all(holdingId).map((r) => this._mapOrderRow(r)); + } + /** - * 委托归属候选列表(用户手动设置用;候选 = 该 code 当前持仓的策略/持仓,Q3 全手动选) + * 委托归属候选列表(用户手动设置用;Q3 全手动选) + * R-017 迭代 15:候选 = 该 code 当前持仓 + 近 7 天清仓的持仓(closed 标记)—— + * 清仓后(幽灵清仓最快约 30s 转历史)当日委托仍可补关联到对应 holding。 * @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) { this.init(); if (!code) return []; + const weekAgo = Date.now() - 7 * 86400000; return this.db.prepare( - 'SELECT holding_id, strategy_id, shares FROM strategy_holdings WHERE code=? AND closed_at IS NULL ORDER BY shares DESC' - ).all(code).map((h) => ({ + `SELECT holding_id, strategy_id, shares, closed_at FROM strategy_holdings + 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, holdingId: h.holding_id, shares: h.shares, + closed: h.closed_at != null, + closedAt: h.closed_at ?? null, })); } + _mapOrderRow(r) { return { orderId: r.order_id,