Accueil / Articles / Interfaces de trading réactives avec SSE, virtualisation et un budget pour 20 marchés

Interfaces de trading réactives avec SSE, virtualisation et un budget pour 20 marchés

Coordonnez les lignes virtualisées de React, un planificateur central de abonnements SSE et un stock externe de devis afin que les tickets ouverts conservent des prix à jour sans dépasser les limites de connexion.

1870 mots

Un journal de transactions peut afficher des milliers d’instruments alors que seules quelques lignes sont visibles à l’écran. Les cotes s’affichent en continu, l’utilisateur fait défiler la page, et un ticket de commande ouvert peut encore avoir besoin de prix en temps réel même après que le marché concerné ait quitté la zone visible.

Ces problématiques relèvent de mécanismes distincts :

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

Attribuez un responsable unique à chaque problématique. Les extraits ci-dessous illustrent cette séparation ; il s’agit de parties structurelles, non d’un client de trading complet.

Définissez les règles avant de choisir la mise en œuvre

Supposons que le backend autorise au maximum 20 identifiants de marché par connexion SSE. La politique devient alors la suivante :

  • Un EventSource actif par onglet de navigateur
  • Au maximum 20 identifiants de marché distincts par connexion
  • Un ticket de commande ouvert a la priorité sur les lignes ordinaires de la liste
  • Les besoins peuvent changer sans avoir à supprimer l’enregistrement du consommateur
  • Les cotes en temps réel existent indépendamment de l’état du composant React
  • Les frames entrants sont validés avant qu’ils ne modifient le stockage
  • Les chemins de soumission relisent la dernière cote, et non seulement celle qui a été affichée en dernier
  • Les cotes expirées ou déconnectées ne peuvent pas être utilisées pour soumettre
  • L’architecture

    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
    

    Les composants React déclarent les demandes et lisent des captures d’écran. Ils n’ouvrent ni ne gèrent de sockets.

    1. Stocker les cotes actuelles indépendamment de React

    Les cotes validées ont l’aspect suivant :

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

    Les captures d’écran exposent à la fois l’état de disponibilité et le contenu :

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

    La surface du stockage reste limitée :

    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 logique de réception compare les versions avant d’accepter une mise à jour :

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

    Les compteurs de durée d’expiration, l’invalidation ainsi que la diffusion auprès des abonnés se font directement dans le magasin. L’historique n’est pas illimité — seule la dernière cotation par marché est conservée :

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

    Si le serveur réutilise des espaces de version entre différents types de frames, le réducteur doit effectuer la fusion conformément à ce contrat.

    2. Allouer les 20 abonnements de manière centralisée

    Les consommateurs annoncent leur demande en indiquant une priorité :

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

    Un planificateur élimine les doublons et conserve la priorité la plus élevée pour chaque marché :

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

    Lorsque la demande dépasse vingt marchés, les lignes excédentaires restent visiblement en attente. L’interface utilisateur ne doit pas faire croire qu’elles disposent d’une couverture en temps réel.

    3. Mettre à jour la demande sans redémarrer un effet React

    Chaque consommateur dispose d’un propriétaire stable :

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

    Les changements d’ID mettent à jour ce propriétaire sur place ; le démontage sert à le supprimer.

    Le changement rapide de la demande est regroupé dans une fenêtre de planification fixe :

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

    Puisque cette fenêtre est fixe et non décalable, le scroll continu ne peut pas différer indéfiniment la réconciliation. La connexion initiale et le démontage final peuvent toujours s’exécuter immédiatement.

    4. Remplacer la connexion SSE en toute sécurité

    De nouveaux paramètres de requête impliquent un nouveau EventSource. Fermez le flux précédent avant d’ouvrir le suivant :

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

    Les outils de surveillance, de parsing et d’affichage de l’état de connexion restent dans des modules distincts. Un compteur de génération élimine les événements tardifs provenant de sockets obsolètes. Le EventSource natif se reconnecte en cas d’erreurs corrigeables ; appeler .close() met fin à cette instance. Consultez le guide de MDN sur l’utilisation des événements envoyés par serveur pour en connaître le comportement dans le navigateur.

    Les écouteurs en ligne/hors ligne ainsi que ceux liés au cycle de vie des pages se trouvent au niveau du gestionnaire : le passage en mode hors ligne invalide les requêtes et ferme le socket ; la récupération permet de se reconnecter à partir de la demande actuelle. Une nouvelle connexion attend des requêtes fraîches avant d’autoriser l’envoi.

    5. Virtualiser la liste et afficher sa zone de visualisation

    La virtualisation limite le DOM monté ; le planificateur de abonnements limite séparément les marchés diffusés. Avec TanStack Virtual, les lignes visibles peuvent demander une priorité plus élevée que celles situées en dehors de leur zone de visualisation :

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

    Ces exemples partent du principe d’une hauteur de ligne fixe ; pour les hauteurs variables, des mesures sont nécessaires. Le virtualiseur monte les lignes ; la politique d’abonnement reste gérée par l’application (voir les documents React de TanStack Virtual). Le focus du clavier doit également maintenir une ligne en focus lorsqu’elle quitte la fenêtre de rendu normale.

    6. Rendre chaque marché à l’aide de son propre instantané

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

    Jusqu’à ce que des changements significatifs surviennent sur ce marché, la fonction select continue de renvoyer la même référence d’aperçu—exactement ce que React attend d’un stock externe utilisé avec 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 changement de cote sur le Marché A n’informe que le lecteur de ce marché ; il ne remplace pas l’ensemble du tableau des marchés dans la liste principale. Les boutons ouvrent une demande ; ils ne passent pas d’ordre en silence.

    La demande indique elle-même sa propre exigence :

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

    Faire défiler le tableau ne annule pas la demande en question. Fermer cette demande ne supprime que son propriétaire ; une ligne visible ou une entrée d’overscan voisine peut encore nécessiter le même instrument.

    Vérifiez à nouveau la cote en temps réel juste avant d’acheter ou de vendre

    Ce que le dernier élément affiché peut différer légèrement de la valeur officielle. Par conséquent, le chemin d’envoi est à nouveau lu comme suit :

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

    Les réponses sont analysées pour déterminer s’ils sont acceptés, rejetés ou si leur signification est ambiguë. Une nouvelle tentative pour une même action réutilise son identifiant d’ordre client initial. L’autorisation, la vérification des prix, l’acceptation et l’exécution restent des décisions du serveur. Les prix plafond ne servent qu’à limiter les exécutions autorisées — ils ne garantissent jamais une exécution.

    Les deux décisions liées au timing

    Décision But
    Fenêtre de souscription d’environ 150 ms Grouper les changements rapides dans la vue avant de remplacer la connexion
    Intervalle de publication visuelle d’environ 100 ms Limiter la fréquence de réaffichage des prix ordinaires

    Les messages entrants mettent toujours à jour le stock immédiat en temps réel. La mise à jour instantanée élimine le délai visuel habituel. La fenêtre de 150 ms n’a pas pour but de ralentir le défilement, les clics ou les prix déjà affichés sur la connexion active.

    Mémoire et nettoyage