迭代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
+2 -2
View File
@@ -60,8 +60,8 @@ const HANDLERS = [
* @param {import('@deepseek-ai/cordis').Context} ctx
* @param {object} runtime { manager, settings, dataSource, marketCache }
*/
export function registerApi(ctx, { manager, settings, dataSource, marketHub, marketFeed, qmtHealthMonitor, storage, tradeSync, positionSync }) {
const runtime = { ctx, manager, settings, dataSource, marketHub, marketFeed, qmtHealthMonitor, storage, tradeSync, positionSync };
export function registerApi(ctx, { manager, settings, dataSource, marketHub, marketFeed, qmtHealthMonitor, storage, tradeSync, positionSync, attribution }) {
const runtime = { ctx, manager, settings, dataSource, marketHub, marketFeed, qmtHealthMonitor, storage, tradeSync, positionSync, attribution };
ctx.effect(() => ctx.webServer.register({
kind: 'prefix',
+24 -3
View File
@@ -2,24 +2,45 @@
* 服务端 API:持仓域(从 api.js 拆分,2026-08-31
*
* 端点:
* positions → 全量持仓
* positions → 全量持仓
* positions/orphan-hints → R-018 漏关联软提示:账本活动行 ∩ QMT 快照缺失的 code(只读,不动账本)
*/
/** 持仓端点方法表 */
export const POSITION_METHODS = new Set([
'positions',
'positions/orphan-hints',
]);
/**
* 处理持仓端点
* @param {string} method
* @param {object} args
* @param {object} runtime { manager }
* @param {object} runtime { manager, storage, positionSync }
*/
export async function handlePosition(method, args, { manager }) {
export async function handlePosition(method, args, { manager, storage, positionSync }) {
switch (method) {
case 'positions':
return await manager.getAllPositions();
case 'positions/orphan-hints': {
// R-018 T3:漏关联软提示。账本有活动行(closed/void 双空)但 QMT 快照无此 code → 提示去关联/移出。
// 只读不写(同步机制与账本分界:软提示不自动改账本)。
const snapshot = positionSync && Array.isArray(positionSync.getSnapshot())
? positionSync.getSnapshot()
: null;
if (!storage) return [];
const holdings = await storage.getCurrentHoldings(); // 全策略当前活动持仓
if (!snapshot) return []; // 快照不可用(从未同步/未注入)→ 不提示(避免误报)
const snapshotCodes = new Set(snapshot.map((p) => p.code).filter(Boolean));
return holdings
.filter((h) => !snapshotCodes.has(h.code))
.map((h) => ({
holdingId: h.holdingId,
strategyId: h.strategyId,
code: h.code,
shares: h.shares,
}));
}
default:
throw Object.assign(new Error('unknown position method: ' + method), { code: 'not-found' });
}
+5 -2
View File
@@ -118,12 +118,15 @@ export async function handleStrategy(method, args, { manager, settings, storage
}
const rows = await storage.getHoldingsHistory(args.strategyId, { sinceMs });
// name 兜底:该 holding 最近一笔关联委托的 name(前端免二次请求)
const ids = rows.map((r) => r.holdingId);
// R-018:真相源 = 段表 —— 历史行关联委托经段表 join(同一单多段也能兜到 name)
const ids = rows.map((r) => r.holdingId).filter((x) => x != null);
const nameMap = new Map();
if (ids.length > 0) {
const ph = ids.map(() => '?').join(',');
const orows = storage.sqlite.db.prepare(
"SELECT holding_id, name FROM trade_orders WHERE holding_id IN (" + ph + ") AND name != '' ORDER BY insert_ts DESC"
'SELECT a.holding_id, t.name FROM trade_order_attributions a ' +
'JOIN trade_orders t ON t.order_id = a.order_id ' +
'WHERE a.holding_id IN (' + ph + ") AND t.name != '' ORDER BY t.insert_ts DESC"
).all(...ids);
for (const o of orows) if (!nameMap.has(o.holding_id)) nameMap.set(o.holding_id, o.name);
}
+78 -34
View File
@@ -1,12 +1,17 @@
/**
* 服务端 API:交易记录域(R-0072026-09-01
* 服务端 API:交易记录域(R-0072026-09-01R-018 迭代 16 归属改造
*
* 端点:
* orders → 当日委托(支持 code/status 过滤)
* trades → 当日成交
* trading-dates → 交易日历(日期导航)
* trades/history → 本地 SQLite 历史查询(R-009时间段/code/策略/方向过滤)
* trades/by-holding → 按 holding_id 查委托汇总(R-010:策略持仓行展开
* orders → 当日委托(code/status 过滤R-018 附加 segments 归属段
* trades → 当日成交
* trading-dates → 交易日历(日期导航)
* trades/history → 本地 SQLite 历史查询(时间段/code/策略/方向过滤;策略过滤经段表
* trades/by-holding → 按 holding_id 查委托汇总(R-010,段表 join
* orders/attribution-targets → R-018 策略级归属目标(候选 = 全部策略 + 该 code 活动份额)
* orders/attribution-set → R-018 全量替换归属段(segments 数组,原子)
* orders/attribution-segments → R-018 读某 order 的归属段
* orders/attribution-candidates / orders/set-attribution 已退役:R-017 7 天候选退役,
* 归属候选改策略级 targets;归属写改 attribution-set
*
* 数据流:前端轮询 → /odl/api/orders|trades → dataSource.getOrders/getTrades
* (服务端直连 QMT Bridge REST,技术约束-003
@@ -20,18 +25,19 @@ export const TRADE_METHODS = new Set([
'trades',
'trading-dates',
'trades/history', // R-009:本地 SQLite 历史查询(策略过滤)
'orders/attribution-candidates', // R-009:委托归属候选列表(该 code 当前持仓策略
'orders/set-attribution', // R-009:手动设置委托归属
'trades/by-holding', // R-010:按 holding_id 查委托汇总(策略持仓行展开
'trades/by-holding', // R-010:按 holding_id 查委托汇总(策略持仓行展开
'orders/attribution-targets', // R-018:策略级归属候选(含 activityShares
'orders/attribution-set', // R-018:归属段全量替换(账本写操作
'orders/attribution-segments', // R-018:读某 order 归属段
]);
/**
* 处理交易记录端点
* @param {string} method
* @param {object} args
* @param {object} runtime { dataSource }
* @param {object} runtime { dataSource, storage, attribution, settings }
*/
export async function handleTrade(method, args, { dataSource, storage, settings }) {
export async function handleTrade(method, args, { dataSource, storage, attribution, settings }) {
switch (method) {
case 'orders': {
const orders = await dataSource.getOrders({
@@ -40,18 +46,25 @@ export async function handleTrade(method, args, { dataSource, storage, settings
start: args.start,
end: args.end,
});
// R-009:今日实时委托附加【持久化归属】(本地库中用户已设置的 strategy_id/holding_id
// 未设置为 null(前端显示「未关联」,用户可手动设置);候选由 orders/attribution-candidates 提供
// R-018:今日实时委托附加【持久化归属】(段表真相源;order 带 segments 数组 + 兼容首段 strategyId/holdingId
if (storage && Array.isArray(orders) && orders.length > 0) {
const ids = orders.map((o) => o.orderId).filter(Boolean);
const ph = ids.map(() => '?').join(',');
const rows = storage.sqlite.db.prepare(
'SELECT order_id, strategy_id, holding_id FROM trade_orders WHERE order_id IN (' + ph + ')'
).all(...ids);
const attr = new Map(rows.map((r) => [r.order_id, { strategyId: r.strategy_id ?? null, holdingId: r.holding_id ?? null }]));
const idList = orders.map((o) => o.orderId).filter(Boolean);
const segMap = storage.getOrderSegmentsByOrderIds(idList); // Map<orderId, segment[]>
const strategyName = (sid) => {
if (!sid) return '';
const found = (getStrategies(settings) || []).find((s) => s.id === sid);
return found ? found.name : sid;
};
return orders.map((o) => {
const a = attr.get(o.orderId) ?? { strategyId: null, holdingId: null };
return { ...o, strategyId: a.strategyId, holdingId: a.holdingId };
const segs = segMap.get(o.orderId) ?? [];
const withName = segs.map((s) => ({ ...s, strategyName: strategyName(s.strategyId) }));
const first = withName[0];
return {
...o,
segments: withName,
strategyId: first ? first.strategyId : null, // 兼容首段(旧 UI 字段)
holdingId: first ? first.holdingId : null,
};
});
}
return orders;
@@ -66,19 +79,37 @@ export async function handleTrade(method, args, { dataSource, storage, settings
start: args.start,
end: args.end,
});
case 'orders/attribution-candidates': {
// R-009:委托归属候选(该 code 当前持仓策略,Q3 全手动选)
case 'orders/attribution-targets': {
// R-018(Q1):候选 = 策略级下拉。全部策略 + 该 code 每策略当前活动份额 activityShares0=无仓)。
// 不预筛(有无持仓/历史持仓由指向后服务端判定);holdingId 不再作下拉键。
if (!storage) throw Object.assign(new Error('本地存储不可用'), { code: 'storage-unavailable' });
const list = await storage.getTradeAttributionCandidates(args.code);
// 附策略名(settings 的策略 name,便于前端展示)
const nameMap = new Map((getStrategies(settings) || []).map((s) => [s.id, s.name]));
return list.map((c) => ({ ...c, strategyName: nameMap.get(c.strategyId) ?? c.strategyId }));
const code = args.code;
const strategies = getStrategies(settings) || [];
// 该 code 当前活动持仓(closed/void 双空)per strategygetCurrentHoldings 无过滤=全策略)
const allHoldings = await storage.getCurrentHoldings();
const holdings = code ? allHoldings.filter((h) => h.code === code) : [];
const shareByStrategy = new Map(holdings.map((h) => [h.strategyId, h.shares]));
return strategies.map((s) => ({
strategyId: s.id,
strategyName: s.name,
activityShares: Number(shareByStrategy.get(s.id) ?? 0),
hasActiveHolding: (shareByStrategy.get(s.id) ?? 0) > 0,
}));
}
case 'orders/set-attribution': {
// R-009:手动设置委托归属(Q4 可随时改,以最终为准)
case 'orders/attribution-set': {
// R-018:全量替换归属段(account写操作)。segments: [{strategyId, volume}]
if (!attribution || !storage) throw Object.assign(new Error('归属服务不可用'), { code: 'storage-unavailable' });
const res = attribution.setSegments({
orderId: args.orderId,
segments: Array.isArray(args.segments) ? args.segments : [],
});
return { ok: true, segments: res.segments };
}
case 'orders/attribution-segments': {
if (!storage) throw Object.assign(new Error('本地存储不可用'), { code: 'storage-unavailable' });
const ok = await storage.setTradeOrderAttribution(args.orderId, args.strategyId ?? null, args.holdingId ?? null);
return { ok };
const segs = storage.getOrderSegments(args.orderId);
const nameMap = new Map((getStrategies(settings) || []).map((s) => [s.id, s.name]));
return segs.map((s) => ({ ...s, strategyName: nameMap.get(s.strategyId) ?? s.strategyId }));
}
case 'trades/by-holding': {
// R-010:按 holding_id 查委托汇总(策略持仓行展开)
@@ -86,7 +117,7 @@ export async function handleTrade(method, args, { dataSource, storage, settings
return await storage.getTradeOrdersByHolding(args.holdingId);
}
case 'trades/history': {
// R-009:本地 SQLite 历史查询(时间段/code/策略/方向过滤)
// R-009:本地 SQLite 历史查询(时间段/code/策略/方向过滤R-018 orders 附加 segments
const opts = {
start: args.start,
end: args.end,
@@ -101,9 +132,22 @@ export async function handleTrade(method, args, { dataSource, storage, settings
storage.getTradeOrderHistory(opts),
storage.getTradeFillHistory(opts),
]);
return { orders, fills };
const idList = (orders ?? []).map((o) => o.orderId).filter(Boolean);
const segMap = storage.getOrderSegmentsByOrderIds(idList);
const strategyName = (sid) => {
if (!sid) return '';
const found = (getStrategies(settings) || []).find((s) => s.id === sid);
return found ? found.name : sid;
};
const withSegs = (orders ?? []).map((o) => {
const segs = segMap.get(o.orderId) ?? [];
const withName = segs.map((s) => ({ ...s, strategyName: strategyName(s.strategyId) }));
const first = withName[0];
return { ...o, segments: withName, strategyId: first ? first.strategyId : null, holdingId: first ? first.holdingId : null };
});
return { orders: withSegs, fills };
}
default:
throw Object.assign(new Error('unknown trade method: ' + method), { code: 'not-found' });
}
}
}