165 lines
9.6 KiB
JavaScript
165 lines
9.6 KiB
JavaScript
/**
|
|
* R-018 / 迭代 16 回归:交易关联驱动持仓份额动态调整
|
|
* 覆盖:数据域分界(PositionSync 不写账本)/ 归属分段表 / 分配器动作表(买建仓·买加仓·卖减仓·卖清仓·仓不足拒绝·无仓拒绝)/
|
|
* 撤段逆操作(撤建仓=作废·撤加仓=减回·撤清仓卖出段=恢复活动·乱序拒绝)/ 全量替换原子 / 存量迁移 / API / 软提示只读。
|
|
*/
|
|
import { DataStore } from '../src/storage/DataStore.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 tmp = mkdtempSync(join(tmpdir(), 'odl-r018-'));
|
|
const storage = new DataStore({ dataDir: tmp });
|
|
await storage._ensure();
|
|
|
|
const now = Date.now();
|
|
function insOrder(orderId, code, direction, tradedVolume) {
|
|
storage.sqlite.db.prepare(
|
|
'INSERT INTO trade_orders (order_id, trade_date, code, name, exchange, direction, order_volume, traded_volume, insert_date, insert_time, insert_ts, fetched_at) ' +
|
|
'VALUES (?, \'20260908\', ?, \'X\', \'SH\', ?, ?, ?, \'20260908\', \'093000\', ?, ?)'
|
|
).run(orderId, code, direction, tradedVolume, tradedVolume, now, now);
|
|
}
|
|
|
|
const { PositionSync } = await import('../src/position/PositionSync.js');
|
|
const { AttributionService } = await import('../src/trades/AttributionService.js');
|
|
const svc = new AttributionService({ store: storage.sqlite });
|
|
|
|
console.log('\n[1] 数据域分界:PositionSync 不写账本(幽灵退役)');
|
|
{
|
|
const ds = {
|
|
async getPositions() { return [{ code: '600519.SH', volume: 100 }]; },
|
|
async getAsset() { return { accountId: 'ACC' }; },
|
|
async isAvailable() { return true; },
|
|
};
|
|
const holdings = [{ holdingId: 1, strategyId: 'grid', code: '000001.SZ', shares: 100 }];
|
|
const calls = { closed: 0 };
|
|
const st = { async getCurrentHoldings() { return holdings; }, async closeHolding() { calls.closed++; } };
|
|
const sync = new PositionSync({ runtime: { dataSource: ds, storage: st } });
|
|
for (let i = 0; i < 5; i++) await sync.syncNow();
|
|
assert(calls.closed === 0, '1a 多轮消失后账本不被 closeHolding(幽灵退役)');
|
|
assert(sync.stats.closedGhosts === undefined, '1b stats.closedGhosts 移除');
|
|
}
|
|
|
|
console.log('\n[2] 存储地基:作废态 / closed_shares / 段表 / 兼容迁移');
|
|
{
|
|
const h1 = await storage.openHolding('grid', '600519.SH', 1000);
|
|
await storage.addShares('grid', '600519.SH', 500);
|
|
const act = storage.sqlite._getActive('grid', '600519.SH');
|
|
assert(act.shares === 1500, '2a addShares 累加 1500');
|
|
await storage.closeHolding('grid', '600519.SH');
|
|
const closedRow = storage.sqlite.getHoldingById(h1.holdingId);
|
|
assert(closedRow.closedShares === 1500 && closedRow.shares === 0, '2b closeHolding 记录 closed_shares=1500 且 shares 置 0');
|
|
const re = storage.sqlite.reopenHolding(h1.holdingId);
|
|
assert(re && re.shares === 1500, '2c reopenHolding 恢复活动 shares=1500');
|
|
const hv = await storage.openHolding('grid', '300057.SZ', 300);
|
|
storage.sqlite.voidHolding('grid', '300057.SZ');
|
|
const vRow = storage.sqlite.getHoldingById(hv.holdingId);
|
|
assert(vRow.voidAt != null && vRow.closedAt == null, '2d voidHolding 置 void_at 非 close');
|
|
assert(storage.sqlite.getCurrentHoldings('grid').length === 1, '2e 作废行不在当前持仓');
|
|
assert(storage.sqlite.getHoldingsHistory('grid').length === 0, '2f 历史层无作废行(600519 已恢复活动、300057 作废不进历史)');
|
|
insOrder('M-1', '600519.SH', 'buy', 1000);
|
|
storage.sqlite.setOrderAttribution('M-1', 'grid', h1.holdingId);
|
|
const segs = storage.sqlite.getOrderAttributions('M-1');
|
|
assert(segs.length === 1 && segs[0].volume === 1000, '2g 兼容 setOrderAttribution 同步段表第一段');
|
|
}
|
|
|
|
console.log('\n[3] 分配器动作表:买建仓 / 买加仓 / 卖减仓 / 卖清仓 / 拒绝');
|
|
{
|
|
insOrder('B-1', '600000.SH', 'buy', 800);
|
|
svc.applySegment({ orderId: 'B-1', strategyId: 'grid', volume: 800 });
|
|
const a1 = storage.sqlite._getActive('grid', '600000.SH');
|
|
assert(a1 && a1.shares === 800, '3a 买入无仓 → openHolding 建仓 800');
|
|
insOrder('B-2', '600000.SH', 'buy', 300);
|
|
svc.applySegment({ orderId: 'B-2', strategyId: 'grid', volume: 300 });
|
|
const a2 = storage.sqlite._getActive('grid', '600000.SH');
|
|
assert(a2.shares === 1100, '3b 买入有仓 → addShares 1100');
|
|
insOrder('S-1', '600000.SH', 'sell', 600);
|
|
svc.applySegment({ orderId: 'S-1', strategyId: 'grid', volume: 600 });
|
|
const a3 = storage.sqlite._getActive('grid', '600000.SH');
|
|
assert(a3.shares === 500, '3c 卖出减仓 → 500');
|
|
insOrder('S-2', '600000.SH', 'sell', 500);
|
|
svc.applySegment({ orderId: 'S-2', strategyId: 'grid', volume: 500 });
|
|
assert(storage.sqlite._getActive('grid', '600000.SH') === null, '3d 卖出归零 → closeHolding 清仓');
|
|
insOrder('S-3', '600000.SH', 'sell', 999);
|
|
let err = null; try { svc.applySegment({ orderId: 'S-3', strategyId: 'grid', volume: 999 }); } catch (e) { err = e; }
|
|
assert(err && err.code === 'no-active-holding', '3e 卖出无仓 → no-active-holding 拒绝');
|
|
insOrder('B-3', '601111.SH', 'buy', 500);
|
|
svc.applySegment({ orderId: 'B-3', strategyId: 'grid', volume: 500 });
|
|
insOrder('S-4', '601111.SH', 'sell', 800);
|
|
err = null; try { svc.applySegment({ orderId: 'S-4', strategyId: 'grid', volume: 800 }); } catch (e) { err = e; }
|
|
assert(err && err.code === 'segment-exceeds', '3f 卖出段 > 活动仓 → segment-exceeds 拒绝');
|
|
}
|
|
|
|
console.log('\n[4] 撤段逆操作');
|
|
{
|
|
insOrder('B-V', '600800.SH', 'buy', 400);
|
|
svc.applySegment({ orderId: 'B-V', strategyId: 'grid', volume: 400 });
|
|
const hV = storage.sqlite._getActive('grid', '600800.SH');
|
|
svc.revokeSegment({ orderId: 'B-V', strategyId: 'grid' });
|
|
const vRow = storage.sqlite.getHoldingById(hV.holdingId);
|
|
assert(vRow.voidAt != null && vRow.closedAt == null, '4a 撤建仓买入段 → void 作废(无假清仓历史)');
|
|
assert(storage.sqlite.getHoldingsHistory('grid').filter((x) => x.code === '600800.SH').length === 0, '4a 作废不进历史层');
|
|
insOrder('B-R', '600900.SH', 'buy', 700);
|
|
svc.applySegment({ orderId: 'B-R', strategyId: 'grid', volume: 700 });
|
|
insOrder('S-R', '600900.SH', 'sell', 700);
|
|
svc.applySegment({ orderId: 'S-R', strategyId: 'grid', volume: 700 });
|
|
assert(storage.sqlite._getActive('grid', '600900.SH') === null, '4b 前置:已清仓');
|
|
svc.revokeSegment({ orderId: 'S-R', strategyId: 'grid' });
|
|
const aR = storage.sqlite._getActive('grid', '600900.SH');
|
|
assert(aR && aR.shares === 700, '4b 撤清仓卖出段 → 恢复活动 700');
|
|
insOrder('B-X1', '600300.SH', 'buy', 300);
|
|
svc.applySegment({ orderId: 'B-X1', strategyId: 'grid', volume: 300 });
|
|
insOrder('B-X2', '600300.SH', 'buy', 200);
|
|
svc.applySegment({ orderId: 'B-X2', strategyId: 'grid', volume: 200 });
|
|
svc.revokeSegment({ orderId: 'B-X2', strategyId: 'grid' });
|
|
const aX = storage.sqlite._getActive('grid', '600300.SH');
|
|
assert(aX.shares === 300, '4c 撤加仓买入段 → 减回 300');
|
|
insOrder('S-X', '600300.SH', 'sell', 300);
|
|
svc.applySegment({ orderId: 'S-X', strategyId: 'grid', volume: 300 });
|
|
let err = null;
|
|
try { svc.revokeSegment({ orderId: 'B-X1', strategyId: 'grid' }); } catch (e) { err = e; }
|
|
assert(err && err.code === 'segment-order-conflict', '4d 已清仓后撤更早买入段 → 冲突拒绝(逆序约束)');
|
|
}
|
|
|
|
console.log('\n[5] 全量替换 setSegments(多段 + 原子)');
|
|
{
|
|
await storage.openHolding('grid', '600999.SH', 500);
|
|
await storage.openHolding('manual', '600999.SH', 300);
|
|
insOrder('S-SPLIT', '600999.SH', 'sell', 800);
|
|
const r = svc.setSegments({ orderId: 'S-SPLIT', segments: [{ strategyId: 'grid', volume: 500 }, { strategyId: 'manual', volume: 300 }] });
|
|
assert(r.segments.length === 2, '5a 拆成两段');
|
|
assert(storage.sqlite._getActive('grid', '600999.SH') === null && storage.sqlite._getActive('manual', '600999.SH') === null, '5b 两段均归零清仓');
|
|
svc.revokeSegment({ orderId: 'S-SPLIT', strategyId: 'manual' });
|
|
const aM = storage.sqlite._getActive('manual', '600999.SH');
|
|
assert(aM && aM.shares === 300, '5c 撤 manual 段 → 该 holding 恢复活动 300(跨 holding 独立撤)');
|
|
assert(storage.sqlite._getActive('grid', '600999.SH') === null, '5c grid 段不受影响(保持清仓)');
|
|
let err = null;
|
|
try { svc.setSegments({ orderId: 'S-SPLIT', segments: [{ strategyId: 'grid', volume: 500 }, { strategyId: 'manual', volume: 600 }] }); } catch (e) { err = e; }
|
|
assert(err != null && err.code === 'segment-exceeds-order', '5d 段合计超已成交量 → 事务前拒绝');
|
|
const segsAfter = storage.sqlite.getOrderAttributions('S-SPLIT');
|
|
assert(segsAfter.length === 1 && segsAfter[0].strategyId === 'grid' && segsAfter[0].volume === 500, '5e 拒绝未改段表(manual 段已在 5c 撤销,grid 段保留)');
|
|
}
|
|
|
|
console.log('\n[6] 漏关联软提示只读');
|
|
{
|
|
const { handlePosition } = await import('../src/api/positions.js');
|
|
const sync = new PositionSync({ runtime: { dataSource: { getPositions: async () => [{ code: '600519.SH' }], getAsset: async () => ({ accountId: 'A' }), isAvailable: async () => true }, storage } });
|
|
await sync.syncNow();
|
|
const rt = { storage, positionSync: sync };
|
|
const hints = await handlePosition('positions/orphan-hints', {}, rt);
|
|
assert(Array.isArray(hints), '6a 返回数组');
|
|
assert(hints.every((h) => h.code !== '600519.SH'), '6b 快照内的 code 不提示');
|
|
}
|
|
|
|
storage.close();
|
|
try { rmSync(tmp, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
|
|
console.log('\n=== 结果: ' + pass + ' 通过 / ' + fail + ' 失败 ===');
|
|
process.exitCode = fail > 0 ? 1 : 0;
|