Розроблення Time & Sales (ленти сделок)
Time & Sales (T&S) — це безперервний потік усіх виконаних сделок у реальному часі: час, ціна, обсяг, напрям. Професійні трейдери читають T&S як пульс ринку — бачать агресію покупців та продавців, крупні блоки, послідовність ударів по bid/ask.
Структура ленти сделок
interface Trade {
id: string;
timestamp: number; // unix мілісекунди
price: number;
quantity: number;
side: 'buy' | 'sell'; // агресивна сторона
value: number; // price * quantity в USD
isLargeTrade: boolean; // вище порогу значимого обсягу
}
Кожен рядок T&S містить:
- Час: HH:MM:SS.mmm (з мілісекундами для професійних платформ)
- Ціна: з виділенням напрямку
- Обсяг: у base asset
- Сторона: Buy (зелений) / Sell (червоний)
Backend: агрегація та стриминг
type TimeAndSalesHub struct {
trades chan Trade
clients map[string]map[*WSClient]bool // pair -> clients
mu sync.RWMutex
recentBuf map[string]*RingBuffer // зберігає останні N сделок для нових підключень
}
type RingBuffer struct {
items []Trade
head int
size int
mu sync.Mutex
}
func (rb *RingBuffer) Add(trade Trade) {
rb.mu.Lock()
defer rb.mu.Unlock()
rb.items[rb.head%rb.size] = trade
rb.head++
}
func (rb *RingBuffer) GetAll() []Trade {
rb.mu.Lock()
defer rb.mu.Unlock()
result := make([]Trade, 0, rb.size)
start := rb.head - rb.size
if start < 0 { start = 0 }
for i := start; i < rb.head; i++ {
result = append(result, rb.items[i%rb.size])
}
return result
}
func (hub *TimeAndSalesHub) OnTrade(trade Trade) {
// Зберігаємо в ring buffer
hub.recentBuf[trade.Pair].Add(trade)
// Broadcast всім підписчикам
hub.mu.RLock()
defer hub.mu.RUnlock()
data, _ := json.Marshal(trade)
for client := range hub.clients[trade.Pair] {
select {
case client.send <- data:
default:
go client.close()
}
}
}
// Новий клієнт отримує останні 100 сделок одразу
func (hub *TimeAndSalesHub) OnClientConnect(client *WSClient, pair string) {
hub.mu.Lock()
hub.clients[pair][client] = true
hub.mu.Unlock()
// Відправляємо історію
recent := hub.recentBuf[pair].GetAll()
for _, trade := range recent {
data, _ := json.Marshal(trade)
client.send <- data
}
}
Frontend: високопродуктивний рендеринг
T&S оновлюється дуже часто — на BTC/USDT до 10–20 сделок на секунду в активні періоди. Стандартний React список буде тормозити.
import { useRef, useEffect, useCallback } from 'react';
const MAX_ROWS = 200; // максимум рядків у ленті
function TimeAndSalesList({ pair }: { pair: string }) {
const containerRef = useRef<HTMLDivElement>(null);
const tradesRef = useRef<Trade[]>([]);
const wsRef = useRef<WebSocket | null>(null);
// Прямий DOM manipulation для hot path — без React re-render
const appendTrade = useCallback((trade: Trade) => {
const container = containerRef.current;
if (!container) return;
// Створюємо новий рядок
const row = document.createElement('div');
row.className = `trade-row ${trade.side} ${trade.isLargeTrade ? 'large' : ''}`;
const time = new Date(trade.timestamp);
const timeStr = `${time.getHours().toString().padStart(2,'0')}:` +
`${time.getMinutes().toString().padStart(2,'0')}:` +
`${time.getSeconds().toString().padStart(2,'0')}`;
row.innerHTML = `
<span class="time">${timeStr}</span>
<span class="price">${formatPrice(trade.price)}</span>
<span class="qty">${formatQuantity(trade.quantity)}</span>
<span class="value">$${formatVolume(trade.value)}</span>
`;
// Вставляємо у початок (нові сделки зверху)
container.insertBefore(row, container.firstChild);
// Видаляємо зайві рядки внизу
while (container.children.length > MAX_ROWS) {
container.removeChild(container.lastChild!);
}
// Flash анімація для крупних сделок
if (trade.isLargeTrade) {
row.classList.add('flash');
setTimeout(() => row.classList.remove('flash'), 500);
}
}, []);
useEffect(() => {
wsRef.current = new WebSocket(`wss://api.exchange.com/ws`);
wsRef.current.send(JSON.stringify({ op: 'subscribe', channel: `trades.${pair}` }));
wsRef.current.onmessage = (e) => {
const trade = JSON.parse(e.data);
appendTrade(trade);
};
return () => wsRef.current?.close();
}, [pair, appendTrade]);
return (
<div className="time-and-sales">
<div className="ts-header">
<span>Time</span>
<span>Price</span>
<span>Size</span>
<span>Value</span>
</div>
<div ref={containerRef} className="ts-body" />
</div>
);
}
Фільтрація та підсвітка
interface TSFilters {
minValue: number; // показувати тільки сделки >= $X
side: 'all' | 'buy' | 'sell';
highlightLargeThreshold: number; // поріг для виділення крупних блоків
}
// CSS класи для стилізації
const styles = `
.trade-row {
display: grid;
grid-template-columns: 80px 100px 80px 80px;
padding: 1px 8px;
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
border-bottom: 1px solid #1e2030;
transition: background-color 0.2s;
}
.trade-row.buy .price { color: #00B15E; }
.trade-row.sell .price { color: #E84242; }
.trade-row.large {
background: rgba(255, 215, 0, 0.05);
font-weight: 600;
}
.trade-row.large.buy { background: rgba(0, 177, 94, 0.15); }
.trade-row.large.sell { background: rgba(232, 66, 66, 0.15); }
@keyframes flash-green {
0% { background-color: rgba(0, 177, 94, 0.4); }
100% { background-color: transparent; }
}
.trade-row.flash { animation: flash-green 0.5s ease-out; }
`;
Агрегація по часу
Для менш щільних ринків — об'єднання сделок за короткий інтервал (100–500 мс):
class TradeAggregator {
private buffer: Trade[] = [];
private flushInterval: number = 100; // ms
private onFlush: (aggregated: AggregatedTrade[]) => void;
add(trade: Trade) {
this.buffer.push(trade);
}
private flush() {
if (this.buffer.length === 0) return;
// Групуємо по цені (тік) та стороні
const groups = new Map<string, AggregatedTrade>();
for (const trade of this.buffer) {
const key = `${trade.price}:${trade.side}`;
const existing = groups.get(key);
if (existing) {
existing.quantity += trade.quantity;
existing.value += trade.value;
existing.count++;
} else {
groups.set(key, { ...trade, count: 1 });
}
}
this.onFlush([...groups.values()].sort((a, b) => b.timestamp - a.timestamp));
this.buffer = [];
}
}
Статистика потоку сделок
Корисно показувати агрегаційну статистику поруч з лентою для останніх N секунд:
function TradeFlowStats({ trades }: { trades: Trade[] }) {
const stats = useMemo(() => {
const last60s = trades.filter(t => Date.now() - t.timestamp < 60000);
const buyVolume = last60s.filter(t => t.side === 'buy')
.reduce((sum, t) => sum + t.value, 0);
const sellVolume = last60s.filter(t => t.side === 'sell')
.reduce((sum, t) => sum + t.value, 0);
return {
buyVolume,
sellVolume,
delta: buyVolume - sellVolume,
buyPercent: buyVolume / (buyVolume + sellVolume) * 100,
};
}, [trades]);
return (
<div className="flow-stats">
<span className="green">Buy: ${formatVolume(stats.buyVolume)}</span>
<FlowBar buyPct={stats.buyPercent} />
<span className="red">Sell: ${formatVolume(stats.sellVolume)}</span>
<span className={stats.delta > 0 ? 'green' : 'red'}>
Δ {stats.delta > 0 ? '+' : ''}${formatVolume(Math.abs(stats.delta))}
</span>
</div>
);
}
Розроблення Time & Sales ленти з real-time WebSocket оновленнями, фільтрацією, підсвіткою крупних блоків та flow статистикою: 3–4 тижні.







