Pull to refresh

История одного тестового задания в HFT-компанию под NDA

Level of difficultyHard
Reading time9 min
Views2K

Введение

В один прекрасный день мне написал рекрутер с крайне заманчивым предложением

Дмитрий, привет!

Наткнулся на твой профиль и подумал, что тебе может быть интересна вакансия в независимой HFT-компании, где много свободы и крутого R&D. Они не просто гонятся за скоростью, а активно используют машинное обучение в своем трейдинге.

На проде C++ (17/20), упор на многопоточку, Linux, оптимизацию и низколатентные системы. В зависимости от опыта есть несколько направлений — от гринфилд-разработки платформы до создания симулятора для тестирования стратегий. Команда сильная, процесс собеседования непростой, но если тебя драйвит алгоритмика и системное программирование, может быть, это как раз то, что ты ищешь.

Буду рад сотруднечиству!

Я на тот момент как раз находился в поиске новой работы, поэтому предложение принял. Опустим стандартный звонок с этим рекрутером, с HR'ом компании и онлайн-тестовое и перейдём к более интересному - тестовому заданию. Сразу скажу, что тестовое не оплачивалось, и я взялся за него по нескольким причинам. Во-первых, оно мне и вправду понравилось, во-вторых, кодовую базу я планировал использовать в своём с корешами pet-проекте по финансам, в-третьих, не оставлял надежд пройти отбор до конца и получить желаемый offer. Спойлер - игра стоила свеч, поэтому прошу к прочтению.

Тестовое задание

Требовалось реализовать на C++ парсер pcap-файла, декодер протокола Spectra Simba (пары сообщений достаточно), проверить их в связке на тестовых данных Мосбиржы и сохранить результат в .jsonl-файле. И это всё без сторонних библиотек. Ни с чем из вышеперечисленного я раньше не работал, поэтому давайте разбираться вместе.

Парсер pcap-файла

Pcap (англ. Packet Capture) - это бинарный формат со структурой GlobalHeader, PacketHeader, PacketData, ..., PacketHeader, PacketData. Т.е. сначала идёт глобальный заголовок с полями, относящимися ко всему файлу, а дальше чередуются в паре заголовок пакета и его данные (см. руководства 1 и 2).

struct GlobalHeader {  
    uint32_t magic_number;  
    uint16_t version_major;  
    uint16_t version_minor;  
    int32_t thiszone;  
    uint32_t sigfigs;  
    uint32_t snaplen;  
    uint32_t network;  
};  
  
struct PacketHeader {  
    uint32_t ts_sec;  
    uint32_t ts_usec;  
    uint32_t incl_len;  
    uint32_t orig_len;  
};  
  
using PacketData = std::vector<uint8_t>;  
  
struct Packet {  
    PacketHeader header;  
    PacketData data;  
};

Если говорить про поля глобального заголовка, то

  • magic_number - магическое число=)

  • version_major/version_minor - версии формата, обычно 2 и 4 соответственно

  • thiszone - смещение от UTC в сек., чаще всего 0

  • sigfigs - точность времени, чаще всего 0

  • snaplen - максимальная длина пакета, обычно 65535

  • network - тип канального уровня

Из всех полей больший интерес представляет network, которое в нашем случае должно соответствовать Ethernet, т.к. протокол Spectra Simba перекидывает сообщения именно по Ethernet.

Если говорить про поля заголовка пакета, то

  • ts_sec - время захвата в сек.

  • ts_usec - микросекунды времени захвата

  • incl_len - длина фактических данных

  • orig_len - длина оригинальных данных

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

GlobalHeader Parser::ParseGlobalHeader(std::istream& file) {  
    GlobalHeader global_header{};  
  
    file.read(reinterpret_cast<char*>(&global_header), sizeof(global_header));  
    if (!file)  
        throw std::runtime_error("Can't read global header");  
  
    return global_header;  
}  
  
Packet Parser::ParsePacket(std::istream& file) {  
    PacketHeader header{};  
    file.read(reinterpret_cast<char*>(&header), sizeof(header));  
    if (!file)  
        throw std::runtime_error("Can't read packet header");  
  
    PacketData data(header.incl_len);  
    file.read(reinterpret_cast<char*>(data.data()), header.incl_len);  
    if (!file)  
        throw std::runtime_error("Can't read packet data");  
  
    return {header, data};  
}

Декодер протокола Spectra Simba

Это бинарный протокол для срочного рынка Мосбиржы. Декодировать просили не все сообщения, а лишь OrderUpdate (обновления заявок), OrderExecution (исполнение заявок) и OrderBookSnapshot (снимок стакана).

Скрытый текст

Срочный рынок - это рынок форвардов, фьючерсов, опционов и свопов.

Форвард (англ. forward) - это обязательство купли/продажи в будущем, не регулируемое биржей.

Фьючерс (англ. future) - это обязательство купли/продажи в будущем, регулируемое биржей.

Опцион (англ. option) - это право купли/продажи в будущем, но в отличие от фьючерса сделка может быть отклонена.

Свап (англ. swap) - это денежный обмен в будущем.

Биржевая заявка (англ. order) - это указание купли/продажи.

Биржевой стакан (англ. order book) - это таблица лимитных заявок, т.е. тех, для которых указана желаемая цена (лимит) купли/продажи.

Заголовки

Согласно спецификации протокольные сообщения лежат внутри UDP-пакета, который внутри IPv4-пакета, а тот уже в Ethernet-пакете. Прям как с Кощеем - ... игла в яйце, яйцо в утке, утка в зайце и т.д.
Подключаем заголовочные файлы netinet и приступаем к разбору. Не лишним проверить, что протокол в Ethernet-заголовке именно IPv4, а протокол в IPv4-заголовке именно UDP

Decoder::PacketData Decoder::DecodeNetworkHeaders(const PacketData &packet_data) {  
    auto payload = packet_data.data();  
  
    const auto ethernet_header = reinterpret_cast<const struct ether_header*>(payload);  
    if (ntohs(ethernet_header->ether_type) != ETHERTYPE_IP)  
        throw std::runtime_error("Packet data isn't IPv4");  
    payload += sizeof(ether_header);  
  
    const auto ip_header = reinterpret_cast<const struct ip*>(payload);  
    if (ip_header->ip_p != IPPROTO_UDP)  
        throw std::runtime_error("Packet data isn't UDP");  
    const auto ip_header_size = (ip_header->ip_v & 0x0F) * 4;  
    payload += ip_header_size;  
  
    payload += sizeof(struct udphdr);  
    const auto payload_size = packet_data.size() - (sizeof(struct ether_header) + ip_header_size + sizeof(struct udphdr));  
  
    return {payload, payload + payload_size};  
}

И только после всего этого можно разбирать заголовки протокола, первое из которых MarketDataPacketHeader. В зависимости от значения его поля msg_flags можно перейти к разбору либо сообщений с инкрементом либо со снимком

void Decoder::DecodeMessage(const PacketData& packet_data) {  
    size_t offset = 0;  
    const auto market_data_packet_header = Cast<MarketDataPacketHeader>(packet_data.data(), offset);  
    const bool is_incremental = market_data_packet_header.msg_flags & 0x8;  
    if (is_incremental)  
        DecodeIncrementalMessage(packet_data, offset);  
    else {  
        DecodeSnapshotMessage(packet_data, offset);  
    }  
}

Incremental Packet

Это OrderUpdate и OrderExecution

struct OrderUpdate {  
    static constexpr uint16_t TEMPLATE_ID = 15;  
  
    int64_t md_entry_id;  
    Decimal5 md_entry_px;  
    int64_t md_entry_size;  
    MDFlagsSet md_flags;  
    MDFlags2Set md_flags2;  
    int32_t security_id;  
    uint32_t rpt_seq;  
    MDUpdateAction md_update_action;  
    MDEntryType md_entry_type;  
};  
  
struct OrderExecution {  
    static constexpr uint16_t TEMPLATE_ID = 16;  
  
    int64_t md_entry_id;  
    Decimal5NULL md_entry_px;  
    int64_t md_entry_size;  
    Decimal5 last_px;  
    int64_t last_qty;  
    int64_t trade_id;  
    MDFlagsSet md_flags;  
    MDFlags2Set md_flags2;  
    int32_t security_id;  
    uint32_t rpt_seq;  
    MDUpdateAction md_update_action;  
    MDEntryType md_entry_type;  
};

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

void Decoder::DecodeIncrementalMessage(const PacketData& packet_data, size_t& offset) {  
    Cast<IncrementalPacketHeader>(packet_data.data(), offset);  
  
    while (offset < packet_data.size()) {  
        const auto simple_binary_encoding_header = Cast<SimpleBinaryEncodingHeader>(packet_data.data(), offset);  
  
        switch (simple_binary_encoding_header.template_id) {  
            case OrderUpdate::TEMPLATE_ID:  
                _message_handler(Cast<OrderUpdate>(packet_data.data(), offset));  
                break;  
            case OrderExecution::TEMPLATE_ID:  
                _message_handler(Cast<OrderExecution>(packet_data.data(), offset));  
                break;  
            default:  
                offset += simple_binary_encoding_header.block_length;  
                break;  
        }  
    }  
}

Snapshot Packet

Это OrderBookSnapshot

struct OrderBookSnapshot {  
    static constexpr uint16_t TEMPLATE_ID = 17;  
  
    int32_t security_id;  
    uint32_t last_msg_seq_num_processed;  
    uint32_t rpt_seq;  
    uint32_t exchange_trading_session_id;  
    GroupSize no_md_entries;  
  
    struct Entry {  
        int64_t md_entry_id;  
        uint64_t transact_time;  
        Decimal5NULL md_entry_px;  
        int64_t md_entry_size;  
        int64_t trade_id;  
        MDFlagsSet md_flags;  
        MDFlags2Set md_flags2;  
        MDEntryType md_entry_type;  
    };  
  
    std::vector<Entry> entries;  
  
    static constexpr size_t SIZE =  
        sizeof(security_id) +  
        sizeof(last_msg_seq_num_processed) +  
        sizeof(rpt_seq) +  
        sizeof(exchange_trading_session_id) +  
        sizeof(no_md_entries);  
};

С этим типом пакета можно сразу перейти к чтению одного SimpleBinaryEncodingHeader и одного сообщения

void Decoder::DecodeSnapshotMessage(const PacketData& packet_data, size_t& offset) {  
    const auto simple_binary_encoding_header = Cast<SimpleBinaryEncodingHeader>(packet_data.data(), offset);  
  
    switch (simple_binary_encoding_header.template_id) {  
        case OrderBookSnapshot::TEMPLATE_ID: {  
            OrderBookSnapshot order_book_snapshot{};  
            std::memcpy(&order_book_snapshot, &packet_data[offset], OrderBookSnapshot::SIZE);  
            offset += OrderBookSnapshot::SIZE;  
  
            order_book_snapshot.entries.resize(order_book_snapshot.no_md_entries.num_in_group);  
            for (auto& entry: order_book_snapshot.entries) {  
                std::memcpy(&entry, &packet_data[offset], sizeof(OrderBookSnapshot::Entry));  
                offset += sizeof(OrderBookSnapshot::Entry);  
            }  
  
            _message_handler(order_book_snapshot);  
            break;  
        }  
        default:  
            break;  
    }  
}

Сохранение в .jsonl

.jsonl - это формат такого файла, каждая строчка которого является JSON'ом. Ну т.е. требовалось вести потоковую запись JSON'ов в файл. Если бы не ограничение на использование сторонних библиотек, то можно было взять одну из библиотек libjsoncpp, rapidjson или nlohmann_json. Но как оказалось формировать JSON на голом STL не такая уж и сложная задача, особенно в современных стандартах C++. Начиная с C++20 появился долгожданный std::format. Например, для сообщения OrderUpdate будет

struct MessageVisitor {  
    std::string operator()(const OrderUpdate& message); 
  
    std::string operator()(const OrderExecution& message);  
  
	std::string operator()(const OrderBookSnapshot& message);
}; 

template<class T>  
requires requires(T t) {  
    { T::exponent } -> std::same_as<const double&>;  
    { t.mantissa } -> std::same_as<int64_t&>;  
}  
double to_decimal(const T& value) {  
    return static_cast<double>(value.mantissa) * T::exponent;  
}

std::string MessageVisitor::operator()(const OrderUpdate& message) {  
    return std::format(  
        R"({{"md_entry_id": {}, "md_entry_px": {}, "md_entry_size": {}, "md_flags": {}, "md_flags2": {}, "security_id": {}, "rpt_seq": {}, "md_update_action": {}, "md_entry_type": {}}})",  
        message.md_entry_id,  
        to_decimal(message.md_entry_px),  
        message.md_entry_size,  
        std::to_underlying(message.md_flags),  
        std::to_underlying(message.md_flags2),  
        message.security_id,  
        message.rpt_seq,  
        std::to_underlying(message.md_update_action),  
        std::to_underlying(message.md_entry_type));  
}

Для сообщения OrderExecution примерно тоже самое. А вот с сообщением OrderBookSnapshot оказалось не так просто из-за вектора Entry, но и тут на помощь пришёл C++20 с std::formatter. Достаточно определить способ форматирования для вектора

template<typename T>  
struct std::formatter<std::vector<T>> {  
    constexpr auto parse(std::format_parse_context& ctx) {  
        return ctx.begin();  
    }  
  
    auto format(const std::vector<T>& vec, std::format_context& ctx) const {  
        auto out = ctx.out();  
        out = std::format_to(out, "[");  
        for (size_t i = 0; i < vec.size(); ++i) {  
            if (i > 0)  
                out = std::format_to(out, ", ");  
            out = std::format_to(out, "{}", vec[i]);  
        }  
        out = std::format_to(out, "]");  
        return out;  
    }  
};

Ну и в итоге

std::string MessageVisitor::operator()(const OrderBookSnapshot& message) {  
    std::vector<std::string> entries;  
    entries.resize(message.entries.size());  
    std::transform(message.entries.begin(), message.entries.end(), entries.begin(),  
                   [](const auto& entry) {  
                       return std::format(  
                           R"({{"md_entry_id": {}, "transact_time": {}, "md_entry_px": {}, "md_entry_size": {}, "trade_id": {}, "md_flags": {}, "md_flags2": {}, "md_entry_type": {}}})",  
                           entry.md_entry_id,  
                           entry.transact_time,  
                           to_decimal(entry.md_entry_px),  
                           entry.md_entry_size,  
                           entry.trade_id,  
                           std::to_underlying(entry.md_flags),  
                           std::to_underlying(entry.md_flags2),  
                           std::to_underlying(entry.md_entry_type));  
                   }  
    );  
  
    return std::format(  
        R"({{"security_id": {}, "last_msg_seq_num_processed": {}, "rpt_seq": {}, "exchange_trading_session_id": {}, "no_md_entries": {{"block_length": {}, "num_in_group": {}}}, "entries": {}}})",  
        message.security_id,  
        message.last_msg_seq_num_processed,  
        message.rpt_seq,  
        message.exchange_trading_session_id,  
        message.no_md_entries.block_length,  
        message.no_md_entries.num_in_group,  
        entries);  
}

Заключение

В общей сложности тестовое заняло у меня одну неделю. Основные трудности лично у меня были по связке парсера Pcap-файла с декодером протокола Spectra Simba - долго не мог понять, что перед декодированием протокольных сообщений нужно прочитать заголовки Ethernet, IPv4 и UDP.

Все исходные тексты выложены в открытый доступ - https://github.com/dkosaty/simba_pcap_decoder. Ставьте звёзды, добавляйтесь в follower'ы.


Спасибо, что почитали до конца. Напишите в комментариях была ли полезной статья. Буду признателен любой оценке.

Tags:
Hubs:
+9
Comments13

Articles