迭代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' });
}
}
}
+270 -102
View File
@@ -12,6 +12,12 @@
* - 策略过滤下拉(全部 / 各策略 / 未关联):今日实时按归属过滤(前端),历史走服务端过滤
* - 历史范围从「接口开发中」占位切换为查本地 SQLitetrades/history
*
* R-018(迭代 16):归属 = 账本写操作(份额分配器)
* - 端点:orders/attribution-targets(策略级候选)/ orders/attribution-set(全量替换段,原子)
* - 交互:每笔委托归属列 = 段 chips;点开分配器:策略下拉 + 段量输入(默认余额全量,可改小)
* + 段列表(每段可撤)——每步操作即时 setSegments,买卖份额联动账本
* - 过滤按首段兼容字段(order.strategyId = 主段);多段订单按主段归属展示
*
* 方向:红买绿卖(Q7 定稿);状态中文映射;未成交显示 —
*/
@@ -111,11 +117,13 @@ export function TradeRecordsTab() {
// R-009:策略过滤(''=全部;'__unassigned__'=未关联;其他=strategyId
const [strategies, setStrategies] = useState([]);
const [strategyFilter, setStrategyFilter] = useState('');
const [attributionSaved, setAttributionSaved] = useState(null); // 归属保存提示(R-009
const [attributionCandidates, setAttributionCandidates] = useState({}); // R-009code → 归属候选列表缓存
const attributionCandidatesRef = useRef({}); // 同步缓存(loadCandidates 判重用)
const [attributionSaved, setAttributionSaved] = useState(null); // 归属保存提示(R-009/R-018
const [attributionTargets, setAttributionTargets] = useState({}); // R-018code → 策略级归属目标缓存(含 activityShares
const attributionTargetsRef = useRef({}); // 同步缓存(loadTargets 判重用)
// 同步 ref
useEffect(() => { attributionCandidatesRef.current = attributionCandidates; }, [attributionCandidates]);
useEffect(() => { attributionTargetsRef.current = attributionTargets; }, [attributionTargets]);
// R-018 T3:漏关联软提示(账本有活动行 + QMT 快照无此 code → 提示去关联/移出;只读不写账本)
const [orphanHints, setOrphanHints] = useState([]);
const call = useRpc();
@@ -192,54 +200,61 @@ export function TradeRecordsTab() {
}
}, [call, range, isHistoryRange, strategyFilter]);
// 拉取某 code 的归属候选(该 code 当前持仓策略;Q3 全手动选
const loadCandidates = useCallback(async (code) => {
// R-018拉取某 code 的归属目标(策略级候选 + activityShares;Q1 指向后判定,候选不预筛持仓
const loadTargets = useCallback(async (code) => {
if (!code) return;
// 已有缓存不重复请求
if (attributionCandidatesRef.current[code]) return;
if (attributionTargetsRef.current[code]) return;
try {
const res = await call('one-divine-lot/orders/attribution-candidates', { args: { code } });
const res = await call('one-divine-lot/orders/attribution-targets', { args: { code } });
if (res?.ok && Array.isArray(res.value)) {
setAttributionCandidates((prev) => ({ ...prev, [code]: res.value }));
setAttributionTargets((prev) => ({ ...prev, [code]: res.value }));
}
} catch { /* ignore */ }
}, [call]);
// 手动设置归属(Q4:可随时改,以最终为准)
const setAttribution = useCallback(async (orderId, code, strategyId, holdingId) => {
try {
const res = await call('one-divine-lot/orders/set-attribution', {
args: { orderId, strategyId: strategyId || null, holdingId: holdingId || null },
});
if (res?.ok) {
setAttributionSaved('归属已保存: ' + orderId);
setTimeout(() => setAttributionSaved(null), 2000);
// 立即更新本地 state(避免竞态:不等 load() 重新拉,归属立刻生效)
setOrders((prev) => (prev ?? []).map((o) =>
o.orderId === orderId ? { ...o, strategyId, holdingId } : o
));
setTrades((prev) => (prev ?? []).map((t) =>
t.orderId === orderId ? { ...t, strategyId, holdingId } : t
));
load(); // 后台刷新兜底(同步 TradeSync 可能更新的数据)
} else {
setAttributionSaved('保存失败: ' + ((res && res.error && res.error.message) || 'unknown'));
setTimeout(() => setAttributionSaved(null), 3000);
}
} catch (e) {
setAttributionSaved('保存失败: ' + e.message);
setTimeout(() => setAttributionSaved(null), 3000);
// R-018:全量替换归属段(segments = [{strategyId, volume}];账本写操作,原子)。返回新 segments
const saveSegments = useCallback(async (orderId, segments) => {
const res = await call('one-divine-lot/orders/attribution-set', {
args: { orderId, segments },
});
if (!res?.ok) {
throw new Error((res && res.error && res.error.message) || '保存失败');
}
const nextSegs = Array.isArray(res.value?.segments) ? res.value.segments : [];
// 立即更新本地 state(归属立刻生效;多段 order 主段 = 第一段)
const first = nextSegs[0];
setOrders((prev) => (prev ?? []).map((o) =>
o.orderId === orderId ? { ...o, segments: nextSegs, strategyId: first ? first.strategyId : null, holdingId: first ? first.holdingId : null } : o
));
setTrades((prev) => (prev ?? []).map((t) =>
t.orderId === orderId ? { ...t, segments: nextSegs, strategyId: first ? first.strategyId : null, holdingId: first ? first.holdingId : null } : t
));
load(); // 后台刷新兜底(同步 TradeSync 可能更新的数据)
return nextSegs;
}, [call, load]);
// 归属候选自动预载:数据加载后,为所有委托 code 预取候选(下拉选项齐全,避免只有「未关联」
// 归属目标自动预载:数据加载后,为所有委托 code 预取目标(策略下拉选项齐全)
useEffect(() => {
const codes = [...new Set((orders ?? []).map((o) => o.code).filter(Boolean))];
if (codes.length === 0) return;
for (const code of codes) {
loadCandidates(code);
loadTargets(code);
}
}, [orders, loadCandidates]);
}, [orders, loadTargets]);
// R-018 T3:拉取漏关联软提示(每 20s + 今日数据加载后)
const loadOrphanHints = useCallback(async () => {
try {
const res = await call('one-divine-lot/positions/orphan-hints', { args: {} });
if (res?.ok && Array.isArray(res.value)) setOrphanHints(res.value);
} catch { /* ignore(服务端不可用时静默) */ }
}, [call]);
useEffect(() => {
if (!isTodayRange) { setOrphanHints([]); return; }
loadOrphanHints();
const timer = setInterval(loadOrphanHints, 20000);
return () => clearInterval(timer);
}, [isTodayRange, loadOrphanHints, range]);
// 时间段变化 → 重新加载(展开重置)
useEffect(() => {
@@ -327,6 +342,21 @@ export function TradeRecordsTab() {
{attributionSaved}
</div>
)}
{/* R-018 T3:漏关联软提示(幽灵清仓退役后,账本活动行但对账单已无此票 → 提示去关联卖出单或移出) */}
{Array.isArray(orphanHints) && orphanHints.length > 0 && (
<div
style={{
marginBottom: 8, fontSize: 12, padding: '6px 10px', borderRadius: 4,
border: '1px solid var(--dsw-alias-state-warning-primary, #f9a825)',
background: 'color-mix(in srgb, var(--dsw-alias-state-warning-primary, #f9a825) 10%, var(--dsw-alias-bg-layer-1, #fff))',
color: 'var(--dsw-alias-label-secondary, #555)',
}}
title="幽灵自动清仓已退役:这些持仓对账单已无此票,账本仍记着 —— 请去「策略持仓」tab 移出或为当日卖出委托设置归属"
>
{orphanHints.length} 个持仓对账单已无此票账本待处理{orphanHints.slice(0, 3).map((h) => h.code).join('、')}
{orphanHints.length > 3 ? ' 等' : ''} 请在对应策略 tab 移出或在下方为当日卖出委托设置归属关联后份额自动清零转历史
</div>
)}
<LoadState loading={loading && !orders} error={error} onRetry={load} autoRetry={2}>
{!orders?.length ? (
@@ -339,7 +369,7 @@ export function TradeRecordsTab() {
</div>
</div>
) : (
<TradeTable rows={rows} toggleSort={toggleSort} sortKey={sortKey} sortDir={sortDir} arrow={arrow} toggleExpand={toggleExpand} expanded={expanded} attributionCandidates={attributionCandidates} loadCandidates={loadCandidates} setAttribution={setAttribution} />
<TradeTable rows={rows} toggleSort={toggleSort} sortKey={sortKey} sortDir={sortDir} arrow={arrow} toggleExpand={toggleExpand} expanded={expanded} targets={attributionTargets} loadTargets={loadTargets} saveSegments={saveSegments} onNotice={setAttributionSaved} />
)}
<div style={{ marginTop: 8, fontSize: 12, color: 'var(--dsw-alias-label-tertiary, #999)' }}>
<span style={{ color: 'var(--dsw-alias-state-error-primary, #d32f2f)' }}> </span>
@@ -352,8 +382,8 @@ export function TradeRecordsTab() {
);
}
/** 交易记录表格(主行 + 展开明细 + R-009 手动归属列 */
function TradeTable({ rows, toggleSort, sortKey, sortDir, arrow, toggleExpand, expanded, attributionCandidates, loadCandidates, setAttribution }) {
/** 交易记录表格(主行 + 展开明细 + R-018 归属分配器 */
function TradeTable({ rows, toggleSort, sortKey, sortDir, arrow, toggleExpand, expanded, targets, loadTargets, saveSegments, onNotice }) {
return (
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
@@ -383,9 +413,10 @@ function TradeTable({ rows, toggleSort, sortKey, sortDir, arrow, toggleExpand, e
order={o}
expanded={expanded.has(o.orderId)}
onToggle={() => toggleExpand(o.orderId)}
attributionCandidates={attributionCandidates[o.code] ?? []}
onLoadCandidates={() => loadCandidates(o.code)}
setAttribution={setAttribution}
targets={targets[o.code] ?? []}
loadTargets={() => loadTargets(o.code)}
saveSegments={saveSegments}
onNotice={onNotice}
/>
))}
</tbody>
@@ -393,13 +424,11 @@ function TradeTable({ rows, toggleSort, sortKey, sortDir, arrow, toggleExpand, e
);
}
/** 委托主行 + 展开明细(含 R-009 手动归属下拉 */
function OrderRows({ order, expanded, onToggle, attributionCandidates, onLoadCandidates, setAttribution }) {
/** 委托主行 + 展开明细(含 R-018 归属分配器入口 */
function OrderRows({ order, expanded, onToggle, targets, loadTargets, saveSegments, onNotice }) {
const hasTrades = order.trades.length > 0;
const strategyName = (sid) => {
if (!sid) return '未关联';
return order.strategyName || sid;
};
const segs = Array.isArray(order.segments) ? order.segments : [];
const totalSeg = segs.reduce((s, x) => s + Number(x?.volume ?? 0), 0);
return (
<>
<tr
@@ -432,11 +461,14 @@ function OrderRows({ order, expanded, onToggle, attributionCandidates, onLoadCan
<td style={{ ...tdStyle, textAlign: 'right' }}>{hasTrades ? fmtNum(order.aggStampTax, 2) : '—'}</td>
<td style={{ ...tdStyle, textAlign: 'right' }}>{hasTrades ? fmtNum(order.aggTransferFee, 2) : '—'}</td>
<td style={{ ...tdStyle }} onClick={(e) => e.stopPropagation()}>
<AttributionSelect
<AttributionCell
order={order}
candidates={attributionCandidates}
onLoadCandidates={onLoadCandidates}
setAttribution={setAttribution}
segments={segs}
totalSeg={totalSeg}
targets={targets}
loadTargets={loadTargets}
saveSegments={saveSegments}
onNotice={onNotice}
/>
</td>
</tr>
@@ -472,65 +504,201 @@ function OrderRows({ order, expanded, onToggle, attributionCandidates, onLoadCan
);
}
/** 委托归属选择下拉(R-009,Q3 全手动选;Q4 可随时改)
* 候选 = 该 code 当前持仓 + 近 7 天清仓持仓(R-017orders/attribution-candidates);
* 锚点键 = holdingId(同策略可有多轮持仓行,strategyId 会撞值,R-017 定稿);
* 未设置显示「未关联」,用户点选后持久化(orders/set-attribution)。
/**
* R-018 归属单元格:展示段 chips;点开进入份额分配器(AttributionEditor)。
* - 已关联段:策略名 + 量(段 chip);未关联(余额 > 0 且无段)显示「未关联」
* - 点「分配」→ 打开编辑器(加载 targets 惰性)
*/
function AttributionSelect({ order, candidates, onLoadCandidates, setAttribution }) {
const currentKey = order.holdingId != null ? String(order.holdingId) : '';
function AttributionCell({ order, segments, totalSeg, targets, loadTargets, saveSegments, onNotice }) {
const [editing, setEditing] = useState(false);
const hasSeg = (segments ?? []).length > 0;
const aggVolume = Number(order.aggVolume ?? order.tradedVolume ?? 0);
const leftover = Math.max(0, aggVolume - totalSeg); // 未分配余额(今日实时 aggVolume=已成交量;历史 aggVolume 由 fills 聚合并)
const handleSelect = (ev) => {
const val = ev.target.value;
if (val === currentKey) return;
if (val === '__unassigned__') {
setAttribution(order.orderId, order.code, null, null);
return;
}
const cand = (candidates ?? []).find((c) => String(c.holdingId) === val);
setAttribution(order.orderId, order.code, cand ? cand.strategyId : null, cand ? cand.holdingId : null);
const open = () => {
loadTargets();
setEditing(true);
};
// 已归属但候选列表缺失(如清仓超 7 天):显示「当前归属」占位,避免 select 值悬空
const candByKey = new Map((candidates ?? []).map((c) => [String(c.holdingId), c]));
const hasCurrent = currentKey && candByKey.has(currentKey);
const closedSuffix = (c) => (c.closed ? '(已清仓 ' + fmtClosed(c.closedAt) + '' : '');
if (editing) {
return (
<AttributionEditor
order={order}
segments={segments}
leftover={leftover}
targets={targets}
saveSegments={saveSegments}
onDone={() => setEditing(false)}
onNotice={onNotice}
/>
);
}
return (
<select
value={hasCurrent || !currentKey ? (currentKey || '__unassigned__') : currentKey}
onChange={handleSelect}
style={{
fontSize: 12,
padding: '2px 4px',
border: '1px solid ' + (currentKey ? 'var(--dsw-alias-state-success-primary, #2e7d32)' : 'var(--dsw-alias-border-l2, #ccc)'),
borderRadius: 4,
background: currentKey ? 'color-mix(in srgb, var(--dsw-alias-state-success-primary, #2e7d32) 12%, var(--dsw-alias-bg-layer-1, #fff))' : 'var(--dsw-alias-bg-layer-1, #fff)',
color: currentKey ? 'var(--dsw-alias-state-success-primary, #1b5e20)' : 'var(--dsw-alias-label-tertiary, #999)',
maxWidth: 150,
}}
title="设置该委托的策略/持仓归属(可随时修改;清仓 7 天内可补关联)"
>
<option value="__unassigned__">未关联</option>
{!hasCurrent && currentKey && (
<option value={currentKey}>当前归属{order.strategyId || '未知策略'}</option>
<span style={{ display: 'inline-flex', flexDirection: 'column', alignItems: 'flex-start', gap: 2, minWidth: 120 }}>
{hasSeg ? (
<span style={{ display: 'flex', flexWrap: 'wrap', gap: 2 }}>
{(segments ?? []).map((s) => (
<span
key={s.strategyId}
title={s.strategyName || s.strategyId}
style={{
fontSize: 11, padding: '1px 5px', borderRadius: 3,
border: '1px solid var(--dsw-alias-state-success-primary, #2e7d32)',
color: 'var(--dsw-alias-state-success-primary, #1b5e20)',
background: 'color-mix(in srgb, var(--dsw-alias-state-success-primary, #2e7d32) 10%, var(--dsw-alias-bg-layer-1, #fff))',
}}
>
{(s.strategyName || s.strategyId)}·{fmtNum(s.volume, 0)}
</span>
))}
</span>
) : (
<span style={{ color: 'var(--dsw-alias-label-tertiary, #999)', fontSize: 12 }}>未关联</span>
)}
{(candidates ?? []).map((c) => (
<option key={c.holdingId} value={String(c.holdingId)}>
{c.strategyName || c.strategyId}{closedSuffix(c)}
</option>
))}
</select>
{leftover > 1e-9 && (
<span style={{ fontSize: 11, color: 'var(--dsw-alias-state-error-primary, #d32f2f)' }}>
未分配剩余 {fmtNum(leftover, 0)}
</span>
)}
<button
onClick={open}
style={{
fontSize: 11, padding: '1px 8px', cursor: 'pointer', borderRadius: 3,
border: '1px solid var(--dsw-alias-border-l2, #ccc)',
background: 'var(--dsw-alias-bg-layer-1, #fff)', color: 'var(--dsw-alias-label-primary, #333)',
}}
title="分配该委托的归属策略/份额(关联 = 调整对应策略持仓份额)"
>
{hasSeg ? '调整' : '分配'}
</button>
</span>
);
}
/** 清仓日期短格式(MM-DD */
function fmtClosed(ts) {
if (!ts) return '';
const d = new Date(Number(ts));
if (!isFinite(d.getTime())) return '';
const p2 = (n) => String(n).padStart(2, '0');
return (d.getMonth() + 1) + '-' + p2(d.getDate());
/**
* R-018 份额分配器(编辑器):策略下拉 + 段量输入 + 段列表(可撤)。
* - 余额 = 已成交量 − 已分配段和(默认输入 = 余额全量,可改小)
* - 每步操作(加段/撤段)即时 saveSegments(全量替换,原子)
*/
function AttributionEditor({ order, segments, leftover, targets, saveSegments, onDone, onNotice }) {
const list = (targets ?? []).length > 0 ? targets : null;
const [selStrategy, setSelStrategy] = useState('');
const [amount, setAmount] = useState('');
const [busy, setBusy] = useState(false);
const aggVolume = Number(order.aggVolume ?? order.tradedVolume ?? 0);
const segs = Array.isArray(segments) ? segments : [];
const totalSeg = segs.reduce((s, x) => s + Number(x?.volume ?? 0), 0);
const remaining = Math.max(0, aggVolume - totalSeg);
const notice = (msg, isErr) => {
onNotice?.(msg);
if (isErr) setTimeout(() => onNotice?.(null), 3000);
else setTimeout(() => onNotice?.(null), 2000);
};
const run = async (nextSegments) => {
setBusy(true);
try {
await saveSegments(order.orderId, nextSegments);
notice('归属已保存: ' + order.orderId, false);
} catch (e) {
notice('保存失败: ' + (e?.message ?? String(e)), true);
} finally {
setBusy(false);
}
};
const addSegment = async () => {
if (!selStrategy) { notice('请选择策略', true); return; }
const v = Number(amount);
if (!Number.isFinite(v) || v <= 0) { notice('请输入有效份额量', true); return; }
if (v > remaining + 1e-9) { notice('份额量超过未分配余额 ' + fmtNum(remaining, 0), true); return; }
// 卖出场景:若该策略无活动仓(activityShares=0 且方向 sell)→ 服务端会拒绝;这里给前端提示
const t = (targets ?? []).find((x) => x.strategyId === selStrategy);
const isSell = String(order.direction ?? '') === 'sell';
if (isSell && t && !t.hasActiveHolding) {
notice('该策略无此持仓可减(关联失败)', true);
return;
}
// sell 且 activityShares < amount → 自动截断为可容纳量(R4 老师拍板:自动截断续分)
let vol = v;
if (isSell && t && t.hasActiveHolding && Number(t.activityShares) + 1e-9 < v) {
vol = Number(t.activityShares);
notice('超出该策略可容纳,已自动截断为 ' + fmtNum(vol, 0) + '(余额保留)', false);
}
const merged = [...segs.filter((s) => s.strategyId !== selStrategy), { strategyId: selStrategy, volume: vol }];
await run(merged);
};
const removeSegment = async (strategyId) => {
const merged = segs.filter((s) => s.strategyId !== strategyId);
await run(merged);
};
const clearAll = async () => {
await run([]);
};
const amountDefaultHint = remaining > 0 ? String(remaining) : '';
return (
<span style={{ display: 'inline-flex', flexDirection: 'column', gap: 4, alignItems: 'flex-start', minWidth: 190 }}>
<div style={{ fontSize: 11, color: 'var(--dsw-alias-label-secondary, #666)' }}>
已成交量 {fmtNum(aggVolume, 0)} 已分配 {fmtNum(totalSeg, 0)} 余额 <span style={{ color: remaining > 1e-9 ? 'var(--dsw-alias-state-error-primary, #d32f2f)' : 'var(--dsw-alias-state-success-primary, #1b5e20)', fontWeight: 'bold' }}>{fmtNum(remaining, 0)}</span>
</div>
{(segs.length > 0) && (
<span style={{ display: 'flex', flexWrap: 'wrap', gap: 2, alignItems: 'center' }}>
{segs.map((s) => (
<span key={s.strategyId} style={{ display: 'inline-flex', alignItems: 'center', gap: 3, fontSize: 11, padding: '1px 5px', borderRadius: 3, border: '1px solid var(--dsw-alias-state-success-primary, #2e7d32)', color: 'var(--dsw-alias-state-success-primary, #1b5e20)' }}>
{s.strategyName || s.strategyId}·{fmtNum(s.volume, 0)}
<span
title="撤销该段(逆操作账本)"
onClick={() => !busy && removeSegment(s.strategyId)}
style={{ cursor: busy ? 'wait' : 'pointer', color: 'var(--dsw-alias-state-error-primary, #c62828)' }}
></span>
</span>
))}
</span>
)}
{list && list.length > 0 && remaining > 1e-9 && (
<span style={{ display: 'inline-flex', gap: 3, alignItems: 'center' }}>
<select value={selStrategy} onChange={(e) => setSelStrategy(e.target.value)} style={{ fontSize: 11, padding: '1px 3px' }} title="选择归属策略">
<option value="">策略</option>
{list.map((t) => (
<option key={t.strategyId} value={t.strategyId}>
{t.strategyName || t.strategyId}{t.hasActiveHolding ? '(仓 ' + fmtNum(t.activityShares, 0) + '' : ''}
</option>
))}
</select>
<input
type="number"
min="1"
step={100}
value={amount}
placeholder={amountDefaultHint}
onChange={(e) => setAmount(e.target.value)}
style={{ width: 70, padding: '1px 3px', fontSize: 11 }}
title="分配份额量(默认 = 未分配余额全量,可改小)"
/>
<button onClick={() => !busy && addSegment()} disabled={busy} style={{ fontSize: 11, padding: '1px 6px', cursor: 'pointer' }}>
分配
</button>
</span>
)}
{remaining <= 1e-9 && (
<span style={{ fontSize: 11, color: 'var(--dsw-alias-state-success-primary, #1b5e20)' }}>已全部分配</span>
)}
<span style={{ display: 'inline-flex', gap: 4 }}>
{segs.length > 0 && (
<button onClick={() => !busy && clearAll()} disabled={busy} style={{ fontSize: 11, padding: '1px 6px', cursor: 'pointer', color: 'var(--dsw-alias-state-error-primary, #c62828)' }}>
全部撤除
</button>
)}
<button onClick={onDone} style={{ fontSize: 11, padding: '1px 6px', cursor: 'pointer' }}>完成</button>
</span>
</span>
);
}
const thStyle = { padding: '8px 6px', lineHeight: '24px', whiteSpace: 'nowrap' };
+6 -2
View File
@@ -23,6 +23,7 @@ import { QuoteHub } from './market/QuoteHub.js';
import { QuoteSync } from './market/QuoteSync.js';
import { QmtHealthMonitor } from './data-source/QmtHealthMonitor.js';
import { TradeSync } from './trades/TradeSync.js';
import { AttributionService } from './trades/AttributionService.js';
const name = 'one-divine-lot';
@@ -87,8 +88,11 @@ async function apply(ctx, config) {
const tradeSync = new TradeSync({ runtime: { dataSource, storage }, logger });
tradeSync.start();
// S6: 服务端 HTTP APIR-004:注入 dataSource 以编排激活热切换;R-009:注入 storage 供 trades/history
registerApi(ctx, { manager, settings, dataSource, marketHub, marketFeed, qmtHealthMonitor, storage, tradeSync, positionSync });
// R-018 迭代 16:归属服务(关联 = 账本写操作;经 SqliteStore 段表/生命周期同步接口
const attribution = new AttributionService({ store: storage.sqlite });
// S6: 服务端 HTTP APIR-004:注入 dataSource 以编排激活热切换;R-009:注入 storage 供 trades/historyR-018:注入 attribution
registerApi(ctx, { manager, settings, dataSource, marketHub, marketFeed, qmtHealthMonitor, storage, tradeSync, positionSync, attribution });
// 启动时可用性检查(日志,不阻塞)
dataSource.isAvailable().then((ok) => {
+20 -97
View File
@@ -1,50 +1,43 @@
/**
* PositionSync —— 全量持仓内存快照同步2026-09-02 讨论定稿)
* PositionSync —— 全量持仓(对账单)内存快照同步
*
* 背景:策略持仓 / 全部持仓 / 未分配三个接口原先在请求时穿透 QMT
* PositionManager.getAllPositions 实时拉 /trade/positions),带来两个问题:
* ① QMT 一抖(超时/掉线)所有持仓页面当场空白;
* ② 每次进 tab 都打一次 QMT HTTP。
*
* 方案(老师拍板):服务端建内存快照,以快照为准:
* 方案(老师拍板 R-014):服务端建内存快照,以快照为准:
* - 启动预热一次,之后每 10s 全量拉 QMT 持仓 → 校验 → 整体替换内存快照;
* - 不落库(内存管理,讨论明确:不建缓存表、不复用 strategy_holdings)——
* 持仓快照随时可用一次调用重拿全,落库只会引入「过期数据冒充实时的说谎风险」;
* - 同步失败保留上次快照(不清空、不报错给读方);
* - 空快照需双重确认(/health 可用 + getAsset 账户身份可识别)才接受为「真清仓」,
* 否则视为 QMT 异常(如未登录),保留旧快照;
* - 缓存为空时由 PositionManager 读穿透兜底(当场拉一次 QMT 并回填 backfill
* - 缓存为空时由 PositionManager 读穿透兜底(当场拉一次 QMT 并回填 backfill
*
* 幽灵持仓自动清仓2026-09-02 老师选定):
* - QMT 快照中消失的 code(本地 strategy_holdings 仍有当前持仓),连续 3 轮同步
* (约 30s)仍消失 → 该 code 全部策略的当前持仓 closeHolding 转历史(不物理删除);
* - 防抖护栏:
* a) 账户身份守卫——每轮同步顺带 getAsset 取 accountId;身份未知(接口失败/未登录)
* 当轮跳过清仓判定(不累计计数);accountId 变化(R-004 连接热切换/换账户)时
* 重置防抖计数,防止 A 账户持仓被 B 账户快照误清;
* b) 部分减持不触发(QMT 仍有该 code,只是 volume 变小)——账实差额由前端
* 「未分配为负」暴露,属产品已知行为,不在本模块处理。
*
* 设计对齐:TradeSync 同构(启动预热 + 定时 + 失败容忍);技术约束-011 测试隔离不受影响
* (本模块无持久化,测试无需隔离数据目录)。
* 数据域分界2026-09-08 R-018 老师拍板):
* - 本模块是「全部持仓」= 对账单域的唯一同步机制,**只维护内存快照**;
* - 幽灵持仓自动清仓(R-014 原条款:QMT 快照连续 3 轮消失 → 本地全部策略
* 当前持仓 closeHolding 转历史)**已退役**——同步机制不写 strategy_holdings
* 账本(老师原话:幽灵清仓本就是一个同步机制,不可以让幽灵把爪子伸太长);
* - 账本行转历史唯一途径 = 卖出单关联份额减至 0(R-018 归属服务);
* - 对账单码消失仅表现为:快照无此行 + 前端漏关联软提示(positions/orphan-hints
* 只读检测「账本有活动行 + QMT 快照无此 code」,提示老师去关联/移出,不动账本)。
*/
const SYNC_INTERVAL_MS = 10 * 1000; // 同步间隔(老师定:10 秒)
const GHOST_CLOSE_ROUNDS = 3; // 幽灵防抖:连续 N 轮消失才自动清仓(约 30s)
export class PositionSync {
/**
* @param {object} opts
* @param {object} opts.runtime { dataSource, storage } —— dataSource 拉实盘storage 做幽灵清仓
* @param {object} opts.runtime { dataSource, storage } —— dataSource 拉实盘storage 保留供软提示检测编排,本类不写账本)
* @param {object} [opts.logger]
* @param {number} [opts.intervalMs] 同步间隔(测试可调小)
* @param {number} [opts.ghostRounds] 幽灵清仓防抖轮数(测试可调小)
*/
constructor({ runtime, logger, intervalMs = SYNC_INTERVAL_MS, ghostRounds = GHOST_CLOSE_ROUNDS } = {}) {
constructor({ runtime, logger, intervalMs = SYNC_INTERVAL_MS } = {}) {
this.runtime = runtime;
this.logger = logger;
this.intervalMs = intervalMs;
this.ghostRounds = Math.max(1, ghostRounds);
/** 内存快照:Position[]QmtBridgeRestDataSource.mapPosition 语义化输出;只读约定) */
this.snapshot = [];
/** 最近一次成功同步时间(毫秒;0 = 尚未同步成功过) */
@@ -52,11 +45,7 @@ export class PositionSync {
this.timer = null;
this.mounted = false;
this.syncing = false;
/** 幽灵防抖计数:code → 连续从 QMT 快照消失的轮数 */
this._ghostMiss = new Map();
/** 账户身份守卫:最近一次 getAsset 的 accountId(连接热切换时重置防抖) */
this._lastAccountId = null;
this.stats = { syncCount: 0, failCount: 0, lastError: '', closedGhosts: 0, lastSyncedAt: 0 };
this.stats = { syncCount: 0, failCount: 0, lastError: '', lastSyncedAt: 0 };
}
/** 启动:立即预热一次 + 定时同步 */
@@ -69,7 +58,7 @@ export class PositionSync {
this.timer = setInterval(() => {
this.syncNow().catch(() => { /* syncNow 内部已容错 */ });
}, this.intervalMs);
this.logger?.info?.('[one-divine-lot] PositionSync 启动(10s 内存快照同步 + 幽灵持仓自动清仓防抖 ' + this.ghostRounds + ' 轮');
this.logger?.info?.('[one-divine-lot] PositionSync 启动(10s 内存快照同步;幽灵自动清仓已退役 R-018,不写账本');
}
/** 停止(插件释放时);内存快照保留(读方在停止后仍可消费最后快照) */
@@ -105,9 +94,9 @@ export class PositionSync {
}
/**
* 同步一次:拉 QMT 全量持仓 → 校验 → 整体替换内存快照 → 幽灵清仓判定
* 同步一次:拉 QMT 全量持仓 → 校验 → 整体替换内存快照。
* 任何失败只记统计,不动内存(读方继续消费上次快照)。
* @returns {Promise<{positions:number, closed:Array}|{kept:true, reason:string}|null>}
* @returns {Promise<{positions:number}|{kept:true, reason:string}|null>}
*/
async syncNow() {
// mounted 不拦手动同步(测试/热切换后手动触发均可用);syncing 只防重入
@@ -132,7 +121,7 @@ export class PositionSync {
this.stats.lastSyncedAt = this.syncedAt;
return { kept: true, reason: this.stats.lastError };
}
// 真清仓:接受空快照(幽灵清仓会把本地当前持仓全部转历史
// 真清仓:接受空快照(仅对账单域清空;本地账本不动——账本生命周期由交易关联驱动,R-018
}
// ① 整体替换内存快照(校验通过才动内存)
@@ -140,10 +129,7 @@ export class PositionSync {
this.syncedAt = Date.now();
this.stats.syncCount++;
this.stats.lastSyncedAt = this.syncedAt;
// ② 幽灵持仓自动清仓(带防抖;失败不影响快照)
const closed = await this._autoCloseGhosts(positions);
return { positions: positions.length, closed };
return { positions: positions.length };
} catch (e) {
this.stats.failCount++;
this.stats.lastError = e?.message ?? String(e);
@@ -154,7 +140,7 @@ export class PositionSync {
}
}
/** 取账户身份(getAsset 失败/无 accountId 返回 null */
/** 取账户身份(getAsset 失败/无 accountId 返回 null;空快照双重确认用 */
async _fetchAccountId() {
try {
const a = await this.runtime.dataSource.getAsset();
@@ -163,67 +149,4 @@ export class PositionSync {
return null;
}
}
/**
* 幽灵清仓:快照中消失的 code,连续 N 轮仍消失 → 该 code 全部策略的当前持仓转历史。
* 账户身份守卫见类注释;closeHolding 置 shares=0 + closed_at(不物理删除,历史保留)。
* @param {Array} snapshot 本轮成功的 QMT 快照
* @returns {Promise<Array<{code:string, strategies:string[]}>>} 本轮实际清仓的 code
*/
async _autoCloseGhosts(snapshot) {
const { storage } = this.runtime;
const closed = [];
// 守卫:账户身份未知 → 本轮跳过清仓判定(不累计、不清零,保守)
const accountId = await this._fetchAccountId();
if (!accountId) return closed;
if (this._lastAccountId != null && accountId !== this._lastAccountId) {
// 换账户(连接热切换):旧计数作废,本轮直接跳过判定(连旧账户的快照都不可信)
this._ghostMiss.clear();
this._lastAccountId = accountId;
this.logger?.info?.('[one-divine-lot] PositionSync 检测到账户切换(' + this._lastAccountId + ' → ' + accountId + '),本轮跳过幽灵判定');
return closed;
}
this._lastAccountId = accountId;
let holdings;
try {
holdings = await storage.getCurrentHoldings(); // 全策略当前持仓(closed_at IS NULL
} catch (e) {
this.logger?.debug?.('[one-divine-lot] PositionSync 读本地持仓失败,跳过幽灵判定: ' + (e?.message ?? e));
return closed;
}
const snapshotCodes = new Set(snapshot.map((p) => p.code).filter(Boolean));
const localCodes = [...new Set(holdings.map((h) => h.code).filter(Boolean))];
for (const code of localCodes) {
if (snapshotCodes.has(code)) {
this._ghostMiss.delete(code); // QMT 仍有 → 计数复位(部分减持不在此处理)
continue;
}
const miss = (this._ghostMiss.get(code) ?? 0) + 1;
if (miss < this.ghostRounds) {
this._ghostMiss.set(code, miss);
continue;
}
// 连续 N 轮消失 → 清仓该 code 全部策略的当前持仓(转历史,不物理删除)
try {
const rows = holdings.filter((h) => h.code === code);
for (const h of rows) {
await storage.closeHolding(h.strategyId, code);
}
this._ghostMiss.delete(code);
this.stats.closedGhosts += rows.length;
closed.push({ code, strategies: rows.map((r) => r.strategyId) });
this.logger?.warn?.(
'[one-divine-lot] PositionSync 幽灵清仓: ' + code + ' × ' + rows.length + ' 个策略(QMT 连续 ' + this.ghostRounds + ' 轮无此持仓,自动转历史)'
);
} catch (e) {
// 单码清仓失败:保留计数,下轮重试
this.logger?.debug?.('[one-divine-lot] PositionSync 幽灵清仓失败(下轮重试): ' + code + ' ' + (e?.message ?? e));
}
}
return closed;
}
}
+15
View File
@@ -170,6 +170,21 @@ export class DataStore {
return this.sqlite.getOrdersByHolding(holdingId);
}
/** 按 order_id 读委托(R-018 归属服务用) */
getTradeOrderById(orderId) {
return this.sqlite.getOrderById(orderId);
}
/** 读某 order 的全部归属段(R-018[]=无段) */
getOrderSegments(orderId) {
return this.sqlite.getOrderAttributions(orderId);
}
/** 批量读多 order 归属段(R-018 orders 列表附加) */
getOrderSegmentsByOrderIds(orderIds) {
return this.sqlite.getAttributionsByOrderIds(orderIds);
}
/** 关闭(插件释放时) */
close() {
this.sqlite.close();
+376 -66
View File
@@ -34,15 +34,17 @@ const LEGACY_FILE = 'allocations.json';
const SCHEMA_SQL = `
DROP TABLE IF EXISTS market_quotes_cache; -- R-015:存量库清理(幂等;行情改内存快照)
CREATE TABLE IF NOT EXISTS strategy_holdings (
holding_id INTEGER PRIMARY KEY AUTOINCREMENT,
strategy_id TEXT NOT NULL,
code TEXT NOT NULL,
shares REAL NOT NULL,
created_at INTEGER NOT NULL,
closed_at INTEGER
holding_id INTEGER PRIMARY KEY AUTOINCREMENT,
strategy_id TEXT NOT NULL,
code TEXT NOT NULL,
shares REAL NOT NULL,
created_at INTEGER NOT NULL,
closed_at INTEGER, -- 清仓时间(NULL=未清仓)
void_at INTEGER, -- R-018 作废时间(NULL=有效;非 NULL=建仓被撤=从未成立;不进历史层)
closed_shares REAL -- R-018 清仓前份额快照(closeHolding 写入,供撤卖出段恢复活动)
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_active_holding
ON strategy_holdings (strategy_id, code) WHERE closed_at IS NULL;
-- R-018:活动唯一索引收窄至两态皆空 —— 索引在 _ensureHoldingStateColumns(补列后)统一重建,
-- 避免旧库无 void_at 列时 CREATE INDEX 引用不存在的列(SCHEMA_SQL 与补列分离)
-- market_quotes_cache 已退役(R-015/迭代 13:行情改内存快照 QuoteHub,价格单一入口不再落库)
-- 交易委托(R-009 迭代 07:委托主行;order_id 唯一,UPSERT 幂等;零冗余 strategy_id/holding_id
CREATE TABLE IF NOT EXISTS trade_orders (
@@ -91,6 +93,20 @@ CREATE INDEX IF NOT EXISTS idx_trade_orders_date ON trade_orders (trade_date);
CREATE INDEX IF NOT EXISTS idx_trade_orders_ts ON trade_orders (insert_ts);
CREATE INDEX IF NOT EXISTS idx_trade_fills_order ON trade_fills (order_id);
CREATE INDEX IF NOT EXISTS idx_trade_fills_date ON trade_fills (trade_date);
-- R-018:委托归属分段表(真相源 = 段表;一笔委托可拆 N 段关联不同策略/持仓)
CREATE TABLE IF NOT EXISTS trade_order_attributions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_id TEXT NOT NULL, -- → trade_orders.order_id
strategy_id TEXT NOT NULL, -- 段归属策略
holding_id INTEGER NOT NULL, -- 段锚点持仓(R-010 展开用)
code TEXT NOT NULL, -- 冗余
direction TEXT NOT NULL, -- buy/sell(冗余,撤段判向)
volume REAL NOT NULL, -- 该段已成交量(>0;总额 ≤ order.traded_volume
created_at INTEGER NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_attr_order_strategy ON trade_order_attributions (order_id, strategy_id);
CREATE INDEX IF NOT EXISTS idx_attr_holding ON trade_order_attributions (holding_id);
CREATE INDEX IF NOT EXISTS idx_attr_strategy ON trade_order_attributions (strategy_id);
`;
/** 解析 strategy_holdings."values" JSON 列(容错:空/脏返回 null;模块级,供 .map(this._mapHolding) 裸调用) */
@@ -132,6 +148,10 @@ export class SqliteStore {
this._ensureTradeAttributionColumns();
// 存量库迁移:strategy_holdings 补自定义字段值列(R-013 迭代 11;幂等)
this._ensureHoldingValuesColumn();
// R-018 迭代 16strategy_holdings 补 void_at/closed_shares 列 + 重建活动唯一索引(旧库 ALTER,幂等)
this._ensureHoldingStateColumns();
// R-018 迭代 16:存量 trade_orders 单组归属 → 段表第一段(幂等;真相源迁移)
this._migrateLegacyAttributionToSegments();
return this.db;
}
@@ -179,6 +199,79 @@ export class SqliteStore {
}
}
/**
* R-018 迭代 16:确保 strategy_holdings 存在 void_at / closed_shares 列(旧库 ALTER 补列,幂等)
* + 重建活动唯一索引(活动 = closed_at IS NULL AND void_at IS NULL,两态皆空才唯一)。
* 新库在 SCHEMA_SQL 已含列与新索引定义;旧库(无新列)在此补齐并重建索引。
*/
_ensureHoldingStateColumns() {
try {
const cols = new Set(this.db.prepare('PRAGMA table_info(strategy_holdings)').all().map((c) => c.name));
if (!cols.has('void_at')) {
this.db.exec('ALTER TABLE strategy_holdings ADD COLUMN void_at INTEGER');
}
if (!cols.has('closed_shares')) {
this.db.exec('ALTER TABLE strategy_holdings ADD COLUMN closed_shares REAL');
}
// 列就绪(新库建表已含 / 旧库已补)→ 重建索引(DROP 旧定义 + CREATE 新定义;幂等)
this.db.exec('DROP INDEX IF EXISTS idx_active_holding');
this.db.exec(
'CREATE UNIQUE INDEX IF NOT EXISTS idx_active_holding ' +
'ON strategy_holdings (strategy_id, code) WHERE closed_at IS NULL AND void_at IS NULL'
);
} catch (err) {
if (String(err?.message ?? '').includes('readonly')) {
console.warn('[one-divine-lot] strategy_holdings void_at/closed_shares 迁移跳过(只读连接,插件持锁中)');
} else {
throw err;
}
}
}
/**
* R-018 迭代 16:存量归属迁移 —— trade_orders 单组 strategy_id/holding_id → 段表第一段。
* 幂等:仅当 trade_order_attributions 中该 order 无任何段、且 trade_orders 归属列非空时写入
* volume = 该单已成交量 traded_volume;无法确定段归属时视为已成交量全量)。
* 迁移后归属真相源 = 段表;trade_orders 两列退役为冗余(不删除,防外部脚本 break)。
*/
_migrateLegacyAttributionToSegments() {
try {
const rows = this.db.prepare(
'SELECT order_id, code, direction, traded_volume, strategy_id, holding_id FROM trade_orders ' +
'WHERE strategy_id IS NOT NULL OR holding_id IS NOT NULL'
).all();
if (rows.length === 0) return;
const hasSeg = this.db.prepare(
'SELECT 1 FROM trade_order_attributions WHERE order_id=?'
);
const insert = this.db.prepare(
'INSERT OR IGNORE INTO trade_order_attributions (order_id, strategy_id, holding_id, code, direction, volume, created_at) ' +
'VALUES (?,?,?,?,?,?,?)'
);
const now = Date.now();
this.db.exec('BEGIN');
try {
for (const r of rows) {
if (!r.strategy_id || r.holding_id == null) continue; // 不完整归属不迁
if (hasSeg.get(r.order_id)) continue; // 已有段不重复迁
const vol = Number(r.traded_volume) > 0 ? Number(r.traded_volume) : 0;
if (vol <= 0) continue;
insert.run(r.order_id, r.strategy_id, r.holding_id, r.code, r.direction ?? '', vol, now);
}
this.db.exec('COMMIT');
} catch (err) {
this.db.exec('ROLLBACK');
throw err;
}
} catch (err) {
if (String(err?.message ?? '').includes('readonly')) {
console.warn('[one-divine-lot] 存量归属迁移跳过(只读连接,插件持锁中)');
} else {
throw err;
}
}
}
// ===== 持仓自定义字段值(R-013 迭代 11=====
/**
@@ -309,74 +402,127 @@ export class SqliteStore {
} catch { /* 已备份或不存在 */ }
}
// ===== 持仓生命周期 =====
// ===== 持仓生命周期R-018:活动 = closed_at IS NULL AND void_at IS NULL=====
/** 建仓:创建一笔新持仓(该策略该 code 无当前持仓时) */
/** 建仓:创建一笔新持仓(该策略该 code 无当前/作废持仓时) */
openHolding(strategyId, code, shares) {
this.init();
const now = Date.now();
const r = this.db.prepare(
'INSERT INTO strategy_holdings (strategy_id, code, shares, created_at, closed_at) VALUES (?,?,?,?,NULL)'
).run(strategyId, code, Number(shares), now);
return { holdingId: Number(r.lastInsertRowid), strategyId, code, shares: Number(shares), createdAt: now, closedAt: null, values: null }; // R-013:新持仓无自定义字段值
return { holdingId: Number(r.lastInsertRowid), strategyId, code, shares: Number(shares), createdAt: now, closedAt: null, voidAt: null, closedShares: null, values: null }; // R-013:新持仓无自定义字段值
}
/** 加仓:当前持仓份额累加 */
/** 加仓:当前活动持仓份额累加(活动 = closed/void 双空) */
addShares(strategyId, code, shares) {
this.init();
const r = this.db.prepare(
'UPDATE strategy_holdings SET shares = shares + ? WHERE strategy_id=? AND code=? AND closed_at IS NULL'
'UPDATE strategy_holdings SET shares = shares + ? WHERE strategy_id=? AND code=? AND closed_at IS NULL AND void_at IS NULL'
).run(Number(shares), strategyId, code);
if (r.changes === 0) {
// 无当前持仓(被关过),退化为建仓
// 无当前持仓(被关过/作废),退化为建仓
return this.openHolding(strategyId, code, shares);
}
return this._getActive(strategyId, code);
}
/** 减仓:当前持仓份额减少(需先确认减后 > 0,调用方校验) */
/** 减仓:当前活动持仓份额减少(需先确认减后 0,调用方校验) */
reduceShares(strategyId, code, shares) {
this.init();
this.db.prepare(
'UPDATE strategy_holdings SET shares = shares - ? WHERE strategy_id=? AND code=? AND closed_at IS NULL'
'UPDATE strategy_holdings SET shares = shares - ? WHERE strategy_id=? AND code=? AND closed_at IS NULL AND void_at IS NULL'
).run(Number(shares), strategyId, code);
return this._getActive(strategyId, code);
}
/** 清仓:份额归零,closed_at=now 转历史(不物理删除) */
/**
* 清仓:份额归零,closed_at=now 转历史(不物理删除;R-016 Q2:shares 展示仍 0)。
* R-018:同时记录 closed_shares=清仓前份额快照(供撤卖出段恢复活动用)。
*/
closeHolding(strategyId, code) {
this.init();
const now = Date.now();
// 先读清仓前份额(事务内快照由调用方保证,这里单语句内取值)
const active = this._getActive(strategyId, code);
this.db.prepare(
'UPDATE strategy_holdings SET shares = 0, closed_at = ? WHERE strategy_id=? AND code=? AND closed_at IS NULL'
).run(now, strategyId, code);
return { strategyId, code, closedAt: now };
'UPDATE strategy_holdings SET shares = 0, closed_at = ?, closed_shares = ? WHERE strategy_id=? AND code=? AND closed_at IS NULL AND void_at IS NULL'
).run(now, active ? Number(active.shares) : null, strategyId, code);
return { strategyId, code, closedAt: now, closedShares: active ? Number(active.shares) : null };
}
/** 读取某策略某 code 的当前持仓(可能为 null) */
/**
* 作废(R-018 R3-1/R6):建仓被撤 = 从未成立 —— shares 置 0 + void_at=now(非清仓,不产生假清仓历史)。
* 作废行不进 R-016 历史层(getHoldingsHistory 过滤 void_at IS NULL)。
*/
voidHolding(strategyId, code) {
this.init();
const now = Date.now();
const active = this._getActive(strategyId, code);
this.db.prepare(
'UPDATE strategy_holdings SET shares = 0, void_at = ? WHERE strategy_id=? AND code=? AND closed_at IS NULL AND void_at IS NULL'
).run(now, strategyId, code);
return { strategyId, code, voidAt: now, holdingId: active ? active.holdingId : null };
}
/**
* 恢复活动仓(R-018 R3-2:撤卖出段 → 撤销 closeHolding):
* closed_at=NULL、shares=closed_shares(清仓前份额快照)、closed_shares 清空。
* 调用方须先确认:该 strategy/code 当前无其他活动仓(否则撞唯一索引)且该行 closed_shares 有效。
* @param {number} holdingId
* @returns {object|null} 恢复后的活动行(失败 null)
*/
reopenHolding(holdingId) {
this.init();
const row = this.db.prepare(
'SELECT holding_id, strategy_id, code, closed_shares FROM strategy_holdings WHERE holding_id=? AND closed_at IS NOT NULL AND void_at IS NULL'
).get(holdingId);
if (!row || row.closed_shares == null) return null;
const conflict = this.db.prepare(
'SELECT 1 FROM strategy_holdings WHERE strategy_id=? AND code=? AND holding_id<>? AND closed_at IS NULL AND void_at IS NULL'
).get(row.strategy_id, row.code, holdingId);
if (conflict) return null; // 同 code 已存在新活动仓,无法恢复
this.db.prepare(
'UPDATE strategy_holdings SET closed_at = NULL, shares = ?, closed_shares = NULL WHERE holding_id=?'
).run(Number(row.closed_shares), holdingId);
return this.db.prepare(
'SELECT holding_id, strategy_id, code, shares, created_at, closed_at, void_at, closed_shares, "values" FROM strategy_holdings WHERE holding_id=?'
).get(holdingId);
}
/** 读取某策略某 code 的当前活动持仓(closed/void 双空;可能为 null */
_getActive(strategyId, code) {
this.init();
const row = this.db.prepare(
'SELECT holding_id, strategy_id, code, shares, created_at, closed_at, "values" FROM strategy_holdings WHERE strategy_id=? AND code=? AND closed_at IS NULL'
'SELECT holding_id, strategy_id, code, shares, created_at, closed_at, void_at, closed_shares, "values" FROM strategy_holdings WHERE strategy_id=? AND code=? AND closed_at IS NULL AND void_at IS NULL'
).get(strategyId, code);
return row ? this._mapHolding(row) : null;
}
/** 查询当前持仓(closed_at IS NULL;可加 strategy_id 过滤 */
/** 按 holding_id 取行(含历史/作废;归属服务撤段用 */
getHoldingById(holdingId) {
this.init();
const row = this.db.prepare(
'SELECT holding_id, strategy_id, code, shares, created_at, closed_at, void_at, closed_shares, "values" FROM strategy_holdings WHERE holding_id=?'
).get(holdingId);
return row ? this._mapHolding(row) : null;
}
/** 查询当前活动持仓(closed/void 双空;可加 strategy_id 过滤) */
getCurrentHoldings(strategyId) {
this.init();
if (strategyId) {
return this.db.prepare(
'SELECT holding_id, strategy_id, code, shares, created_at, closed_at, "values" FROM strategy_holdings WHERE strategy_id=? AND closed_at IS NULL ORDER BY holding_id'
'SELECT holding_id, strategy_id, code, shares, created_at, closed_at, void_at, closed_shares, "values" FROM strategy_holdings WHERE strategy_id=? AND closed_at IS NULL AND void_at IS NULL ORDER BY holding_id'
).all(strategyId).map(this._mapHolding);
}
return this.db.prepare(
'SELECT holding_id, strategy_id, code, shares, created_at, closed_at, "values" FROM strategy_holdings WHERE closed_at IS NULL ORDER BY strategy_id, holding_id'
'SELECT holding_id, strategy_id, code, shares, created_at, closed_at, void_at, closed_shares, "values" FROM strategy_holdings WHERE closed_at IS NULL AND void_at IS NULL ORDER BY strategy_id, holding_id'
).all().map(this._mapHolding);
}
/**
* 查询某策略已清仓持仓(closed_at 非空;R-016 迭代 14
* 查询某策略已清仓持仓(closed_at 非空 且 未作废R-016 迭代 14 + R-018 过滤 void
* 只读新增,不动任何写路径(closeHolding 置 shares=0 语义维持——Q2 老师拍板:历史行份额显示 0)。
* @param {string} strategyId 策略 ID
* @param {{sinceMs?: number}} [opts] sinceMsclosed_at 下限(毫秒,含);缺省不过滤
@@ -385,7 +531,7 @@ export class SqliteStore {
getHoldingsHistory(strategyId, { sinceMs } = {}) {
this.init();
const params = [strategyId];
let sql = 'SELECT holding_id, strategy_id, code, shares, created_at, closed_at, "values" FROM strategy_holdings WHERE strategy_id=? AND closed_at IS NOT NULL';
let sql = 'SELECT holding_id, strategy_id, code, shares, created_at, closed_at, void_at, closed_shares, "values" FROM strategy_holdings WHERE strategy_id=? AND closed_at IS NOT NULL AND void_at IS NULL';
if (sinceMs != null && Number.isFinite(Number(sinceMs))) {
sql += ' AND closed_at >= ?';
params.push(Number(sinceMs));
@@ -394,16 +540,16 @@ export class SqliteStore {
return this.db.prepare(sql).all(...params).map(this._mapHolding);
}
/** 查询历史(含当前 + 已清仓;可加 code 过滤) */
/** 查询历史(含当前活动 + 已清仓 + 作废;可加 code 过滤) */
getHoldingHistory(code) {
this.init();
if (code) {
return this.db.prepare(
'SELECT holding_id, strategy_id, code, shares, created_at, closed_at, "values" FROM strategy_holdings WHERE code=? ORDER BY holding_id'
'SELECT holding_id, strategy_id, code, shares, created_at, closed_at, void_at, closed_shares, "values" FROM strategy_holdings WHERE code=? ORDER BY holding_id'
).all(code).map(this._mapHolding);
}
return this.db.prepare(
'SELECT holding_id, strategy_id, code, shares, created_at, closed_at, "values" FROM strategy_holdings ORDER BY holding_id'
'SELECT holding_id, strategy_id, code, shares, created_at, closed_at, void_at, closed_shares, "values" FROM strategy_holdings ORDER BY holding_id'
).all().map(this._mapHolding);
}
@@ -415,6 +561,8 @@ export class SqliteStore {
shares: row.shares,
createdAt: row.created_at,
closedAt: row.closed_at,
voidAt: row.void_at ?? null, // R-018:作废时间(NULL=有效)
closedShares: row.closed_shares ?? null, // R-018:清仓前份额快照
values: parseValues(row.values), // R-013:自定义字段值(未配置 null;模块级函数,.map 裸传不依赖 this)
};
}
@@ -514,6 +662,33 @@ export class SqliteStore {
return list.length;
}
/**
* R-018:事务包裹(同步 API 直接 exec)。fn 内抛错 → ROLLBACK 后重抛。
* 归属服务 setSegments 全量替换(撤旧段 + 加新段)必须整体原子。
* @param {Function} fn 同步回调(内部经本 store 写库)
* @returns {*} fn 返回值
*/
runInTransaction(fn) {
this.init();
this.db.exec('BEGIN');
try {
const result = fn();
this.db.exec('COMMIT');
return result;
} catch (err) {
try { this.db.exec('ROLLBACK'); } catch { /* ignore */ }
throw err;
}
}
/** 按 order_id 读委托(trade_orders;无 → null */
getOrderById(orderId) {
this.init();
if (!orderId) return null;
const row = this.db.prepare('SELECT * FROM trade_orders WHERE order_id=?').get(orderId);
return row ? this._mapOrderRow(row) : null;
}
/**
* 委托时间 → 毫秒时间戳(insert_date YYYYMMDD + insert_time HHMMSS
* 时间字段缺失时回退 fetched_at(同步时间),保证 join 窗口可用
@@ -553,7 +728,7 @@ export class SqliteStore {
if (codes.length === 0) return out;
const placeholders = codes.map(() => '?').join(',');
const rows = this.db.prepare(
'SELECT holding_id, strategy_id, code, shares, created_at, closed_at, "values" FROM strategy_holdings WHERE code IN (' + placeholders + ')'
'SELECT holding_id, strategy_id, code, shares, created_at, closed_at, void_at, "values" FROM strategy_holdings WHERE code IN (' + placeholders + ') AND void_at IS NULL'
).all(...codes);
const holdingsByCode = new Map();
for (const h of rows) {
@@ -562,9 +737,9 @@ export class SqliteStore {
}
for (const o of list) {
const ts = this._orderInsertTs(o);
// 方案 A2026-09-01 老师确认):当前持仓(closed_at IS NULL)只做同 code 匹配
// 方案 A2026-09-01 老师确认):当前活动持仓(closed_at IS NULL AND void_at IS NULL)只做同 code 匹配
// created_at 可能是迁移时间戳,不要求 ≤ 委托时间;现在持有=当日交易可归);
// 已清仓(closed_at 非空)才按时间窗口(created_at ≤ t < closed_at)判断。
// 已清仓(closed_at 非空)才按时间窗口(created_at ≤ t < closed_at)判断;作废行(void_at 非空)不参与
const candidates = (holdingsByCode.get(o.code) ?? []).filter((h) => {
if (h.closed_at == null) return true; // 当前持仓:同 code 即匹配
if (h.created_at > ts) return false;
@@ -614,14 +789,24 @@ export class SqliteStore {
if (where.length) sql += ' WHERE ' + where.join(' AND ');
sql += ' ORDER BY insert_ts DESC';
const rows = this.db.prepare(sql).all(...params);
// 归属 = 用户手动设置的持久化列(Q3 全手动选;未设置为 null=未关联
// R-018:归属真相源 = 段表(strategyId 命中任一策略段即算该策略;__unassigned__ = 无任何段
const mapped = rows.map((r) => this._mapOrderRow(r));
if (!strategyId) return mapped;
const ids = mapped.map((o) => o.orderId).filter(Boolean);
if (ids.length === 0) return [];
const ph = ids.map(() => '?').join(',');
const segRows = this.db.prepare(
'SELECT order_id, strategy_id FROM trade_order_attributions WHERE order_id IN (' + ph + ')'
).all(...ids);
const segByOrder = new Map();
for (const s of segRows) {
if (!segByOrder.has(s.order_id)) segByOrder.set(s.order_id, new Set());
segByOrder.get(s.order_id).add(s.strategy_id);
}
if (strategyId === '__unassigned__') {
return rows.map((r) => this._mapOrderRow(r)).filter((o) => !o.strategyId);
return mapped.filter((o) => !segByOrder.has(o.orderId));
}
if (strategyId) {
return rows.map((r) => this._mapOrderRow(r)).filter((o) => o.strategyId === strategyId);
}
return rows.map((r) => this._mapOrderRow(r));
return mapped.filter((o) => segByOrder.get(o.orderId)?.has(strategyId));
}
/**
@@ -643,22 +828,29 @@ export class SqliteStore {
const rows = this.db.prepare(sql).all(...params);
const fills = rows.map((r) => this._mapFillRow(r));
if (!strategyId) return fills;
// 策略过滤:按所属委托的【持久化归属】strategy_idfill → order 的 strategy_id
// R-018:策略过滤经段表fill → order → 段;命中任一策略段即算
const orderIds = [...new Set(fills.map((f) => f.orderId).filter(Boolean))];
if (orderIds.length === 0) return [];
const ph = orderIds.map(() => '?').join(',');
const orderRows = this.db.prepare(
'SELECT order_id, strategy_id FROM trade_orders WHERE order_id IN (' + ph + ')'
const segRows = this.db.prepare(
'SELECT order_id, strategy_id FROM trade_order_attributions WHERE order_id IN (' + ph + ')'
).all(...orderIds);
const orderStrategy = new Map(orderRows.map((r) => [r.order_id, r.strategy_id ?? null]));
if (strategyId === '__unassigned__') {
return fills.filter((f) => !orderStrategy.get(f.orderId));
const segByOrder = new Map();
for (const s of segRows) {
if (!segByOrder.has(s.order_id)) segByOrder.set(s.order_id, new Set());
segByOrder.get(s.order_id).add(s.strategy_id);
}
return fills.filter((f) => orderStrategy.get(f.orderId) === strategyId);
if (strategyId === '__unassigned__') {
return fills.filter((f) => !segByOrder.has(f.orderId));
}
return fills.filter((f) => segByOrder.get(f.orderId)?.has(strategyId));
}
/**
* 设置委托归属(Q4 老师确认:可随时改,以最终修改为准)
* R-018 兼容封装:段表是真相源 —— 单组归属(strategyId+holdingId)写冗余列,
* 并同步为段表一段(volume = 该 order 已成交量;无 order 记录时仅写冗余列,供旧脚本/一次性修复)。
* 正式 UI 走 AttributionService.setSegments(多段)。
* @param {string} orderId
* @param {string|null} strategyId null=清除归属(未关联)
* @param {number|null} holdingId null=清除归属
@@ -666,49 +858,167 @@ export class SqliteStore {
*/
setOrderAttribution(orderId, strategyId, holdingId) {
this.init();
const r = this.db.prepare(
'UPDATE trade_orders SET strategy_id=?, holding_id=? WHERE order_id=?'
).run(strategyId ?? null, holdingId ?? null, orderId);
return r.changes > 0;
const row = this.db.prepare(
'SELECT code, direction, traded_volume FROM trade_orders WHERE order_id=?'
).get(orderId);
if (!row) {
const r = this.db.prepare(
'UPDATE trade_orders SET strategy_id=?, holding_id=? WHERE order_id=?'
).run(strategyId ?? null, holdingId ?? null, orderId);
return r.changes > 0;
}
if (strategyId && holdingId != null) {
// 单段兼容 = 替换语义(R-018:归属真相源 = 段表;旧通道一次只表达一段 → 清空旧段再设新段)
const vol = Number(row.traded_volume ?? 0);
this.deleteAllOrderSegments(orderId);
this.upsertOrderSegment({
orderId, strategyId, holdingId: Number(holdingId),
code: row.code, direction: row.direction ?? '', volume: vol,
});
} else {
// 清除归属:删全部段(冗余列由镜像清空)
this.deleteAllOrderSegments(orderId);
}
return true;
}
/**
* 按 holding_id 查询委托汇总(R-010 迭代 08:策略持仓行展开用)
* 按 holding_id 查询委托汇总(R-010 迭代 08:策略持仓行展开用;R-018:经段表 join——同一单多段可出现在多个 holding 展开
* @param {number} holdingId 持仓 ID
* @returns {Array} 该 holding 的委托列表(含归属字段,按时间降序)
*/
getOrdersByHolding(holdingId) {
this.init();
if (holdingId == null) return [];
return this.db.prepare(
'SELECT * FROM trade_orders WHERE holding_id=? ORDER BY insert_ts DESC'
).all(holdingId).map((r) => this._mapOrderRow(r));
const rows = this.db.prepare(
'SELECT t.* FROM trade_orders t ' +
'JOIN trade_order_attributions a ON a.order_id = t.order_id ' +
'WHERE a.holding_id = ? ORDER BY t.insert_ts DESC'
).all(holdingId);
return rows.map((r) => this._mapOrderRow(r));
}
/**
* 委托归属候选列表(用户手动设置用;Q3 全手动选)
* R-017 迭代 15:候选 = 该 code 当前持仓 + 近 7 天清仓的持仓(closed 标记)——
* 清仓后(幽灵清仓最快约 30s 转历史)当日委托仍可补关联到对应 holding
* 委托归属候选(存储层兼容方法;R-018 T2 退役 7 天清仓窗口后:候选 = 该 code **当前活动持仓**)。
* R-018 产品语义的正式候选 = 策略级 attribution-targets(阶段 D 新端点);本方法供 DataStore/
* 旧脚本(verify-real)读当前持仓用,不含清仓/作废行
* @param {string} code 证券代码(含后缀)
* @returns {Array<{strategyId: string, holdingId: number, shares: number, closed: boolean, closedAt: number|null}>} 候选列表(可空)
*/
getAttributionCandidates(code) {
this.init();
if (!code) return [];
const weekAgo = Date.now() - 7 * 86400000;
return this.db.prepare(
`SELECT holding_id, strategy_id, shares, closed_at FROM strategy_holdings
WHERE code=? AND (closed_at IS NULL OR closed_at >= ?)
ORDER BY (closed_at IS NULL) DESC, shares DESC, closed_at DESC`
).all(code, weekAgo).map((h) => ({
'SELECT holding_id, strategy_id, shares, closed_at FROM strategy_holdings ' +
'WHERE code=? AND closed_at IS NULL AND void_at IS NULL ' +
'ORDER BY shares DESC'
).all(code).map((h) => ({
strategyId: h.strategy_id,
holdingId: h.holding_id,
shares: h.shares,
closed: h.closed_at != null,
closedAt: h.closed_at ?? null,
closed: false,
closedAt: null,
}));
}
// ===== 归属分段表(R-018 迭代 16:真相源 = 段表;trade_orders 冗余列 = 主段镜像)=====
/**
* 读某 order 的全部归属段(无段 → [])
*/
getOrderAttributions(orderId) {
this.init();
if (!orderId) return [];
return this.db.prepare(
'SELECT id, order_id, strategy_id, holding_id, code, direction, volume, created_at FROM trade_order_attributions WHERE order_id=? ORDER BY id'
).all(orderId).map((r) => this._mapSegmentRow(r));
}
/**
* 批量读多个 order 的归属段(orders 列表附加用):{ orderId: segments[] }
*/
getAttributionsByOrderIds(orderIds) {
this.init();
const out = new Map();
const ids = [...new Set((orderIds ?? []).filter(Boolean))];
if (ids.length === 0) return out;
const ph = ids.map(() => '?').join(',');
const rows = this.db.prepare(
'SELECT id, order_id, strategy_id, holding_id, code, direction, volume, created_at FROM trade_order_attributions WHERE order_id IN (' + ph + ') ORDER BY id'
).all(...ids);
for (const r of rows) {
const s = this._mapSegmentRow(r);
if (!out.has(s.orderId)) out.set(s.orderId, []);
out.get(s.orderId).push(s);
}
return out;
}
/**
* 插入一个归属段(归属服务 apply 后调用)+ 同步冗余列主段镜像。
* 唯一键 (order_id, strategy_id):已存在则更新 volume/holding 不新建(幂等改归属)。
*/
upsertOrderSegment({ orderId, strategyId, holdingId, code, direction, volume }) {
this.init();
const now = Date.now();
this.db.prepare(
'INSERT INTO trade_order_attributions (order_id, strategy_id, holding_id, code, direction, volume, created_at) ' +
'VALUES (?,?,?,?,?,?,?) ' +
'ON CONFLICT(order_id, strategy_id) DO UPDATE SET ' +
'holding_id=excluded.holding_id, code=excluded.code, direction=excluded.direction, ' +
'volume=excluded.volume, created_at=excluded.created_at'
).run(orderId, strategyId, holdingId, code, direction, Number(volume), now);
this._syncOrderMirror(orderId);
return this.db.prepare(
'SELECT id, order_id, strategy_id, holding_id, code, direction, volume, created_at FROM trade_order_attributions WHERE order_id=? AND strategy_id=?'
).get(orderId, strategyId);
}
/**
* 删除一个归属段(归属服务 revoke 后调用)+ 同步冗余列主段镜像。
*/
deleteOrderSegment(orderId, strategyId) {
this.init();
const r = this.db.prepare(
'DELETE FROM trade_order_attributions WHERE order_id=? AND strategy_id=?'
).run(orderId, strategyId);
if (r.changes > 0) this._syncOrderMirror(orderId);
return r.changes > 0;
}
/** 删除某 order 全部段(清空归属用) */
deleteAllOrderSegments(orderId) {
this.init();
const r = this.db.prepare(
'DELETE FROM trade_order_attributions WHERE order_id=?'
).run(orderId);
if (r.changes > 0) this._syncOrderMirror(orderId);
return r.changes > 0;
}
/** 冗余列主段镜像:trade_orders.strategy_id/holding_id = 第一条段(段表真相源,冗余列兼容旧查询/外部脚本) */
_syncOrderMirror(orderId) {
const seg = this.db.prepare(
'SELECT strategy_id, holding_id FROM trade_order_attributions WHERE order_id=? ORDER BY id LIMIT 1'
).get(orderId);
this.db.prepare(
'UPDATE trade_orders SET strategy_id=?, holding_id=? WHERE order_id=?'
).run(seg ? seg.strategy_id : null, seg ? seg.holding_id : null, orderId);
}
_mapSegmentRow(r) {
return {
id: r.id,
orderId: r.order_id,
strategyId: r.strategy_id,
holdingId: r.holding_id,
code: r.code,
direction: r.direction,
volume: r.volume,
createdAt: r.created_at,
};
}
_mapOrderRow(r) {
return {
orderId: r.order_id,
@@ -730,8 +1040,8 @@ export class SqliteStore {
insertTs: r.insert_ts,
cancelInfo: r.cancel_info,
errorMsg: r.error_msg,
strategyId: r.strategy_id ?? null, // 手动归属(Q1 冗余)
holdingId: r.holding_id ?? null, // 手动归属(Q1 冗余)
strategyId: r.strategy_id ?? null, // 冗余主段镜像(R-018:真相源=段表
holdingId: r.holding_id ?? null, // 冗余主段镜像(R-018:真相源=段表
fetchedAt: r.fetched_at,
};
}
+214
View File
@@ -0,0 +1,214 @@
/**
* AttributionService —— 交易关联归属服务(R-018 / 迭代 16)
*
* 语义(R-018 老师拍板):
* - 归属 = 账本写操作:把委托的已成交量拆成 0..N 段(策略 × 量)分配到 strategy_holdings
* - 买入段:策略有活动仓 → addShares(加仓);无 → openHolding(建新仓,不复活旧行);
* - 卖出段:活动仓 ≥ 段量 → reduceShares,归 0 → closeHolding(账本转历史唯一途径);
* 活动仓 < 段量 → 拒绝(segment-exceeds,UI 截断后重提);无活动仓 → 拒绝(no-active-holding);
* - 撤段(revoke,粒度=段):撤 buy 段 → 减回,归 0 且无真实卖出 → voidHolding(作废第三态);
* 撤 sell 段 → 加回;若该段触发清仓且 closed_shares==段量 → reopenHolding 恢复活动仓;
* - 同 holding 内段撤销按逆序支持;乱序 → segment-order-conflict(宁可拒绝不写错账);
* - 改归属 = setSegments(全量替换:撤旧段 + 加新段,单事务原子)。
*
* 真相源 = trade_order_attributions 段表;trade_orders 冗余列由存储层镜像维护。
*/
/** 段级 apply:把一段归属落到账本。返回该段(含最终 holdingId)。 */
export class AttributionService {
/**
* @param {object} opts
* @param {import('../storage/SqliteStore.js').SqliteStore} opts.store SqliteStore(段表 CRUD / 持仓生命周期同步接口)
*/
constructor({ store }) {
this.store = store;
}
/**
* 应用一段归属(外部单段入口也走这里;内部 setSegments diff 复用)
* @param {object} seg { orderId, strategyId, volume }
* @returns {object} { segment, holdingId }
*/
applySegment({ orderId, strategyId, volume }) {
const store = this.store;
const order = store.getOrderById(orderId);
if (!order) throw Object.assign(new Error('委托不存在: ' + orderId), { code: 'order-not-found' });
if (!strategyId) throw Object.assign(new Error('缺少策略'), { code: 'bad-request' });
const v = Number(volume);
if (!Number.isFinite(v) || v <= 0) throw Object.assign(new Error('段量必须为正数'), { code: 'bad-request' });
const tv = Number(order.tradedVolume ?? 0);
if (v > tv) {
throw Object.assign(new Error('段量超过该委托已成交量: ' + v + ' > ' + tv), { code: 'segment-exceeds-order' });
}
// 累计校验:该 order 已有段合计 + 新增 ≤ 已成交量(单段入口不允许一单追加超量)
const existing = this.store.getOrderAttributions(orderId);
const already = existing.filter((s) => s.strategyId !== strategyId).reduce((s, x) => s + Number(x.volume), 0);
if (already + v > tv + 1e-9) {
throw Object.assign(new Error('该委托已关联 ' + already + ',再加 ' + v + ' 超过已成交量 ' + tv), { code: 'segment-exceeds-order' });
}
const direction = String(order.direction ?? '');
const code = order.code;
return this._applyOne({ orderId, code, direction, strategyId, volume: v });
}
/**
* 撤销一段归属(撤段 = 逆操作)。
* @param {object} arg { orderId, strategyId }
* @returns {boolean}
*/
revokeSegment({ orderId, strategyId }) {
const store = this.store;
const seg = store.db.prepare(
'SELECT * FROM trade_order_attributions WHERE order_id=? AND strategy_id=?'
).get(orderId, strategyId);
if (!seg) return false; // 无该段(已撤/不存在)→ 视为成功(幂等)
const holding = store.getHoldingById(seg.holding_id);
if (!holding) {
// 锚点行丢失(异常数据):仅删段(不可逆),账本已不可考
store.deleteOrderSegment(orderId, strategyId);
return true;
}
const segVolume = Number(seg.volume);
if (seg.direction === 'sell') {
this._revokeSellSegment(orderId, strategyId, seg, holding, segVolume);
} else {
// buy(含缺省方向视为 buy 侧处理)
this._revokeBuySegment(orderId, strategyId, seg, holding, segVolume);
}
return true;
}
/**
* 全量替换某 order 的归属段(改归属 = 撤旧段 + 加新段,单事务原子)。
* @param {object} arg { orderId, segments: [{ strategyId, volume }] }
* @returns {object} { segments: 最终段列表 }
*/
setSegments({ orderId, segments }) {
const store = this.store;
const order = store.getOrderById(orderId);
if (!order) throw Object.assign(new Error('委托不存在: ' + orderId), { code: 'order-not-found' });
const list = Array.isArray(segments) ? segments : [];
const tv = Number(order.tradedVolume ?? 0);
const total = list.reduce((s, x) => s + Number(x?.volume ?? 0), 0);
if (total > tv + 1e-9) {
throw Object.assign(new Error('段合计超过委托已成交量: ' + total + ' > ' + tv), { code: 'segment-exceeds-order' });
}
return store.runInTransaction(() => {
// 1) 撤掉「不在新集合」的旧段(或量变化的旧段先撤)
const oldSegs = store.getOrderAttributions(orderId);
const target = new Map();
for (const s of list) {
if (!s?.strategyId || !(Number(s.volume) > 0)) throw Object.assign(new Error('段格式无效(需 strategyId + volume>0'), { code: 'bad-request' });
target.set(s.strategyId, Number(s.volume));
}
// 收集需要撤销的段:新集合没有的 strategy,或 volume 变化需先撤(同 strategy 走 upsert 覆盖即可——
// 但份额变更不能简单覆盖:同 strategy 段从 v1→v2 时账本已 apply 过 v1,须先撤 v1 再 apply v2
for (const old of oldSegs) {
const want = target.get(old.strategyId);
if (want === undefined || Math.abs(Number(old.volume) - want) > 1e-9) {
this._revokeOneInternal(orderId, old); // 撤旧段
}
}
// 2) 应用新集合(含量未变的段:重新 apply 幂等 add 会重复加 → 需判断)
for (const [sid, vol] of target.entries()) {
const old = oldSegs.find((o) => o.strategyId === sid);
if (old && Math.abs(Number(old.volume) - vol) <= 1e-9) continue; // 未变:保留
// 量变或新增:apply(买入加仓/建仓、卖出减/清、失败即回滚)
this._applyOne({ orderId, code: order.code, direction: String(order.direction ?? ''), strategyId: sid, volume: vol });
}
return { segments: store.getOrderAttributions(orderId) };
});
}
/** 单段 apply(内部;含校验与 holding 状态判定) */
_applyOne({ orderId, code, direction, strategyId, volume }) {
const store = this.store;
const active = store._getActive(strategyId, code);
let holdingId;
if (direction === 'sell') {
if (!active) {
throw Object.assign(new Error('该策略无此持仓可减,关联失败: ' + code), { code: 'no-active-holding' });
}
if (Number(active.shares) + 1e-9 < volume) {
throw Object.assign(new Error('卖出段量超过策略持仓(可容纳 ' + active.shares + ',请求 ' + volume + ''), { code: 'segment-exceeds' });
}
const remain = Number(active.shares) - volume;
if (remain <= 1e-9) {
store.closeHolding(strategyId, code); // 归零清仓(closed_shares 快照由存储层记录)
} else {
store.reduceShares(strategyId, code, volume);
}
holdingId = active.holdingId;
} else {
// buy:有活动仓 → 加仓;无 → 建新仓
if (active) {
store.addShares(strategyId, code, volume);
holdingId = active.holdingId;
} else {
const created = store.openHolding(strategyId, code, volume);
holdingId = created.holdingId;
}
}
const seg = store.upsertOrderSegment({ orderId, strategyId, holdingId, code, direction, volume });
return { segment: seg, holdingId };
}
/** 内部撤段(已取段对象;事务内用) */
_revokeOneInternal(orderId, seg) {
const holding = this.store.getHoldingById(seg.holding_id);
const vol = Number(seg.volume);
if (seg.direction === 'sell') {
this._revokeSellSegment(orderId, seg.strategy_id, seg, holding, vol);
} else {
this._revokeBuySegment(orderId, seg.strategy_id, seg, holding, vol);
}
}
/** 撤卖出段 */
_revokeSellSegment(orderId, strategyId, seg, holding, vol) {
const store = this.store;
if (!holding) { store.deleteOrderSegment(orderId, strategyId); return; }
if (holding.closedAt == null && holding.voidAt == null) {
// holding 仍活动(该段只是减仓)→ 加回份额
store.addShares(strategyId, holding.code, vol);
store.deleteOrderSegment(orderId, strategyId);
return;
}
// holding 已清仓:仅当该段是清仓段(closed_shares == 段量)且其后无新仓 → 恢复活动
const closedShares = Number(holding.closedShares ?? 0);
if (holding.closedAt != null && holding.voidAt == null && Math.abs(closedShares - vol) <= 1e-9) {
const reopened = store.reopenHolding(holding.holdingId);
if (!reopened) {
throw Object.assign(new Error('无法恢复该持仓(同 code 已存在新活动仓或份额快照缺失),请先处理新仓'), { code: 'segment-order-conflict' });
}
store.deleteOrderSegment(orderId, strategyId);
return;
}
throw Object.assign(new Error('该卖出段不是最近一笔(请先撤销更晚的段)'), { code: 'segment-order-conflict' });
}
/** 撤买入段 */
_revokeBuySegment(orderId, strategyId, seg, holding, vol) {
const store = this.store;
if (!holding) { store.deleteOrderSegment(orderId, strategyId); return; }
if (holding.voidAt != null) {
// 已作废(之前撤销过建仓):仅删段
store.deleteOrderSegment(orderId, strategyId);
return;
}
if (holding.closedAt != null) {
throw Object.assign(new Error('该买入段对应持仓已清仓(请先处理卖出段或按逆序撤销)'), { code: 'segment-order-conflict' });
}
const shares = Number(holding.shares);
if (shares + 1e-9 < vol) {
throw Object.assign(new Error('该买入段之后已有卖出段作用于同一持仓,无法单独撤销(请先撤销更晚的段)'), { code: 'segment-order-conflict' });
}
const remain = shares - vol;
if (remain <= 1e-9) {
store.voidHolding(strategyId, holding.code); // 归零且无真实卖出 → 作废(非 closeHolding
} else {
store.reduceShares(strategyId, holding.code, vol);
}
store.deleteOrderSegment(orderId, strategyId);
}
}