11/**
2- * RecentTrades - 实时成交列表
2+ * RecentTrades (TradeAnalysis) - 实时成交分析
33 *
4- * 显示最近 50 笔成交记录,数据来自 Binance WebSocket trade stream
4+ * 显示最近 50 笔成交记录,并提供买卖盘分析(针对特定 Symbol):
5+ * - 买卖量对比 (Taker Buy/Sell Volume)
6+ * - 大单监控 (Whale Alerts)
7+ * - 净流入计算
58 */
69
7- import { memo } from 'react' ;
10+ import { memo , useMemo } from 'react' ;
811import type { TradeRecord } from '../../hooks/useBinanceMarket' ;
12+ import { ArrowDown , ArrowUp } from 'lucide-react' ;
913
1014/* ============================================
1115 格式化工具
@@ -20,9 +24,9 @@ function formatTime(ms: number): string {
2024}
2125
2226function formatQty ( qty : number ) : string {
23- if ( qty >= 1 ) return qty . toFixed ( 4 ) ;
24- if ( qty >= 0.01 ) return qty . toFixed ( 5 ) ;
25- return qty . toFixed ( 6 ) ;
27+ if ( qty >= 10 ) return qty . toFixed ( 2 ) ;
28+ if ( qty >= 1 ) return qty . toFixed ( 3 ) ;
29+ return qty . toFixed ( 4 ) ;
2630}
2731
2832/* ============================================
@@ -31,48 +35,137 @@ function formatQty(qty: number): string {
3135
3236interface RecentTradesProps {
3337 trades : TradeRecord [ ] ;
38+ symbol ?: string ;
3439}
3540
36- function RecentTrades ( { trades } : RecentTradesProps ) {
41+ function RecentTrades ( { trades, symbol = 'BTC' } : RecentTradesProps ) {
42+ // 统计分析
43+ const stats = useMemo ( ( ) => {
44+ let buyVol = 0 ;
45+ let sellVol = 0 ;
46+ let buyCount = 0 ;
47+ let sellCount = 0 ;
48+ let maxQty = 0 ;
49+
50+ trades . forEach ( ( t ) => {
51+ // isBuyerMaker = false -> 主动买入 (Up)
52+ // isBuyerMaker = true -> 主动卖出 (Down)
53+ if ( ! t . isBuyerMaker ) {
54+ buyVol += t . qty ;
55+ buyCount ++ ;
56+ } else {
57+ sellVol += t . qty ;
58+ sellCount ++ ;
59+ }
60+ if ( t . qty > maxQty ) maxQty = t . qty ;
61+ } ) ;
62+
63+ return {
64+ buyVol,
65+ sellVol,
66+ buyCount,
67+ sellCount,
68+ netVol : buyVol - sellVol ,
69+ maxQty : Math . max ( maxQty , 0.0001 ) , // 避免除以0
70+ isBuyDominant : buyVol > sellVol ,
71+ } ;
72+ } , [ trades ] ) ;
73+
3774 if ( trades . length === 0 ) {
3875 return (
39- < div className = "flex items-center justify-center h-full text-gray-600 text-[10px] font-mono" >
40- 等待成交数据...
76+ < div className = "flex flex-col items-center justify-center h-full text-gray-500 text-[10px] font-mono gap-2" >
77+ < span className = "w-4 h-4 rounded-full border-2 border-border-dark border-t-accent animate-spin" />
78+ < span > Waiting for trades...</ span >
4179 </ div >
4280 ) ;
4381 }
4482
83+ // 大单阈值:最大成交量的 30% 或者 绝对值(BTC > 0.1, ETH > 2)
84+ // 这里简化为相对阈值,因为 symbol 不确定
85+ const largeOrderThreshold = stats . maxQty * 0.3 ;
86+
4587 return (
46- < div className = "flex flex-col h-full overflow-hidden" >
47- { /* 表头 */ }
48- < div className = "flex items-center px-2 py-1 text-[9px] font-mono text-gray-500 border-b border-border-dark shrink-0" >
49- < span className = "w-[32%]" > Price (USDT)</ span >
50- < span className = "w-[32%] text-right" > Amount (BTC)</ span >
51- < span className = "w-[36%] text-right" > Time</ span >
88+ < div className = "flex flex-col h-full overflow-hidden bg-bg-surface text-[10px] font-mono select-none" >
89+ { /* ========== 统计面板 ========== */ }
90+ < div className = "px-2 py-1.5 border-b border-border-dark bg-bg-surface-alt" >
91+ { /* 标题行 */ }
92+ < div className = "flex items-center justify-between mb-1.5" >
93+ < span className = "font-bold text-gray-300" > { symbol } Analysis</ span >
94+ < span className = { stats . netVol > 0 ? 'text-success' : 'text-danger' } >
95+ Net: { stats . netVol > 0 ? '+' : '' } { formatQty ( stats . netVol ) }
96+ </ span >
97+ </ div >
98+
99+ { /* 买卖量条 */ }
100+ < div className = "flex items-center justify-between mb-1 text-[9px] text-gray-400" >
101+ < span className = "text-success flex items-center gap-1" >
102+ < ArrowUp className = "w-2.5 h-2.5" /> Buy { formatQty ( stats . buyVol ) }
103+ </ span >
104+ < span className = "text-danger flex items-center gap-1" >
105+ Sell { formatQty ( stats . sellVol ) } < ArrowDown className = "w-2.5 h-2.5" />
106+ </ span >
107+ </ div >
108+
109+ { /* 比例条 */ }
110+ < div className = "h-1.5 w-full bg-border-dark rounded-full overflow-hidden flex" >
111+ < div
112+ className = "h-full bg-success transition-all duration-300"
113+ style = { { width : `${ ( stats . buyVol / ( stats . buyVol + stats . sellVol || 1 ) ) * 100 } %` } }
114+ />
115+ < div
116+ className = "h-full bg-danger transition-all duration-300"
117+ style = { { width : `${ ( stats . sellVol / ( stats . buyVol + stats . sellVol || 1 ) ) * 100 } %` } }
118+ />
119+ </ div >
120+
121+ { /* 净流量 */ }
122+ < div className = "flex justify-between mt-1 text-[9px]" >
123+ < span className = "text-gray-500" > Vol Ratio</ span >
124+ < span className = { stats . netVol > 0 ? 'text-success' : 'text-danger' } >
125+ Net: { stats . netVol > 0 ? '+' : '' } { formatQty ( stats . netVol ) }
126+ </ span >
127+ </ div >
128+ </ div >
129+
130+ { /* ========== 表头 ========== */ }
131+ < div className = "flex items-center px-2 py-1 text-[9px] text-gray-500 border-b border-border-dark shrink-0 bg-bg-surface" >
132+ < span className = "w-[30%]" > Price</ span >
133+ < span className = "w-[30%] text-right" > Qty</ span >
134+ < span className = "w-[40%] text-right" > Time</ span >
52135 </ div >
53136
54- { /* 成交列表 */ }
137+ { /* ========== 成交列表 ========== */ }
55138 < div className = "flex-1 overflow-y-auto scrollbar-thin" >
56139 { trades . map ( ( trade ) => {
57- // isBuyerMaker = true → 卖方主动(价格下跌方向)
58- // isBuyerMaker = false → 买方主动(价格上涨方向)
59- const colorClass = trade . isBuyerMaker ? 'text-danger' : 'text-success' ;
140+ const isBuy = ! trade . isBuyerMaker ;
141+ const isLarge = trade . qty >= largeOrderThreshold ;
142+
143+ // 数量条长度 (相对于最大单)
144+ const barWidth = Math . min ( ( trade . qty / stats . maxQty ) * 100 , 100 ) ;
60145
61146 return (
62147 < div
63148 key = { trade . id }
64- className = "flex items-center px-2 py-px text-[10px] font-mono tabular-nums hover:bg-bg-surface/50 transition-colors"
149+ className = { `relative flex items-center px-2 py-[2px] transition-colors hover:bg-white/5 ${
150+ isLarge ? 'bg-white/5' : ''
151+ } `}
65152 >
66- < span className = { `w-[32%] ${ colorClass } ` } >
153+ { /* 背景数量条 */ }
154+ < div
155+ className = { `absolute left-0 top-0 bottom-0 opacity-10 ${ isBuy ? 'bg-success' : 'bg-danger' } ` }
156+ style = { { width : `${ barWidth } %` } }
157+ />
158+
159+ < span className = { `w-[30%] relative z-10 ${ isBuy ? 'text-success' : 'text-danger' } ${ isLarge ? 'font-bold' : '' } ` } >
67160 { trade . price . toLocaleString ( undefined , {
68- minimumFractionDigits : 2 ,
69- maximumFractionDigits : 2 ,
161+ minimumFractionDigits : 1 ,
162+ maximumFractionDigits : 1 , // 减少小数位,节省空间
70163 } ) }
71164 </ span >
72- < span className = " w-[32 %] text-right text-gray-300" >
165+ < span className = { ` w-[30 %] text-right relative z-10 ${ isLarge ? ' text-gray-100 font-bold' : 'text-gray-400' } ` } >
73166 { formatQty ( trade . qty ) }
74167 </ span >
75- < span className = "w-[36 %] text-right text-gray-500" >
168+ < span className = "w-[40 %] text-right text-gray-500 relative z-10 opacity-70 " >
76169 { formatTime ( trade . time ) }
77170 </ span >
78171 </ div >
0 commit comments