Files
one_divine_lot/scripts/test-r016-history.mjs

118 lines
7.7 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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=0Q2closeHolding 语义不变)且 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.SH40 天前行被滤掉)');
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;