Торгові дашборди ламаються не так, як звичайні веб-застосунки. Дані надходять безперервно, а не за запитом; десятки віджетів залежать від пересічних зрізів одного стану; а пропущений кадр у волатильну хвилину — реальна втрата для людини перед екраном. У цій статті розбираємо архітектуру, яку ми використали для TradeBot — дашборду для операторів автоматизованих стратегій на кількох біржах, — і рішення, що зберегли його чутливість.

Розділіть три види стану

Перша помилка команд — складати все в одне сховище. Торговий UI має три види стану з різними життєвими циклами:

  1. Серверний стан — те, що живе в базі й змінюється за запитом: налаштування акаунта, визначення стратегій, історія виконань, збережені макети. Його отримують, кешують та інвалідують.
  2. Потоковий стан — те, що безперервно надходить через сокет: книги ордерів, тікери, позиції, відкриті ордери, PnL. Його пушать, нормалізують і зливають.
  3. UI-стан — те, що має значення лише в браузері: обраний ринок, активна вкладка, відкриті модалки, ширина колонок.

Ми використовуємо TanStack Query для серверного стану, сховище Zustand для потокового та UI-стану і ніколи не дозволяємо одному протікати в інший. Серверний стан безкоштовно отримує дедуплікацію запитів, фонове оновлення та інвалідацію кешу. Потоковий стан отримує плоску нормалізовану форму й вибіркові підписки. UI-стан лишається локальним і дешевим.

Виграш у тому, що кожен вид стану оптимізується незалежно. Налаштування кешу запитів не мають стосунку до пропускної здатності сокета; пропускна здатність сокета не має стосунку до того, як часто перемальовується модалка.

Нормалізуйте потік до того, як він торкнеться React

Біржові фіди безладні. Одна площадка шле повні знімки книги щосекунди; інша — дельти з порядковими номерами; третя — позиції в іншій схемі, ніж виконання. Якщо ця різноманітність дійде до компонентів, кожен віджет стане шаром трансляції.

Наше правило: єдиний потоковий шар поза React володіє сокет-з'єднаннями й перетворює кожне повідомлення на одну з невеликого набору внутрішніх подій — book.snapshot, book.delta, ticker, position, order, fill. Сховище застосовує ці події до нормалізованої структури з ключами за площадкою та ринком:

type Store = {
  books: Record<MarketId, OrderBook>;
  tickers: Record<MarketId, Ticker>;
  positions: Record<PositionId, Position>;
  orders: Record<OrderId, Order>;
  connection: Record<Venue, "live" | "reconnecting" | "stale">;
};

Компоненти ніколи не бачать специфічних для площадки пейлоадів. Вони підписуються на books[marketId] або positions і більше ні на що. Коли додається нова площадка, потоковий шар отримує адаптер, а дерево компонентів не змінюється.

Рендеріть лише те, що змінилося

React спокійно витримує тисячі оновлень на секунду, якщо кожне перемальовує невелике піддерево. І не витримує, якщо кожне перемальовує весь дашборд. Три техніки роблять більшість роботи:

Вибіркові підписки. Кожен віджет підписується на найвужчий потрібний зріз через селектор, і сховище сповіщає лише тих підписників, чий зріз змінився за посиланням. Віджет книги BTC/USDT не перемальовується, коли тікає ETH/USDT.

Пакетування та тротлінг на межі. Дельти книги можуть надходити швидше, ніж оновлюється монітор. Потоковий шар пакетує дельти й скидає їх у сховище щонайбільше раз на кадр анімації. Нічого видимого не втрачається — книга коректна після кожного скидання, — а робота React під час сплесків падає на порядок.

Стабільні ідентичності рядків. Таблиці ордерів і виконань використовують стабільні ключі та мемоізовані компоненти рядків, тож нове виконання додає один рядок замість звірки всього списку. Віртуалізовані списки роблять довгі історії дешевими.

Графіки працюють за тим самим принципом. Ми передаємо бібліотеці графіків інкрементальні оновлення (update(lastBar)), а не замінюємо серію, і тримаємо стан графіка повністю поза React, оскільки бібліотека сама керує своїм canvas.

Проєктуйте з розрахунком на розриви з'єднання

Сокети падають. Площадки перезапускаються. Ноутбуки засинають. Дашборд, що мовчки показує вчорашню позицію, гірший за той, що не показує нічого. Ми ставимося до стану з'єднання як до повноцінних даних:

  • У кожної площадки є статус — live, reconnecting, stale, — виведений із heartbeat і часу останнього повідомлення, і кожен віджет, що залежить від цієї площадки, показує однозначний індикатор, коли вона не live.
  • Перепідключення використовують експоненційну затримку з джитером і автоматично перепідписуються. Після перепідключення потоковий шар запитує свіжий знімок кожної підписаної книги та відкидає дельти з порядковими номерами, старішими за знімок.
  • Запити серверного стану, що впливають на рішення (відкриті ордери, позиції), повторно виконуються після перепідключення, бо сокет міг пропустити події.

Це непоказна робота, і саме вона відрізняє демо від інструменту, якому люди довіряють гроші.

Керуванню для операторів потрібне тертя

TradeBot — не пасивний дисплей. Оператори зупиняють і відновлюють стратегії, змінюють параметри та розміщують ручні ордери. Кожна з цих дій може коштувати грошей, якщо це помилковий клік, тому керування навмисно повільніше за дані:

  • Деструктивні дії та дії з грошима вимагають кроку підтвердження, що переформульовує наслідок («Скасувати 12 відкритих ордерів на Binance BTC/USDT?»).
  • Зміни параметрів готуються й застосовуються явно, а не на кожне натискання клавіші.
  • Кожна дія записується в історію дій: хто, що, коли та яка відповідь сервера, — і це видно прямо в дашборді.

Рішення про оптимістичне оновлення ми приймаємо для кожної дії окремо. Скасування ордера оновлює UI негайно і звіряється з сервером; розміщення ордера чекає підтвердження, бо ордер, що виглядає розміщеним, але не є таким, — гірша помилка, ніж коротка затримка.

Макети й персоналізація

Оператори дивляться на різне. Ми випустили налаштовувані макети — сітку віджетів на користувача з розмірами й позиціями — та персональні watchlist, що зберігаються як серверний стан. Макети — це JSON-документи, валідовані схемою, тож старий макет переживає нову версію дашборду. Макет за замовчуванням — той, який, на нашу думку, потрібен більшості; на практиці майже всі змінили його протягом дня, і в цьому суть.

Що ми зробили б знову, а що змінили б

Знову: поділ на три види стану, нормалізований потоковий шар і статус з'єднання для кожної площадки. Це фундамент, який дозволив додавати площадки та віджети, не чіпаючи існуючий код.

Змінили б: спочатку ми дозволяли віджетам підписуватися на цілі книги замість top-N рівнів. Для книги на 200 рівнів це була марна робота; більшість віджетів рендерять лише 20 рівнів. Відкривайте селектори з обмеженням глибини з самого початку.

Також змінили б: ми недоінвестували в інструмент відтворення на ранньому етапі. Можливість записати сесію потоку і відтворити її на UI кардинально спрощує роботу з продуктивністю та відтворення багів. Будуйте його в перший місяць.

Підсумок

Торговий дашборд — це потокова система з React-фронтендом, а не React-застосунок, що випадково використовує WebSockets. Тримайте потоковий стан поза компонентами, агресивно нормалізуйте, рендеріть вибірково, ставтеся до статусу з'єднання як до даних і додавайте тертя до дій, що рухають гроші. Зробіть це — і вибір фреймворку матиме значно менше значення, ніж прийнято думати.