Files
one_divine_lot/src/client/views/TradeRecordsTab.jsx
T
kyugao b3526c7693 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
2026-09-02 01:20:50 +08:00

539 lines
23 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* TradeRecordsTab —— 交易记录 tabR-007 v3 + R-009 v52026-09-01
*
* 单表合并视图:
* - 主行 = 一笔委托(按 m_strOrderSysID 唯一),聚合成交信息(成交量/加权均价/金额/手续费)
* - 点击展开/折叠 → 逐笔成交明细(成交时间/价/量/额/手续费/成交号)
* - 排序:默认按委托时间降序(最新在前);点击表头手动排序
* - 时间段查询:标题 + RangeSelector 同一行(今日/本周/本月快捷 + 手动起止日期)
* - 轮询:仅「今日」范围 3-5s 自动刷新;其他范围一次性查询
*
* R-009(迭代 07):
* - 策略过滤下拉(全部 / 各策略 / 未关联):今日实时按归属过滤(前端),历史走服务端过滤
* - 历史范围从「接口开发中」占位切换为查本地 SQLitetrades/history
*
* 方向:红买绿卖(Q7 定稿);状态中文映射;未成交显示 —
*/
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { useRpc } from './connection.jsx';
import { LoadState } from './LoadState.jsx';
import { RangeSelector } from './RangeSelector.jsx';
const POLL_INTERVAL_MS = 4000; // 3-5s 轮询(取 4s
/** 委托状态码 → 中文(官方 ORDER_STATUS 字典) */
const ORDER_STATUS = {
48: '未报', 49: '待报', 50: '已报', 51: '已报待撤', 52: '部成待撤',
53: '部撤', 54: '已撤', 55: '部成', 56: '已成', 57: '废单',
};
/** HHMMSS → HH:MM:SS */
function fmtTime(t) {
if (!t || t.length !== 6) return t ?? '';
return t.slice(0, 2) + ':' + t.slice(2, 4) + ':' + t.slice(4, 6);
}
/** 数字格式化 */
function fmtNum(v, digits = 2) {
if (v == null || !isFinite(v)) return '—';
return Number(v).toLocaleString('zh-CN', { minimumFractionDigits: digits, maximumFractionDigits: digits });
}
/** 方向样式:红买绿卖 */
function directionStyle(direction) {
if (direction === 'buy') return { color: '#d32f2f', fontWeight: 'bold' };
if (direction === 'sell') return { color: '#2e7d32', fontWeight: 'bold' };
return { color: '#999' };
}
function directionText(direction) {
if (direction === 'buy') return '买入';
if (direction === 'sell') return '卖出';
return '—';
}
/** 费用计算:按整笔委托合并(同一委托分笔成交只收一次佣金,最低5元;2026-09-01 老师修正) */
function calcOrderFeesLocal(trades, commissionRateWan) {
const list = Array.isArray(trades) ? trades : [];
const totalAmount = list.reduce((s, t) => s + Math.max(0, +(t.amount ?? 0)), 0);
const direction = list[0]?.direction ?? '';
let commission = totalAmount * (commissionRateWan / 10000);
if (commission < 5 && commission > 0) commission = 5;
const stampTax = direction === 'sell' ? totalAmount * 0.0005 : 0;
const transferFee = totalAmount * 0.00001;
return {
commission: +commission.toFixed(4),
stampTax: +stampTax.toFixed(4),
transferFee: +transferFee.toFixed(4),
total: +(commission + stampTax + transferFee).toFixed(4),
};
}
/** 合并逻辑:按 m_strOrderSysID 把成交聚合进委托 */
function mergeOrdersAndTrades(orders, trades) {
const tradeByOrder = new Map();
for (const t of trades) {
if (!tradeByOrder.has(t.orderId)) tradeByOrder.set(t.orderId, []);
tradeByOrder.get(t.orderId).push(t);
}
return orders.map((o) => {
const ts = tradeByOrder.get(o.orderId) ?? [];
const totalVol = ts.reduce((s, t) => s + t.volume, 0);
const totalAmount = ts.reduce((s, t) => s + t.amount, 0);
const weightedPrice = totalVol > 0
? ts.reduce((s, t) => s + t.price * t.volume, 0) / totalVol
: 0;
const rateWan = ts[0]?.commissionRateWan ?? 0;
const fees = calcOrderFeesLocal(ts, rateWan);
return {
...o,
trades: ts,
aggVolume: totalVol,
aggPrice: weightedPrice,
aggAmount: totalAmount,
aggCommission: fees.commission,
aggStampTax: fees.stampTax,
aggTransferFee: fees.transferFee,
aggFeeTotal: fees.total,
};
});
}
export function TradeRecordsTab() {
const [orders, setOrders] = useState(null);
const [trades, setTrades] = useState(null);
const [error, setError] = useState(null);
const [loading, setLoading] = useState(true);
const [range, setRange] = useState(null); // { start, end } YYYYMMDD
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]);
const call = useRpc();
// 默认加载「今日」(preset='today',单向意图)
useEffect(() => {
if (lastDate && !range) {
setRange({ start: lastDate, end: lastDate });
}
}, [lastDate, range]);
const [expanded, setExpanded] = useState(new Set());
const [sortKey, setSortKey] = useState('insertTime');
const [sortDir, setSortDir] = useState('desc');
// 加载策略列表(过滤下拉用,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';
// 是否历史范围(非今日意图)→ 查本地 SQLite(R-009
const isHistoryRange = !!range && preset !== 'today';
const load = useCallback(async () => {
if (!range) return;
if (isHistoryRange) {
// 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);
setError(null);
try {
const [oRes, tRes] = await Promise.all([
call('one-divine-lot/orders', { args: { start: range.start, end: range.end } }),
call('one-divine-lot/trades', { args: { start: range.start, end: range.end } }),
]);
if (oRes?.ok && Array.isArray(oRes.value)) setOrders(oRes.value);
else throw new Error((oRes && oRes.error && oRes.error.message) || '委托加载失败');
if (tRes?.ok && Array.isArray(tRes.value)) setTrades(tRes.value);
else throw new Error((tRes && tRes.error && tRes.error.message) || '成交加载失败');
} catch (e) {
setError(e.message);
setOrders([]);
setTrades([]);
} finally {
setLoading(false);
}
}, [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(() => {
setExpanded(new Set());
load();
}, [load, range]);
// 仅「今日」范围轮询
useEffect(() => {
if (!isTodayRange) return;
const timer = setInterval(() => {
Promise.all([
call('one-divine-lot/orders', { args: { start: range.start, end: range.end } }),
call('one-divine-lot/trades', { args: { start: range.start, end: range.end } }),
]).then(([o, t]) => {
if (o?.ok && Array.isArray(o.value)) setOrders(o.value);
if (t?.ok && Array.isArray(t.value)) setTrades(t.value);
}).catch(() => {});
}, POLL_INTERVAL_MS);
return () => clearInterval(timer);
}, [call, isTodayRange, range]);
// 合并 + 策略过滤(今日实时按归属过滤前端;历史已在服务端过滤)+ 排序
const rows = useMemo(() => {
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];
const vb = b[sortKey];
if (typeof va === 'string' && typeof vb === 'string') return va.localeCompare(vb) * dir;
return (va - vb) * dir;
});
}, [orders, trades, sortKey, sortDir, strategyFilter]);
const toggleSort = (key) => {
if (sortKey === key) {
setSortDir((d) => (d === 'desc' ? 'asc' : 'desc'));
} else {
setSortKey(key);
setSortDir('desc');
}
};
const toggleExpand = (orderId) => {
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(orderId)) next.delete(orderId);
else next.add(orderId);
return next;
});
};
const arrow = (key) => (sortKey === key ? (sortDir === 'desc' ? ' ▼' : ' ▲') : '');
return (
<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>
<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 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>
);
}
/** 交易记录表格(主行 + 展开明细 + 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
onClick={onToggle}
style={{
borderBottom: '1px solid #eee',
cursor: hasTrades ? 'pointer' : 'default',
background: expanded ? '#f5f5f5' : 'transparent',
}}
>
<td style={{ ...tdStyle, textAlign: 'center', width: 28 }}>
{hasTrades ? (
<svg
width="14"
height="14"
viewBox="0 0 14 14"
style={{
display: 'inline-block',
transition: 'transform .15s ease',
transform: expanded ? 'rotate(90deg)' : 'rotate(0deg)',
cursor: 'pointer',
color: '#2e7d32',
verticalAlign: 'middle',
}}
>
<path
d="M4.5 2.5 L9.5 7 L4.5 11.5"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
) : (
<span style={{ color: '#ccc', fontSize: 10 }}>·</span>
)}
</td>
<td style={tdStyle}>{fmtTime(order.insertTime)}</td>
<td style={tdStyle}>{order.code}</td>
<td style={tdStyle}>{order.name}</td>
<td style={{ ...tdStyle, ...directionStyle(order.direction) }} title={order.optName}>{directionText(order.direction)}</td>
<td style={tdStyle}>{ORDER_STATUS[order.status] ?? order.status ?? '—'}</td>
<td style={{ ...tdStyle, textAlign: 'right' }}>{fmtNum(order.orderVolume, 0)}</td>
<td style={{ ...tdStyle, textAlign: 'right' }}>{hasTrades ? fmtNum(order.aggVolume, 0) : '—'}</td>
<td style={{ ...tdStyle, textAlign: 'right' }}>{fmtNum(order.limitPrice)}</td>
<td style={{ ...tdStyle, textAlign: 'right' }}>{hasTrades ? fmtNum(order.aggPrice) : '—'}</td>
<td style={{ ...tdStyle, textAlign: 'right' }}>{hasTrades ? fmtNum(order.aggAmount) : '—'}</td>
<td style={{ ...tdStyle, textAlign: 'right' }}>{hasTrades ? fmtNum(order.aggFeeTotal, 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.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={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' }}>
<th style={{ ...tdStyle, textAlign: 'left' }}>成交时间</th>
<th style={{ ...tdStyle, textAlign: 'right' }}>成交价</th>
<th style={{ ...tdStyle, textAlign: 'right' }}>成交量</th>
<th style={{ ...tdStyle, textAlign: 'right' }}>成交额</th>
<th style={{ ...tdStyle, textAlign: 'right' }}>成交号</th>
</tr>
</thead>
<tbody>
{order.trades.map((t) => (
<tr key={t.tradeId} style={{ borderBottom: '1px solid #f0f0f0' }}>
<td style={tdStyle}>{fmtTime(t.tradeTime)}</td>
<td style={{ ...tdStyle, textAlign: 'right' }}>{fmtNum(t.price)}</td>
<td style={{ ...tdStyle, textAlign: 'right' }}>{fmtNum(t.volume, 0)}</td>
<td style={{ ...tdStyle, textAlign: 'right' }}>{fmtNum(t.amount)}</td>
<td style={{ ...tdStyle, textAlign: 'right', color: '#999' }}>{t.tradeId}</td>
</tr>
))}
</tbody>
</table>
</td>
</tr>
)}
</>
);
}
/** 委托归属选择下拉(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' };