迭代16: 交易关联驱动持仓份额动态调整(R-018)

This commit is contained in:
2026-09-08 11:09:05 +08:00
parent 22ec732607
commit 1223e2e74b
25 changed files with 1659 additions and 405 deletions
+9 -41
View File
@@ -56,11 +56,11 @@ console.log('[1] startup warmup + basic sync');
{
const ds = mockDataSource({ positions: [pos('600519.SH', 100), pos('300057.SZ', 200)] });
const st = mockStorage([]);
const sync = new PositionSync({ runtime: { dataSource: ds, storage: st }, ghostRounds: 3 });
const sync = new PositionSync({ runtime: { dataSource: ds, storage: st } });
const r = await sync.syncNow();
ok(sync.getSnapshot().length === 2, 'snapshot has 2 rows');
ok(sync.getSyncedAt() > 0, 'syncedAt recorded');
ok(r && r.positions === 2 && Array.isArray(r.closed), 'returns {positions, closed}');
ok(r && r.positions === 2 && r.closed === undefined, 'returns {positions} (ghost-close retired, no closed)');
ok(st.calls.closed.length === 0, 'no local holdings -> no close');
}
@@ -116,8 +116,9 @@ console.log('[3] empty snapshot double-confirmation');
ok(s4.getSnapshot().length === 1, '3d empty + identity unknown -> old snapshot kept');
}
console.log('[4] ghost holdings auto-close (debounce + account guard)');
console.log('[4] R-018 data-domain boundary: ghost-close retired (sync never writes ledger)');
{
// 账本有活动行(本地仍记 000001.SZ),QMT 快照无此 code —— 同步机制不再自动 closeHolding(分界拍板)
const holdings = [
{ holdingId: 1, strategyId: 'grid', code: '000001.SZ', shares: 100 },
{ holdingId: 2, strategyId: 'manual-t', code: '000001.SZ', shares: 50 },
@@ -125,44 +126,11 @@ console.log('[4] ghost holdings auto-close (debounce + account guard)');
];
const ds = mockDataSource({ positions: [pos('600519.SH')] });
const st = mockStorage(holdings);
const sync = new PositionSync({ runtime: { dataSource: ds, storage: st }, ghostRounds: 3 });
await sync.syncNow();
ok(st.calls.closed.length === 0, '4a round1 miss: no close');
await sync.syncNow();
ok(st.calls.closed.length === 0, '4a round2 miss: no close');
const r3 = await sync.syncNow();
ok(st.calls.closed.length === 2 && st.calls.closed.every((c) => c.code === '000001.SZ'), '4a round3: 000001.SZ closed across 2 strategies');
ok(r3.closed.length === 1 && r3.closed[0].code === '000001.SZ' && r3.closed[0].strategies.length === 2, '4a close detail returned');
ok(!st.calls.closed.some((c) => c.code === '600519.SH'), '4a code still in snapshot untouched');
const st2 = mockStorage(holdings);
const ds2 = mockDataSource({ positions: [pos('600519.SH')] });
const sync2 = new PositionSync({ runtime: { dataSource: ds2, storage: st2 }, ghostRounds: 3 });
await sync2.syncNow(); await sync2.syncNow();
ds2._positions = [pos('600519.SH'), pos('000001.SZ')];
await sync2.syncNow();
ds2._positions = [pos('600519.SH')];
await sync2.syncNow();
ok(st2.calls.closed.length === 0, '4b reappear resets debounce counter');
const st3 = mockStorage(holdings);
const ds3 = mockDataSource({ positions: [pos('600519.SH')] });
const sync3 = new PositionSync({ runtime: { dataSource: ds3, storage: st3 }, ghostRounds: 3 });
await sync3.syncNow(); await sync3.syncNow();
ds3._accountId = 'ACC-2';
await sync3.syncNow(); await sync3.syncNow(); await sync3.syncNow();
ok(st3.calls.closed.length === 0, '4c accountId change resets debounce (never reaches 3 consecutive)');
const st4 = mockStorage(holdings);
const ds4 = mockDataSource({ positions: [pos('600519.SH')] });
const sync4 = new PositionSync({ runtime: { dataSource: ds4, storage: st4 }, ghostRounds: 3 });
await sync4.syncNow(); await sync4.syncNow(); // miss x2(差 1 轮到阈值)
ds4._accountId = null; // 身份丢失
await sync4.syncNow(); await sync4.syncNow(); // 期间跳过判定,不累计
ok(st4.calls.closed.length === 0, '4d identity unknown -> skip close judgement');
ds4._accountId = 'ACC-1'; // 身份恢复
await sync4.syncNow(); // 第 3 次 miss → 达阈值清仓
ok(st4.calls.closed.length === 2, '4d identity restored -> 3rd miss closes');
const sync = new PositionSync({ runtime: { dataSource: ds, storage: st } });
for (let i = 0; i < 5; i++) await sync.syncNow(); // 远超旧 3 轮阈值
ok(st.calls.closed.length === 0, '4a multiple rounds missing: ledger NOT closed (ghost retired)');
ok(sync.getSnapshot().every((p) => p.code === '600519.SH'), '4a snapshot only reflects QMT (600519.SH)');
ok(sync.stats.closedGhosts === undefined, '4a stats.closedGhosts removed');
}
console.log('[5] PositionManager snapshot read + read-through fallback');
+26 -14
View File
@@ -37,27 +37,39 @@ const dataSource = {
const runtime = { dataSource, storage, settings };
// 端点注册齐全
assert(TRADE_METHODS.has('orders/attribution-candidates'), '端点 orders/attribution-candidates 已注册');
assert(TRADE_METHODS.has('orders/set-attribution'), '端点 orders/set-attribution 已注册');
// 端点注册齐全R-018candidates/set-attribution 退役 → targets/set/segments
assert(TRADE_METHODS.has('orders/attribution-targets'), '端点 orders/attribution-targets 已注册');
assert(TRADE_METHODS.has('orders/attribution-set'), '端点 orders/attribution-set 已注册');
assert(TRADE_METHODS.has('orders/attribution-segments'), '端点 orders/attribution-segments 已注册');
assert(TRADE_METHODS.has('trades/history'), '端点 trades/history 已注册');
assert(!TRADE_METHODS.has('orders/attribution-candidates'), '端点 orders/attribution-candidates 已退役');
assert(!TRADE_METHODS.has('orders/set-attribution'), '端点 orders/set-attribution 已退役');
// 候选端点(含策略名
// attribution-targets 端点(策略级 + 策略名 + activityShares
storage.openHolding('manual-t', '300057.SZ', 1000);
const cands = await handleTrade('orders/attribution-candidates', { code: '300057.SZ' }, runtime);
assert(cands.length === 1 && cands[0].strategyName === '手动做T', '候选含策略名(手动做T');
const tgt = await handleTrade('orders/attribution-targets', { code: '300057.SZ' }, runtime);
assert(tgt.length === 2, 'targets 返回全部策略');
const tRow = tgt.find((x) => x.strategyId === 'manual-t');
assert(tRow && tRow.strategyName === '手动做T' && tRow.activityShares === 1000, 'targets 含策略名 + activityShares(手动做T 1000');
// set-attribution 端点
storage.upsertTradeOrders([{ orderId: 'X-1', tradeDate: '20260901', code: '300057.SZ', insertDate: '20260901', insertTime: '093000' }]);
const h = storage.sqlite.db.prepare('SELECT holding_id FROM strategy_holdings WHERE strategy_id=?').get('manual-t');
const setRes = await handleTrade('orders/set-attribution', { orderId: 'X-1', strategyId: 'manual-t', holdingId: h.holding_id }, runtime);
assert(setRes.ok === true, 'set-attribution 端点返回 ok');
const row = storage.sqlite.db.prepare('SELECT strategy_id FROM trade_orders WHERE order_id=?').get('X-1');
assert(row.strategy_id === 'manual-t', '归属已持久化');
// attribution-set 端点(R-018 账本写操作)
storage.upsertTradeOrders([{ orderId: 'X-1', tradeDate: '20260901', code: '300057.SZ', insertDate: '20260901', insertTime: '093000', tradedVolume: 1000 }]);
const { AttributionService } = await import('../src/trades/AttributionService.js');
const svc = new AttributionService({ store: storage.sqlite });
const rt = { ...runtime, attribution: svc };
const setRes = await handleTrade('orders/attribution-set', { orderId: 'X-1', segments: [{ strategyId: 'manual-t', volume: 1000 }] }, rt);
assert(setRes.ok === true && setRes.segments.length === 1, 'attribution-set 返回 ok + segments');
const row = storage.sqlite.db.prepare('SELECT strategy_id, holding_id FROM trade_orders WHERE order_id=?').get('X-1');
assert(row.strategy_id === 'manual-t' && row.holding_id != null, '归属已持久化(冗余列镜像)');
// trades/history 端点(策略过滤)
// attribution-segments 端点
const segs = await handleTrade('orders/attribution-segments', { orderId: 'X-1' }, rt);
assert(segs.length === 1 && segs[0].strategyName === '手动做T', 'attribution-segments 读段含策略名');
// trades/history 端点(策略过滤经段表)
const hist = await handleTrade('trades/history', { start: '20260901', end: '20260901', strategyId: 'manual-t' }, runtime);
assert(hist.orders.length === 1 && hist.orders[0].orderId === 'X-1', 'history 端点按策略过滤命中');
assert(Array.isArray(hist.orders[0].segments) && hist.orders[0].segments.length === 1, 'history orders 附加 segments');
storage.close();
rmSync(dir, { recursive: true, force: true });
+5 -2
View File
@@ -66,8 +66,11 @@ try {
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');
// 归属到 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');
+30 -34
View File
@@ -1,7 +1,7 @@
/**
* R-017 / 迭代 15 回归:归属候选纳入近期清仓持仓
* 独立临时数据目录(技术约束-011)。覆盖:当前持仓在前 / 近 7 天清仓行入选(closed 标记)/
* 超 7 天清仓行被滤 / 策略隔离(同 code 多轮多策略/ 空 code / 端点透传
* R-017 / 迭代 15 回归 → R-018 / 迭代 16 语义修订:归属候选退役 7 天清仓窗口
* 新语义(R-018 T2 老师拍板):候选回归「当前活动持仓 + 未关联」;正式候选为策略级 attribution-targets。
* 覆盖:当前活动持仓候选 / 清仓/作废行不进候选 / 多策略 / 空 code / targets 端点。
*/
import { DataStore } from '../src/storage/DataStore.js';
import { handleTrade } from '../src/api/trades.js';
@@ -18,51 +18,47 @@ function assert(cond, msg) {
const DAY = 86400000;
const tmp = mkdtempSync(join(tmpdir(), 'odl-r017-'));
const storage = new DataStore({ dataDir: tmp });
const runtime = { storage, settings: { get: () => ({}) } }; // 策略名缺省 → fallback strategyId
const runtime = {
storage,
settings: { get: () => ({ strategies: [{ id: 's-t', name: '做T' }, { id: 's-g', name: '网格' }] }) },
};
try {
console.log('\n[1] 当前持仓候选(closed=false');
const h1 = await storage.openHolding('s-t', '600719.SH', 1000);
console.log('\n[1] 当前活动持仓候选(closed=false');
await storage.openHolding('s-t', '600719.SH', 1000);
await storage.openHolding('s-g', '600719.SH', 800); // 同码第二策略
let cands = await storage.getTradeAttributionCandidates('600719.SH');
assert(cands.length === 2, '同码两策略当前持仓都在候选');
assert(cands.every((c) => c.closed === false && c.closedAt === null), '当前持仓 closed=false / closedAt=null');
assert(cands[0].shares >= cands[1].shares, '当前持仓按 shares DESC');
console.log('\n[2] 近 7 天清仓行入选(closed=true),当前在前');
await storage.openHolding('s-t', '000001.SZ', 500);
await storage.closeHolding('s-t', '000001.SZ'); // 刚清仓(做T场景
console.log('\n[2] 清仓/作废行不进候选(R-018 退役 7 天窗口)');
const hc = await storage.openHolding('s-t', '000001.SZ', 500);
await storage.closeHolding('s-t', '000001.SZ'); // 刚清仓(R-017 时代会入选,R-018 起不再
cands = await storage.getTradeAttributionCandidates('000001.SZ');
assert(cands.length === 1 && cands[0].closed === true, '刚清仓行进入候选');
assert(cands[0].holdingId != null && cands[0].closedAt > 0, '清仓行带 holdingId + closedAt');
assert(cands[0].strategyId === 's-t', '清仓行策略正确');
console.log('\n[3] 超 7 天清仓行被滤除');
const hOld = await storage.openHolding('s-t', '600000.SH', 300);
await storage.closeHolding('s-t', '600000.SH');
storage.sqlite.db.prepare('UPDATE strategy_holdings SET closed_at=? WHERE holding_id=?')
.run(Date.now() - 8 * DAY, hOld.holdingId);
assert(cands.length === 0, '刚清仓行不进候选(7 天窗口退役)');
const hv = await storage.openHolding('s-t', '600000.SH', 300);
storage.sqlite.voidHolding('s-t', '600000.SH');
cands = await storage.getTradeAttributionCandidates('600000.SH');
assert(cands.length === 0, '8 天前清仓行不候选');
assert(cands.length === 0, '作废行不候选');
console.log('\n[4] 排序:当前持仓在前,清仓行 closed_at DESC');
await storage.openHolding('s-g', '300750.SZ', 100);
await storage.closeHolding('s-g', '300750.SZ');
const hOlder = await storage.openHolding('s-g', '300750.SZ', 200); // 复用同码再建仓再清
await storage.closeHolding('s-g', '300750.SZ');
storage.sqlite.db.prepare('UPDATE strategy_holdings SET closed_at=? WHERE holding_id=?')
.run(Date.now() - 3 * DAY, hOlder.holdingId);
cands = await storage.getTradeAttributionCandidates('300750.SZ');
assert(cands.length === 2 && cands.every((c) => c.closed), '同码两轮清仓都入选');
assert(cands[0].closedAt >= cands[1].closedAt, '清仓行按 closed_at DESC');
console.log('\n[5] 空 code / 无持仓 code');
console.log('\n[3] 空 code / 无持仓 code');
assert((await storage.getTradeAttributionCandidates('')).length === 0, '空 code → []');
assert((await storage.getTradeAttributionCandidates('999999.SH')).length === 0, '无持仓 code → []');
console.log('\n[6] API 端点透传(含 strategyName fallback');
const api = await handleTrade('orders/attribution-candidates', { code: '000001.SZ' }, runtime);
assert(api.length === 1 && api[0].strategyName === 's-t', '端点返回候选且策略名 fallback 到 id');
console.log('\n[4] R-018 attribution-targets(策略级候选端点');
const hc2 = await storage.openHolding('s-t', '000002.SZ', 500);
const tgt = await handleTrade('orders/attribution-targets', { code: '000002.SZ' }, runtime);
assert(Array.isArray(tgt), 'targets 端点返回数组');
assert(tgt.every((x) => x.strategyId && x.activityShares != null), 'targets 每项含 strategyId + activityShares');
const rowT = tgt.find((x) => x.strategyId === 's-t');
assert(rowT && rowT.activityShares === 500 && rowT.hasActiveHolding === true, '有仓策略 activityShares=500 / hasActiveHolding=true');
const rowG = tgt.find((x) => x.strategyId === 's-g');
assert(rowG && rowG.activityShares === 0 && rowG.hasActiveHolding === false, '无仓策略 activityShares=0 / hasActiveHolding=false');
// 关闭的持仓不占 hasActiveHolding
await storage.closeHolding('s-t', '000002.SZ');
const tgt2 = await handleTrade('orders/attribution-targets', { code: '000002.SZ' }, runtime);
assert(tgt2.find((x) => x.strategyId === 's-t').hasActiveHolding === false, '清仓后该策略 hasActiveHolding=false');
} finally {
storage.close();
try { rmSync(tmp, { recursive: true, force: true }); } catch { /* ignore */ }
+164
View File
@@ -0,0 +1,164 @@
/**
* 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;