Compare commits
7 commits
f80c425e2b
...
c62f79cbb2
Author | SHA1 | Date | |
---|---|---|---|
Bruno BELANYI | c62f79cbb2 | ||
Bruno BELANYI | fa563f2f59 | ||
Bruno BELANYI | e5ddd16da2 | ||
Bruno BELANYI | 37b04a678a | ||
Bruno BELANYI | 5f1fbac76c | ||
Bruno BELANYI | 4515fc1c36 | ||
Bruno BELANYI | 0817e7ac7e |
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
|
|
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
|
|
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
|
|
|
@ -10,7 +10,6 @@ add_subdirectory(utils)
|
||||||
configure_file(config.h.in config.h)
|
configure_file(config.h.in config.h)
|
||||||
|
|
||||||
target_link_libraries(kraken PRIVATE
|
target_link_libraries(kraken PRIVATE
|
||||||
book
|
|
||||||
csv
|
csv
|
||||||
engine
|
engine
|
||||||
parse
|
parse
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
add_library(engine STATIC
|
add_library(engine STATIC
|
||||||
csv-engine-listener.cc
|
csv-engine-listener.cc
|
||||||
csv-engine-listener.hh
|
csv-engine-listener.hh
|
||||||
|
engine.cc
|
||||||
|
engine.hh
|
||||||
engine-listener.cc
|
engine-listener.cc
|
||||||
engine-listener.hh
|
engine-listener.hh
|
||||||
)
|
)
|
||||||
|
|
|
@ -6,6 +6,10 @@ CsvEngineListener::CsvEngineListener() = default;
|
||||||
|
|
||||||
CsvEngineListener::~CsvEngineListener() = default;
|
CsvEngineListener::~CsvEngineListener() = default;
|
||||||
|
|
||||||
|
csv::csv_type const& CsvEngineListener::output() const {
|
||||||
|
return output_;
|
||||||
|
}
|
||||||
|
|
||||||
void CsvEngineListener::on_acknowledgement(User user, UserOrderId id) {
|
void CsvEngineListener::on_acknowledgement(User user, UserOrderId id) {
|
||||||
output_.emplace_back(csv::csv_line_type{
|
output_.emplace_back(csv::csv_line_type{
|
||||||
"A",
|
"A",
|
||||||
|
|
|
@ -13,6 +13,8 @@ struct CsvEngineListener : EngineListener {
|
||||||
|
|
||||||
virtual ~CsvEngineListener();
|
virtual ~CsvEngineListener();
|
||||||
|
|
||||||
|
csv::csv_type const& output() const;
|
||||||
|
|
||||||
/// Called when a new trade or cancel order has been acknowledged.
|
/// Called when a new trade or cancel order has been acknowledged.
|
||||||
void on_acknowledgement(User user, UserOrderId id) override;
|
void on_acknowledgement(User user, UserOrderId id) override;
|
||||||
|
|
||||||
|
|
187
src/engine/engine.cc
Normal file
187
src/engine/engine.cc
Normal 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
46
src/engine/engine.hh
Normal 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
|
|
@ -1,5 +1,18 @@
|
||||||
#include <iostream>
|
#include <iostream>
|
||||||
|
|
||||||
|
#include "csv/write-csv.hh"
|
||||||
|
#include "engine/csv-engine-listener.hh"
|
||||||
|
#include "engine/engine.hh"
|
||||||
|
#include "parse/parse.hh"
|
||||||
|
|
||||||
int main() {
|
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());
|
||||||
}
|
}
|
||||||
|
|
|
@ -1 +1,2 @@
|
||||||
|
add_subdirectory(integration)
|
||||||
add_subdirectory(unit)
|
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)
|
43
tests/integration/test-output.sh
Executable file
43
tests/integration/test-output.sh
Executable 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))"
|
|
@ -25,4 +25,16 @@ target_link_libraries(parse_test PRIVATE
|
||||||
|
|
||||||
gtest_discover_tests(parse_test)
|
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})
|
endif (${GTest_FOUND})
|
||||||
|
|
99
tests/unit/engine.cc
Normal file
99
tests/unit/engine.cc
Normal 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");
|
||||||
|
}
|
Loading…
Reference in a new issue