Binance вже виконав ордер, а ваш Python-процес упав: чому transactional outbox не вирішує всю consistency

💡 Усі статті, обговорення, новини про Python — в одному місці. Приєднуйтесь до Python спільноти!

У попередніх матеріалах я вже писав про те, як trading bot поступово виріс із Python-скрипта в значно складнішу систему, а кількість тестів зросла від 130 до 5595. Але всю цю складність легко сприймати як зайву, поки не подивишся на конкретний момент, де система може втратити зв’язок між тим, що сталося на біржі, і тим, що вона сама встигла зафіксувати.

Найнеприємніший збій у торговій системі не той, де ордер не виконався. Набагато цікавіша ситуація, коли він виконався, а ваш процес про це не знає. З боку все виглядає просто: купує дешевше, продає дорожче. Часто я саме так і пояснюю суть у чотирьох словах.

Але між рішенням купити і моментом, коли система може впевнено сказати «так, ця покупка відбулася і правильно врахована», є доволі багато місць, де все може піти не за планом.

Візьмемо один сценарій.

Python-процес відправив ордер на Binance. Біржа його прийняла і виконала. Процес упав раніше, ніж результат був повністю оброблений локально.

Що робити після запуску?

[Ілюстрація 1. Python process відправляє BUY у зовнішню біржу. Біржа показує FILLED. Між біржею та локальним станом процес падає.]

На перший погляд відповідь проста: повторити операцію.

На практиці це може бути найгіршою можливою дією.

Помилка запиту ще не означає, що операція не відбулася

Ми легко звикаємо до моделі:

request failed = operation failed

Для зовнішньої системи, яка змінює реальний стан, це не завжди працює. З’єднання могло обірватися. Міг спрацювати timeout. Процес міг завершитися через секунду після відправки запиту. А Binance до цього моменту вже міг прийняти ордер, виконати його частково або виконати повністю.

Після перезапуску ми опиняємося в ситуації, де не можемо чесно сказати ні success, ні failed. Результат просто невідомий.

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

Обидва варіанти схожі на спробу автоматично відновитися. Обидва можуть зробити ситуацію гіршою.

Де тут transactional outbox

Transactional outbox вирішує важливу задачу. Наприклад, нам потрібно змінити локальний стан і в тій самій транзакції зафіксувати повідомлення, яке буде доставлене пізніше.

Спрощено:

BEGIN

change local state
save message

COMMIT

Якщо процес впаде після commit, повідомлення не зникне. Його можна буде відправити повторно.

Але з Binance є додаткова межа. Ми можемо надійно записати:

I intend to BUY

Можемо зберегти історію спроби. Можемо організувати надійну доставку команди. Але після того, як команда пішла в зовнішню систему, виникає інше питання:

що реально сталося там?

Binance не бере участі в нашій локальній транзакції. Не існує такого:

BEGIN our local state Binance matching engine COMMIT

Тому надійно зберегти намір недостатньо. Після невизначеного результату треба ще встановити факт.

У цей момент у системи з’являються дві різні картини події. Локальна сторона знає, що вона хотіла виконати і що встигла зафіксувати. Біржа знає, що реально сталося з ордером. Завдання recovery не в тому, щоб вибрати одну з них, а в тому, щоб безпечно звести їх між собою.

У самій ідеї тут немає нічого нового. Distributed systems давно живуть з idempotency, journals, workflows, sagas та reconciliation. Цікаве починається там, де закінчуються гарантії кожного окремого механізму.

Спочатку факт, потім дія

У багатьох системах retry є нормальною реакцією на помилку. Запит не пройшов, пробуємо ще раз.

Для торгової операції безпечніший порядок інший.

Якщо результат невідомий, спочатку треба з’ясувати, що вже сталося на біржі. Потім перевірити, що система вже встигла врахувати локально. І тільки після цього вирішувати, чи потрібна наступна дія.

невідомий результат
↓
що сталося на біржі?
↓
що ми вже врахували?
↓
ці стани узгоджуються?
↓
продовжити / нічого не робити / зупинитися 

У моїй реалізації recovery спочатку лише звіряє факти. Він не створює нові ордери і не намагається автоматично «виправити» стан на біржі.

Спочатку треба зрозуміти, що вже відбулося.

Треба знати, який саме ордер ми перевіряємо

Після перезапуску марно питати:

Чи не купували ми десь приблизно в цей час приблизно таку кількість?

Ще до відправки ордера система повинна створити стабільний зв’язок між локальною операцією та конкретним ордером на біржі. Тоді після збою ми перевіряємо не «схожу угоду», а конкретну операцію.

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

[Ілюстрація 2. Ліворуч локальна операція з умовним ID ABC. Праворуч ордер біржі з тим самим ID. Поруч перекреслений пошук «приблизно за часом, ціною і кількістю».]

Навіть FILLED ще не означає, що все завершено

Припустимо, після перезапуску біржа підтвердила, що ордер виконаний. Здається, найскладніше позаду, але насправді тепер починається друга половина проблеми.

Результат треба правильно відобразити в даних системи, а процес може впасти прямо посеред цього. Частина даних уже могла змінитися, а частина ще ні. Або всі зміни вже відбулися, але система не встигла зафіксувати завершення операції.

Після наступного запуску треба відповісти на питання:

ми ще не застосували результат чи вже застосували його, але не встигли це підтвердити?

Якщо просто виконати все вдруге, можна подвоїти кількість активу, двічі врахувати комісію або повторно записати ту саму угоду. Тому безпечне повторне виконання потрібне не тільки на рівні HTTP-запиту. Воно потрібне на всьому шляху від результату біржі до даних системи.

Треба відрізняти стан до зміни від стану після неї

Один із принципів, який тут виявився дуже корисним, доволі простий. Перед важливою зміною система повинна мати достатньо інформації, щоб після збою зрозуміти: результат ще не був застосований, уже був застосований або ми бачимо щось, чого не очікували.

Тоді після перезапуску є три основні варіанти.

  • ми все ще бачимо старий стан. Отже зміна ще не була застосована;
  • ми бачимо саме той стан, який очікували після операції, отже повторювати її не потрібно;
  • поточний стан не відповідає ні першому, ні другому варіанту.

Саме третій випадок найцікавіший.

У такій ситуації автоматично «довести все до правильного вигляду» дуже спокусливо. Але система вже не може довести, звідки взялася ця різниця. Можливо, частина операції встигла виконатися. Можливо, дані змінив інший процес. Можливо, ми взагалі зіткнулися з новою помилкою.

Тому тут безпечніше не вгадувати.

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

Це вже траплялося не лише в тестах

Під час одного з контрольованих TESTNET запусків біржа підтвердила FILLED. Тобто з точки зору біржі операція вже відбулася, але під час локальної обробки перевірка побачила стан, який не відповідав тому, що система очікувала після цієї операції.

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

Нічого з цього автоматично не сталося.

Система не переписала локальний стан, не відправила нову торгову операцію і не зробила вигляд, що все добре. Подальша робота була зупинена до окремого розбору.

Для мене цей випадок був значно цікавішим за десятки успішних запусків. Система отримала шанс замаскувати неоднозначність під успішний стан і відмовилася це робити.

А якщо ордер виконався лише частково

Тепер трохи ускладнимо сценарій. Ми відправили BUY, частина ордера виконалася, а процес упав. Поки він був вимкнений, на біржі могла виконатися ще одна частина.

Після запуску питання вже звучить не так:

Ордер виконався чи ні?

Тепер треба зрозуміти:

Яку частину виконання ми вже врахували, а яку ще ні?

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

І якщо нові дані суперечать тому, що система вже вважала підтвердженим, це теж треба побачити, а не виправити мовчки.

Ось приблизно тут і з’являється та сама купа коду і купа тестів.

Не через любов до складності. Просто happy path тут виявляється найпростішою частиною.

Хороше відновлення іноді нічого не робить

Успішний сценарій короткий:

order sent
order filled
response received
local state updated
done

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

У кожному випадку питання однакове:

чи можемо ми довести, що наступна автоматична дія не зробить ситуацію гіршою?

Якщо відповідь «ні», система не повинна продовжувати торгівлю тільки тому, що Python-процес успішно запустився.

При цьому хороший recovery далеко не завжди щось змінює. Цілком нормальний результат:

стан біржі відомий
локальні дані вже правильні
нічого робити не потрібно

Інший нормальний результат:

безпечний стан довести не вдалося
торгівля заблокована

Обидва варіанти для мене кращі за автоматичне «виправлення», яке система не може обґрунтувати.

А що з Temporal, Saga та idempotency key

Temporal або інший workflow engine цілком може бути корисним у такій системі. Він може зберігати хід процесу, запускати кроки, повторювати дозволені операції, працювати з timeout і показувати, де саме щось зупинилося.

Але Binance все одно залишається зовнішньою системою. Якщо дія вже відбулася, а ми не отримали відповідь, хтось усе одно повинен перевірити реальний результат і зрозуміти, що він означає для наших даних.

Те саме стосується Saga. Якщо BUY уже виконаний, SELL не повертає систему в минуле. Це нова торгова операція з іншою ціною, комісією і власним ризиком. Тому compensating action не є справжнім ROLLBACK.

Сильний idempotency key також дуже допомагає, бо може не дозволити повторному запиту створити другу незалежну операцію. Але після цього все одно залишаються питання: що сталося з першою операцією, яка її частина вже виконана, що ми вже врахували локально і чи можна безпечно продовжувати.

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

Це не тільки про trading

У trading ці наслідки просто особливо помітні. Схожа ситуація виникає в платежах, коли клієнт не отримав відповідь, але провайдер уже списав гроші. У cloud automation API може повернути timeout, хоча ресурс уже створений. У deployment system процес може впасти, коли частина змін на сервері вже застосована.

У всіх цих випадках є наша локальна картина світу і є зовнішня система. Іноді вони розходяться.

Тому питання consistency для мене з часом перестало звучати так:

Який готовий патерн сюди поставити?

Кориснішими стали три інші питання:

  • Що вже реально відбулося?
  • Що система вже встигла врахувати?
  • Яку наступну дію ще безпечно виконувати?

Якщо на одне з них немає надійної відповіді, це вже важливий факт. Його не варто приховувати за автоматичним retry.

Один BUY уже створює досить багато роботи

Слово «бот» тут трохи вводить в оману. Навіть один BUY уже трохи схожий на маленьку distributed system: є локальне рішення, мережа, зовнішня система, фактичне виконання і власний стан, який треба з усім цим узгодити.

Зовні все ще можна намалювати так:

market
↓
BUY
↓
SELL 

А під цим ховаються мережа, біржа, часткові виконання, локальні дані, падіння процесу та повторний запуск. І спільної кнопки ROLLBACK, яка поверне все назад, немає.

[Ілюстрація 3. Айсберг. Над водою BUY і SELL. Під водою: timeout, partial fill, restart, local state, duplicate prevention, reconciliation, blocked state. Без внутрішніх назв компонентів.]

Саме тому велика частина роботи навколо execution полягає не у відправці ордера.

Вона полягає у здатності після збою відповісти:

  • що вже сталося;
  • що система вже врахувала;
  • що їй зараз дозволено робити.

І це тільки після того, як рішення вже прийняте

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

Але перед BUY є інша проблема. Навіть хороший торговий сигнал ще не означає, що система має право автоматично відправити ордер.

Це вже окрема історія.

Сигнал ще не означає ордер.

👍ПодобаєтьсяСподобалось4
До обраногоВ обраному1
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter