Розробка системи ордерів (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 тижнів
Бюджет проєкту розраховується індивідуально під ваші вимоги. Отримайте консультацію по вашому проєкту — зв'яжіться з нами для попередньої оцінки. Також ви можете замовити аудит поточної архітектури — ми надамо звіт з рекомендаціями.







