Compare commits
33 commits
f80c425e2b
...
main
Author | SHA1 | Date | |
---|---|---|---|
Bruno BELANYI | f8558238bf | ||
Bruno BELANYI | 7c2a25a6b0 | ||
Bruno BELANYI | a95df7a00f | ||
Bruno BELANYI | 654be9fc70 | ||
Bruno BELANYI | 22fe2ad421 | ||
Bruno BELANYI | 3998b9839f | ||
Bruno BELANYI | 8bba52d3b9 | ||
Bruno BELANYI | e601250e7b | ||
Bruno BELANYI | d159cfb877 | ||
Bruno BELANYI | 8474ed0c69 | ||
Bruno BELANYI | 6fc5502b99 | ||
Bruno BELANYI | a681671efa | ||
Bruno BELANYI | ea8c880cca | ||
Bruno BELANYI | 14645524e2 | ||
Bruno BELANYI | afe78c4d8c | ||
Bruno BELANYI | 4b06f591c6 | ||
Bruno BELANYI | 832abfb224 | ||
Bruno BELANYI | ef027c77f6 | ||
Bruno BELANYI | 1cf45d9125 | ||
Bruno BELANYI | bc20b1ee9a | ||
Bruno BELANYI | ce9457fabd | ||
Bruno BELANYI | 68a7e55238 | ||
Bruno BELANYI | 803452bfa2 | ||
Bruno BELANYI | 8534a74c87 | ||
Bruno BELANYI | fae4a9d5c7 | ||
Bruno BELANYI | acbae579b3 | ||
Bruno BELANYI | c62f79cbb2 | ||
Bruno BELANYI | fa563f2f59 | ||
Bruno BELANYI | e5ddd16da2 | ||
Bruno BELANYI | 37b04a678a | ||
Bruno BELANYI | 5f1fbac76c | ||
Bruno BELANYI | 4515fc1c36 | ||
Bruno BELANYI | 0817e7ac7e |
184
README.md
Normal file
184
README.md
Normal file
|
@ -0,0 +1,184 @@
|
|||
# Kraken technical assessment: matching engine
|
||||
|
||||
## How to
|
||||
|
||||
### Build
|
||||
|
||||
This project was written on a Linux (NixOS) machine, using the `CMake` build
|
||||
system.
|
||||
|
||||
To build the project you should run the following commands:
|
||||
|
||||
```sh
|
||||
mkdir build
|
||||
cmake -B build
|
||||
cmake --build build
|
||||
```
|
||||
|
||||
To run unit and integration tests you can use:
|
||||
|
||||
```sh
|
||||
cmake --build build --target test
|
||||
```
|
||||
|
||||
### Run
|
||||
|
||||
The `kraken` binary should be built in `build/src/kraken`. You can see example
|
||||
inputs and their matching outputs in the `data/` directory at the root of the
|
||||
repository.
|
||||
|
||||
`kraken` reads its input from standard input, and displays its results on
|
||||
the standard output. For example
|
||||
|
||||
```sh
|
||||
kraken < ./data/inputs/balanced-book-1.in.csv
|
||||
```
|
||||
|
||||
You can use `--enable-trade` to enable trade matching. Be careful, it has not
|
||||
been as carefully tested, a wild `assert` could stop your session short.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Libraries
|
||||
|
||||
The project is divided into small libraries in separate directories, each for
|
||||
a specific purpose:
|
||||
|
||||
* `book`: defines the vocabulary types to quantify orders.
|
||||
* `csv`: reading and writing CSV files, in a very naive way.
|
||||
* `engine`: the matching engine proper, and a listener interface used to create
|
||||
the expected output.
|
||||
* `parse`: parsing the raw CSV data into a list of orders.
|
||||
* `utils`: utility types, specifically a `StrongType` wrapper to ensure a `User`
|
||||
is not mistaken for a `Quantity`.
|
||||
|
||||
### A KISS architecture
|
||||
|
||||
In each step of the project, the code was kept to its simplest, trying to solve
|
||||
the problem at hand in the simplest way possible, while keeping to the single
|
||||
responsibility principle. This is why for example:
|
||||
|
||||
* The input is parsed at once, and processed in a single step, by different
|
||||
components.
|
||||
* Almost no efforts were made to avoid superfluous copies, or such
|
||||
optimizations.
|
||||
* The engine and the expected output are separated from each other through a
|
||||
listener interface, instead of entangling both in the same class.
|
||||
|
||||
### A test harness
|
||||
|
||||
To allow for refactoring without fear, each library has a test-suite to ensure
|
||||
it is in working order.
|
||||
|
||||
This allowed me to simplify cancelling orders from having to potentially look
|
||||
at *all currently active orders* to just a few orders on a given price level.
|
||||
|
||||
### Reasonably extensible
|
||||
|
||||
Given the focus on "events" (the engine processes each order separately, calling
|
||||
the listener at opportune times), it should be fairly simple to extend the core
|
||||
of this code to allow for online processing (i.e: the engine reads its input and
|
||||
displays its output as it comes along), etc...
|
||||
|
||||
### What I would improve
|
||||
|
||||
#### Matching trades
|
||||
|
||||
The logic used when trade matching is enabled is pretty limited: it wasn't clear
|
||||
to me what to do when either of the orders have left-over quantities to be
|
||||
fulfilled. More explicit instructions on this point would lead to the removal of
|
||||
the final `FIXME`s and `assert`s in the code.
|
||||
|
||||
I have added explicit test files of those cases in `data/invalid`.
|
||||
|
||||
##### Picking prices
|
||||
|
||||
Another improvement that is specific to trade matching would be scripting the
|
||||
behaviour when bid/ask prices are not exactly equal when matching trades: the
|
||||
current behaviour is to always use the asking price. One could imagine wanting
|
||||
to use bid price, or crossing order
|
||||
price, or the average (weighted by quantity?) of both prices, etc...
|
||||
|
||||
|
||||
#### Cancelling orders
|
||||
|
||||
I do not like the way I have done the `cancel_reverse_info_` mapping: to
|
||||
simplify I use a `CancelOrder` value as a key instead of creating an
|
||||
`Engine`-specific type.
|
||||
|
||||
#### Repetition
|
||||
|
||||
I do not like the repetition that happens due to `asks_` and `bids_` being
|
||||
"mirror" of each other in the way they should be handled. This is mitigated
|
||||
somewhat by making use of helper lambda functions when the code is identical.
|
||||
|
||||
#### Top of the book handling
|
||||
|
||||
I feel like the `CallbackOnTopOfBookChange` is kind of a hack, even though it
|
||||
was very effective to solve the problem I had of:
|
||||
|
||||
* I want to memorize the current top of the book
|
||||
* At the very end of my current operation, calculate the top of the book again
|
||||
* Then call the listener back to notify of a change if it happened.
|
||||
|
||||
I think there might be a smarter way to go about this. To (mis-)quote Blaise
|
||||
Pascal:
|
||||
|
||||
> If I had more time, I would have made it shorter.
|
||||
|
||||
### Complexity analysis
|
||||
|
||||
This will focus on the matching engine code, let's discard the complexity of
|
||||
the input pre-processing from this discussion.
|
||||
|
||||
Given the use of `std::{multi_,}map` types, the space analysis is pretty simple:
|
||||
linear with respect to the number of active orders. Empty books are not removed
|
||||
and would therefore also consume a small amount of space: I am not accounting
|
||||
for this in this analysis.
|
||||
|
||||
Let's focus on the time complexity.
|
||||
|
||||
#### Flush orders
|
||||
|
||||
The simplest to process, we just empty all the book information, in a time
|
||||
complexity linear in the number of active orders in the book.
|
||||
|
||||
#### Cancel order
|
||||
|
||||
The first version of this code would have a worst-case cost linear in the
|
||||
number of active orders in the book, simply iterating through each one in turn.
|
||||
|
||||
Thanks to a reverse-mapping, the cancel cost is now the following:
|
||||
|
||||
* Lookup in `cancel_reverse_info_`: logarithmic with respect to the number of
|
||||
active orders across all instruments.
|
||||
* Lookup in `bids_`/`asks_` for the book on a given `Symbol`: logarithmic with
|
||||
respect to the number of symbols.
|
||||
* Finding the bounds on the price range: logarithmic with respect to the number
|
||||
of orders in the given book.
|
||||
* Iterating through that range: linear with respect to the number of orders
|
||||
at the given price range.
|
||||
|
||||
#### Trade order
|
||||
|
||||
* Lookup on `bids_` and `asks_` for the given symbol: logarithmic to the number
|
||||
of symbols.
|
||||
* Look for a cross of the book, ensure the book is not empty (constant time),
|
||||
and look at the first value in the book: logarithmic to the number of orders
|
||||
in the book
|
||||
* Inserting the order in the book: logarithmic to the number of orders in the
|
||||
book.
|
||||
* Inserting into `cancel_reverse_info_` (for faster cancelling): logarithmic
|
||||
to the number of orders across all instruments.
|
||||
|
||||
#### Top-of-book handling
|
||||
|
||||
For both trade orders and cancel orders, the `CallbackOnTopOfBookChange` does
|
||||
the following:
|
||||
|
||||
* Lookup on `bids_` and `asks_` for the given symbol: logarithmic to the number
|
||||
of symbols.
|
||||
* Check the size of the book (constant time) and look at the first order's
|
||||
price: logarithmic to the number of orders in the book.
|
||||
* Find the price range: logarithmic yet again.
|
||||
* Iterating on the range: linear to the number of orders at the given price.
|
2
data/inputs/matching-asking-price-2.in.csv
Normal file
2
data/inputs/matching-asking-price-2.in.csv
Normal file
|
@ -0,0 +1,2 @@
|
|||
N,1,IBM,8,100,S,101
|
||||
N,2,IBM,10,100,B,102
|
|
2
data/inputs/matching-asking-price.in.csv
Normal file
2
data/inputs/matching-asking-price.in.csv
Normal file
|
@ -0,0 +1,2 @@
|
|||
N,1,IBM,10,100,B,101
|
||||
N,2,IBM,8,100,S,102
|
|
2
data/inputs/matching.in.csv
Normal file
2
data/inputs/matching.in.csv
Normal file
|
@ -0,0 +1,2 @@
|
|||
N,1,IBM,10,100,B,101
|
||||
N,2,IBM,10,100,S,102
|
|
3
data/inputs/only-one-cancelled.in.csv
Normal file
3
data/inputs/only-one-cancelled.in.csv
Normal file
|
@ -0,0 +1,3 @@
|
|||
N,1,VAL,10,100,B,1
|
||||
C,1,1
|
||||
F
|
|
2
data/inputs/only-one.in.csv
Normal file
2
data/inputs/only-one.in.csv
Normal file
|
@ -0,0 +1,2 @@
|
|||
N,1,VAL,10,100,B,1
|
||||
F
|
|
2
data/inputs/two.in.csv
Normal file
2
data/inputs/two.in.csv
Normal file
|
@ -0,0 +1,2 @@
|
|||
N,1,VAL,10,100,B,1
|
||||
N,1,VAL,9,100,B,2
|
|
2
data/invalid/matching-remaining-quantity-ask.csv
Normal file
2
data/invalid/matching-remaining-quantity-ask.csv
Normal file
|
@ -0,0 +1,2 @@
|
|||
N,1,IBM,8,100,S,101
|
||||
N,2,IBM,10,50,B,102
|
|
2
data/invalid/matching-remaining-quantity-bid.csv
Normal file
2
data/invalid/matching-remaining-quantity-bid.csv
Normal file
|
@ -0,0 +1,2 @@
|
|||
N,1,IBM,8,50,S,101
|
||||
N,2,IBM,10,100,B,102
|
|
10
data/outputs/balanced-book-3.trades.out.csv
Normal file
10
data/outputs/balanced-book-3.trades.out.csv
Normal file
|
@ -0,0 +1,10 @@
|
|||
A,1,1
|
||||
B,B,10,100
|
||||
A,1,2
|
||||
B,S,12,100
|
||||
A,2,101
|
||||
A,2,102
|
||||
B,S,11,100
|
||||
A,1,103
|
||||
T,1,103,2,102,11,100
|
||||
B,S,12,100
|
|
3
data/outputs/matching-asking-price-2.out.csv
Normal file
3
data/outputs/matching-asking-price-2.out.csv
Normal file
|
@ -0,0 +1,3 @@
|
|||
A,1,101
|
||||
B,S,8,100
|
||||
R,2,102
|
|
5
data/outputs/matching-asking-price-2.trades.out.csv
Normal file
5
data/outputs/matching-asking-price-2.trades.out.csv
Normal file
|
@ -0,0 +1,5 @@
|
|||
A,1,101
|
||||
B,S,8,100
|
||||
A,2,102
|
||||
T,2,102,1,101,8,100
|
||||
B,S,-,-
|
|
3
data/outputs/matching-asking-price.out.csv
Normal file
3
data/outputs/matching-asking-price.out.csv
Normal file
|
@ -0,0 +1,3 @@
|
|||
A,1,101
|
||||
B,B,10,100
|
||||
R,2,102
|
|
5
data/outputs/matching-asking-price.trades.out.csv
Normal file
5
data/outputs/matching-asking-price.trades.out.csv
Normal file
|
@ -0,0 +1,5 @@
|
|||
A,1,101
|
||||
B,B,10,100
|
||||
A,2,102
|
||||
T,1,101,2,102,8,100
|
||||
B,B,-,-
|
|
3
data/outputs/matching.out.csv
Normal file
3
data/outputs/matching.out.csv
Normal file
|
@ -0,0 +1,3 @@
|
|||
A,1,101
|
||||
B,B,10,100
|
||||
R,2,102
|
|
5
data/outputs/matching.trades.out.csv
Normal file
5
data/outputs/matching.trades.out.csv
Normal file
|
@ -0,0 +1,5 @@
|
|||
A,1,101
|
||||
B,B,10,100
|
||||
A,2,102
|
||||
T,1,101,2,102,10,100
|
||||
B,B,-,-
|
|
4
data/outputs/only-one-cancelled.out.csv
Normal file
4
data/outputs/only-one-cancelled.out.csv
Normal file
|
@ -0,0 +1,4 @@
|
|||
A,1,1
|
||||
B,B,10,100
|
||||
A,1,1
|
||||
B,B,-,-
|
|
2
data/outputs/only-one.out.csv
Normal file
2
data/outputs/only-one.out.csv
Normal file
|
@ -0,0 +1,2 @@
|
|||
A,1,1
|
||||
B,B,10,100
|
|
10
data/outputs/shallow-ask.trades.out.csv
Normal file
10
data/outputs/shallow-ask.trades.out.csv
Normal file
|
@ -0,0 +1,10 @@
|
|||
A,1,1
|
||||
B,B,10,100
|
||||
A,2,101
|
||||
A,2,102
|
||||
B,S,11,100
|
||||
A,1,2
|
||||
T,1,2,2,102,11,100
|
||||
B,S,-,-
|
||||
A,2,103
|
||||
B,S,11,100
|
|
3
data/outputs/two.out.csv
Normal file
3
data/outputs/two.out.csv
Normal file
|
@ -0,0 +1,3 @@
|
|||
A,1,1
|
||||
B,B,10,100
|
||||
A,1,2
|
|
|
@ -43,6 +43,10 @@
|
|||
pkg-config
|
||||
];
|
||||
|
||||
buildInputs = with final; [
|
||||
boost
|
||||
];
|
||||
|
||||
checkInputs = with final; [
|
||||
gtest
|
||||
];
|
||||
|
|
|
@ -9,11 +9,13 @@ add_subdirectory(utils)
|
|||
|
||||
configure_file(config.h.in config.h)
|
||||
|
||||
find_package(Boost REQUIRED COMPONENTS thread)
|
||||
|
||||
target_link_libraries(kraken PRIVATE
|
||||
book
|
||||
csv
|
||||
engine
|
||||
parse
|
||||
Boost::thread
|
||||
)
|
||||
|
||||
install(TARGETS kraken)
|
||||
|
|
|
@ -66,6 +66,6 @@ struct FlushOrder {
|
|||
auto operator<=>(FlushOrder const&) const = default;
|
||||
};
|
||||
|
||||
using Order = std::variant<TradeOrder, CancelOrder, FlushOrder>;
|
||||
using Order = std::variant<FlushOrder, TradeOrder, CancelOrder>;
|
||||
|
||||
} // namespace kraken
|
||||
|
|
|
@ -4,10 +4,7 @@
|
|||
|
||||
namespace kraken::csv {
|
||||
|
||||
namespace {
|
||||
|
||||
// for convenience, use a stringstream which does not accept string_view inputs
|
||||
csv_line_type parse_line(std::string const& line) {
|
||||
csv_line_type read_csv_line(std::string const& line) {
|
||||
auto parsed = csv_line_type{};
|
||||
|
||||
auto input = std::istringstream(line);
|
||||
|
@ -18,8 +15,6 @@ csv_line_type parse_line(std::string const& line) {
|
|||
return parsed;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
csv_type read_csv(std::istream& input, CsvHeader header) {
|
||||
auto parsed = std::vector<csv_line_type>{};
|
||||
|
||||
|
@ -30,7 +25,7 @@ csv_type read_csv(std::istream& input, CsvHeader header) {
|
|||
continue;
|
||||
}
|
||||
|
||||
parsed.emplace_back(parse_line(std::move(line)));
|
||||
parsed.emplace_back(read_csv_line(std::move(line)));
|
||||
}
|
||||
|
||||
return parsed;
|
||||
|
|
|
@ -14,6 +14,9 @@ enum class CsvHeader {
|
|||
KEEP,
|
||||
};
|
||||
|
||||
/// Parse a single CSV line, no error checking.
|
||||
csv_line_type read_csv_line(std::string const& input);
|
||||
|
||||
/// Parse a CSV file from an input-stream, return a vector of parsed lines.
|
||||
csv_type read_csv(std::istream& input, CsvHeader header = CsvHeader::SKIP);
|
||||
|
||||
|
|
|
@ -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
|
||||
)
|
||||
|
|
|
@ -6,6 +6,14 @@ CsvEngineListener::CsvEngineListener() = default;
|
|||
|
||||
CsvEngineListener::~CsvEngineListener() = default;
|
||||
|
||||
csv::csv_type const& CsvEngineListener::output() const {
|
||||
return output_;
|
||||
}
|
||||
|
||||
csv::csv_type& CsvEngineListener::output() {
|
||||
return output_;
|
||||
}
|
||||
|
||||
void CsvEngineListener::on_acknowledgement(User user, UserOrderId id) {
|
||||
output_.emplace_back(csv::csv_line_type{
|
||||
"A",
|
||||
|
|
|
@ -13,6 +13,9 @@ struct CsvEngineListener : EngineListener {
|
|||
|
||||
virtual ~CsvEngineListener();
|
||||
|
||||
csv::csv_type const& output() const;
|
||||
csv::csv_type& output();
|
||||
|
||||
/// Called when a new trade or cancel order has been acknowledged.
|
||||
void on_acknowledgement(User user, UserOrderId id) override;
|
||||
|
||||
|
|
241
src/engine/engine.cc
Normal file
241
src/engine/engine.cc
Normal file
|
@ -0,0 +1,241 @@
|
|||
#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:
|
||||
using info_type = std::optional<std::pair<Price, Quantity>>;
|
||||
|
||||
struct TopInfo {
|
||||
info_type asks;
|
||||
info_type bids;
|
||||
};
|
||||
|
||||
TopInfo calculate_top() const {
|
||||
auto info = TopInfo{};
|
||||
|
||||
auto const make_top_for = [](auto const& order_map) -> info_type {
|
||||
if (order_map.size() > 0) {
|
||||
auto const price = order_map.begin()->first;
|
||||
int quantity = 0;
|
||||
auto const [begin, end] = order_map.equal_range(price);
|
||||
|
||||
for (auto it = begin; it != end; ++it) {
|
||||
quantity += int(it->second.quantity);
|
||||
}
|
||||
|
||||
return std::make_pair(price, Quantity(quantity));
|
||||
}
|
||||
|
||||
return std::nullopt;
|
||||
};
|
||||
|
||||
if (auto const bid_it = engine_.bids_.find(symbol_);
|
||||
bid_it != engine_.bids_.end()) {
|
||||
auto const& [_, bid_map] = *bid_it;
|
||||
info.bids = make_top_for(bid_map);
|
||||
}
|
||||
|
||||
if (auto const ask_it = engine_.asks_.find(symbol_);
|
||||
ask_it != engine_.asks_.end()) {
|
||||
auto const& [_, ask_map] = *ask_it;
|
||||
info.asks = make_top_for(ask_map);
|
||||
}
|
||||
|
||||
return info;
|
||||
}
|
||||
|
||||
Symbol symbol_;
|
||||
Engine& engine_;
|
||||
TopInfo top_info_{};
|
||||
};
|
||||
|
||||
Engine::Engine(std::shared_ptr<EngineListener> listener,
|
||||
CrossBehaviour cross_behaviour)
|
||||
: listener_(listener), cross_behaviour_(cross_behaviour) {}
|
||||
|
||||
void Engine::process_single_order(Order const& order) {
|
||||
std::visit([this](auto const& trade_order) { (*this)(trade_order); },
|
||||
order);
|
||||
}
|
||||
|
||||
void Engine::process_orders(std::vector<Order> const& orders) {
|
||||
for (auto const& order : orders) {
|
||||
process_single_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);
|
||||
|
||||
// FIXME: assumes a single trade for the order
|
||||
// FIXME: assumes no remaining orders on both sides
|
||||
auto const do_cross = [this, &trade_order](auto const& bid_order,
|
||||
auto const& ask_order,
|
||||
Price matching_price) {
|
||||
auto const matching_quantity
|
||||
= std::min(bid_order.quantity, ask_order.quantity);
|
||||
|
||||
listener_->on_acknowledgement(trade_order.user, trade_order.id);
|
||||
listener_->on_match(bid_order.user, bid_order.id, ask_order.user,
|
||||
ask_order.id, matching_price, matching_quantity);
|
||||
assert(matching_quantity == bid_order.quantity
|
||||
&& "multiple matches not implemented");
|
||||
assert(matching_quantity == ask_order.quantity
|
||||
&& "multiple matches not implemented");
|
||||
};
|
||||
|
||||
// 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) {
|
||||
if (cross_behaviour_ == CrossBehaviour::REJECT) {
|
||||
listener_->on_rejection(trade_order.user, trade_order.id);
|
||||
} else {
|
||||
auto matching = bid_map.begin();
|
||||
auto const& bid_order = matching->second;
|
||||
// NOTE: Pick the asking price for matching
|
||||
auto const matching_price = trade_order.price;
|
||||
do_cross(bid_order, trade_order, matching_price);
|
||||
bid_map.erase(matching);
|
||||
}
|
||||
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) {
|
||||
if (cross_behaviour_ == CrossBehaviour::REJECT) {
|
||||
listener_->on_rejection(trade_order.user, trade_order.id);
|
||||
} else {
|
||||
auto matching = ask_map.begin();
|
||||
// NOTE: Pick the asking price for matching
|
||||
auto const& [matching_price, ask_order] = *matching;
|
||||
do_cross(trade_order, ask_order, matching_price);
|
||||
ask_map.erase(matching);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
bids_[trade_order.symbol].insert(
|
||||
{trade_order.price, OrderMetaData{trade_order.user, trade_order.id,
|
||||
trade_order.quantity}});
|
||||
break;
|
||||
}
|
||||
|
||||
// Record reverse-lookup info for faster cancelling
|
||||
cancel_reverse_info_.insert({
|
||||
{
|
||||
trade_order.user,
|
||||
trade_order.id,
|
||||
},
|
||||
{
|
||||
trade_order.side,
|
||||
trade_order.symbol,
|
||||
trade_order.price,
|
||||
},
|
||||
});
|
||||
|
||||
listener_->on_acknowledgement(trade_order.user, trade_order.id);
|
||||
}
|
||||
|
||||
void Engine::operator()(CancelOrder const& cancel_order) {
|
||||
auto const matches_order = [&](auto const& data) {
|
||||
return data.second.user == cancel_order.user
|
||||
&& data.second.id == cancel_order.id;
|
||||
};
|
||||
|
||||
if (auto it = cancel_reverse_info_.find(cancel_order);
|
||||
it != cancel_reverse_info_.end()) {
|
||||
auto const& [_, info] = *it;
|
||||
|
||||
// Set-up automatic call-back in case top-of-book changes
|
||||
auto __ = CallbackOnTopOfBookChange(info.symbol, *this);
|
||||
|
||||
switch (info.side) {
|
||||
case Side::ASK: {
|
||||
auto& ask_map = asks_[info.symbol];
|
||||
auto const [begin, end] = ask_map.equal_range(info.price);
|
||||
auto it = std::find_if(begin, end, matches_order);
|
||||
if (it == end) {
|
||||
listener_->on_bad_order(cancel_order.user, cancel_order.id);
|
||||
return;
|
||||
}
|
||||
ask_map.erase(it);
|
||||
break;
|
||||
}
|
||||
case Side::BID: {
|
||||
auto& bid_map = bids_[info.symbol];
|
||||
auto const [begin, end] = bid_map.equal_range(info.price);
|
||||
auto it = std::find_if(begin, end, matches_order);
|
||||
if (it == end) {
|
||||
listener_->on_bad_order(cancel_order.user, cancel_order.id);
|
||||
return;
|
||||
}
|
||||
bid_map.erase(it);
|
||||
break;
|
||||
}
|
||||
}
|
||||
cancel_reverse_info_.erase(it);
|
||||
listener_->on_acknowledgement(cancel_order.user, cancel_order.id);
|
||||
}
|
||||
}
|
||||
|
||||
void Engine::operator()(FlushOrder const&) {
|
||||
bids_.clear();
|
||||
asks_.clear();
|
||||
cancel_reverse_info_.clear();
|
||||
|
||||
listener_->on_flush();
|
||||
}
|
||||
|
||||
} // namespace kraken::engine
|
68
src/engine/engine.hh
Normal file
68
src/engine/engine.hh
Normal file
|
@ -0,0 +1,68 @@
|
|||
#pragma once
|
||||
|
||||
#include <functional>
|
||||
#include <map>
|
||||
#include <memory>
|
||||
#include <vector>
|
||||
|
||||
#include "book/order.hh"
|
||||
|
||||
namespace kraken::engine {
|
||||
|
||||
/// Which behaviour on cross orders.
|
||||
enum class CrossBehaviour {
|
||||
/// Reject the crossing order.
|
||||
REJECT,
|
||||
/// Make the trade with the matching order(s).
|
||||
MATCH,
|
||||
};
|
||||
|
||||
struct CallbackOnTopOfBookChange;
|
||||
struct EngineListener;
|
||||
|
||||
/// Matching engine which processes orders and keeps the book up-to-date.
|
||||
struct Engine {
|
||||
Engine(std::shared_ptr<EngineListener> listener,
|
||||
CrossBehaviour cross_behaviour = CrossBehaviour::REJECT);
|
||||
|
||||
/// Process a single order, triggerring the listener appropriately.
|
||||
void process_single_order(Order const& order);
|
||||
|
||||
/// 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_;
|
||||
CrossBehaviour cross_behaviour_;
|
||||
|
||||
// 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_;
|
||||
|
||||
// Map cancel information to allow faster lookups
|
||||
struct CancelReverseInfo {
|
||||
Side side;
|
||||
Symbol symbol;
|
||||
Price price;
|
||||
};
|
||||
|
||||
std::map<CancelOrder, CancelReverseInfo> cancel_reverse_info_;
|
||||
};
|
||||
|
||||
} // namespace kraken::engine
|
|
@ -1,5 +1,55 @@
|
|||
#include <iostream>
|
||||
#include <thread>
|
||||
|
||||
int main() {
|
||||
std::cout << "Hello World!\n";
|
||||
#include <boost/lockfree/spsc_queue.hpp>
|
||||
|
||||
#include "csv/read-csv.hh"
|
||||
#include "csv/write-csv.hh"
|
||||
#include "engine/csv-engine-listener.hh"
|
||||
#include "engine/engine.hh"
|
||||
#include "parse/parse.hh"
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
auto cross_behaviour = kraken::engine::CrossBehaviour::REJECT;
|
||||
|
||||
if (argc > 1) {
|
||||
using namespace std::literals;
|
||||
if (argv[1] == "--enable-trade"sv) {
|
||||
cross_behaviour = kraken::engine::CrossBehaviour::MATCH;
|
||||
}
|
||||
}
|
||||
|
||||
// Up to 512 pending orders.
|
||||
auto pending_orders = boost::lockfree::spsc_queue<kraken::Order>(512);
|
||||
|
||||
auto writer = std::jthread([&](std::stop_token stop_token) {
|
||||
auto listener = std::make_shared<kraken::engine::CsvEngineListener>();
|
||||
auto engine = kraken::engine::Engine(listener, cross_behaviour);
|
||||
|
||||
while (true) {
|
||||
auto order = kraken::Order();
|
||||
while (!pending_orders.pop(order)) {
|
||||
// FIXME: busy wait
|
||||
// Check that we didn't miss an order between last 'pop' and
|
||||
// stop request, just in case.
|
||||
if (stop_token.stop_requested() && pending_orders.empty()) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
engine.process_single_order(order);
|
||||
auto& output = listener->output();
|
||||
kraken::csv::write_csv(std::cout, output);
|
||||
output.resize(0);
|
||||
}
|
||||
});
|
||||
|
||||
for (std::string line; std::getline(std::cin, line);) {
|
||||
auto const order = kraken::parse::parse_single_order(
|
||||
kraken::csv::read_csv_line(line));
|
||||
while (!pending_orders.push(order)) {
|
||||
// FIXME: busy wait
|
||||
}
|
||||
}
|
||||
// EOF, process orders and bring down
|
||||
writer.request_stop();
|
||||
}
|
||||
|
|
|
@ -57,20 +57,24 @@ CancelOrder cancel_from_raw(csv::csv_line_type const& raw_order) {
|
|||
|
||||
} // namespace
|
||||
|
||||
Order parse_single_order(csv::csv_line_type const& order) {
|
||||
if (order[0] == "N") {
|
||||
return trade_from_raw(order);
|
||||
} else if (order[0] == "C") {
|
||||
return cancel_from_raw(order);
|
||||
} else if (order[0] == "F") {
|
||||
return FlushOrder{};
|
||||
} else {
|
||||
throw ParseError("Not a valid order");
|
||||
}
|
||||
}
|
||||
|
||||
std::vector<Order> parse_orders(std::istream& input) {
|
||||
auto const raw_orders = read_csv(input, kraken::csv::CsvHeader::KEEP);
|
||||
auto orders = std::vector<Order>{};
|
||||
|
||||
for (auto const& raw_order : raw_orders) {
|
||||
if (raw_order[0] == "N") {
|
||||
orders.emplace_back(trade_from_raw(raw_order));
|
||||
} else if (raw_order[0] == "C") {
|
||||
orders.emplace_back(cancel_from_raw(raw_order));
|
||||
} else if (raw_order[0] == "F") {
|
||||
orders.emplace_back(FlushOrder{});
|
||||
} else {
|
||||
throw ParseError("Not a valid order");
|
||||
}
|
||||
orders.emplace_back(parse_single_order(raw_order));
|
||||
}
|
||||
|
||||
return orders;
|
||||
|
|
|
@ -5,6 +5,7 @@
|
|||
#include <vector>
|
||||
|
||||
#include "book/order.hh"
|
||||
#include "csv/csv.hh"
|
||||
|
||||
namespace kraken::parse {
|
||||
|
||||
|
@ -12,6 +13,10 @@ struct ParseError : public std::runtime_error {
|
|||
using std::runtime_error::runtime_error;
|
||||
};
|
||||
|
||||
/// Convert a single CSV input line to an `Order`.
|
||||
Order parse_single_order(csv::csv_line_type const& order);
|
||||
|
||||
/// Parse orders from an input stream as CSV.
|
||||
std::vector<Order> parse_orders(std::istream& input);
|
||||
|
||||
} // namespace kraken::parse
|
||||
|
|
|
@ -7,6 +7,9 @@ template <typename T, typename Tag>
|
|||
struct StrongType {
|
||||
explicit StrongType(T value) : value_(value) {}
|
||||
|
||||
StrongType(StrongType const&) = default;
|
||||
StrongType& operator=(StrongType const&) = default;
|
||||
|
||||
explicit operator T() const {
|
||||
return value_;
|
||||
}
|
||||
|
|
|
@ -1 +1,2 @@
|
|||
add_subdirectory(integration)
|
||||
add_subdirectory(unit)
|
||||
|
|
13
tests/integration/CMakeLists.txt
Normal file
13
tests/integration/CMakeLists.txt
Normal 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)
|
63
tests/integration/test-output.sh
Executable file
63
tests/integration/test-output.sh
Executable file
|
@ -0,0 +1,63 @@
|
|||
#!/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
|
||||
}
|
||||
|
||||
test_file_trades() {
|
||||
if ! diff="$(diff <("$KRAKEN" --enable-trade < "$DATA_DIR/inputs/$1.in.csv") "$DATA_DIR/outputs/$1.trades.out.csv")"; then
|
||||
((FAILURES += 1))
|
||||
echo "$1 (trades): FAIL"
|
||||
if [ -n "$VERBOSE" ]; then
|
||||
printf '%s\n' "$diff"
|
||||
fi
|
||||
else
|
||||
((SUCCESSES += 1))
|
||||
echo "$1 (trades): 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
|
||||
|
||||
for test_name in "$DATA_DIR"/outputs/*.trades.out.csv; do
|
||||
test_name="$(basename "$test_name")"
|
||||
test_name="${test_name%%.trades.out.csv}"
|
||||
|
||||
test_file_trades "$test_name"
|
||||
done
|
||||
|
||||
printf '\nSummary: %d successes, %d failures\n' "$SUCCESSES" "$FAILURES"
|
||||
|
||||
exit "$((FAILURES != 0))"
|
|
@ -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})
|
||||
|
|
123
tests/unit/engine.cc
Normal file
123
tests/unit/engine.cc
Normal file
|
@ -0,0 +1,123 @@
|
|||
#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 do_compare(std::istream& input, std::istream& expected_output,
|
||||
CrossBehaviour behaviour) {
|
||||
auto listener = std::make_shared<CsvEngineListener>();
|
||||
auto engine = Engine{listener, behaviour};
|
||||
|
||||
engine.process_orders(parse_orders(input));
|
||||
|
||||
auto expected = read_csv(expected_output, CsvHeader::KEEP);
|
||||
|
||||
ASSERT_EQ(listener->output(), expected);
|
||||
}
|
||||
|
||||
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 expected_output_file = std::ifstream{
|
||||
CMAKE_SOURCE_DIR + "/data/outputs/"s + test_name + ".out.csv"};
|
||||
|
||||
do_compare(input, expected_output_file, CrossBehaviour::REJECT);
|
||||
}
|
||||
|
||||
void compare_trades(std::string const& test_name) {
|
||||
using namespace std::literals;
|
||||
auto input = std::ifstream{CMAKE_SOURCE_DIR + "/data/inputs/"s + test_name
|
||||
+ ".in.csv"};
|
||||
auto expected_output_file = std::ifstream{
|
||||
CMAKE_SOURCE_DIR + "/data/outputs/"s + test_name + ".trades.out.csv"};
|
||||
|
||||
do_compare(input, expected_output_file, CrossBehaviour::MATCH);
|
||||
}
|
||||
|
||||
} // 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");
|
||||
}
|
||||
|
||||
TEST(engine_trade, balanced_book_3) {
|
||||
compare_trades("balanced-book-3");
|
||||
}
|
||||
|
||||
TEST(engine_trade, shallow_ask) {
|
||||
compare_trades("shallow-ask");
|
||||
}
|
Loading…
Reference in a new issue