Inicio / Artículos / Interfaz de usuario para operaciones financieras adaptativa con SSE, virtualización y un presupuesto para 20 mercados

Interfaz de usuario para operaciones financieras adaptativa con SSE, virtualización y un presupuesto para 20 mercados

Coordine las filas virtualizadas de React, un planificador central de suscripciones SSE y un almacén externo de cotizaciones para que los tickets abiertos mantengan precios actualizados sin exceder los límites de conexión.

1870 palabras

Un registro de operaciones puede listar miles de instrumentos mientras solo unas pocas filas se muestran en la pantalla. Las cotizaciones fluyen continuamente, el usuario hace scroll y una orden abierta aún puede necesitar precios en tiempo real incluso después de que su mercado salga del área visible.

Esos problemas tienen criterios diferentes:

Scrolling changes visibility.
Subscription policy changes network demand.
SSE changes quote data.
React changes what is displayed.

Asigne un único responsable a cada problema. Los fragmentos siguientes ilustran esa división; se trata de partes arquitectónicas, no de un cliente de operaciones completo.

Defina las reglas antes de elegir la implementación

Supongamos que el backend permite como máximo 20 IDs de mercado por conexión SSE. La política entonces sería:

  • Un EventSource activo por pestaña del navegador
  • Nunca más de 20 IDs de mercado distintos en esa conexión
  • Una orden abierta tiene prioridad sobre las filas normales de la lista
  • La demanda puede cambiar sin tener que eliminar el registro del consumidor
  • Las cotizaciones en tiempo real se mantienen fuera del estado de los componentes React
  • Los frames entrantes se validan antes de que modifiquen el almacén
  • Las rutas de envío vuelven a leer la última cotización, no solo la que se mostró por última vez
  • Las cotizaciones vencidas o desconectadas no pueden utilizarse para enviar datos
  • La arquitectura

    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
    

    Los componentes React declaran las solicitudes y leen los snapshots. Ellos no abren ni gestionan sockets.

    1. Almacenar las cotizaciones actuales de forma independiente de React

    Las cotizaciones validadas tienen el siguiente formato:

    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;
    }>;
    

    Los snapshots exponen tanto el estado de disponibilidad como el contenido:

    type QuoteSnapshot =
      | { state: "waiting"; quote: null }
      | { state: "fresh" | "stale"; quote: Quote };
    

    La superficie del almacén se mantiene reducida:

    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;
    }
    

    La lógica de recepción compara las versiones antes de aceptar una actualización:

    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();
    }
    

    Los temporizadores de vencimiento, la invalidación y la distribución a los suscriptores se gestionan directamente en la tienda. El historial no es ilimitado: solo se mantiene la última cotización por mercado:

    Market A, version 10
            ↓ replaced
    Market A, version 11
            ↓ replaced
    Market A, version 12
    

    Si el servidor reutiliza espacios de versión entre diferentes tipos de marcos, el reductor debe fusionarlos según dicho contrato.

    2. Asignar las 20 suscripciones de forma centralizada

    Los consumidores anuncian su demanda con un nivel de prioridad:

    type Demand = {
      marketIds: readonly string[];
      priority: number;
    };
    
    const PRIORITY = {
      orderTicket: 0,
      visible: 1,
      nearby: 2,
    } as const;const MAX_MARKETS = 20;
    

    Un planificador elimina las duplicadas y mantiene la prioridad más alta por mercado:

    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();
    }
    

    Cuando la demanda supera las veinte mercados, las filas excedentes permanecen visiblemente en espera. La interfaz de usuario no debe dar la impresión de que cuentan con cobertura en tiempo real.

    3. Actualizar la demanda sin reiniciar un efecto de React

    Cada consumidor cuenta con un propietario estable:

    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]);
    }
    

    Cuando cambia el ID, ese propietario se actualiza en su lugar; al desmontar la componente, se elimina.

    El rápido cambio en la demanda se procesa en lotes dentro de un período de programación fijo:

    let reconciliationTimer:
      ReturnType<typeof setTimeout> | undefined;
    
    function scheduleReconciliation() {
      if (reconciliationTimer !== undefined) return;  reconciliationTimer = setTimeout(() => {
        reconciliationTimer = undefined;
        reconcileSubscriptions();
      }, 150);
    }
    

    Dado que el período es fijo y no se trata de un mecanismo desactivador dinámico, el desplazamiento continuo no puede posponer la reconciliación indefinidamente. La conexión inicial y el cierre final aún pueden realizarse de inmediato.

    4. Reemplazar la conexión SSE de forma segura

    Nuevos parámetros de consulta implican un nuevo EventSource. Cierre la transmisión anterior antes de abrir la siguiente:

    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.
      };
    }
    

    Los auxiliares para el monitoreo, el análisis y el estado de la conexión permanecen en módulos separados. Un contador de generaciones descarta los eventos posteriores provenientes de sockets obsoletos. El EventSource nativo se reconecta ante errores recuperables; llamar a .close() finaliza esa instancia. Consulte la guía de MDN sobre el uso de eventos enviados por servidor para conocer el comportamiento del navegador.

    Los listeners en modo online/offline y relacionados con el ciclo de vida de la página se encuentran al nivel del gestor: al pasar a modo offline, las cotizaciones se invalidan y el socket se cierra; la recuperación vuelve a conectarse desde la solicitud actual. Una nueva conexión espera nuevas cotizaciones antes de permitir el envío.

    5. Virtualizar la lista e indicar su área de visualización

    La virtualización limita el DOM montado; el planificador de suscripciones limita por separado los mercados transmitidos en streaming. Con TanStack Virtual, las filas visibles pueden solicitar una prioridad mayor que sus vecinas en el área de escaneo adicional:

    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>
      );
    }
    

    Estos ejemplos suponen una altura fija de las filas; las alturas variables requieren mediciones. El virtualizador monta las filas; la política de suscripción sigue siendo propiedad de la aplicación (ver los documentos de React de TanStack Virtual). El enfoque del teclado también debe mantener disponible una fila enfocada cuando esta sale de la ventana de renderizado normal.

    6. Renderizar cada mercado a través de su propio snapshot

    function useQuote(marketId: string) {
      const selection = useMemo(
        () => quoteStore.select(marketId),
        [marketId],
      );
    
      return useSyncExternalStore(
        selection.subscribe,
        selection.getSnapshot,
      );
    }
    

    Mientras no cambie algo significativo en ese mercado, select sigue devolviendo la misma referencia de captura instantánea: exactamente lo que React espera de un almacén externo utilizado con 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>
      );
    });
    

    Un cambio en la cotización del Mercado A solo notifica al lector de ese mercado; no reemplaza todo el array de mercados del listado principal. Los botones abren un ticket; no realizan pedidos de forma silenciosa.

    El ticket declara su propia demanda:

    function OrderTicket({ marketId }: { marketId: string }) {
      useMarketDemand([marketId], PRIORITY.orderTicket);
    
      const snapshot = useQuote(marketId);  // Render quantity, side, current quote, review and submit controls.
      // ...
    }
    

    Desplazarse por la cuadrícula no cancela la solicitud del ticket. Eliminar el ticket solo quita a ese propietario; una fila visible o una entrada de escaneo adicional cercana podría seguir necesitando el mismo instrumento.

    Revisa nuevamente la cotización en tiempo real justo antes de comprar o vender

    Lo que se pintó en la última fila puede hacer que la tienda autorizada tarde un momento. Por lo tanto, la ruta de envío se lee nuevamente:

    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);
    }
    

    Las respuestas se analizan para determinar si son aceptadas, rechazadas o ambiguas. Al intentarlo de nuevo, se reutiliza el identificador original del pedido del cliente. La autorización, la verificación de cotizaciones, la aceptación y los llenados son decisiones que se toman en el servidor. Los precios límite solo limitan qué ejecuciones están permitidas; nunca garantizan un llenado.

    Las dos decisiones de temporización

    Decisión Propósito
    Período de suscripción de ~150 ms Unir los cambios rápidos en la vista antes de reemplazar la conexión
    Intervalo de publicación visual de ~100 ms Limitar la frecuencia habitual de actualización de precios

    Los mensajes entrantes siguen actualizando inmediatamente la tienda correspondiente. La disponibilidad cambia sin experimentar la habitual demora visual. El intervalo de 150 ms no está diseñado para ralentizar el desplazamiento, los clics ni los precios que ya llegan a la conexión activa.

    Memoria y limpieza

    Un caché de catálogos (por ejemplo, React Query) necesita sus propias reglas de retención. Una lista virtualizada puede mostrar diez filas mientras sigue almacenando en caché miles de registros cargados. Mantenga las tareas separadas: la virtualización se encarga de la interfaz gráfica mostrada, el planificador gestiona la capacidad de suscripción, la tienda controla el estado de las cotizaciones, y React dibuja los instantáneos relevantes.

    Lecturas relacionadas