Разработка Time & Sales (ленты сделок)
Time & Sales (T&S) — это непрерывный поток всех исполненных сделок в реальном времени: время, цена, объём, направление. Профессиональные трейдеры читают T&S как пульс рынка — видят агрессию покупателей и продавцов, крупные блоки, последовательность ударов по bid/ask.
Структура ленты сделок
interface Trade {
id: string;
timestamp: number; // unix milliseconds
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 недели.







