Скулятчер

в 18:34, , рубрики: sql, финансы в IT, юмор

Сижу я вчера спокойно, как водится никого особо не трогаю. Тут с двух разных контактов, почти одновременно присылают ссылку на небезызвестный твит про JSON из SQL. Одно из сообщений выглядело так:

Скулятчер - 1

Это был уже прямой вызов. Игнорировать его я не мог. Поэтому решил рассказать историю, которая до сих пор вызывает во мне двойственные чувства. Спустя три года.

В то благословенное время все грезили криптой, занимались ICO и ваяли криптобиржи. Это было действительно что-то новое. У меня был опыт создания систем для классического управления активами (акции, облигации и т.п.). Проблема была в том, что он был сформирован вокруг учетных систем. Мне хотелось реализоваться в создании биржи. Не удивительно, что я с удовольствием нырнул в этот кипящий котел.

Это предыстория. Было много чего интересного, но сегодня я хочу рассказать о конкретном случае — как мы создавали свой матчер.

Матчер, это ядро биржи. Именно в нем происходят сделки. В классическом стереотипе это высокопроизводительная подсистема. Но это справедливо для крупных бирж. Там открытые заявки на куплю и продажу исчисляются сотнями тысяч.

Создаваемая “на коленке” биржа не может нагнать себе такой трафик. Она вынуждена искать нишу. Один из методов — создание специфичных инструментов, которые оттягивают на себя часть пользователей крупных бирж. Самый тупой метод — демпинг комиссии на ввод и вывод крипты.

В нашем случае разговор шел о сотнях сделок в сутки.

Матчер у нас был на Java от стороннего вендора. Эта штука регулярно валилась, требовала ресурсов, и стоила денег по подписке. Плюс имела свою экосистему, которую без разрешения вендора развивать было нельзя. Отсюда мы попросту не могли предоставить те самые “уникальные” инструменты трейдеру. Именно это стало причиной писать свой матчер.

В своей практике я всегда стараюсь придерживаться пути селекции решений. Под селекцией я понимаю выбор перспективных решений, затем их сравнение, прототипирование и возможное “скрещивание” для получения лучших результатов. В этот раз все пошло по той же схеме. В результате появился прототип матчера на С, который был способен выдавать около 270К сделок в секунду на моем компе. Принимал заявки и отдавал сделки он в RabbitMQ. Но….

Но матчер требовал доработок. Пока это был прототип. Оценка была около 2х месяцев. И, к сожалению, допилить мог его только я. Конечно, если бы он взлетел, то речь бы шла о включении С в стек и наем персонала.

Состоялся разговор с фаундером. Он скептически отнесся к идее. Т.е. да, это здорово… но требует времени, людей и т.п. И это не весело. Честно сказать я расстроился и брякнул что-то типа:

Для наших объемов матчер можно и в мускуле наваять! (с)

Кто бы мог подумать, но фаундер зацепился за эту идею. И предложил ее прототипировать. Ну как предложил… сказал сделать. Нет слов описать мои чувства на тот момент.

Одно дело брякнуть, другое сделать. Суть задачи в том, что есть входящие заявки на покупку и продажу. Одни хотят продать подороже, другие купить подешевле. Эти заявки нужно сопоставлять и выполнять — генерировать сделку.

Фишка в том, что заявка может матчиться с несколькими заявками. Например, есть несколько заявок на продажу по 100р, каждая с объемом 5. В этом случае заявка на покупку по 100р с объемом 7 должна полностью погасить первую по хронологии заявку и частично погасить вторую по хронологии заявку. Если же заявка на покупку превосходит цену продажи, то сматчиться она должна по цене продажи.

Из этого рисуется не сильно простой и быстрый SQL код. Я искренне надеялся, что результат окажется плачевным. Но дело было с MySQL и обнаружилась интересная фича. Дело в том, что внутри запросов MySQL можно менять значение переменной. Т.е. есть возможность считать накопительный итог построчно выполняя запрос. Выглядит это примерно так:

SET depth_sell.`limit` = depth_sell.`limit` - IF(
                    ((@tofill := IF(
                            depth_sell.`limit` <= @limit,
                            depth_sell.`limit`,
                            @limit
                        ))
                        + (@limit := @limit - @tofill)),
                    @tofill,
                    @tofill
                ),
        depth_sell.`executed` = @tofill
WHERE @limit > 0;

и отдельно

@limit := @limit - @tofill

В голове быстро родился план как филить (исполнять) заявку по стакану.

Второй прекрасной фичей оказались IN MEMORY таблицы. Т.е. данные этих таблиц хранятся в ОЗУ сервера. Что делает операции с ними ощутимо быстрыми.

Все внезапно срослось и родился прототип. Имя мы ему дали — Скулятчер. Во многом это название отражало мое отношение к нему. В гит я класть его не стал, спрятал тут, в подкате.

Таблицы

CREATE TABLE transactions
(
    id INT AUTO_INCREMENT PRIMARY KEY,
    moment TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
    side1 INT NOT NULL,
    side2 INT NOT NULL,
    price BIGINT NOT NULL,
    volume BIGINT NOT NULL
);

CREATE TABLE depth_buy
(
    id BIGINT PRIMARY KEY NOT NULL AUTO_INCREMENT,
    order_id  BIGINT NOT NULL,
    type INT DEFAULT '0' NOT NULL,
    market INT DEFAULT '0' NOT NULL,
    account INT NOT NULL,
    price BIGINT DEFAULT '0' NOT NULL,
    `limit` BIGINT DEFAULT '0' NOT NULL,
    taker INT,
    rev_price BIGINT NOT NULL,
    executed BIGINT
) ENGINE = MEMORY;

CREATE TABLE depth_sell
(
    id BIGINT PRIMARY KEY NOT NULL AUTO_INCREMENT,
    order_id  INT NOT NULL,
    type INT DEFAULT '0' NOT NULL,
    market INT DEFAULT '0' NOT NULL,
    account INT NOT NULL,
    price BIGINT DEFAULT '0' NOT NULL,
    `limit` BIGINT DEFAULT '0' NOT NULL,
    taker INT,
    rev_price BIGINT NOT NULL,
    executed  BIGINT
) ENGINE = MEMORY;

Процедура выставления заявки

CREATE PROCEDURE `make_order_v2`(IN order_id INT,
                                 IN order_type INT,
                                 IN order_account INT,
                                 IN order_market INT,
                                 IN order_limit BIGINT,
                                 IN order_price BIGINT)
BEGIN
    START TRANSACTION;
    SET @limit := order_limit;
    IF order_type = 21 THEN
        UPDATE depth_sell
            INNER JOIN (
                SELECT id
                FROM depth_sell
                WHERE market = order_market
                  AND depth_sell.price <= order_price
                ORDER BY depth_sell.price + id ASC
            ) source ON depth_sell.id = source.id
        SET depth_sell.taker      = order_id,
            depth_sell.`limit`    = depth_sell.`limit` - IF(
                    ((@tofill := IF(
                            depth_sell.`limit` <= @limit,
                            depth_sell.`limit`,
                            @limit
                        ))
                        + (@limit := @limit - @tofill)),
                    @tofill,
                    @tofill
                ),
            depth_sell.`executed` = @tofill
        WHERE @limit > 0;

        INSERT INTO transactions (moment, side1, side2, price, volume)
        SELECT now(), depth_sell.id, order_id, depth_sell.price, depth_sell.executed
        FROM depth_sell
        WHERE depth_sell.`taker` = order_id;

        DELETE
        FROM depth_sell
        WHERE market = order_market
          AND depth_sell.`limit` = 0;

        IF @limit > 0 THEN
            INSERT INTO depth_buy (order_id, type, market, account, price, rev_price, `limit`)
            VALUES (order_id, order_type, order_market, order_account, order_price, -order_price, @limit);
        END IF;
    ELSE
        UPDATE depth_buy
            INNER JOIN (
                SELECT id
                FROM depth_buy
                WHERE market = order_market
                  AND depth_buy.price >= order_price
                ORDER BY depth_buy.rev_price - id ASC
            ) source ON depth_buy.id = source.id
        SET depth_buy.taker      = order_id,
            depth_buy.`limit`    = depth_buy.`limit` - IF(
                    ((@tofill := IF(
                            depth_buy.`limit` <= @limit,
                            depth_buy.`limit`,
                            @limit
                        ))
                        + (@limit := @limit - @tofill)),
                    @tofill,
                    @tofill
                ),
            depth_buy.`executed` = @tofill
        WHERE @limit > 0;

        INSERT INTO transactions (moment, side1, side2, price, volume)
        SELECT now(), depth_buy.id, order_id, depth_buy.price, depth_buy.executed
        FROM depth_buy
        WHERE depth_buy.`taker` = order_id;

        DELETE
        FROM depth_buy
        WHERE market = order_market
          AND depth_buy.`limit` = 0;
        IF @limit > 0 THEN
            INSERT INTO depth_sell (order_id, type, market, account, price, rev_price, `limit`)
            VALUES (order_id, order_type, order_market, order_account, order_price, -order_price, @limit);
        END IF;
    END IF;
    COMMIT;
END;

Нагрузочная процедура

CREATE PROCEDURE do_load_matcher_v2(IN market INT)
BEGIN
    DECLARE count INT DEFAULT 100000;
    WHILE count > 0 DO
            call make_order_v2(
                    count,
                    IF(count % 2 = 0, 21, 22),
                    1,
                    market,
                    FLOOR(1 + (RAND() * 1000)),
                    FLOOR(1 + (RAND() * 1000))
                );
            SET count = count - 1;
        END WHILE;
END;

Дам буквально несколько пояснений к коду, т.к. в целом он лаконичен. Имеются три таблицы:

  1. depth_buy — содержит заявки на покупку;
  2. depth_sell — содержит заявки на продажу;
  3. transaction — исполненные заявки = сделки.

Вызывая процедуру make_order_v2 ей передаются параметры заявки:

  1. order_id — Уникальный идентификатор заявки.
  2. order_type — Тип заявки. 21 — покупка, 22 — продажа.
  3. order_account — Идентификатор клиента (владельца ордера).
  4. order_market — Идентификатор торговой площадки. Сделки по площадкам не должны пересекаться.
  5. order_limit — Объем заявки. Дробные значения закодированы в целые числа путем умножения на десятки. Например, на 100. Т.е. 1.15btc в этом случае будет равно 115.
  6. order_price — Цена заявки. Также кодируется в целые числа.

При вызове процедуры, она проверяет тип заявки и матчит ее с противоположными по типу. Ищет в стакане подходящие заявки и филит их — указывает исполненный объем по каждой заявке. Затем, пробегает по полученной таблице и выбирает исполненные заявки генерируя сделки. Последним шагом удаляет исполненные заявки из стакана.

Если заявка не была выполнена или оказалась выполнена частично, она помещается в соответствующий стакан и ждет встречной заявки.

Производительность, зависит от глубины стаканов. В подкате можно найти процедуру нагрузки. Она генерирует 100К заявок. В результате получается около 95К сделок. На моей машине процедура выполняется за 1.46 минуты. Это около 570 сделок в секунду. Более чем достаточно для задач биржи на тот момент.

Еще раз обращу внимание, что это прототип. Ему требуется обвязка. Например, по контролю баланса. И много что еще. Т.ч. свою биржу за вечер наваять не удасться.

Почему до сих пор во мне бушуют чувства, когда я вспоминаю этот опыт? С одной стороны:

  • Я не уверен в устойчивости производительности этого решения. На него влияет множество факторов.
  • Я до сих пор не смирился с тем, что тупая идея кинутая для того, чтобы наглядно продемонстрировать необходимость правильного выбора была реализована. Причем мной же.

С другой стороны:

  • Бизнес получил то что ему нужно было в минимальное время. Конечно, завтра он мог упереться в лютые ограничения. Но не сегодня.
  • Действия в таком матчере прозрачны. Его может поддерживать любой бэкендер. Решение имеет все профиты транзакционной СУБД.

Конечно, я не разделяю оптимизма автора оригинального поста о том, что можно все сделать через СУБД, но опыт показывает, что через СУБД можно сделать все. Если захотеть. Поэтому, нужно аккуратно относиться к своим желаниям. В противном случае можно испытывать душевную боль годами.

Всем добра!

Оригинальный тивит

Автор: Роман

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js