Розробка системи ордерів (limit, market, stop)
Запуск криптобіржі: баг в matching engine може за секунди обнулити ліквідність. Помилка в обробці ринкового ордера здатна викликати slippage на 20% і відтік користувачів. Ми розробили десятки торгових систем за 5+ років і знаємо, як уникнути цих ризиків. Наш matching engine на Go досягає latency <1 мс на рівні order book, що в 10 разів швидше типових реалізацій на Node.js. Ми використовуємо btree для зберігання price levels та бедлок-фрі структури для конкурентного доступу. В результаті система витримує до 100 000 ордерів за секунду на одному інстансі. Середня економія на інфраструктурі порівняно з рішеннями на Node.js досягає 40%, а термін окупності становить 6–9 місяців. Нижче — деталі реалізації та ключові архітектурні рішення.
Типи ордерів та їх семантика
Limit order
Користувач вказує ціну та обсяг. Ордер виконується лише якщо ринок досягне вказаної ціни або кращої.
-
Buy limit: виконується за ціною ≤ вказаної -
Sell limit: виконується за ціною ≥ вказаної - Може бути частково виконаний (partial fill)
- Невиконана частина залишається в order book
Додаткові модифікатори: GTC (Good Till Cancelled), GTD (Good Till Date), IOC (Immediate Or Cancel), FOK (Fill Or Kill), Post-Only.
Market order
Виконується негайно за найкращою доступною ціною. Гарантує виконання, але не гарантує ціну. На неліквідних ринках можливий значний slippage. Безпечна реалізація включає ліміт slippage — якщо виконання вимагає проходження більше ніж на X%, ордер відхиляється з помилкою PRICE_IMPACT_TOO_HIGH.
Stop order
Тригерний ордер. Активується коли ціна досягає stop price. Після активації перетворюється на market або limit.
- Stop-Market: при досягненні stop price створюється market ордер
- Stop-Limit: при досягненні stop price створюється limit ордер із вказаним limit price
- Trailing Stop: stop price слідує за ринком на задану відстань
Stop ордери не знаходяться в order book — вони зберігаються окремо в stop orders storage та моніторяться за зміною ціни.
Архітектура matching engine
Структура даних order book
Класична реалізація — два sorted map (bid side та ask side) з ціною як ключем. У кожному price level — черга ордерів (FIFO для price-time priority).
type PriceLevel struct { Price decimal.Decimal Orders []*Order // FIFO queue Total decimal.Decimal // cached volume } type OrderBook struct { Bids *btree.BTree // descending (max bid first) Asks *btree.BTree // ascending (min ask first) mu sync.RWMutex } Вибір структури даних критичний: Red-Black Tree (Go btree) — O(log n) insert/delete, Skip List — конкурентний доступ, Array + binary search — швидко на малих книгах. Для <10,000 active orders btree достатньо; при >100,000 та latency <100 мкс потрібна складніша архітектура. Згідно з CME Globex Matching Algorithm, гібридні схеми забезпечують найкращий баланс.
| Структура даних | O-нотація | Конкурентність | Застосовність |
|---|---|---|---|
| B-tree (Go btree) | O(log n) | RWLock | <100k orders |
| Skip List | O(log n) avg | Lock-free | >100k orders, high concurrency |
| Array + Binary Search | O(log n) search, O(n) insert | Lock per operation | Small order books, <1k |
Алгоритм matching
Price-time priority (FIFO) — стандарт для більшості бірж:
func (ob *OrderBook) Match(incoming *Order) ([]Trade, *Order) { ob.mu.Lock() defer ob.mu.Unlock() var trades []Trade remaining := incoming.Quantity for remaining > 0 { bestLevel := ob.getBestOppositeLevel(incoming.Side) if bestLevel == nil { break } if !ob.priceMatches(incoming, bestLevel) { break } for len(bestLevel.Orders) > 0 && remaining > 0 { maker := bestLevel.Orders[0] fillQty := min(remaining, maker.RemainingQty) trade := Trade{ TakerOrderID: incoming.ID, MakerOrderID: maker.ID, Price: bestLevel.Price, Quantity: fillQty, Timestamp: time.Now().UnixNano(), } trades = append(trades, trade) remaining -= fillQty maker.RemainingQty -= fillQty if maker.RemainingQty == 0 { bestLevel.Orders = bestLevel.Orders[1:] } } if len(bestLevel.Orders) == 0 { ob.removeLevel(incoming.Side.Opposite(), bestLevel.Price) } } incoming.RemainingQty = remaining return trades, incoming } | Алгоритм | Застосування | Особливості |
|---|---|---|
| FIFO (Price-Time) | Більшість CEX | Простий, справедливий |
| Pro-Rata | Ф'ючерси (CME) | Великі ордери отримують пріоритет |
| FIFO + Pro-Rata | ICE, Euronext | Гібридний |
| Uniform Price (Batch) | DEX, аукціони | Всі угоди за однією ціною |
Для стандартної CEX обираємо FIFO. Pro-Rata ускладнює реалізацію та провокує спам дрібними ордерами.
Чому ми використовуємо in-memory matching engine?
Matching engine працює в пам'яті — це дає latency в одиниці мілісекунд замість десятків. База даних (PostgreSQL) використовується лише для персистентності: при старті сервер завантажує всі open ордери в пам'ять. Запис в БД — асинхронний, через чергу. Такий підхід витримує 50,000–100,000 ордерів/сек на одному інстансі. Для масштабування використовуємо шардинг за торговими парами. Розробка власного рішення обходиться в 3–5 разів дешевше щорічної ліцензії готового пропрієтарного двигуна.
Як захиститися від race conditions при відміні та fill?
Перед розміщенням ордера резервуємо кошти: buy limit — price * quantity в quote, sell limit — quantity в base. При відміні звільняємо резерв. Atomicity забезпечується через in-memory баланс з асинхронною синхронізацією в БД. В-пам'яті баланс — source of truth для торгівлі, БД — для персистентності та UI. Всі операції з балансом виконуються під м'ютексом, що запобігає race conditions.
Модель даних
CREATE TABLE orders ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id BIGINT NOT NULL REFERENCES users(id), pair_id SMALLINT NOT NULL, side SMALLINT NOT NULL, type SMALLINT NOT NULL, status SMALLINT NOT NULL DEFAULT 0, price NUMERIC(36,18), stop_price NUMERIC(36,18), quantity NUMERIC(36,18) NOT NULL, filled_qty NUMERIC(36,18) NOT NULL DEFAULT 0, time_in_force SMALLINT NOT NULL DEFAULT 0, expire_at TIMESTAMPTZ, client_order_id VARCHAR(64), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE TABLE trades ( id BIGSERIAL PRIMARY KEY, pair_id SMALLINT NOT NULL, taker_order_id UUID NOT NULL, maker_order_id UUID NOT NULL, taker_user_id BIGINT NOT NULL, maker_user_id BIGINT NOT NULL, price NUMERIC(36,18) NOT NULL, quantity NUMERIC(36,18) NOT NULL, taker_fee NUMERIC(36,18) NOT NULL, maker_fee NUMERIC(36,18) NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX idx_orders_user_status ON orders(user_id, status) WHERE status IN (0, 1); CREATE INDEX idx_orders_pair_side_price ON orders(pair_id, side, price) WHERE status IN (0, 1); Критичний момент: matching engine працює в пам'яті, БД — лише для персистентності. Запис в БД асинхронний, через чергу.
Stop orders та тригерний механізм
Stop ордери зберігаються в окремій структурі — sorted по stop price. При кожній угоді matching engine публікує останню ціну. Stop orders processor підписується на price updates:
func (sp *StopProcessor) OnPriceUpdate(pair string, lastPrice decimal.Decimal) { triggeredBuys := sp.buyStops.GetTriggered(pair, lastPrice) triggeredSells := sp.sellStops.GetTriggered(pair, lastPrice) for _, stop := range append(triggeredBuys, triggeredSells...) { sp.activateStop(stop, lastPrice) } } Trailing stop — особливий випадок. При русі ціни в бік користувача stop price перераховується. Реалізація через event-driven перерахунок при кожному trade.
Деталі реалізації trailing stop
Trailing stop — динамічний стоп-ордер, чия тригерна ціна слідує за ринком з фіксованим відступом. Алгоритм: при кожному оновленні ціни, якщо ціна рухається в бік клієнта, стоп-ціна перераховується: new_stop_price = current_market_price - distance для sell trailing stop. При зворотному русі (навпроти клієнта) стоп-ціна не змінюється, що дозволяє зафіксувати прибуток. В реалізації ми використовуємо priority queue по stop_price, яка оновлюється при кожному trade.
Decimal precision та floating point
Ніколи не використовуйте float64 для фінансових розрахунків. 0.1 + 0.2 != 0.3 в IEEE 754. Використовуємо: Go — shopspring/decimal, Python — decimal.Decimal, Java — BigDecimal, JavaScript — decimal.js. Всі збережені значення — NUMERIC(36,18). Precision та scale задаються для кожної торгової пари окремо (Bitcoin: 8 знаків, мем-коїни: до 18).
Етапи реалізації
- Проектування — аналіз вимог, вибір алгоритмів, моделювання потоків з навантаженням до 100 000 ордерів/сек.
- Розробка — написання matching engine з нуля або на основі референсної архітектури (Go, btree, decimal).
- Інтеграція — зв'язок з PostgreSQL, налаштування асинхронного запису та балансового модуля.
- Тестування — unit-тести (покриття >85%), property-based testing (fuzzing), навантажувальні тести з вимірюванням latency/throughput.
- Деплой — розгортання на інфраструктурі, моніторинг, навчання команди.
Що входить в роботу
При замовленні ви отримуєте:
- Архітектурну документацію matching engine та API специфікацію
- Репозиторій з вихідним кодом (Go, production-ready)
- Набір unit-тестів та інтеграційних тестів (покриття >85%)
- Результати навантажувального тестування з метриками latency/throughput
- Керівництво з розгортання та експлуатації
- 2 місяці постпродакшн підтримки та навчання вашої команди
Тестування
Matching engine покривається unit-тестами на граничні випадки:
- Partial fill з залишком
- FOK при недостатній ліквідності
- IOC з частковим виконанням
- Одночасна відміна та fill (race condition)
- Stop ордер спрацьовує в момент свого розміщення
- Decimal overflow на крайніх значеннях
Property-based testing (fuzzing) — генеруються випадкові послідовності ордерів, перевіряється інваріант: сумарний обсяг купленого = сумарному обсягу проданого, баланси сходяться.
Терміни розробки
- MVP (limit + market, без stop, без time-in-force): 3–4 тижні
- Повна система з stop orders, всіма TIF модифікаторами, trailing stop: 8–12 тижнів
- Production-ready з аудитом, навантажувальними тестами, моніторингом: +4–6 тижнів
Бюджет проєкту розраховується індивідуально під ваші вимоги. Отримайте консультацію по вашому проєкту — зв'яжіться з нами для попередньої оцінки. Також ви можете замовити аудит поточної архітектури — ми надамо звіт з рекомендаціями.







