feat(交易记录): SQLite 本地存储 + 手动归属(迭代 07,R-009)

- trade_orders + trade_fills 两表(委托/成交,holding_id/strategy_id 手动归属)
- TradeSync 定时同步(启动预热 + 60s UPSERT,不覆盖归属列)
- 手动设置归属(candidates 候选 + set-attribution 端点 + 前端归属下拉)
- 历史查询 trades/history + 策略过滤
- QMT code 归一化(补后缀)+ tradeDate 兜底 insertDate
This commit is contained in:
2026-09-02 01:20:50 +08:00
parent dd98b4d45b
commit b3526c7693
9 changed files with 933 additions and 89 deletions
+2 -2
View File
@@ -59,8 +59,8 @@ const HANDLERS = [
* @param {import('@deepseek-ai/cordis').Context} ctx * @param {import('@deepseek-ai/cordis').Context} ctx
* @param {object} runtime { manager, settings, dataSource, marketCache } * @param {object} runtime { manager, settings, dataSource, marketCache }
*/ */
export function registerApi(ctx, { manager, settings, dataSource, marketHub, marketFeed, qmtHealthMonitor }) { export function registerApi(ctx, { manager, settings, dataSource, marketHub, marketFeed, qmtHealthMonitor, storage, tradeSync }) {
const runtime = { ctx, manager, settings, dataSource, marketHub, marketFeed, qmtHealthMonitor }; const runtime = { ctx, manager, settings, dataSource, marketHub, marketFeed, qmtHealthMonitor, storage, tradeSync };
ctx.effect(() => ctx.webServer.register({ ctx.effect(() => ctx.webServer.register({
kind: 'prefix', kind: 'prefix',
+64 -3
View File
@@ -5,16 +5,24 @@
* orders → 当日委托(支持 code/status 过滤) * orders → 当日委托(支持 code/status 过滤)
* trades → 当日成交 * trades → 当日成交
* trading-dates → 交易日历(日期导航) * trading-dates → 交易日历(日期导航)
* trades/history → 本地 SQLite 历史查询(R-009:时间段/code/策略/方向过滤)
* trades/by-holding → 按 holding_id 查委托汇总(R-010:策略持仓行展开)
* *
* 数据流:前端轮询 → /odl/api/orders|trades → dataSource.getOrders/getTrades * 数据流:前端轮询 → /odl/api/orders|trades → dataSource.getOrders/getTrades
* (服务端直连 QMT Bridge REST,技术约束-003 * (服务端直连 QMT Bridge REST,技术约束-003
*/ */
import { getStrategies } from '../settings.js';
/** 交易记录端点方法表 */ /** 交易记录端点方法表 */
export const TRADE_METHODS = new Set([ export const TRADE_METHODS = new Set([
'orders', 'orders',
'trades', 'trades',
'trading-dates', '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 查委托汇总(策略持仓行展开)
]); ]);
/** /**
@@ -23,15 +31,31 @@ export const TRADE_METHODS = new Set([
* @param {object} args * @param {object} args
* @param {object} runtime { dataSource } * @param {object} runtime { dataSource }
*/ */
export async function handleTrade(method, args, { dataSource }) { export async function handleTrade(method, args, { dataSource, storage, settings }) {
switch (method) { switch (method) {
case 'orders': case 'orders': {
return await dataSource.getOrders({ const orders = await dataSource.getOrders({
code: args.code, code: args.code,
status: args.status, status: args.status,
start: args.start, start: args.start,
end: args.end, end: args.end,
}); });
// R-009:今日实时委托附加【持久化归属】(本地库中用户已设置的 strategy_id/holding_id
// 未设置为 null(前端显示「未关联」,用户可手动设置);候选由 orders/attribution-candidates 提供
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 }]));
return orders.map((o) => {
const a = attr.get(o.orderId) ?? { strategyId: null, holdingId: null };
return { ...o, strategyId: a.strategyId, holdingId: a.holdingId };
});
}
return orders;
}
case 'trades': case 'trades':
return await dataSource.getTrades({ return await dataSource.getTrades({
start: args.start, start: args.start,
@@ -42,6 +66,43 @@ export async function handleTrade(method, args, { dataSource }) {
start: args.start, start: args.start,
end: args.end, end: args.end,
}); });
case 'orders/attribution-candidates': {
// R-009:委托归属候选(该 code 当前持仓策略,Q3 全手动选)
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 }));
}
case 'orders/set-attribution': {
// R-009:手动设置委托归属(Q4 可随时改,以最终为准)
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 };
}
case 'trades/by-holding': {
// R-010:按 holding_id 查委托汇总(策略持仓行展开)
if (!storage) throw Object.assign(new Error('本地存储不可用'), { code: 'storage-unavailable' });
return await storage.getTradeOrdersByHolding(args.holdingId);
}
case 'trades/history': {
// R-009:本地 SQLite 历史查询(时间段/code/策略/方向过滤)
const opts = {
start: args.start,
end: args.end,
code: args.code,
strategyId: args.strategyId,
direction: args.direction,
};
if (!storage) {
throw Object.assign(new Error('本地存储不可用'), { code: 'storage-unavailable' });
}
const [orders, fills] = await Promise.all([
storage.getTradeOrderHistory(opts),
storage.getTradeFillHistory(opts),
]);
return { orders, fills };
}
default: default:
throw Object.assign(new Error('unknown trade method: ' + method), { code: 'not-found' }); throw Object.assign(new Error('unknown trade method: ' + method), { code: 'not-found' });
} }
+254 -72
View File
@@ -1,17 +1,21 @@
/** /**
* TradeRecordsTab —— 交易记录 tabR-007 v32026-09-01 * TradeRecordsTab —— 交易记录 tabR-007 v3 + R-009 v52026-09-01
* *
* 单表合并视图: * 单表合并视图:
* - 主行 = 一笔委托(按 m_strOrderSysID 唯一),聚合成交信息(成交量/加权均价/金额/手续费) * - 主行 = 一笔委托(按 m_strOrderSysID 唯一),聚合成交信息(成交量/加权均价/金额/手续费)
* - 点击展开/折叠 → 逐笔成交明细(成交时间/价/量/额/手续费/成交号) * - 点击展开/折叠 → 逐笔成交明细(成交时间/价/量/额/手续费/成交号)
* - 排序:默认按委托时间降序(最新在前);点击表头手动排序 * - 排序:默认按委托时间降序(最新在前);点击表头手动排序
* - 时间段查询:标题 + RangeSelector 同一行(今日/本周/上周快捷 + 手动起止日期) * - 时间段查询:标题 + RangeSelector 同一行(今日/本周/本月快捷 + 手动起止日期)
* - 轮询:仅「今日」范围 3-5s 自动刷新;其他范围一次性查询 * - 轮询:仅「今日」范围 3-5s 自动刷新;其他范围一次性查询
* *
* R-009(迭代 07):
* - 策略过滤下拉(全部 / 各策略 / 未关联):今日实时按归属过滤(前端),历史走服务端过滤
* - 历史范围从「接口开发中」占位切换为查本地 SQLitetrades/history
*
* 方向:红买绿卖(Q7 定稿);状态中文映射;未成交显示 — * 方向:红买绿卖(Q7 定稿);状态中文映射;未成交显示 —
*/ */
import { useState, useEffect, useCallback, useMemo } from 'react'; import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { useRpc } from './connection.jsx'; import { useRpc } from './connection.jsx';
import { LoadState } from './LoadState.jsx'; import { LoadState } from './LoadState.jsx';
import { RangeSelector } from './RangeSelector.jsx'; import { RangeSelector } from './RangeSelector.jsx';
@@ -79,8 +83,7 @@ function mergeOrdersAndTrades(orders, trades) {
const weightedPrice = totalVol > 0 const weightedPrice = totalVol > 0
? ts.reduce((s, t) => s + t.price * t.volume, 0) / totalVol ? ts.reduce((s, t) => s + t.price * t.volume, 0) / totalVol
: 0; : 0;
// 费用:按整笔委托合并计算(同一委托分笔成交只收一次佣金,calcOrderFees const rateWan = ts[0]?.commissionRateWan ?? 0;
const rateWan = ts[0]?.commissionRateWan ?? 0; // 佣金费率(万分之,来自成交,账号级一致)
const fees = calcOrderFeesLocal(ts, rateWan); const fees = calcOrderFeesLocal(ts, rateWan);
return { return {
...o, ...o,
@@ -102,10 +105,20 @@ export function TradeRecordsTab() {
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [range, setRange] = useState(null); // { start, end } YYYYMMDD const [range, setRange] = useState(null); // { start, end } YYYYMMDD
const [preset, setPreset] = useState('today'); // 快捷意图(单向,操作→状态→UI):'today'|'thisWeek'|'thisMonth'|null const [preset, setPreset] = useState('today'); // 快捷意图(单向):'today'|'thisWeek'|'thisMonth'|null
const [lastDate, setLastDate] = useState(null); // 最近交易日 const [lastDate, setLastDate] = useState(null); // 最近交易日
// 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 判重用)
// 同步 ref
useEffect(() => { attributionCandidatesRef.current = attributionCandidates; }, [attributionCandidates]);
// 默认加载「今日」(preset='today',单向意图):lastDate 到位且 range 未设置时自动设为今日 const call = useRpc();
// 默认加载「今日」(preset='today',单向意图)
useEffect(() => { useEffect(() => {
if (lastDate && !range) { if (lastDate && !range) {
setRange({ start: lastDate, end: lastDate }); setRange({ start: lastDate, end: lastDate });
@@ -114,20 +127,48 @@ export function TradeRecordsTab() {
const [expanded, setExpanded] = useState(new Set()); const [expanded, setExpanded] = useState(new Set());
const [sortKey, setSortKey] = useState('insertTime'); const [sortKey, setSortKey] = useState('insertTime');
const [sortDir, setSortDir] = useState('desc'); const [sortDir, setSortDir] = useState('desc');
const call = useRpc();
// 加载策略列表(过滤下拉用,R-009)
useEffect(() => {
call('one-divine-lot/strategies', { args: {} })
.then((res) => {
if (res?.ok && Array.isArray(res.value)) setStrategies(res.value);
})
.catch(() => {});
}, [call]);
// 是否「今日」意图(preset='today',单向)→ 轮询 // 是否「今日」意图(preset='today',单向)→ 轮询
const isTodayRange = preset === 'today'; const isTodayRange = preset === 'today';
// 是否历史范围(非今日意图)→ 本期占位(QMT Bridge 历史接口开发中 // 是否历史范围(非今日意图)→ 查本地 SQLiteR-009
const isHistoryRange = !!range && preset !== 'today'; const isHistoryRange = !!range && preset !== 'today';
const load = useCallback(async () => { const load = useCallback(async () => {
if (!range) return; if (!range) return;
if (isHistoryRange) { if (isHistoryRange) {
// 历史范围:本期占位(老师将完善 QMT Bridge 历史接口) // R-009 历史范围:查本地 SQLitetrades/history),不再占位
setOrders([]); setLoading(true);
setTrades([]); setError(null);
setLoading(false); try {
const res = await call('one-divine-lot/trades/history', {
args: {
start: range.start,
end: range.end,
strategyId: strategyFilter || undefined, // '__unassigned__' 由服务端处理(未关联)
},
});
if (res?.ok && res.value) {
setOrders(res.value.orders ?? []);
setTrades(res.value.fills ?? []);
} else {
throw new Error((res && res.error && res.error.message) || '历史记录加载失败');
}
} catch (e) {
setError(e.message);
setOrders([]);
setTrades([]);
} finally {
setLoading(false);
}
return; return;
} }
setLoading(true); setLoading(true);
@@ -148,7 +189,56 @@ export function TradeRecordsTab() {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [call, range, isHistoryRange]); }, [call, range, isHistoryRange, strategyFilter]);
// 拉取某 code 的归属候选(该 code 当前持仓策略;Q3 全手动选)
const loadCandidates = useCallback(async (code) => {
if (!code) return;
// 已有缓存不重复请求
if (attributionCandidatesRef.current[code]) return;
try {
const res = await call('one-divine-lot/orders/attribution-candidates', { args: { code } });
if (res?.ok && Array.isArray(res.value)) {
setAttributionCandidates((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);
}
}, [call, load]);
// 归属候选自动预载:数据加载后,为所有委托 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);
}
}, [orders, loadCandidates]);
// 时间段变化 → 重新加载(展开重置) // 时间段变化 → 重新加载(展开重置)
useEffect(() => { useEffect(() => {
@@ -171,9 +261,15 @@ export function TradeRecordsTab() {
return () => clearInterval(timer); return () => clearInterval(timer);
}, [call, isTodayRange, range]); }, [call, isTodayRange, range]);
// 合并 + 排序 // 合并 + 策略过滤(今日实时按归属过滤前端;历史已在服务端过滤)+ 排序
const rows = useMemo(() => { const rows = useMemo(() => {
const merged = mergeOrdersAndTrades(orders ?? [], trades ?? []); let merged = mergeOrdersAndTrades(orders ?? [], trades ?? []);
if (strategyFilter) {
merged = merged.filter((o) => {
if (strategyFilter === '__unassigned__') return !o.strategyId;
return o.strategyId === strategyFilter;
});
}
const dir = sortDir === 'asc' ? 1 : -1; const dir = sortDir === 'asc' ? 1 : -1;
return merged.slice().sort((a, b) => { return merged.slice().sort((a, b) => {
const va = a[sortKey]; const va = a[sortKey];
@@ -181,7 +277,7 @@ export function TradeRecordsTab() {
if (typeof va === 'string' && typeof vb === 'string') return va.localeCompare(vb) * dir; if (typeof va === 'string' && typeof vb === 'string') return va.localeCompare(vb) * dir;
return (va - vb) * dir; return (va - vb) * dir;
}); });
}, [orders, trades, sortKey, sortDir]); }, [orders, trades, sortKey, sortDir, strategyFilter]);
const toggleSort = (key) => { const toggleSort = (key) => {
if (sortKey === key) { if (sortKey === key) {
@@ -204,69 +300,105 @@ export function TradeRecordsTab() {
const arrow = (key) => (sortKey === key ? (sortDir === 'desc' ? ' ▼' : ' ▲') : ''); const arrow = (key) => (sortKey === key ? (sortDir === 'desc' ? ' ▼' : ' ▲') : '');
return ( return (
<div style={{ padding: 16, fontFamily: 'system-ui' }}> <div className="odl-root" style={{ padding: 16, fontFamily: 'system-ui' }}>
{/* 标题 + 时间段选择同一行(2026-09-01 老师反馈 */} {/* 标题 + 策略过滤 + 时间段选择同一行(R-007/R-009 */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12, flexWrap: 'wrap', gap: 12 }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12, flexWrap: 'wrap', gap: 12 }}>
<h3 style={{ margin: 0 }}>交易记录</h3> <h3 style={{ margin: 0 }}>交易记录</h3>
<RangeSelector preset={preset} onPresetChange={setPreset} range={range} onChange={setRange} onLastDate={setLastDate} /> <div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
</div> {/* R-009:策略过滤下拉(全部/各策略/未关联) */}
<select
{isHistoryRange ? ( value={strategyFilter}
<div style={{ padding: 32, textAlign: 'center', color: '#999' }}> onChange={(e) => setStrategyFilter(e.target.value)}
<div style={{ fontSize: 15, marginBottom: 8 }}>{range.start} ~ {range.end}自定义时间段</div> style={{ fontSize: 12, padding: '2px 6px', border: '1px solid #ccc', borderRadius: 4, color: '#555' }}
<div style={{ fontSize: 13 }}>历史数据接口开发中敬请期待老师完善 QMT Bridge 历史接口后接入</div> title="按策略过滤"
>
<option value="">全部策略</option>
<option value="__unassigned__">未关联</option>
{strategies.map((s) => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</select>
<RangeSelector preset={preset} onPresetChange={setPreset} range={range} onChange={setRange} onLastDate={setLastDate} />
</div>
</div>
{attributionSaved && (
<div style={{ marginBottom: 8, fontSize: 12, color: attributionSaved.startsWith('保存失败') ? '#c62828' : '#1b5e20', background: attributionSaved.startsWith('保存失败') ? '#fdecea' : '#e8f5e9', padding: '4px 8px', borderRadius: 4 }}>
{attributionSaved}
</div> </div>
) : (
<LoadState loading={loading && !orders} error={error} onRetry={load} autoRetry={2}>
{!orders?.length ? (
<div style={{ padding: 32, textAlign: 'center', color: '#999' }}>该时间段暂无委托记录</div>
) : (
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr style={{ textAlign: 'left', borderBottom: '2px solid #ddd' }}>
<th style={thStyle}> </th>
<th onClick={() => toggleSort('insertTime')} style={thClickStyle}>时间{arrow('insertTime')}</th>
<th onClick={() => toggleSort('code')} style={thClickStyle}>代码{arrow('code')}</th>
<th style={thStyle}>名称</th>
<th onClick={() => toggleSort('direction')} style={thClickStyle}>方向{arrow('direction')}</th>
<th onClick={() => toggleSort('status')} style={thClickStyle}>状态{arrow('status')}</th>
<th onClick={() => toggleSort('orderVolume')} style={thClickStyle} align="right">委托量{arrow('orderVolume')}</th>
<th onClick={() => toggleSort('aggVolume')} style={thClickStyle} align="right">成交量{arrow('aggVolume')}</th>
<th onClick={() => toggleSort('limitPrice')} style={thClickStyle} align="right">委托价{arrow('limitPrice')}</th>
<th align="right">成交均价</th>
<th onClick={() => toggleSort('aggAmount')} style={thClickStyle} align="right">成交额{arrow('aggAmount')}</th>
<th align="right">总费用</th>
<th align="right">佣金</th>
<th align="right">印花税</th>
<th align="right">过户费</th>
</tr>
</thead>
<tbody>
{rows.map((o) => (
<OrderRows
key={o.orderId}
order={o}
expanded={expanded.has(o.orderId)}
onToggle={() => toggleExpand(o.orderId)}
/>
))}
</tbody>
</table>
)}
<div style={{ marginTop: 8, fontSize: 12, color: '#999' }}>
<span style={{ color: '#d32f2f' }}> </span>
<span style={{ marginLeft: 8, color: '#2e7d32' }}> </span>
<span style={{ marginLeft: 12 }}>点击委托行展开成交明细 · 点击表头排序</span>
</div>
</LoadState>
)} )}
<LoadState loading={loading && !orders} error={error} onRetry={load} autoRetry={2}>
{!orders?.length ? (
<div style={{ padding: 32, textAlign: 'center', color: '#999' }}>
<div style={{ fontSize: 15, marginBottom: 8 }}>
{range ? range.start + ' ~ ' + range.end + (isHistoryRange ? '(本地历史)' : '') : ''}
</div>
<div style={{ fontSize: 13 }}>
{isHistoryRange ? '该时间段暂无本地交易记录(数据自插件运行起逐日积累)' : '该时间段暂无委托记录'}
</div>
</div>
) : (
<TradeTable rows={rows} toggleSort={toggleSort} sortKey={sortKey} sortDir={sortDir} arrow={arrow} toggleExpand={toggleExpand} expanded={expanded} attributionCandidates={attributionCandidates} loadCandidates={loadCandidates} setAttribution={setAttribution} />
)}
<div style={{ marginTop: 8, fontSize: 12, color: '#999' }}>
<span style={{ color: '#d32f2f' }}> </span>
<span style={{ marginLeft: 8, color: '#2e7d32' }}> </span>
<span style={{ marginLeft: 12 }}>点击委托行展开成交明细 · 点击表头排序</span>
{isHistoryRange && <span style={{ marginLeft: 12 }}>· 历史数据 = 本地积累</span>}
</div>
</LoadState>
</div> </div>
); );
} }
/** 委托主行 + 展开明细 */ /** 交易记录表格(主行 + 展开明细 + R-009 手动归属列) */
function OrderRows({ order, expanded, onToggle }) { function TradeTable({ rows, toggleSort, sortKey, sortDir, arrow, toggleExpand, expanded, attributionCandidates, loadCandidates, setAttribution }) {
return (
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr style={{ textAlign: 'left', borderBottom: '2px solid #ddd' }}>
<th style={thStyle}> </th>
<th onClick={() => toggleSort('insertTime')} style={thClickStyle}>时间{arrow('insertTime')}</th>
<th onClick={() => toggleSort('code')} style={thClickStyle}>代码{arrow('code')}</th>
<th style={thStyle}>名称</th>
<th onClick={() => toggleSort('direction')} style={thClickStyle}>方向{arrow('direction')}</th>
<th onClick={() => toggleSort('status')} style={thClickStyle}>状态{arrow('status')}</th>
<th onClick={() => toggleSort('orderVolume')} style={thClickStyle} align="right">委托量{arrow('orderVolume')}</th>
<th onClick={() => toggleSort('aggVolume')} style={thClickStyle} align="right">成交量{arrow('aggVolume')}</th>
<th onClick={() => toggleSort('limitPrice')} style={thClickStyle} align="right">委托价{arrow('limitPrice')}</th>
<th align="right">成交均价</th>
<th onClick={() => toggleSort('aggAmount')} style={thClickStyle} align="right">成交额{arrow('aggAmount')}</th>
<th align="right">总费用</th>
<th align="right">佣金</th>
<th align="right">印花税</th>
<th align="right">过户费</th>
<th style={thStyle}>归属</th>
</tr>
</thead>
<tbody>
{rows.map((o) => (
<OrderRows
key={o.orderId}
order={o}
expanded={expanded.has(o.orderId)}
onToggle={() => toggleExpand(o.orderId)}
attributionCandidates={attributionCandidates[o.code] ?? []}
onLoadCandidates={() => loadCandidates(o.code)}
setAttribution={setAttribution}
/>
))}
</tbody>
</table>
);
}
/** 委托主行 + 展开明细(含 R-009 手动归属下拉) */
function OrderRows({ order, expanded, onToggle, attributionCandidates, onLoadCandidates, setAttribution }) {
const hasTrades = order.trades.length > 0; const hasTrades = order.trades.length > 0;
const strategyName = (sid) => {
if (!sid) return '未关联';
return order.strategyName || sid;
};
return ( return (
<> <>
<tr <tr
@@ -319,10 +451,18 @@ function OrderRows({ order, expanded, onToggle }) {
<td style={{ ...tdStyle, textAlign: 'right' }}>{hasTrades ? fmtNum(order.aggCommission, 2) : '—'}</td> <td style={{ ...tdStyle, textAlign: 'right' }}>{hasTrades ? fmtNum(order.aggCommission, 2) : '—'}</td>
<td style={{ ...tdStyle, textAlign: 'right' }}>{hasTrades ? fmtNum(order.aggStampTax, 2) : '—'}</td> <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, textAlign: 'right' }}>{hasTrades ? fmtNum(order.aggTransferFee, 2) : '—'}</td>
<td style={{ ...tdStyle }} onClick={(e) => e.stopPropagation()}>
<AttributionSelect
order={order}
candidates={attributionCandidates}
onLoadCandidates={onLoadCandidates}
setAttribution={setAttribution}
/>
</td>
</tr> </tr>
{expanded && hasTrades && ( {expanded && hasTrades && (
<tr style={{ borderBottom: '1px solid #eee', background: '#fafafa' }}> <tr style={{ borderBottom: '1px solid #eee', background: '#fafafa' }}>
<td colSpan={15} style={{ padding: '4px 8px 12px' }}> <td colSpan={16} style={{ padding: '4px 8px 12px' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12, marginTop: 4 }}> <table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12, marginTop: 4 }}>
<thead> <thead>
<tr style={{ borderBottom: '1px solid #ddd', color: '#666' }}> <tr style={{ borderBottom: '1px solid #ddd', color: '#666' }}>
@@ -352,6 +492,48 @@ function OrderRows({ order, expanded, onToggle }) {
); );
} }
/** 委托归属选择下拉(R-009,Q3 全手动选;Q4 可随时改)
* 候选 = 该 code 当前持仓策略(orders/attribution-candidates);
* 未设置显示「未关联」,用户点选后持久化(orders/set-attribution)。
*/
function AttributionSelect({ order, candidates, onLoadCandidates, setAttribution }) {
const current = order.strategyId || '';
const handleSelect = (ev) => {
const val = ev.target.value;
if (val === current) return;
if (val === '__unassigned__') {
setAttribution(order.orderId, order.code, null, null);
return;
}
const cand = (candidates ?? []).find((c) => c.strategyId === val);
setAttribution(order.orderId, order.code, val, cand ? cand.holdingId : null);
};
return (
<select
value={current || '__unassigned__'}
onChange={handleSelect}
style={{
fontSize: 12,
padding: '2px 4px',
border: '1px solid ' + (current ? '#2e7d32' : '#ccc'),
borderRadius: 4,
background: current ? '#e8f5e9' : '#fff',
color: current ? '#1b5e20' : '#999',
maxWidth: 120,
}}
title="设置该委托的策略归属(可随时修改)"
>
<option value="__unassigned__">未关联</option>
{(candidates ?? []).map((c) => (
<option key={c.holdingId} value={c.strategyId}>{c.strategyName || c.strategyId}</option>
))}
</select>
);
}
const thStyle = { padding: '8px 6px', lineHeight: '24px', whiteSpace: 'nowrap' }; const thStyle = { padding: '8px 6px', lineHeight: '24px', whiteSpace: 'nowrap' };
const thClickStyle = { ...thStyle, cursor: 'pointer', userSelect: 'none' }; const thClickStyle = { ...thStyle, cursor: 'pointer', userSelect: 'none' };
const tdStyle = { padding: '8px 6px', lineHeight: '24px', whiteSpace: 'nowrap', fontVariantNumeric: 'tabular-nums' }; const tdStyle = { padding: '8px 6px', lineHeight: '24px', whiteSpace: 'nowrap', fontVariantNumeric: 'tabular-nums' };
+51 -1
View File
@@ -156,8 +156,58 @@ export class DataStore {
return this.sqlite.setMarketQuotes(quotes); return this.sqlite.setMarketQuotes(quotes);
} }
// ===== 交易记录(R-009 迭代 07,委托 SqliteStore=====
/** UPSERT 委托(幂等) */
async upsertTradeOrders(orders) {
await this._ensure();
return this.sqlite.upsertOrders(orders);
}
/** UPSERT 成交(幂等) */
async upsertTradeFills(fills) {
await this._ensure();
return this.sqlite.upsertFills(fills);
}
/** 本地历史委托查询(含策略归属推导) */
async getTradeOrderHistory(opts) {
await this._ensure();
return this.sqlite.getOrderHistory(opts);
}
/** 给任意委托列表附加策略/持仓归属(今日实时委托用) */
async attachTradeStrategyAttribution(orders) {
await this._ensure();
return this.sqlite.attachStrategyAttribution(orders);
}
/** 本地历史成交查询(含策略归属推导) */
async getTradeFillHistory(opts) {
await this._ensure();
return this.sqlite.getFillHistory(opts);
}
/** 设置委托归属(手动,Q4 可改) */
async setTradeOrderAttribution(orderId, strategyId, holdingId) {
await this._ensure();
return this.sqlite.setOrderAttribution(orderId, strategyId, holdingId);
}
/** 委托归属候选列表(该 code 当前持仓策略,Q3 全手动选) */
async getTradeAttributionCandidates(code) {
await this._ensure();
return this.sqlite.getAttributionCandidates(code);
}
/** 按 holding_id 查询委托汇总(R-010 */
async getTradeOrdersByHolding(holdingId) {
await this._ensure();
return this.sqlite.getOrdersByHolding(holdingId);
}
/** 关闭(插件释放时) */ /** 关闭(插件释放时) */
close() { close() {
this.sqlite.close(); this.sqlite.close();
} }
} }
+27 -4
View File
@@ -69,17 +69,40 @@ export class PositionManager {
return { ...(map[code] ?? {}) }; return { ...(map[code] ?? {}) };
} }
/** 某策略下的持仓(份额 > 0 的标的) */ /** 某策略下的持仓(份额 > 0 的标的R-010 附加 holding_id + 成本价/最后一笔成交价 */
async getStrategyPositions(strategyId) { async getStrategyPositions(strategyId) {
const [positions, dataset] = await Promise.all([ const [positions, dataset, holdings] = await Promise.all([
this.getAllPositions(), this.getAllPositions(),
this.storage.getDataset(strategyId), this.storage.getDataset(strategyId),
this.storage.getCurrentHoldings(strategyId), // 含 holding_id(同策略同 code 当前持仓唯一)
]); ]);
const shareMap = new Map(dataset.map((d) => [d.code, d.shares])); const shareMap = new Map(dataset.map((d) => [d.code, d.shares]));
const holdingMap = new Map(holdings.map((h) => [h.code, h.holdingId]));
// R-010 Q4:批量取各 holding 的关联委托(查最后一笔成交价)
const holdingIds = [...new Set(holdings.map((h) => h.holdingId).filter((x) => x != null))];
const tradesByHolding = new Map();
if (holdingIds.length > 0) {
for (const hid of holdingIds) {
const orders = await this.storage.getTradeOrdersByHolding(hid);
tradesByHolding.set(hid, orders);
}
}
return positions return positions
.map((p) => { .map((p) => {
const shares = shareMap.get(p.code) ?? 0; const shares = shareMap.get(p.code) ?? 0;
return shares > 0 ? { ...p, shares } : null; if (shares <= 0) return null;
const holdingId = holdingMap.get(p.code) ?? null;
// 成本价 = QMT avgPrice(纸面数据已有)
const avgPrice = +(p.avgPrice ?? 0);
// 最后一笔成交价:关联委托按时间取最新一笔【有实际成交】(tradedVolume > 0)的 tradedPrice;无则 = 成本价
let lastTradePrice = avgPrice;
const orders = holdingId != null ? (tradesByHolding.get(holdingId) ?? []) : [];
const filled = orders.filter((o) => (o.tradedVolume ?? 0) > 0 && (o.tradedPrice ?? 0) > 0);
if (filled.length > 0) {
// getOrdersByHolding 已按 insert_ts DESC,第一条即最新
lastTradePrice = +filled[0].tradedPrice;
}
return { ...p, shares, holdingId, avgPrice, lastTradePrice };
}) })
.filter(Boolean); .filter(Boolean);
} }
@@ -246,4 +269,4 @@ export class PositionManager {
} }
return before.map((h) => h.code); return before.map((h) => h.code);
} }
} }
+24 -3
View File
@@ -163,12 +163,32 @@ export class QmtBridgeRestDataSource {
}; };
} }
/** 委托字段映射:m_ 原始字段 → 语义字段(R-007 2.1/2.2 */ /**
* 归一化证券代码:QMT 委托/成交返回无后缀(如 001330),持仓体系带后缀(001330.SZ)。
* 规则(与持仓 mapPosition 一致):6 位数字 + 交易所后缀(SH/SZ/BJ);已有后缀原样保留。
*/
normalizeInstrumentCode(raw, exchange) {
let code = String(raw ?? '').trim().toUpperCase();
if (!code) return '';
if (/^\d{6}\.(SH|SZ|BJ)$/.test(code)) return code; // 已带后缀
if (/^\d{6}$/.test(code)) {
const ex = String(exchange ?? '').toUpperCase();
if (ex === 'SH' || ex === 'SZ' || ex === 'BJ') return code + '.' + ex;
// 无交易所信息时按规则推断:6/9 开头沪市,0/3 开头深市
const head = code[0];
if (head === '6' || head === '9') return code + '.SH';
return code + '.SZ';
}
return code;
}
/** 委托字段映射:m_ 原始字段 → 语义字段(R-007 2.1/2.2R-009 迭代 07 补 tradeDate + code 归一化) */
mapOrder(o) { mapOrder(o) {
const direction = o.m_nOffsetFlag === 48 ? 'buy' : o.m_nOffsetFlag === 49 ? 'sell' : ''; const direction = o.m_nOffsetFlag === 48 ? 'buy' : o.m_nOffsetFlag === 49 ? 'sell' : '';
const code = this.normalizeInstrumentCode(o.m_strInstrumentID, o.m_strExchangeID);
return { return {
orderId: o.m_strOrderSysID ?? '', orderId: o.m_strOrderSysID ?? '',
code: o.m_strInstrumentID ?? '', code,
name: o.m_strInstrumentName ?? '', name: o.m_strInstrumentName ?? '',
exchange: o.m_strExchangeID ?? '', exchange: o.m_strExchangeID ?? '',
direction, // 'buy' | 'sell' | '' direction, // 'buy' | 'sell' | ''
@@ -181,6 +201,7 @@ export class QmtBridgeRestDataSource {
limitPrice: +(o.m_dLimitPrice ?? 0), limitPrice: +(o.m_dLimitPrice ?? 0),
tradedPrice: +(o.m_dTradedPrice ?? 0), tradedPrice: +(o.m_dTradedPrice ?? 0),
amount: +(o.m_dTradeAmount ?? 0), amount: +(o.m_dTradeAmount ?? 0),
tradeDate: o.m_strTradeDate ?? o.m_strInsertDate ?? '', // 交易日:委托无独立字段,用 insertDate 兜底
insertDate: o.m_strInsertDate ?? '', insertDate: o.m_strInsertDate ?? '',
insertTime: o.m_strInsertTime ?? '', insertTime: o.m_strInsertTime ?? '',
cancelInfo: o.m_strCancelInfo ?? '', cancelInfo: o.m_strCancelInfo ?? '',
@@ -227,7 +248,7 @@ export class QmtBridgeRestDataSource {
return { return {
tradeId: t.m_strTradeID ?? '', tradeId: t.m_strTradeID ?? '',
orderId: t.m_strOrderSysID ?? '', // 关联委托 orderId: t.m_strOrderSysID ?? '', // 关联委托
code: t.m_strInstrumentID ?? '', code: this.normalizeInstrumentCode(t.m_strInstrumentID, t.m_strExchangeID),
name: t.m_strInstrumentName ?? '', name: t.m_strInstrumentName ?? '',
exchange: t.m_strExchangeID ?? '', exchange: t.m_strExchangeID ?? '',
direction, // 'buy' | 'sell' | '' direction, // 'buy' | 'sell' | ''
+414 -1
View File
@@ -7,6 +7,8 @@
* 表结构(技术实现方案): * 表结构(技术实现方案):
* - strategy_holdings 策略持仓生命周期表(一笔 = 一次「建仓→清仓」,历史保留) * - strategy_holdings 策略持仓生命周期表(一笔 = 一次「建仓→清仓」,历史保留)
* - market_quotes_cache 行情快照缓存(code 维度一行一码,只存 UI 消费的 lastPrice/lastClose * - market_quotes_cache 行情快照缓存(code 维度一行一码,只存 UI 消费的 lastPrice/lastClose
* - trade_orders 交易委托(R-009:委托主行,order_id 主键,UPSERT 幂等,零冗余)
* - trade_fills 交易成交(R-009:成交明细,trade_id 主键,order_id 关联委托)
* *
* 方法集(替代 DataStore 原 getDataset/setDataset/removeDataset/getAllDatasets 整体读写): * 方法集(替代 DataStore 原 getDataset/setDataset/removeDataset/getAllDatasets 整体读写):
* 持仓生命周期:openHolding / addShares / reduceShares / closeHolding * 持仓生命周期:openHolding / addShares / reduceShares / closeHolding
@@ -47,6 +49,53 @@ CREATE TABLE IF NOT EXISTS market_quotes_cache (
last_close REAL NOT NULL, last_close REAL NOT NULL,
updated_at INTEGER NOT NULL DEFAULT 0 updated_at INTEGER NOT NULL DEFAULT 0
); );
-- 交易委托(R-009 迭代 07:委托主行;order_id 唯一,UPSERT 幂等;零冗余 strategy_id/holding_id
CREATE TABLE IF NOT EXISTS trade_orders (
order_id TEXT PRIMARY KEY,
trade_date TEXT NOT NULL,
code TEXT NOT NULL,
name TEXT NOT NULL DEFAULT '',
exchange TEXT NOT NULL DEFAULT '',
direction TEXT NOT NULL DEFAULT '',
direction_code INTEGER,
opt_name TEXT NOT NULL DEFAULT '',
status INTEGER,
order_volume REAL NOT NULL DEFAULT 0,
traded_volume REAL NOT NULL DEFAULT 0,
limit_price REAL NOT NULL DEFAULT 0,
traded_price REAL NOT NULL DEFAULT 0,
amount REAL NOT NULL DEFAULT 0,
insert_date TEXT NOT NULL DEFAULT '',
insert_time TEXT NOT NULL DEFAULT '',
insert_ts INTEGER NOT NULL,
cancel_info TEXT NOT NULL DEFAULT '',
error_msg TEXT NOT NULL DEFAULT '',
strategy_id TEXT, -- 用户手动设置的策略归属(Q1 冗余)
holding_id INTEGER, -- 用户手动设置的持仓归属(Q1 冗余)
fetched_at INTEGER NOT NULL
);
-- 交易成交(trade_id 唯一,order_id 外键关联委托;零冗余 strategy_id/holding_id
CREATE TABLE IF NOT EXISTS trade_fills (
trade_id TEXT PRIMARY KEY,
order_id TEXT NOT NULL,
trade_date TEXT NOT NULL,
code TEXT NOT NULL,
name TEXT NOT NULL DEFAULT '',
exchange TEXT NOT NULL DEFAULT '',
direction TEXT NOT NULL DEFAULT '',
direction_code INTEGER,
opt_name TEXT NOT NULL DEFAULT '',
price REAL NOT NULL DEFAULT 0,
volume REAL NOT NULL DEFAULT 0,
amount REAL NOT NULL DEFAULT 0,
commission_rate_wan REAL NOT NULL DEFAULT 0,
trade_time TEXT NOT NULL DEFAULT '',
fetched_at INTEGER NOT NULL
);
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);
`; `;
export class SqliteStore { export class SqliteStore {
@@ -73,9 +122,35 @@ export class SqliteStore {
mkdirSync(this.dataDir, { recursive: true }); mkdirSync(this.dataDir, { recursive: true });
this.db = new DatabaseSync(this.dbPath); this.db = new DatabaseSync(this.dbPath);
this.db.exec(SCHEMA_SQL); this.db.exec(SCHEMA_SQL);
// 存量库迁移:trade_orders 补手动归属列(R-009 二次定稿;幂等)
this._ensureTradeAttributionColumns();
return this.db; return this.db;
} }
/**
* 确保 trade_orders 存在 strategy_id / holding_id 列(旧库 ALTER 补列,幂等)。
* 注意:只读打开(外部脚本、插件持锁时)会抛「readonly database」——此处容忍,
* 真实迁移由插件进程(持锁方)启动时执行;外部脚本只读验证不应因此崩溃。
*/
_ensureTradeAttributionColumns() {
try {
const cols = new Set(this.db.prepare('PRAGMA table_info(trade_orders)').all().map((c) => c.name));
if (!cols.has('strategy_id')) {
this.db.exec('ALTER TABLE trade_orders ADD COLUMN strategy_id TEXT');
}
if (!cols.has('holding_id')) {
this.db.exec('ALTER TABLE trade_orders ADD COLUMN holding_id INTEGER');
}
} catch (err) {
// 只读场景(如外部验证脚本在插件持锁时打开)→ 忽略,列迁移由插件进程完成
if (String(err?.message ?? '').includes('readonly')) {
console.warn('[one-divine-lot] trade_orders 归属列迁移跳过(只读连接,插件持锁中)');
} else {
throw err;
}
}
}
/** 关闭连接 */ /** 关闭连接 */
close() { close() {
if (this.db) { if (this.db) {
@@ -344,4 +419,342 @@ export class SqliteStore {
updatedAt: row.updated_at, updatedAt: row.updated_at,
}; };
} }
}
// ===== 交易记录(trade_orders / trade_fillsR-009 迭代 07=====
/**
* UPSERT 委托(幂等):order_id 已存在则覆盖(状态/成交量等当日流转更新)
* @param {Array} orders 语义化委托列表(QmtBridgeRestDataSource.mapOrder 输出)
* @returns {number} 写入条数
*/
upsertOrders(orders) {
this.init();
const list = Array.isArray(orders) ? orders : [];
if (list.length === 0) return 0;
const stmt = this.db.prepare(`
INSERT INTO trade_orders (
order_id, trade_date, code, name, exchange, direction, direction_code, opt_name,
status, order_volume, traded_volume, limit_price, traded_price, amount,
insert_date, insert_time, insert_ts, cancel_info, error_msg, fetched_at
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(order_id) DO UPDATE SET
trade_date=excluded.trade_date, code=excluded.code, name=excluded.name,
exchange=excluded.exchange, direction=excluded.direction,
direction_code=excluded.direction_code, opt_name=excluded.opt_name,
status=excluded.status, order_volume=excluded.order_volume,
traded_volume=excluded.traded_volume, limit_price=excluded.limit_price,
traded_price=excluded.traded_price, amount=excluded.amount,
insert_date=excluded.insert_date, insert_time=excluded.insert_time,
insert_ts=excluded.insert_ts, cancel_info=excluded.cancel_info,
error_msg=excluded.error_msg, fetched_at=excluded.fetched_at
-- 注意:不覆盖 strategy_id / holding_id(手动归属为插件逻辑,Q2 老师确认)
`);
const now = Date.now();
this.db.exec('BEGIN');
try {
for (const o of list) {
if (!o || !o.orderId) continue;
const ts = this._orderInsertTs(o);
stmt.run(
o.orderId, o.tradeDate ?? '', o.code ?? '', o.name ?? '', o.exchange ?? '',
o.direction ?? '', o.directionCode ?? null, o.optName ?? '', o.status ?? null,
Number(o.orderVolume ?? 0), Number(o.tradedVolume ?? 0),
Number(o.limitPrice ?? 0), Number(o.tradedPrice ?? 0), Number(o.amount ?? 0),
o.insertDate ?? '', o.insertTime ?? '', ts, o.cancelInfo ?? '', o.errorMsg ?? '', now
);
}
this.db.exec('COMMIT');
} catch (err) {
this.db.exec('ROLLBACK');
throw err;
}
return list.length;
}
/**
* UPSERT 成交(幂等):trade_id 已存在则覆盖
* @param {Array} fills 语义化成交列表(QmtBridgeRestDataSource.mapTrade 输出)
* @returns {number} 写入条数
*/
upsertFills(fills) {
this.init();
const list = Array.isArray(fills) ? fills : [];
if (list.length === 0) return 0;
const stmt = this.db.prepare(`
INSERT INTO trade_fills (
trade_id, order_id, trade_date, code, name, exchange, direction, direction_code,
opt_name, price, volume, amount, commission_rate_wan, trade_time, fetched_at
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(trade_id) DO UPDATE SET
order_id=excluded.order_id, trade_date=excluded.trade_date, code=excluded.code,
name=excluded.name, exchange=excluded.exchange, direction=excluded.direction,
direction_code=excluded.direction_code, opt_name=excluded.opt_name,
price=excluded.price, volume=excluded.volume, amount=excluded.amount,
commission_rate_wan=excluded.commission_rate_wan, trade_time=excluded.trade_time,
fetched_at=excluded.fetched_at
`);
const now = Date.now();
this.db.exec('BEGIN');
try {
for (const t of list) {
if (!t || !t.tradeId) continue;
stmt.run(
t.tradeId, t.orderId ?? '', t.tradeDate ?? '', t.code ?? '', t.name ?? '',
t.exchange ?? '', t.direction ?? '', t.directionCode ?? null, t.optName ?? '',
Number(t.price ?? 0), Number(t.volume ?? 0), Number(t.amount ?? 0),
Number(t.commissionRateWan ?? 0), t.tradeTime ?? '', now
);
}
this.db.exec('COMMIT');
} catch (err) {
this.db.exec('ROLLBACK');
throw err;
}
return list.length;
}
/**
* 委托时间 → 毫秒时间戳(insert_date YYYYMMDD + insert_time HHMMSS
* 时间字段缺失时回退 fetched_at(同步时间),保证 join 窗口可用
*/
_orderInsertTs(o) {
const d = String(o.insertDate ?? '');
const t = String(o.insertTime ?? '');
if (d.length === 8 && t.length === 6) {
const y = Number(d.slice(0, 4));
const m = Number(d.slice(4, 6)) - 1;
const day = Number(d.slice(6, 8));
const hh = Number(t.slice(0, 2));
const mm = Number(t.slice(2, 4));
const ss = Number(t.slice(4, 6));
// 用本地时间构造(与 strategy_holdings.created_at 的 Date.now() 同基准)
const ts = new Date(y, m, day, hh, mm, ss).getTime();
if (Number.isFinite(ts) && ts > 0) return ts;
}
// 回退:以「当日」估算(用于极端缺字段场景)
const dateOnly = Date.parse(String(d).replace(/^(\d{4})(\d{2})(\d{2})$/, '$1-$2-$3'));
return Number.isFinite(dateOnly) ? dateOnly : Date.now();
}
/**
* 委托 → 策略归属推导(外键链:委托时间 join strategy_holdings 生命周期窗口)
* 规则(Q3 老师定稿):created_at ≤ insert_ts < closed_atclosed_at NULL=当前持仓);一码多策略取份额最大;无命中=null
* @param {Array} orders 委托行(含 insert_ts 合成)
* @returns {Map<string, {strategyId: string|null, holdingId: number|null}>} order_id → 归属
*/
_resolveStrategyAttribution(orders) {
this.init();
const out = new Map();
const list = Array.isArray(orders) ? orders.filter((o) => o && o.orderId) : [];
if (list.length === 0) return out;
// 收集涉及的 code + 时间窗
const codes = [...new Set(list.map((o) => o.code).filter(Boolean))];
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 FROM strategy_holdings WHERE code IN (' + placeholders + ')'
).all(...codes);
const holdingsByCode = new Map();
for (const h of rows) {
if (!holdingsByCode.has(h.code)) holdingsByCode.set(h.code, []);
holdingsByCode.get(h.code).push(h);
}
for (const o of list) {
const ts = this._orderInsertTs(o);
// 方案 A2026-09-01 老师确认):当前持仓(closed_at IS NULL)只做同 code 匹配
// created_at 可能是迁移时间戳,不要求 ≤ 委托时间;现在持有=当日交易可归);
// 已清仓(closed_at 非空)才按时间窗口(created_at ≤ t < closed_at)判断。
const candidates = (holdingsByCode.get(o.code) ?? []).filter((h) => {
if (h.closed_at == null) return true; // 当前持仓:同 code 即匹配
if (h.created_at > ts) return false;
if (ts >= h.closed_at) return false;
return true;
});
if (candidates.length === 0) {
out.set(o.orderId, { strategyId: null, holdingId: null });
continue;
}
// 一码多策略消歧:取份额最大持仓(确定性规则)
candidates.sort((a, b) => (b.shares ?? 0) - (a.shares ?? 0));
const pick = candidates[0];
out.set(o.orderId, { strategyId: pick.strategy_id, holdingId: pick.holding_id });
}
return out;
}
/**
* 给任意委托列表附加策略/持仓归属(今日实时委托用;只读 strategy_holdings,不写库)
* @param {Array} orders 语义化委托列表
* @returns {Array} 每项附加 strategyId/holdingId
*/
attachStrategyAttribution(orders) {
const list = Array.isArray(orders) ? orders : [];
const attribution = this._resolveStrategyAttribution(list);
return list.map((o) => {
const a = attribution.get(o?.orderId) ?? { strategyId: null, holdingId: null };
return { ...o, strategyId: a.strategyId, holdingId: a.holdingId };
});
}
/**
* 本地历史委托查询(R-009 Q4):按时间段/code/策略/方向过滤
* @param {object} [opts] { start, end, code, strategyId, direction } 日期 YYYYMMDD
* @returns {Promise<Array>} 委托列表(每项含 strategyId/holdingId 归属字段)
*/
getOrderHistory({ start, end, code, strategyId, direction } = {}) {
this.init();
const where = [];
const params = [];
if (start) { where.push('trade_date >= ?'); params.push(String(start)); }
if (end) { where.push('trade_date <= ?'); params.push(String(end)); }
if (code) { where.push('code = ?'); params.push(String(code)); }
if (direction) { where.push('direction = ?'); params.push(String(direction)); }
let sql = 'SELECT * FROM trade_orders';
if (where.length) sql += ' WHERE ' + where.join(' AND ');
sql += ' ORDER BY insert_ts DESC';
const rows = this.db.prepare(sql).all(...params);
// 归属 = 用户手动设置的持久化列(Q3 全手动选;未设置为 null=未关联)
if (strategyId === '__unassigned__') {
return rows.map((r) => this._mapOrderRow(r)).filter((o) => !o.strategyId);
}
if (strategyId) {
return rows.map((r) => this._mapOrderRow(r)).filter((o) => o.strategyId === strategyId);
}
return rows.map((r) => this._mapOrderRow(r));
}
/**
* 本地历史成交查询(R-009 Q4):策略过滤按所属委托归属(fill → order → holding → strategy
* @param {object} [opts] { start, end, code, strategyId, direction }
* @returns {Array} 成交列表(每项含 strategyId/holdingId 归属字段)
*/
getFillHistory({ start, end, code, strategyId, direction } = {}) {
this.init();
const where = [];
const params = [];
if (start) { where.push('f.trade_date >= ?'); params.push(String(start)); }
if (end) { where.push('f.trade_date <= ?'); params.push(String(end)); }
if (code) { where.push('f.code = ?'); params.push(String(code)); }
if (direction) { where.push('f.direction = ?'); params.push(String(direction)); }
let sql = 'SELECT f.* FROM trade_fills f';
if (where.length) sql += ' WHERE ' + where.join(' AND ');
sql += ' ORDER BY f.trade_date DESC, f.trade_time DESC';
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
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 + ')'
).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));
}
return fills.filter((f) => orderStrategy.get(f.orderId) === strategyId);
}
/**
* 设置委托归属(Q4 老师确认:可随时改,以最终修改为准)
* @param {string} orderId
* @param {string|null} strategyId null=清除归属(未关联)
* @param {number|null} holdingId null=清除归属
* @returns {boolean} 是否更新成功
*/
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;
}
/**
* 按 holding_id 查询委托汇总(R-010 迭代 08:策略持仓行展开用)
* @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));
}
/**
* 委托归属候选列表(用户手动设置用;候选 = 该 code 当前持仓的策略/持仓,Q3 全手动选)
* @param {string} code 证券代码(含后缀)
* @returns {Array<{strategyId: string, holdingId: number, shares: number}>} 候选列表(可空)
*/
getAttributionCandidates(code) {
this.init();
if (!code) return [];
return this.db.prepare(
'SELECT holding_id, strategy_id, shares FROM strategy_holdings WHERE code=? AND closed_at IS NULL ORDER BY shares DESC'
).all(code).map((h) => ({
strategyId: h.strategy_id,
holdingId: h.holding_id,
shares: h.shares,
}));
}
_mapOrderRow(r) {
return {
orderId: r.order_id,
tradeDate: r.trade_date,
code: r.code,
name: r.name,
exchange: r.exchange,
direction: r.direction,
directionCode: r.direction_code,
optName: r.opt_name,
status: r.status,
orderVolume: r.order_volume,
tradedVolume: r.traded_volume,
limitPrice: r.limit_price,
tradedPrice: r.traded_price,
amount: r.amount,
insertDate: r.insert_date,
insertTime: r.insert_time,
insertTs: r.insert_ts,
cancelInfo: r.cancel_info,
errorMsg: r.error_msg,
strategyId: r.strategy_id ?? null, // 手动归属(Q1 冗余)
holdingId: r.holding_id ?? null, // 手动归属(Q1 冗余)
fetchedAt: r.fetched_at,
};
}
_mapFillRow(r) {
return {
tradeId: r.trade_id,
orderId: r.order_id,
tradeDate: r.trade_date,
code: r.code,
name: r.name,
exchange: r.exchange,
direction: r.direction,
directionCode: r.direction_code,
optName: r.opt_name,
price: r.price,
volume: r.volume,
amount: r.amount,
commissionRateWan: r.commission_rate_wan,
tradeTime: r.trade_time,
fetchedAt: r.fetched_at,
};
}
/**
* 统计某日委托/成交条数(调试/验证用)
*/
countTradeRows(tradeDate) {
this.init();
const o = this.db.prepare('SELECT COUNT(*) AS n FROM trade_orders' + (tradeDate ? ' WHERE trade_date=?' : '')).get(...(tradeDate ? [tradeDate] : []));
const f = this.db.prepare('SELECT COUNT(*) AS n FROM trade_fills' + (tradeDate ? ' WHERE trade_date=?' : '')).get(...(tradeDate ? [tradeDate] : []));
return { orders: o?.n ?? 0, fills: f?.n ?? 0 };
}
}
+88
View File
@@ -0,0 +1,88 @@
/**
* TradeSync —— 交易记录本地同步(R-009 / 迭代 072026-09-01
*
* 职责:把 QMT Bridge 当日交易数据(委托 + 成交)定时同步落 SQLitetrade_orders + trade_fills)。
* - 启动预热:插件启动即同步一次(今日数据入库);
* - 定时同步:60s 轮询拉当日 orders+trades → UPSERT(幂等,状态覆盖更新);
* - 失败容忍:单次同步失败只记日志,不中断定时循环。
*
* 数据来源:QmtBridgeRestDataSource.getOrders() / getTrades()(仅当日,QMT 无历史接口)。
* 存储:DataStore.upsertTradeOrders / upsertTradeFills(委托 SqliteStoreUPSERT 幂等)。
*/
const SYNC_INTERVAL_MS = 60 * 1000; // 60s
export class TradeSync {
/**
* @param {object} opts
* @param {object} opts.runtime { dataSource, storage } —— dataSource 拉当日数据,storage 落库
* @param {object} [opts.logger]
*/
constructor({ runtime, logger } = {}) {
this.runtime = runtime;
this.logger = logger;
this.timer = null;
this.mounted = false;
this.syncing = false;
}
/** 启动:立即同步一次(预热)+ 定时同步 */
start() {
if (this.mounted) return;
this.mounted = true;
this.syncNow().catch((e) => {
this.logger?.warn?.('[one-divine-lot] TradeSync 启动预热失败: ' + (e?.message ?? e));
});
this.timer = setInterval(() => {
this.syncNow().catch((e) => {
this.logger?.debug?.('[one-divine-lot] TradeSync 定时同步失败: ' + (e?.message ?? e));
});
}, SYNC_INTERVAL_MS);
this.logger?.info?.('[one-divine-lot] TradeSync 启动(60s 定时同步交易记录)');
}
/** 停止(插件释放时) */
stop() {
this.mounted = false;
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
}
/**
* 同步一次:拉当日 orders+trades → UPSERT 落库(幂等)
* @returns {Promise<{orders: number, fills: number} | null>} 写入条数;数据源不可用返回 null
*/
async syncNow() {
if (!this.mounted || this.syncing) return null;
const { dataSource, storage } = this.runtime;
if (!dataSource || !storage) return null;
this.syncing = true;
try {
const [orders, fills] = await Promise.all([
dataSource.getOrders({}).catch((e) => {
this.logger?.debug?.('[one-divine-lot] TradeSync 拉委托失败: ' + (e?.message ?? e));
return null;
}),
dataSource.getTrades({}).catch((e) => {
this.logger?.debug?.('[one-divine-lot] TradeSync 拉成交失败: ' + (e?.message ?? e));
return null;
}),
]);
const nOrders = Array.isArray(orders) ? await storage.upsertTradeOrders(orders) : 0;
const nFills = Array.isArray(fills) ? await storage.upsertTradeFills(fills) : 0;
if (nOrders > 0 || nFills > 0) {
this.logger?.debug?.('[one-divine-lot] TradeSync 同步完成: 委托 ' + nOrders + ' / 成交 ' + nFills);
}
return { orders: nOrders, fills: nFills };
} finally {
this.syncing = false;
}
}
/** 手动触发同步(api 层可调用;返回最新同步结果) */
async sync() {
return this.syncNow();
}
}
+9 -3
View File
@@ -21,6 +21,7 @@ import { registerApi } from './api/index.js';
import { MarketDataHub } from './component/MarketDataHub.js'; import { MarketDataHub } from './component/MarketDataHub.js';
import { MarketFeed } from './component/MarketFeed.js'; import { MarketFeed } from './component/MarketFeed.js';
import { QmtHealthMonitor } from './component/QmtHealthMonitor.js'; import { QmtHealthMonitor } from './component/QmtHealthMonitor.js';
import { TradeSync } from './component/TradeSync.js';
const name = 'one-divine-lot'; const name = 'one-divine-lot';
@@ -71,8 +72,12 @@ async function apply(ctx, config) {
// S5: 分仓逻辑(份额分配/查询) // S5: 分仓逻辑(份额分配/查询)
const manager = new PositionManager({ dataSource, storage }); const manager = new PositionManager({ dataSource, storage });
// S6: 服务端 HTTP APIR-004:注入 dataSource 以编排激活热切换 // R-009:交易记录本地同步(启动预热 + 60s 定时 UPSERT 落库
registerApi(ctx, { manager, settings, dataSource, marketHub, marketFeed, qmtHealthMonitor }); 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 });
// 启动时可用性检查(日志,不阻塞) // 启动时可用性检查(日志,不阻塞)
dataSource.isAvailable().then((ok) => { dataSource.isAvailable().then((ok) => {
@@ -86,10 +91,11 @@ async function apply(ctx, config) {
return () => { return () => {
marketFeed.stop(); marketFeed.stop();
qmtHealthMonitor.stop(); qmtHealthMonitor.stop();
tradeSync.stop(); // 停止交易记录定时同步(迭代 07)
storage.close(); // 关闭 SQLite 连接(迭代 06 storage.close(); // 关闭 SQLite 连接(迭代 06
logger.info('[one-divine-lot] 插件已释放'); logger.info('[one-divine-lot] 插件已释放');
}; };
}, 'one-divine-lot.dispose'); }, 'one-divine-lot.dispose');
} }
export { name, inject, apply }; export { name, inject, apply };