Интерфейсы торговли с адаптивной разметкой, поддержкой SSE, виртуализацией и бюджетом для 20 рынков
Синхронизируйте виртуализированные строки React, централизованный планировщик подписок SSE и внешний хранилище цитат, чтобы открытые заявки сохраняли актуальные цены без превышения лимитов подключений.
В журнале сделок могут быть указаны тысячи инструментов, при этом на экране отображается лишь несколько строк. Котировки поступают непрерывно, пользователь прокручивает список, и открытая заявка на сделку может по-прежнему нуждаться в актуальных ценах даже после того, как рынок покинет область отображения.
У этих проблем есть свои временные рамки:
Scrolling changes visibility.
Subscription policy changes network demand.
SSE changes quote data.
React changes what is displayed.
Назначьте каждой проблеме отдельного ответственного. Приведённые ниже примеры иллюстрируют такое разделение; это архитектурные элементы, а не полноценный торговый клиент.
Определите правила перед выбором способа реализации
Предположим, что бэкенд позволяет использовать максимум 20 идентификаторов рынков на одном соединении SSE. Тогда правила будут следующими:
- Один активный объект
EventSourceна каждую вкладку браузера - Не более 20 различных идентификаторов рынков на этом соединении
- Открытая заявка на сделку имеет приоритет перед обычными строками списка
- Потребности могут меняться без необходимости деактивации регистрации пользователя
Архитектура
Virtualized list ─── visible and nearby market IDs ──┐
├─► Subscription manager
Open order ticket ─── selected market ID ───────────┘ │
▼
One SSE connection
│
Validated messages
▼
Quote store
/ \
▼ ▼
React snapshots Order validation
Компоненты React объявляют потребности и считывают кадры состояния. Они не открывают и не управляют сокетами.
1. Хранение текущих котировок независимо от React
Проверенные котировки выглядят так:
type Quote = Readonly<{
marketId: string;
bid: string; // Decimal strings preserve wire precision.
ask: string;
version: number;
staleAt: number; // Server expiry timestamp in milliseconds.
tradable: boolean;
}>;
Кадры состояния отображают готовность к использованию, а также содержимое:
type QuoteSnapshot =
| { state: "waiting"; quote: null }
| { state: "fresh" | "stale"; quote: Quote };
Объем хранилища остается небольшим:
interface QuoteStore {
receive(quote: Quote): void;
// Immediate state, used by commands.
readLatest(marketId: string): QuoteSnapshot; // Cached visual snapshots, used by React.
select(marketId: string): {
subscribe(notify: () => void): () => void;
getSnapshot(): QuoteSnapshot;
}; invalidate(marketIds: readonly string[]): void;
retain(marketIds: readonly string[]): void;
}
Логика приема сравнивает версии перед принятием обновления:
function receiveQuote(next: Quote) {
const previous = latestQuotes.get(next.marketId);
// This example assumes strictly increasing quote versions.
if (previous && next.version <= previous.version) return; latestQuotes.set(next.marketId, next); // Independent of whether another price message arrives.
scheduleQuoteExpiry(next.marketId, next.staleAt); // Ordinary prices can wait for the next visual publication.
scheduleVisualPublication();
}
Таймеры истечения срока, аннулирование и распространение данных среди подписчиков реализуются непосредственно в магазине. История данных не является бесконечной — сохраняется только самый свежий котировочный отчет по каждому рынку:
Market A, version 10
↓ replaced
Market A, version 11
↓ replaced
Market A, version 12
Если сервер повторно использует пространства версий для разных типов фреймов, функция сокращения данных должна выполнять объединение в соответствии с установленными правилами.
2. Централизованное распределение 20 подписок
Потребители объявляют о своих потребностях с указанием приоритета:
type Demand = {
marketIds: readonly string[];
priority: number;
};
const PRIORITY = {
orderTicket: 0,
visible: 1,
nearby: 2,
} as const;const MAX_MARKETS = 20;
Система планирования устраняет дубликаты и сохраняет наиболее высокий приоритет для каждого рынка:
function planSubscriptions(
demands: readonly Demand[],
activeIds: readonly string[],
): string[] {
const priorities = new Map<string, number>();
for (const demand of demands) {
for (const marketId of demand.marketIds) {
priorities.set(
marketId,
Math.min(
priorities.get(marketId) ?? Infinity,
demand.priority,
),
);
}
} if (priorities.size === 0) return []; const active = new Set(activeIds); const requested = [...priorities.keys()].sort(
(left, right) =>
priorities.get(left)! - priorities.get(right)! ||
Number(active.has(right)) - Number(active.has(left)),
); return [...new Set([...requested, ...activeIds])]
.slice(0, MAX_MARKETS)
.sort();
}
Когда объем потребностей превышает количество рынков, лишние записи остаются видимыми в состоянии ожидания. Интерфейс не должен создавать впечатление, что они обеспечивают прямую трансляцию.
3. Обновление данных о потребностях без перезапуска эффекта React
Каждый потребитель имеет своего стабильного владельца:
function useMarketDemand(
marketIds: readonly string[],
priority: number,
) {
const [owner] = useState(() =>
subscriptionManager.createOwner(),
);
const key = JSON.stringify([...new Set(marketIds)]);
const stableIds = useMemo<string[]>(
() => JSON.parse(key),
[key],
); useEffect(() => {
owner.update({
marketIds: stableIds,
priority,
});
}, [owner, stableIds, priority]); useEffect(() => {
return () => owner.dispose();
}, [owner]);
}
Изменения ID приводят к обновлению этого владельца на месте; процедура демонтирования удаляет его.
Быстрая смена спроса обрабатывается в рамках фиксированного окна планирования:
let reconciliationTimer:
ReturnType<typeof setTimeout> | undefined;
function scheduleReconciliation() {
if (reconciliationTimer !== undefined) return; reconciliationTimer = setTimeout(() => {
reconciliationTimer = undefined;
reconcileSubscriptions();
}, 150);
}
Поскольку окно является фиксированным, а не скользящим, непрерывное прокручивание не может вечно откладывать синхронизацию. Начальное подключение и окончательное разъединение могут всё равно произойти немедленно.
4. Безопасная замена соединения SSE
Новые параметры запроса означают создание нового объекта EventSource. Перед открытием следующего потока необходимо закрыть предыдущий:
let source: EventSource | undefined;
let generation = 0;
let clearWatchdog: (() => void) | undefined;
function replaceConnection(marketIds: string[]) {
const currentGeneration = ++generation; clearWatchdog?.();
source?.close();
source = undefined; quoteStore.retain(marketIds);
quoteStore.invalidate(marketIds); if (marketIds.length === 0 || !navigator.onLine) return; const query = new URLSearchParams(); for (const marketId of marketIds) {
query.append("marketId", marketId);
} const nextSource = new EventSource(
`/api/v1/stream?${query.toString()}`,
); source = nextSource;
const permittedIds = new Set(marketIds); const watchdog = createSilenceWatchdog(() => {
if (currentGeneration !== generation) return; nextSource.close();
reconcileSubscriptions({ force: true });
}); clearWatchdog = watchdog.stop;
watchdog.reset(); nextSource.addEventListener("quote", event => {
if (currentGeneration !== generation) return; // Parses JSON and validates it against the feed contract.
const quote = parseQuoteMessage(event); if (!quote || !permittedIds.has(quote.marketId)) {
quoteStore.invalidate(marketIds);
return;
} watchdog.reset();
quoteStore.receive(quote);
}); nextSource.addEventListener("heartbeat", () => {
if (currentGeneration === generation) {
watchdog.reset();
}
}); nextSource.onerror = () => {
if (currentGeneration !== generation) return; quoteStore.invalidate(marketIds); if (nextSource.readyState === EventSource.CLOSED) {
watchdog.stop();
reportConnectionError();
} // Recoverable failures are retried by native EventSource.
};
}
Механизмы мониторинга, парсеры и инструменты для отслеживания статуса соединения находятся в отдельных модулях. Счетчик поколений помогает исключить устаревшие события из обработки. Встроенный объект EventSource автоматически восстанавливает соединение при возможных ошибках; вызов метода .close() прерывает работу данного экземпляра. Для ознакомления с поведением браузера при использовании серверных событий см. руководство MDN.
Слушатели для режима онлайн/офлайн и жизненного цикла страниц находятся в области управления: переход в офлайн-режим аннулирует запросы и закрывает сокет; восстановление подключения происходит путем повторной связи с текущим запросом. Новое подключение ожидает поступления новых запросов перед тем, как разрешить их отправку.
5. Виртуализация списка и отображение его области просмотра
Виртуализация ограничивает объем монтируемого DOM; планировщик подписок отдельно ограничивает потоковые рынки. С помощью TanStack Virtual видимые строки могут иметь более высокий приоритет по сравнению с соседними строками, находящимися в режиме оверскана:
function MarketList({
markets,
onOpenTicket,
}: {
markets: Array<{ marketId: string; name: string }>;
onOpenTicket(marketId: string, side: "BUY" | "SELL"): void;
}) {
const [viewport, setViewport] =
useState<HTMLDivElement | null>(null);
const virtualizer = useVirtualizer({
count: markets.length,
getScrollElement: () => viewport,
getItemKey: index => markets[index]!.marketId,
estimateSize: () => 72,
overscan: 2,
}); const rows = virtualizer.getVirtualItems();
const range = virtualizer.range; const visibleIds = range
? markets
.slice(range.startIndex, range.endIndex + 1)
.map(market => market.marketId)
: []; const nearbyIds = rows.map(
row => markets[row.index]!.marketId,
); useMarketDemand(visibleIds, PRIORITY.visible);
useMarketDemand(nearbyIds, PRIORITY.nearby); return (
<div
ref={setViewport}
role="region"
aria-label="Markets"
tabIndex={0}
style={{ height: 560, overflow: "auto" }}
>
<div
style={{
height: virtualizer.getTotalSize(),
position: "relative",
}}
>
{rows.map(row => (
<div
key={row.key}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: 72,
transform: `translateY(${row.start}px)`,
}}
>
<MarketRow
market={markets[row.index]!}
onOpenTicket={onOpenTicket}
/>
</div>
))}
</div>
</div>
);
}
В этих примерах предполагается фиксированная высота строк; для строк с переменной высотой требуется измерение. Виртуализатор монтирует строки; политика подписок остается в собственности приложения (см. документацию TanStack Virtual для React). Фокус клавиатуры также должен обеспечивать доступность активной строки, даже когда она выходит за пределы обычного окна отображения.
6. Отрисовка каждого рынка с использованием его собственного снимка
function useQuote(marketId: string) {
const selection = useMemo(
() => quoteStore.select(marketId),
[marketId],
);
return useSyncExternalStore(
selection.subscribe,
selection.getSnapshot,
);
}
Пока в этом рынке не произойдут значимые изменения, функция select продолжает возвращать тот же самый идентичный ссылочный образ — именно это ожидает React от внешнего хранилища, используемого с useSyncExternalStore.
const MarketRow = memo(function MarketRow({
market,
onOpenTicket,
}: {
market: { marketId: string; name: string };
onOpenTicket(marketId: string, side: "BUY" | "SELL"): void;
}) {
const snapshot = useQuote(market.marketId);
const quote = snapshot.quote;
const available =
snapshot.state === "fresh" && quote?.tradable; return (
<article>
<strong>{market.name}</strong> <button
disabled={!available}
onClick={() => onOpenTicket(market.marketId, "SELL")}
>
Sell · {quote?.bid ?? "—"}
</button> <button
disabled={!available}
onClick={() => onOpenTicket(market.marketId, "BUY")}
>
Buy · {quote?.ask ?? "—"}
</button> {snapshot.state !== "fresh" && (
<span>Prices updating</span>
)}
</article>
);
});
Изменение котировки на рынке A уведомляет только его собственного читатель — оно не заменяет всю массивную структуру рынков в родительском списке. Кнопки открывают заявку; они не размещают заказ незаметно.
Заявка содержит собственное требование:
function OrderTicket({ marketId }: { marketId: string }) {
useMarketDemand([marketId], PRIORITY.orderTicket);
const snapshot = useQuote(marketId); // Render quantity, side, current quote, review and submit controls.
// ...
}
Прокрутка таблицы не аннулирует требование, указанное в заявке. Удаление заявки удаляет только ее владельца; видимая строка или соседний запись могут по-прежнему требовать тот же самый инструмент.
Обязательно проверьте актуальные котировки непосредственно перед покупкой или продажей
То, что было недавно отрисовано на строке, может на мгновение отстать от официального представления данных. Поэтому путь отправки снова выглядит следующим образом:
async function submitOrder(intent: {
clientOrderId: string; // Stable for retries of this exact intent.
marketId: string;
side: "BUY" | "SELL";
quantity: string;
reviewedVersion: number;
}) {
const snapshot = quoteStore.readLatest(intent.marketId);
const quote = snapshot.quote;
if (
!navigator.onLine ||
snapshot.state !== "fresh" ||
!quote ||
!quote.tradable ||
Date.now() >= quote.staleAt
) {
throw new Error("Wait for a fresh, tradable quote.");
} if (quote.version !== intent.reviewedVersion) {
throw new Error("The quote changed. Review it again.");
} const limitPrice =
intent.side === "BUY" ? quote.ask : quote.bid; const response = await fetch("/api/v1/orders", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
...intent,
type: "LIMIT",
limitPrice,
}),
}); return parseOrderResponse(response);
}
Ответы анализируются с целью определения их приемлемости, отклонения или неоднозначности. Повторная попытка выполнения операции использует первоначальный идентификатор заказа клиента. Вопросы авторизации, проверки котировок, приема данных и их заполнения остаются прерогативой сервера. Ограничения цен лишь определяют допустимые способы выполнения операций — они никогда не гарантируют их успешного завершения.
Два параметра времени
| Решение | Цель |
|---|---|
| Интервал подписки около 150 мс | Сглаживание быстрых изменений в области отображения перед заменой соединения |
| Интервал визуальной публикации около 100 мс | Ограничение частоты перерисовки обычных цен |
Входящие сообщения по-прежнему мгновенно обновляют соответствующий хранилище. Функция отображения доступности исключает обычную визуальную задержку. Окно в 150 мс предназначено не для замедления прокрутки, кликов или отображения цен, уже поступающих по активному соединению.
Память и очистка
Кэш каталога (например, React Query) требует собственных правил хранения данных. Виртуализированный список может отобразить десять строк, при этом сохраняя кэш тысяч загруженных записей. Необходимо разделять функции: виртуализация отвечает за отображаемый интерфейс, механизм планирования — за объем хранилища, хранилище — за состояние данных, а React отрисовывает важные кадры.