118 lines
7.7 KiB
JavaScript
118 lines
7.7 KiB
JavaScript
/**
|
||
* 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 的历史行(R-018:真相源 = 段表 —— 段 + 冗余列镜像)
|
||
db.prepare(
|
||
"INSERT INTO trade_order_attributions (order_id, strategy_id, holding_id, code, direction, volume, created_at) VALUES (?,?,?,?,?,?,?)"
|
||
).run('T-001', 's1', week[0].holdingId, '600719.SH', 'sell', 100, Date.now());
|
||
db.prepare('UPDATE trade_orders SET strategy_id=?, holding_id=? WHERE order_id=?').run('s1', 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;
|