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