Compare commits

...

7 commits

Author SHA1 Message Date
Bruno BELANYI c62f79cbb2 tests: add integration tests
All checks were successful
continuous-integration/drone/push Build is passing
2022-03-12 11:39:39 +01:00
Bruno BELANYI fa563f2f59 kraken: make functional binary 2022-03-12 11:39:39 +01:00
Bruno BELANYI e5ddd16da2 tests: unit: add 'engine' suite 2022-03-12 11:39:39 +01:00
Bruno BELANYI 37b04a678a kraken: engine: fix comparisons 2022-03-12 11:39:39 +01:00
Bruno BELANYI 5f1fbac76c data: add additional test cases
I was too tired last night (this morning...) I inverted a couple comparisons.

Those simple tests helped isolate the issue.
2022-03-12 11:39:39 +01:00
Bruno BELANYI 4515fc1c36 kraken: engine: add 'CsvEngineListern.output'
Otherwise it would be difficult to actually get access to the output...
2022-03-12 11:39:39 +01:00
Bruno BELANYI 0817e7ac7e kraken: engin: add 'Engine'
This is the brains of the operation, the matching engine.
2022-03-12 11:39:39 +01:00
18 changed files with 439 additions and 2 deletions

View file

@ -0,0 +1,3 @@
N,1,VAL,10,100,B,1
C,1,1
F
1 N,1,VAL,10,100,B,1
2 C,1,1
3 F

View file

@ -0,0 +1,2 @@
N,1,VAL,10,100,B,1
F
1 N,1,VAL,10,100,B,1
2 F

2
data/inputs/two.in.csv Normal file
View file

@ -0,0 +1,2 @@
N,1,VAL,10,100,B,1
N,1,VAL,9,100,B,2
1 N 1 VAL 10 100 B 1
2 N 1 VAL 9 100 B 2

View file

@ -0,0 +1,4 @@
A,1,1
B,B,10,100
A,1,1
B,B,-,-
1 A,1,1
2 B,B,10,100
3 A,1,1
4 B,B,-,-

View file

@ -0,0 +1,2 @@
A,1,1
B,B,10,100
1 A,1,1
2 B,B,10,100

3
data/outputs/two.out.csv Normal file
View file

@ -0,0 +1,3 @@
A,1,1
B,B,10,100
A,1,2
1 A,1,1
2 B,B,10,100
3 A,1,2

View file

@ -10,7 +10,6 @@ add_subdirectory(utils)
configure_file(config.h.in config.h)
target_link_libraries(kraken PRIVATE
book
csv
engine
parse

View file

@ -1,6 +1,8 @@
add_library(engine STATIC
csv-engine-listener.cc
csv-engine-listener.hh
engine.cc
engine.hh
engine-listener.cc
engine-listener.hh
)

View file

@ -6,6 +6,10 @@ CsvEngineListener::CsvEngineListener() = default;
CsvEngineListener::~CsvEngineListener() = default;
csv::csv_type const& CsvEngineListener::output() const {
return output_;
}
void CsvEngineListener::on_acknowledgement(User user, UserOrderId id) {
output_.emplace_back(csv::csv_line_type{
"A",

View file

@ -13,6 +13,8 @@ struct CsvEngineListener : EngineListener {
virtual ~CsvEngineListener();
csv::csv_type const& output() const;
/// Called when a new trade or cancel order has been acknowledged.
void on_acknowledgement(User user, UserOrderId id) override;

187
src/engine/engine.cc Normal file
View file

@ -0,0 +1,187 @@
#include "engine.hh"
#include <algorithm>
#include <utility>
#include <cassert>
#include "engine-listener.hh"
namespace kraken::engine {
// A RAII wrapper to ensure the top-of-book callback is made
struct CallbackOnTopOfBookChange {
CallbackOnTopOfBookChange(Symbol symbol, Engine& engine)
: symbol_(symbol), engine_(engine) {
top_info_ = calculate_top();
}
~CallbackOnTopOfBookChange() {
auto const new_top = calculate_top();
// Sanity check: both sides of the book are not changing at once
assert(!(new_top.asks != top_info_.asks
&& new_top.bids != top_info_.bids));
if (top_info_.asks != new_top.asks) {
if (new_top.asks) {
engine_.listener_->on_top_of_book_change(
Side::ASK, new_top.asks->first, new_top.asks->second);
} else {
engine_.listener_->on_top_of_book_change(Side::ASK);
}
} else if (top_info_.bids != new_top.bids) {
if (new_top.bids) {
engine_.listener_->on_top_of_book_change(
Side::BID, new_top.bids->first, new_top.bids->second);
} else {
engine_.listener_->on_top_of_book_change(Side::BID);
}
}
}
private:
struct TopInfo {
std::optional<std::pair<Price, Quantity>> asks;
std::optional<std::pair<Price, Quantity>> bids;
};
TopInfo calculate_top() const {
auto info = TopInfo{};
if (auto const bid_it = engine_.bids_.find(symbol_);
bid_it != engine_.bids_.end()) {
auto const& [_, bid_map] = *bid_it;
if (bid_map.size() > 0) {
auto const price = bid_map.begin()->first;
int quantity = 0;
auto const [begin, end] = bid_map.equal_range(price);
for (auto it = begin; it != end; ++it) {
quantity += int(it->second.quantity);
}
info.bids = {price, Quantity(quantity)};
}
}
if (auto const ask_it = engine_.asks_.find(symbol_);
ask_it != engine_.asks_.end()) {
auto const& [_, ask_map] = *ask_it;
if (ask_map.size() > 0) {
auto const price = ask_map.begin()->first;
int quantity = 0;
auto const [begin, end] = ask_map.equal_range(price);
for (auto it = begin; it != end; ++it) {
quantity += int(it->second.quantity);
}
info.asks = {price, Quantity(quantity)};
}
}
return info;
}
Symbol symbol_;
Engine& engine_;
TopInfo top_info_{};
};
Engine::Engine(std::shared_ptr<EngineListener> listener)
: listener_(listener) {}
void Engine::process_orders(std::vector<Order> const& orders) {
for (auto const& order : orders) {
std::visit([this](auto const& trade_order) { (*this)(trade_order); },
order);
}
}
void Engine::operator()(TradeOrder const& trade_order) {
// Set-up automatic call-back in case top-of-book changes
auto _ = CallbackOnTopOfBookChange(trade_order.symbol, *this);
// NOTE: some amount of repetition/mirroring
switch (trade_order.side) {
case Side::ASK:
if (auto bid_map_it = bids_.find(trade_order.symbol);
bid_map_it != bids_.end()) {
auto& [_, bid_map] = *bid_map_it;
if (bid_map.size() > 0
&& bid_map.begin()->first >= trade_order.price) {
// FIXME: handle matching if enabled
listener_->on_rejection(trade_order.user, trade_order.id);
return;
}
}
asks_[trade_order.symbol].insert(
{trade_order.price, OrderMetaData{trade_order.user, trade_order.id,
trade_order.quantity}});
break;
case Side::BID:
if (auto ask_map_it = asks_.find(trade_order.symbol);
ask_map_it != asks_.end()) {
auto& [_, ask_map] = *ask_map_it;
if (ask_map.size() > 0
&& ask_map.begin()->first <= trade_order.price) {
// FIXME: handle matching if enabled
listener_->on_rejection(trade_order.user, trade_order.id);
return;
}
}
bids_[trade_order.symbol].insert(
{trade_order.price, OrderMetaData{trade_order.user, trade_order.id,
trade_order.quantity}});
break;
}
listener_->on_acknowledgement(trade_order.user, trade_order.id);
}
void Engine::operator()(CancelOrder const& cancel_order) {
// Assume that the input is well-behaved,
// no duplicate (userId, userOrderId) values.
auto const matches_order = [&](auto const& data) {
return data.second.user == cancel_order.user
&& data.second.id == cancel_order.id;
};
for (auto& [symbol, bid_map] : bids_) {
auto const it = std::ranges::find_if(bid_map, matches_order);
if (it != bid_map.end()) {
// Set-up automatic call-back in case top-of-book changes
auto _ = CallbackOnTopOfBookChange(symbol, *this);
bid_map.erase(it);
listener_->on_acknowledgement(cancel_order.user, cancel_order.id);
return;
}
}
for (auto& [symbol, ask_map] : asks_) {
auto const it = std::ranges::find_if(ask_map, matches_order);
if (it != ask_map.end()) {
// Set-up automatic call-back in case top-of-book changes
auto _ = CallbackOnTopOfBookChange(symbol, *this);
ask_map.erase(it);
listener_->on_acknowledgement(cancel_order.user, cancel_order.id);
return;
}
}
listener_->on_bad_order(cancel_order.user, cancel_order.id);
}
void Engine::operator()(FlushOrder const&) {
bids_.clear();
asks_.clear();
listener_->on_flush();
}
} // namespace kraken::engine

46
src/engine/engine.hh Normal file
View file

@ -0,0 +1,46 @@
#pragma once
#include <functional>
#include <map>
#include <memory>
#include <vector>
#include "book/order.hh"
namespace kraken::engine {
struct CallbackOnTopOfBookChange;
struct EngineListener;
/// Matching engine which processes orders and keeps the book up-to-date.
struct Engine {
Engine(std::shared_ptr<EngineListener> listener);
/// Process orders, triggerring the listener on each event.
void process_orders(std::vector<Order> const& orders);
private:
void operator()(TradeOrder const& trade_order);
void operator()(CancelOrder const& cancel_order);
void operator()(FlushOrder const& flush_order);
std::shared_ptr<EngineListener> listener_;
// Symbol, price, side are implicit given the way the book is represented
struct OrderMetaData {
User user;
UserOrderId id;
Quantity quantity;
};
friend struct CallbackOnTopOfBookChange;
// Sorted by price, then by time
template <typename Order>
using instrument_side_data = std::multimap<Price, OrderMetaData, Order>;
std::map<Symbol, instrument_side_data<std::greater<void>>> bids_;
std::map<Symbol, instrument_side_data<std::less<void>>> asks_;
};
} // namespace kraken::engine

View file

@ -1,5 +1,18 @@
#include <iostream>
#include "csv/write-csv.hh"
#include "engine/csv-engine-listener.hh"
#include "engine/engine.hh"
#include "parse/parse.hh"
int main() {
std::cout << "Hello World!\n";
auto const orders = kraken::parse::parse_orders(std::cin);
auto listener = std::make_shared<kraken::engine::CsvEngineListener>();
auto engine = kraken::engine::Engine(listener);
engine.process_orders(orders);
kraken::csv::write_csv(std::cout, listener->output());
}

View file

@ -1 +1,2 @@
add_subdirectory(integration)
add_subdirectory(unit)

View file

@ -0,0 +1,13 @@
find_program(BASH_PROGRAM bash)
if (BASH_PROGRAM)
add_test(test_output
${BASH_PROGRAM}
${CMAKE_CURRENT_SOURCE_DIR}/test-output.sh
${CMAKE_SOURCE_DIR}/data/
# FIXME: can't get it working $<TARGET_FILE:kraken>
${CMAKE_BINARY_DIR}/src/kraken
)
endif (BASH_PROGRAM)

View file

@ -0,0 +1,43 @@
#!/usr/bin/env bash
if [ $# != 0 ]; then
DATA_DIR="$1"
KRAKEN="$2"
else
DATA_DIR=data/
KRAKEN=build/src/kraken
fi
if ! [ -x "$KRAKEN" ] || ! [ -d "$DATA_DIR" ]; then
printf 'KRAKEN ('\''%s'\'') or DATA_DIR ('\''%s'\'') incorrectly set\n' "$KRAKEN" "$DATA_DIR"
exit 1
fi
FAILURES=0
SUCCESSES=0
# $1: should be the name of the input/output files couple, stripped of
# the data path prefix, or the `{in,out}.csv` suffix.
test_file() {
if ! diff="$(diff <("$KRAKEN" < "$DATA_DIR/inputs/$1.in.csv") "$DATA_DIR/outputs/$1.out.csv")"; then
((FAILURES += 1))
echo "$1: FAIL"
if [ -n "$VERBOSE" ]; then
printf '%s\n' "$diff"
fi
else
((SUCCESSES += 1))
echo "$1: OK"
fi
}
for test_name in "$DATA_DIR"/inputs/*.in.csv; do
test_name="$(basename "$test_name")"
test_name="${test_name%%.in.csv}"
test_file "$test_name"
done
printf '\nSummary: %d successes, %d failures\n' "$SUCCESSES" "$FAILURES"
exit "$((FAILURES != 0))"

View file

@ -25,4 +25,16 @@ target_link_libraries(parse_test PRIVATE
gtest_discover_tests(parse_test)
add_executable(engine_test engine.cc)
target_link_libraries(engine_test PRIVATE common_options)
target_link_libraries(engine_test PRIVATE
engine
parse
GTest::gtest
GTest::gtest_main
)
gtest_discover_tests(engine_test)
endif (${GTest_FOUND})

99
tests/unit/engine.cc Normal file
View file

@ -0,0 +1,99 @@
#include <fstream>
#include <gtest/gtest.h>
#include "config.h"
#include "csv/read-csv.hh"
#include "engine/csv-engine-listener.hh"
#include "engine/engine.hh"
#include "parse/parse.hh"
// Allow namespace pollution in tests for convenience
using namespace kraken;
using namespace kraken::csv;
using namespace kraken::engine;
using namespace kraken::parse;
namespace {
void compare(std::string const& test_name) {
using namespace std::literals;
auto input = std::ifstream{CMAKE_SOURCE_DIR + "/data/inputs/"s + test_name
+ ".in.csv"};
auto listener = std::make_shared<CsvEngineListener>();
auto engine = Engine{listener};
engine.process_orders(parse_orders(input));
auto expected_output_file = std::ifstream{
CMAKE_SOURCE_DIR + "/data/outputs/"s + test_name + ".out.csv"};
auto expected = read_csv(expected_output_file, CsvHeader::KEEP);
ASSERT_EQ(listener->output(), expected);
}
} // namespace
TEST(engine, empty) {
compare("empty");
}
TEST(engine, only_one) {
compare("only-one");
}
TEST(engine, only_one_cancelled) {
compare("only-one-cancelled");
}
TEST(engine, two) {
compare("two");
}
TEST(engine, balanced_book_1) {
compare("balanced-book-1");
}
TEST(engine, balanced_book_2) {
compare("balanced-book-2");
}
TEST(engine, balanced_book_3) {
compare("balanced-book-3");
}
TEST(engine, balanced_book_4) {
compare("balanced-book-4");
}
TEST(engine, balanced_book_5) {
compare("balanced-book-5");
}
TEST(engine, balanced_book_6) {
compare("balanced-book-6");
}
TEST(engine, balanced_book_7) {
compare("balanced-book-7");
}
TEST(engine, balanced_book_8) {
compare("balanced-book-8");
}
TEST(engine, balanced_book_9) {
compare("balanced-book-9");
}
TEST(engine, shallow_ask) {
compare("shallow-ask");
}
TEST(engine, shallow_bid) {
compare("shallow-bid");
}
TEST(engine, tighten_spread_through_new_limit_orders) {
compare("tighten-spread-through-new-limit-orders");
}