Як побудувати надійне виконання крипторозпоряджень
Надійність виконання крипторозпоряджень тримається на машинах станів, ключах ідемпотентності та невпинній звірці. Уроки з розробки MadaiOps.
Автор Vitor Lima
Більшість криптотрейдингових продуктів продають швидкість і графік. Найскладніше — це не намалювати свічки. Найскладніше — уміти будь-якої мілісекунди відповісти на оманливо просте запитання: який справжній стан мого ордера просто зараз? Коли ми починали будувати MadaiOps, наш внутрішній застосунок для ордерів і торгових операцій, саме це запитання впорядковувало все. Швидкість — це функція. Правда — це продукт. Надійність виконання крипторозпоряджень — це те, що ви маєте, коли застосунок здатен чесно відповісти на це запитання навіть тоді, коли біржа деградує; а застосунок, який показує виконання, якого не було, — або приховує те, що сталося, — руйнує довіру так, що жодна перемога в латентності цього не відкупить.
Цей допис — про інженерію, яка робить застосунок для ордерів гідним довіри: про машину станів, ідемпотентність, звірку через WebSocket і режими відмов, які проявляються лише під навантаженням. Ми маємо чітку позицію щодо всього цього, бо саме нудні рішення тримають вас платоспроможними.
Моделюйте ордер як явну машину станів
Найкраще рішення, яке ми ухвалили, — це перестати ставитися до ордера як до рядка, який ви UPDATE-ите, і почати ставитися до нього як до скінченної машини станів з іменованими, перелічуваними переходами. Ордер живе у станах на кшталт PENDING_NEW, WORKING, PARTIALLY_FILLED, FILLED, CANCEL_REQUESTED, CANCELED, REJECTED та EXPIRED. Кожен перехід — це функція від поточного стану плюс вхідної події, а нелегальні переходи відхиляються гучно, а не поглинаються мовчки.
Виграш: неоднозначність стає питанням часу валідації, а не інцидентом о 2-й ночі. Ордер у стані FILLED ніколи не зможе повернутися до WORKING. Подія виконання, що прийшла для ордера в стані CANCELED, — це не «неможливо», це реальна гонитва станів, і машина станів змушує вас вирішити, що вона означає. Зазвичай це означає, що скасування програло в гонитві, ордер насправді виконався, а ваш локальний CANCELED був хибним. Змоделюйте ордери як змінні рядки — і ця подія просто щось перезапише; ви ніколи цього не помітите, доки звірка — або користувач — не спіймає помилку.
Головний висновок
Перелічіть свої стани, перш ніж написати бодай один виклик API. Якщо ви не можете накреслити діаграму на дошці, ви ще не розумієте свою предметну область — і біржа навчить вас цього важким шляхом. Зберігайте переходи, а не лише поточний стан. Журнал подій «тільки для додавання», що фіксує все, що сталося, кращий за єдину змінну колонку статусу, бо журнал можна відтворити, а колонка — це здогад.
Ідемпотентність не є опцією
Мережі роблять повторні спроби. Ваша власна логіка ретраїв робить повторні спроби. Користувач двічі тапає «Купити». Будь-що з цього може надіслати той самий ордер двічі, а на біржі дубльований ордер — це реальні гроші. Захист — це згенерований на клієнті ключ ідемпотентності, який більшість бірж називають clientOrderId або newClientOrderId, прикріплений до кожного ордера в момент, коли користувач його підтверджує, ще до того, як запит покине пристрій чи сервер.
Правило, яке ми запроваджуємо: ключ генерується один раз, зберігається локально перед мережевим викликом і використовується дослівно на кожній повторній спробі. Якщо перша спроба завершилася таймаутом без відповіді, повторна спроба несе той самий ключ, і біржа або створює ордер один раз, або повертає вже наявний. У будь-якому разі ви сходитеся до одного-єдиного ордера. Згенеруйте ключ після відмови — і ви збудували машину для дублювання ордерів.
Це поширюється і на скасування та зміни. Запит на скасування також має бути ідемпотентним: скасування вже скасованого ордера — це no-op, який повертає успіх, а не помилка, що запускає ще один цикл ретраїв. Ми всюди трактуємо «бажаний кінцевий стан уже істинний» як успіх. Звучить банально. Це усуває цілу категорію штормів повторних спроб.
Головний висновок
Зберігайте ключ ідемпотентності до того, як діяти, а не після того, як досягнете успіху. Проміжок між «я його надіслав» і «я знаю, що він спрацював» — це саме та точка, де зникають гроші, і ключ — єдине, що її закриває.
Звіряйтеся невпинно: WebSocket — це підказка, а не правда
Біржі надсилають оновлення ордерів через WebSocket, і вони чудові, коли працюють: субсекундні виконання, актуальні обсяги часткових виконань, підтвердження скасувань. Але WebSocket — це потік, який можна пропустити. Вас неодмінно відключить. Повідомлення прийдуть у неправильному порядку, загубляться під час перепідключення або просто ніколи не надійдуть, бо біржа деградувала. Якщо стан вашого ордера — це лише те, що останнім сказав сокет, ваш застосунок стає впевнено хибним у ту саму мить, коли сокет затинається.
Тож ми запускаємо два цикли. Швидкий цикл споживає WebSocket і застосовує події оптимістично. Повільний цикл опитує REST API — GET /order та ендпоінти відкритих ордерів — з певним інтервалом та після кожного перепідключення, а потім звіряє цей знімок з локальним станом. REST — джерело правди; сокет — низьколатентна підказка. Коли вони розходяться, перемагає REST, а ми логуємо розбіжність, бо розбіжність — це баг-репорт, який система написала за вас.
Послідовність має значення. Більшість бірж проставляють на оновленнях монотонний порядковий номер або updateTime. Ми відкидаємо будь-яку подію, старішу за останню застосовану для цього ордера, що робить доставку у неправильному порядку нешкідливою. Під час перепідключення правильний хід такий: перепідписатися, негайно витягнути повний REST-знімок, щоб заповнити прогалину, а потім знову довіряти живому потоку. Пропустіть знімок — і ви втратите виконання, що сталося за ті дві секунди, поки ви були офлайн.
Головний висновок
Ніколи не дозволяйте потоку реального часу бути єдиним записувачем стану. Сокет робить вас швидкими; цикл звірки робить вас правильними. Вам потрібні обидва, а коли вони конфліктують, правильність щоразу перемагає швидкість.
Часткові виконання та розриви зв’язку: двоє, що вас викривають
Часткові виконання — це те місце, де наївні застосунки тихо брешуть. Ордер на 1.0 BTC, який виконується на 0.3, потім на 0.4, а тоді зависає, — це не «відкритий» і не «завершений» ордер. Це PARTIALLY_FILLED з 0.7 виконаного та 0.3, що лишилося, і кожне з цих чисел має відстежуватися з окремих подій виконання, а не виводитися з єдиного поля статусу. Ми акумулюємо виконану кількість і середньозважену за обсягом ціну з дискретних виконань, тож позицію завжди можна відновити з примітивів. Користувач, який бачить «виконано», має знати, виконано скільки і за якою середньою ціною. Будь-що менше — це напівправда.
Розриви зв’язку це поглиблюють. Відпадіть офлайн посеред виконання — і WebSocket ніколи не повідомить вам про ті 0.4, тож ваш локальний стан застаріє. Саме тому й існує дисципліна «перепідключення, а потім знімок»: REST-знімок повертає кумулятивну виконану кількість, і цикл звірки закриває прогалину, а користувач так і не бачить хибного числа. Режим відмови, від якого ми проєктуємо захист, — це не «застосунок упав»: падіння чесні. Це «застосунок продовжив працювати й показав щось хибне».
Головний висновок
Відстежуйте кумулятивну виконану кількість як первинний факт і виводьте з нього все інше. Ніколи не зберігайте «залишок» як незалежне поле, яке ви мутуєте. Виводьте його (ordered − executed), щоб воно не могло розсинхронізуватися з реальністю.
Правдивість під тиском перемагає винахідливість
Існує стійка спокуса бути винахідливим: передбачати виконання ще до підтвердження, приховувати «тимчасові» помилки, згладжувати перепідключення так, щоб інтерфейс не блимнув. Ми навчилися опиратися більшості цього. Торговому оператору не потрібна гладка брехня; йому потрібно чесно знати, що з’єднання щойно обірвалося і стан звіряється. Видимий стан «перепідключення, перевіряю ордери» вартий більше за безшовний інтерфейс, який виявляється показом учорашньої правди.
Конкретно, ми виносимо невизначеність назовні, а не заклеюємо її. Коли локальний стан і біржа давно не звірялися, застосунок про це каже. Коли ордер у стані CANCEL_REQUESTED, але скасування ще не підтверджене, він показує саме це, а не передчасний CANCELED. Оптимістичний інтерфейс годиться для застосунку зі списком справ. Для грошей оптимізм — це пасив, за який ви платите тікетами в підтримку і втраченою довірою.
Головний висновок
Коли є сумнів — покажіть сумнів. Найбільш заспокійливе, що фінансовий застосунок може зробити під тиском, — це відмовитися вгадувати.
Прикінцева думка
Нічого з цього не є гламурним. Немає демонстрації, де «наші ключі ідемпотентності зберігаються перед мережевим викликом» зривають оплески. Але надійність — це не функція, яку ви додаєте пізніше. Це набір рішень, які ви ухвалюєте рано й відмовляєтеся ними поступатися, а потім захищаєте щоразу, коли зрізати кут виглядає спокусливо. Побудуйте систему, яка каже правду, коли все горить, — і швидкі, гарні частини подбають про себе самі.