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
+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 唯一),聚合成交信息(成交量/加权均价/金额/手续费)
* - 点击展开/折叠 → 逐笔成交明细(成交时间/价/量/额/手续费/成交号)
* - 排序:默认按委托时间降序(最新在前);点击表头手动排序
* - 时间段查询:标题 + RangeSelector 同一行(今日/本周/上周快捷 + 手动起止日期)
* - 时间段查询:标题 + RangeSelector 同一行(今日/本周/本月快捷 + 手动起止日期)
* - 轮询:仅「今日」范围 3-5s 自动刷新;其他范围一次性查询
*
* R-009(迭代 07):
* - 策略过滤下拉(全部 / 各策略 / 未关联):今日实时按归属过滤(前端),历史走服务端过滤
* - 历史范围从「接口开发中」占位切换为查本地 SQLitetrades/history
*
* 方向:红买绿卖(Q7 定稿);状态中文映射;未成交显示 —
*/
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { useRpc } from './connection.jsx';
import { LoadState } from './LoadState.jsx';
import { RangeSelector } from './RangeSelector.jsx';
@@ -79,8 +83,7 @@ function mergeOrdersAndTrades(orders, trades) {
const weightedPrice = totalVol > 0
? ts.reduce((s, t) => s + t.price * t.volume, 0) / totalVol
: 0;
// 费用:按整笔委托合并计算(同一委托分笔成交只收一次佣金,calcOrderFees
const rateWan = ts[0]?.commissionRateWan ?? 0; // 佣金费率(万分之,来自成交,账号级一致)
const rateWan = ts[0]?.commissionRateWan ?? 0;
const fees = calcOrderFeesLocal(ts, rateWan);
return {
...o,
@@ -102,10 +105,20 @@ export function TradeRecordsTab() {
const [error, setError] = useState(null);
const [loading, setLoading] = useState(true);
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); // 最近交易日
// 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(() => {
if (lastDate && !range) {
setRange({ start: lastDate, end: lastDate });
@@ -114,20 +127,48 @@ export function TradeRecordsTab() {
const [expanded, setExpanded] = useState(new Set());
const [sortKey, setSortKey] = useState('insertTime');
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',单向)→ 轮询
const isTodayRange = preset === 'today';
// 是否历史范围(非今日意图)→ 本期占位(QMT Bridge 历史接口开发中
// 是否历史范围(非今日意图)→ 查本地 SQLiteR-009
const isHistoryRange = !!range && preset !== 'today';
const load = useCallback(async () => {
if (!range) return;
if (isHistoryRange) {
// 历史范围:本期占位(老师将完善 QMT Bridge 历史接口)
setOrders([]);
setTrades([]);
setLoading(false);
// R-009 历史范围:查本地 SQLitetrades/history),不再占位
setLoading(true);
setError(null);
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;
}
setLoading(true);
@@ -148,7 +189,56 @@ export function TradeRecordsTab() {
} finally {
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(() => {
@@ -171,9 +261,15 @@ export function TradeRecordsTab() {
return () => clearInterval(timer);
}, [call, isTodayRange, range]);
// 合并 + 排序
// 合并 + 策略过滤(今日实时按归属过滤前端;历史已在服务端过滤)+ 排序
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;
return merged.slice().sort((a, b) => {
const va = a[sortKey];
@@ -181,7 +277,7 @@ export function TradeRecordsTab() {
if (typeof va === 'string' && typeof vb === 'string') return va.localeCompare(vb) * dir;
return (va - vb) * dir;
});
}, [orders, trades, sortKey, sortDir]);
}, [orders, trades, sortKey, sortDir, strategyFilter]);
const toggleSort = (key) => {
if (sortKey === key) {
@@ -204,69 +300,105 @@ export function TradeRecordsTab() {
const arrow = (key) => (sortKey === key ? (sortDir === 'desc' ? ' ▼' : ' ▲') : '');
return (
<div style={{ padding: 16, fontFamily: 'system-ui' }}>
{/* 标题 + 时间段选择同一行(2026-09-01 老师反馈 */}
<div className="odl-root" style={{ padding: 16, fontFamily: 'system-ui' }}>
{/* 标题 + 策略过滤 + 时间段选择同一行(R-007/R-009 */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12, flexWrap: 'wrap', gap: 12 }}>
<h3 style={{ margin: 0 }}>交易记录</h3>
<RangeSelector preset={preset} onPresetChange={setPreset} range={range} onChange={setRange} onLastDate={setLastDate} />
</div>
{isHistoryRange ? (
<div style={{ padding: 32, textAlign: 'center', color: '#999' }}>
<div style={{ fontSize: 15, marginBottom: 8 }}>{range.start} ~ {range.end}自定义时间段</div>
<div style={{ fontSize: 13 }}>历史数据接口开发中敬请期待老师完善 QMT Bridge 历史接口后接入</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
{/* R-009:策略过滤下拉(全部/各策略/未关联) */}
<select
value={strategyFilter}
onChange={(e) => setStrategyFilter(e.target.value)}
style={{ fontSize: 12, padding: '2px 6px', border: '1px solid #ccc', borderRadius: 4, color: '#555' }}
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>
) : (
<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>
);
}
/** 委托主行 + 展开明细 */
function OrderRows({ order, expanded, onToggle }) {
/** 交易记录表格(主行 + 展开明细 + R-009 手动归属列) */
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 strategyName = (sid) => {
if (!sid) return '未关联';
return order.strategyName || sid;
};
return (
<>
<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.aggStampTax, 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>
{expanded && hasTrades && (
<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 }}>
<thead>
<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 thClickStyle = { ...thStyle, cursor: 'pointer', userSelect: 'none' };
const tdStyle = { padding: '8px 6px', lineHeight: '24px', whiteSpace: 'nowrap', fontVariantNumeric: 'tabular-nums' };