迭代16: 交易关联驱动持仓份额动态调整(R-018)
This commit is contained in:
@@ -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
@@ -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-018:candidates/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 });
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 */ }
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user