/** * 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;