迭代14: 策略tab历史持仓展示(R-016)+ 今日已清仓默认层
- R-016 Q1-Q7: 两个策略 tab title 旁「历史持仓」开关 + 清仓范围筛选(近一周默认/1月/3月/半年/1年) - SqliteStore.getHoldingsHistory(strategy_id + closed_at 非空 + sinceMs,closed_at DESC,只读) - DataStore 门面透传 + strategy-holdings/history 端点(name 兜底 + 参数校验) - 前端: 开关/范围下拉/历史行灰显+「已清仓」徽标/展开复用 R-010(rowKey 唯一化) - R-016 二轮补充(Q8-Q9 老师拍板): 今日已清仓默认层 - range=today = 本地自然日 00:00 起(特判零点,不落回溯毫秒档) - 当天已清仓的持仓默认恒显示、不受「历史持仓」开关控制(开关只控今天之前) - rowsToRender 分层合成 + holdingId 去重;标题计数不含已清仓行;零写路径变更 - 回归: test-r016-history 24/24 + 存量回归全绿
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* 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;
|
||||
Reference in New Issue
Block a user