feat: 迭代03-05 交易记录接入、行情实时、QMT连接配置、data store 标准化
- R-004 QMT连接配置:多配置管理 + 会话头部快捷切换(03-QMT连接配置) - R-005 WS盘中价格实时更新:服务端中转+缓存+前端轮询(04-WS盘中价格实时更新) - R-006 策略数据 JSON data store 标准化(store.json/market.json/schema.json) - R-007 交易记录接入:当日订单+成交单表合并(委托主行+展开成交明细)、时间段查询(今日/本周/本月+手动起止)、费用计算(佣金费率万m_dCommission+最低5元、印花税卖出万5、过户费双向万0.1) - 架构治理:api 按领域拆分、component 目录、QmtHealthMonitor
This commit is contained in:
@@ -0,0 +1,323 @@
|
||||
/**
|
||||
* TradeRecordsTab —— 交易记录 tab(R-007 v3,2026-09-01)
|
||||
*
|
||||
* 单表合并视图:
|
||||
* - 主行 = 一笔委托(按 m_strOrderSysID 唯一),聚合成交信息(成交量/加权均价/金额/手续费)
|
||||
* - 点击展开/折叠 → 逐笔成交明细(成交时间/价/量/额/手续费/成交号)
|
||||
* - 排序:默认按委托时间降序(最新在前);点击表头手动排序
|
||||
* - 时间段查询:标题 + RangeSelector 同一行(今日/本周/上周快捷 + 手动起止日期)
|
||||
* - 轮询:仅「今日」范围 3-5s 自动刷新;其他范围一次性查询
|
||||
*
|
||||
* 方向:红买绿卖(Q7 定稿);状态中文映射;未成交显示 —
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo } 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 '—';
|
||||
}
|
||||
|
||||
/** 合并逻辑:按 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);
|
||||
// 费用逐笔计算后聚合(每笔 commission/stampTax/transferFee 已是金额)
|
||||
const aggCommission = ts.reduce((s, t) => s + t.commission, 0);
|
||||
const aggStampTax = ts.reduce((s, t) => s + t.stampTax, 0);
|
||||
const aggTransferFee = ts.reduce((s, t) => s + t.transferFee, 0);
|
||||
const aggFeeTotal = ts.reduce((s, t) => s + t.feeTotal, 0);
|
||||
const weightedPrice = totalVol > 0
|
||||
? ts.reduce((s, t) => s + t.price * t.volume, 0) / totalVol
|
||||
: 0;
|
||||
return {
|
||||
...o,
|
||||
trades: ts,
|
||||
aggVolume: totalVol,
|
||||
aggPrice: weightedPrice,
|
||||
aggAmount: totalAmount,
|
||||
aggCommission,
|
||||
aggStampTax,
|
||||
aggTransferFee,
|
||||
aggFeeTotal,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
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'); // 快捷意图(单向,操作→状态→UI):'today'|'thisWeek'|'thisMonth'|null
|
||||
const [lastDate, setLastDate] = useState(null); // 最近交易日
|
||||
|
||||
// 默认加载「今日」(preset='today',单向意图):lastDate 到位且 range 未设置时自动设为今日
|
||||
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');
|
||||
const call = useRpc();
|
||||
|
||||
// 是否「今日」意图(preset='today',单向)→ 轮询
|
||||
const isTodayRange = preset === 'today';
|
||||
// 是否历史范围(非今日意图)→ 本期占位(QMT Bridge 历史接口开发中)
|
||||
const isHistoryRange = !!range && preset !== 'today';
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!range) return;
|
||||
if (isHistoryRange) {
|
||||
// 历史范围:本期占位(老师将完善 QMT Bridge 历史接口)
|
||||
setOrders([]);
|
||||
setTrades([]);
|
||||
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]);
|
||||
|
||||
// 时间段变化 → 重新加载(展开重置)
|
||||
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(() => {
|
||||
const merged = mergeOrdersAndTrades(orders ?? [], trades ?? []);
|
||||
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]);
|
||||
|
||||
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 style={{ padding: 16, fontFamily: 'system-ui' }}>
|
||||
{/* 标题 + 时间段选择同一行(2026-09-01 老师反馈) */}
|
||||
<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>
|
||||
) : (
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 委托主行 + 展开明细 */
|
||||
function OrderRows({ order, expanded, onToggle }) {
|
||||
const hasTrades = order.trades.length > 0;
|
||||
return (
|
||||
<>
|
||||
<tr
|
||||
onClick={onToggle}
|
||||
style={{
|
||||
borderBottom: '1px solid #eee',
|
||||
cursor: hasTrades ? 'pointer' : 'default',
|
||||
background: expanded ? '#f5f5f5' : 'transparent',
|
||||
}}
|
||||
>
|
||||
<td style={tdStyle}>{hasTrades ? (expanded ? '▾' : '▸') : <span style={{ color: '#ccc' }}>·</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>
|
||||
</tr>
|
||||
{expanded && hasTrades && (
|
||||
<tr style={{ borderBottom: '1px solid #eee', background: '#fafafa' }}>
|
||||
<td colSpan={15} 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>
|
||||
<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' }}>{fmtNum(t.feeTotal, 2)}</td>
|
||||
<td style={{ ...tdStyle, textAlign: 'right' }}>{fmtNum(t.commission, 2)}</td>
|
||||
<td style={{ ...tdStyle, textAlign: 'right' }}>{fmtNum(t.stampTax, 2)}</td>
|
||||
<td style={{ ...tdStyle, textAlign: 'right' }}>{fmtNum(t.transferFee, 2)}</td>
|
||||
<td style={{ ...tdStyle, textAlign: 'right', color: '#999' }}>{t.tradeId}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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' };
|
||||
Reference in New Issue
Block a user