迭代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
+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;