diff --git a/.gitignore b/.gitignore index 2be208c5..9219a63e 100755 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,8 @@ *.csv /.vscode Cargo.lock +data/venv/ +rotala-python/venv/ +test_data/ +*__pycache__* +.env diff --git a/Cargo.toml b/Cargo.toml index 7e3a5b71..d431c96e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,4 @@ [workspace] resolver = "2" -members = [ - "rotala", - "example_clients/alator" -] +members = ["rotala", "rotala-client", "rotala-http"] diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..5159b59d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,5 @@ +FROM rust:1.83-bullseye + +COPY . . + +RUN cargo build --release diff --git a/data/hl.py b/data/hl.py new file mode 100644 index 00000000..1c765385 --- /dev/null +++ b/data/hl.py @@ -0,0 +1,92 @@ +import argparse +import os +import boto3 +from botocore import UNSIGNED +from botocore.config import Config +import lz4framed +import datetime + + +def path_builder(date, hour, coin): + return f"market_data/{date}/{hour}/l2Book/{coin}.lz4" + + +def parse_date(string): + return (string[0:4], string[4:6]) + + +def zero_padding(number): + if number < 10: + return "0" + str(number) + return str(number) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser( + prog="HL Data Fetcher", + description="Downloads data from HL, unzips and places into directory", + ) + + parser.add_argument("-o", "--outdir") + parser.add_argument("-c", "--coin") + parser.add_argument("-s", "--start") + + args = parser.parse_args() + + max_year = 2024 + hours = list(range(0, 24)) + days = list(range(1, 32)) + months = list(range(1, 13)) + client = boto3.client("s3", config=Config(signature_version=UNSIGNED)) + bucket_name = "hyperliquid-archive" + now = datetime.datetime.now() + + os.makedirs(f"{args.outdir}/{args.coin}", exist_ok=True) + + (start_year, start_month) = parse_date(args.start) + for year in range(int(start_year), max_year + 1): + iter_start = int(start_month) if int(year) == int(start_year) else 1 + + for month in range(iter_start, 13): + chunks = [] + file_path = f"{args.outdir}/{args.coin}/{month}" + if os.path.exists(file_path): + continue + + for day in days: + try: + then = datetime.datetime(year, month, day) + except ValueError: + # Occurs if date isn't valid + continue + + if then > now: + print("Reached the present") + print(file_path) + with open(f"{file_path}", "w") as f: + for chunk in chunks: + f.write(chunk) + exit(1) + + for hour in hours: + date_string = str(year) + zero_padding(month) + zero_padding(day) + key = path_builder(date_string, hour, args.coin) + + try: + response = client.get_object( + Bucket=bucket_name, + Key=key, + ) + contents = response["Body"].read() + except Exception: + print(f"Didn't find - {key}") + continue + + uncompressed = lz4framed.decompress(contents) + print(f"Took - {key}") + chunks.append(uncompressed.decode("utf-8")) + + print(file_path) + with open(f"{file_path}", "w") as f: + for chunk in chunks: + f.write(chunk) diff --git a/data/requirements.txt b/data/requirements.txt new file mode 100644 index 00000000..aa30ec53 --- /dev/null +++ b/data/requirements.txt @@ -0,0 +1,2 @@ +boto3==1.35.29 +py-lz4framed==0.14.0 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..59079f69 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,9 @@ +version: '3.9' + +services: + + uist_v2: + build: . + ports: + - 0.0.0.0:3000:${PORT} + command: cargo run --bin uist_server_v2 0.0.0.0 ${PORT} ${DB_HOST} ${DB_USER} ${DB_PWD} ${DB_NAME diff --git a/example_clients/alator/benches/sim_benchmark.rs b/example_clients/alator/benches/sim_benchmark.rs deleted file mode 100644 index b38fe688..00000000 --- a/example_clients/alator/benches/sim_benchmark.rs +++ /dev/null @@ -1,73 +0,0 @@ -use alator::broker::uist::UistBrokerBuilder; -use alator::broker::{BrokerCost, CashOperations, SendOrder, Update}; -use criterion::{criterion_group, criterion_main, Criterion}; -use std::collections::HashMap; - -use alator::strategy::staticweight::StaticWeightStrategyBuilder; -use rotala::http::uist_v1::{Client, TestClient}; -use rotala::input::penelope::Penelope; - -async fn full_backtest_random_data() { - let source = Penelope::random(100, vec!["ABC", "BCD"]); - - let initial_cash = 100_000.0; - - let mut weights = HashMap::new(); - weights.insert("ABC".to_string(), 0.5); - weights.insert("BCD".to_string(), 0.5); - - let mut client = TestClient::single("Random", source); - let resp = client.init("Random".to_string()).await.unwrap(); - - let simbrkr = UistBrokerBuilder::new() - .with_client(client, resp.backtest_id) - .with_trade_costs(vec![BrokerCost::Flat(1.0)]) - .build() - .await; - - let mut strat = StaticWeightStrategyBuilder::new() - .with_brkr(simbrkr) - .with_weights(weights) - .default(); - - strat.init(&initial_cash); - strat.run().await; -} - -async fn trade_execution_logic() { - let mut source = Penelope::new(); - source.add_quote(100.00, 101.00, 100, "ABC"); - source.add_quote(10.00, 11.00, 100, "BCD"); - source.add_quote(100.00, 101.00, 101, "ABC"); - source.add_quote(10.00, 11.00, 101, "BCD"); - source.add_quote(104.00, 105.00, 102, "ABC"); - source.add_quote(10.00, 11.00, 102, "BCD"); - source.add_quote(104.00, 105.00, 103, "ABC"); - source.add_quote(12.00, 13.00, 103, "BCD"); - - let mut client = TestClient::single("Random", source); - let resp = client.init("Random".to_string()).await.unwrap(); - - let mut brkr = UistBrokerBuilder::new() - .with_client(client, resp.backtest_id) - .build() - .await; - - brkr.deposit_cash(&100_000.0); - brkr.send_order(rotala::exchange::uist_v1::Order::market_buy("ABC", 100.0)); - brkr.send_order(rotala::exchange::uist_v1::Order::market_buy("BCD", 100.0)); - - brkr.check().await; - - brkr.check().await; - - brkr.check().await; -} - -fn benchmarks(c: &mut Criterion) { - c.bench_function("full backtest", |b| b.iter(full_backtest_random_data)); - c.bench_function("trade test", |b| b.iter(trade_execution_logic)); -} - -criterion_group!(benches, benchmarks); -criterion_main!(benches); diff --git a/example_clients/snek/.gitignore b/example_clients/snek/.gitignore deleted file mode 100644 index 93526df6..00000000 --- a/example_clients/snek/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -venv/ -__pycache__/ diff --git a/example_clients/snek/src/broker.py b/example_clients/snek/src/broker.py deleted file mode 100644 index f6826ab3..00000000 --- a/example_clients/snek/src/broker.py +++ /dev/null @@ -1,65 +0,0 @@ -import requests -from dataclasses import dataclass - -class Broker: - - def req_init(self): - r = requests.get(self.path + "/init") - return r.json() - - def req_fetch_quotes(self): - r = requests.get(self.path + "/fetch_quotes") - return r.json().get("quotes") - - def req_insert_order(self, order): - r = requests.post(self.path + "/insert_order", json={"order": order}) - return r.status_code == 200 - - def req_tick(self): - r = requests.get(self.path + "/tick") - return r.json() - - def __init__(self, path): - self.path = path - self.init = self.req_init() - self.quotes = self.req_fetch_quotes() - self.positions = {} - self.cash_balance = 100_000 - self.has_next = True - - def get_position(self, symbol): - return self.positions.get(symbol, 0) - - def fetch_quotes(self): - return self.req_fetch_quotes() - - def tick(self): - if not self.has_next: - return - - tick = self.req_tick() - - for trade in tick["executed_trades"]: - symbol = trade["symbol"] - position = self.positions.get(symbol) - if not position: - self.positions[symbol] = trade['quantity'] - else: - if trade["typ"] == "Buy": - self.positions[symbol] += trade['quantity'] - else: - self.positions[symbol] -= trade['quantity'] - - if trade["typ"] == "Buy": - self.cash_balance -= trade["value"] - else: - self.cash_balance += trade["value"] - return tick["has_next"] - - def insert_order(self, order): - if order["order_type"] == "MarketSell": - symbol = order["symbol"] - position = self.positions.get(symbol) - if not position: - raise ValueError("Can't sell something you don't already own") - return self.req_insert_order(order) diff --git a/example_clients/snek/src/main.py b/example_clients/snek/src/main.py deleted file mode 100644 index 067f2005..00000000 --- a/example_clients/snek/src/main.py +++ /dev/null @@ -1,44 +0,0 @@ -from broker import Broker - -def calc_avg(quotes): - prices = [quote["ask"] for quote in quotes] - return sum(prices) / 5 - -if __name__ == "__main__": - - last_five = [] - - broker = Broker("http://127.0.0.1:8080") - should_continue = True - - while should_continue: - print(broker.get_position("ABC")) - print(broker.cash_balance) - quotes = broker.fetch_quotes() - for quote in quotes: - if quote["symbol"] == "ABC": - if len(last_five) < 5: - last_five.append(quote) - else: - avg = calc_avg(last_five) - curr = quote["ask"] - if curr * 0.95 < avg and broker.get_position("ABC") == 0: - order = { - "order_id": None, - "order_type": "MarketBuy", - "symbol": "ABC", - "shares": 100.0, - "price": None - } - broker.insert_order(order) - elif avg > curr and broker.get_position("ABC") > 0: - order = { - "order_id": None, - "order_type": "MarketSell", - "symbol": "ABC", - "shares": broker.get_position("ABC"), - "price": None - } - broker.insert_order(order) - if not broker.tick(): - should_continue = False diff --git a/example_clients/alator/.gitignore b/rotala-client/.gitignore similarity index 100% rename from example_clients/alator/.gitignore rename to rotala-client/.gitignore diff --git a/example_clients/alator/Cargo.toml b/rotala-client/Cargo.toml similarity index 63% rename from example_clients/alator/Cargo.toml rename to rotala-client/Cargo.toml index 16cbfa15..8659871b 100644 --- a/example_clients/alator/Cargo.toml +++ b/rotala-client/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "alator" +name = "rotala-client" version = "0.4.1" authors = ["Calum Russell "] edition = "2021" @@ -14,22 +14,20 @@ time = { version = "0.3.17", features = ["macros", "parsing"] } rand = "0.8.4" rand_distr = "0.4.1" log = "0.4.17" -env_logger = "0.11.0" -pyo3 = { version = "0.22.1", optional = true } -async-trait = "0.1.73" +pyo3 = { version = "0.23.2", optional = true } tokio = { version = "1.32.0", features = ["full"] } futures = "0.3.28" -rotala = { path = "../../rotala/" } +rotala = { path = "../rotala/" } +rotala-http = { path = "../rotala-http/" } +anyhow = "1.0.86" +reqwest = { version = "0.12.5", features = ["blocking", "json"] } +env_logger = "0.11.5" [dev-dependencies] reqwest = { version = "0.12.5", features = ["blocking"] } -zip = "0.6.2" +zip = "2.2.0" csv = "1.1.6" -criterion = { version = "0.5.1", features = ["async_tokio"] } -[lib] -bench = false - -[[bench]] -name = "sim_benchmark" -harness = false +[[bin]] +name = "uist_client_test" +path = "./src/bin/uist_client_test.rs" diff --git a/example_clients/alator/LICENCE b/rotala-client/LICENCE similarity index 100% rename from example_clients/alator/LICENCE rename to rotala-client/LICENCE diff --git a/example_clients/alator/README.md b/rotala-client/README.md similarity index 100% rename from example_clients/alator/README.md rename to rotala-client/README.md diff --git a/rotala/src/bin/uist_client_test.rs b/rotala-client/src/bin/uist_client_test.rs similarity index 85% rename from rotala/src/bin/uist_client_test.rs rename to rotala-client/src/bin/uist_client_test.rs index be4c9aa6..01937b11 100644 --- a/rotala/src/bin/uist_client_test.rs +++ b/rotala-client/src/bin/uist_client_test.rs @@ -1,7 +1,8 @@ use std::io::Result; use rotala::exchange::uist_v1::Order; -use rotala::http::uist_v1::{Client, HttpClient}; +use rotala_client::client::uist_v1::HttpClient; +use rotala_http::http::uist_v1::Client; #[tokio::main] async fn main() -> Result<()> { diff --git a/example_clients/alator/src/broker/mod.rs b/rotala-client/src/broker/mod.rs similarity index 100% rename from example_clients/alator/src/broker/mod.rs rename to rotala-client/src/broker/mod.rs diff --git a/example_clients/alator/src/broker/uist.rs b/rotala-client/src/broker/uist.rs similarity index 97% rename from example_clients/alator/src/broker/uist.rs rename to rotala-client/src/broker/uist.rs index 17bf61a2..957f8921 100644 --- a/example_clients/alator/src/broker/uist.rs +++ b/rotala-client/src/broker/uist.rs @@ -9,7 +9,7 @@ use std::{ use log::info; use rotala::exchange::uist_v1::{Order, OrderType, Trade, TradeType, UistQuote, UistV1}; -use rotala::http::uist_v1::{BacktestId, Client}; +use rotala_http::http::uist_v1::{BacktestId, Client}; use crate::{broker::BrokerOrder, strategy::staticweight::StaticWeightBroker}; @@ -453,13 +453,14 @@ mod tests { use crate::broker::{ BrokerCashEvent, BrokerCost, BrokerOperations, CashOperations, Portfolio, SendOrder, Update, }; + use crate::client::uist_v1::LocalClient; use rotala::exchange::uist_v1::{Order, OrderType, Trade, TradeType, UistV1}; - use rotala::http::uist_v1::{Client, TestClient}; use rotala::input::penelope::Penelope; + use rotala_http::http::uist_v1::Client; use super::{UistBroker, UistBrokerBuilder, UistBrokerEvent, UistBrokerLog}; - async fn setup() -> UistBroker { + async fn setup() -> UistBroker { let mut source = Penelope::new(); source.add_quote(100.00, 101.00, 100, "ABC"); @@ -474,7 +475,7 @@ mod tests { source.add_quote(95.00, 96.00, 103, "ABC"); source.add_quote(10.00, 11.00, 103, "BCD"); - let mut client = TestClient::single("Random", source); + let mut client = LocalClient::single("Random", source); let resp = client.init("Random".to_string()).await.unwrap(); UistBrokerBuilder::new() @@ -646,7 +647,7 @@ mod tests { source.add_quote(104.00, 105.00, 103, "ABC"); source.add_quote(12.00, 13.00, 103, "BCD"); - let mut client = TestClient::single("Random", source); + let mut client = LocalClient::single("Random", source); let resp = client.init("Random".to_string()).await.unwrap(); let mut brkr = UistBrokerBuilder::new() @@ -691,7 +692,7 @@ mod tests { source.add_quote(150.00, 151.00, 101, "ABC"); source.add_quote(150.00, 151.00, 102, "ABC"); - let mut client = TestClient::single("Random", source); + let mut client = LocalClient::single("Random", source); let resp = client.init("Random".to_string()).await.unwrap(); let mut brkr = UistBrokerBuilder::new() @@ -728,7 +729,7 @@ mod tests { source.add_quote(200.00, 201.00, 101, "ABC"); source.add_quote(200.00, 201.00, 101, "ABC"); - let mut client = TestClient::single("Random", source); + let mut client = LocalClient::single("Random", source); let resp = client.init("Random".to_string()).await.unwrap(); let mut brkr = UistBrokerBuilder::new() @@ -834,7 +835,7 @@ mod tests { #[tokio::test] async fn diff_direction_correct_if_need_to_buy() { let source = Penelope::random(100, vec!["ABC"]); - let mut client = TestClient::single("Random", source); + let mut client = LocalClient::single("Random", source); let resp = client.init("Random".to_string()).await.unwrap(); let mut brkr = UistBrokerBuilder::new() @@ -864,7 +865,7 @@ mod tests { //This is connected to the previous test, if the above fails then this will never pass. //However, if the above passes this could still fail. let source = Penelope::random(100, vec!["ABC"]); - let mut client = TestClient::single("Random", source); + let mut client = LocalClient::single("Random", source); let resp = client.init("Random".to_string()).await.unwrap(); let mut brkr = UistBrokerBuilder::new() @@ -905,7 +906,7 @@ mod tests { //for a given security on a certain date. We are interested in the latter case, not the former but it is more //difficult to test for the latter, and the code should be the same. let source = Penelope::random(100, vec!["ABC"]); - let mut client = TestClient::single("Random", source); + let mut client = LocalClient::single("Random", source); let resp = client.init("Random".to_string()).await.unwrap(); let mut brkr = UistBrokerBuilder::new() @@ -933,7 +934,7 @@ mod tests { //If we get to a point where the client is diffing without cash, we can assume that no further operations are possible //and we should panic let source = Penelope::random(100, vec!["ABC"]); - let mut client = TestClient::single("Random", source); + let mut client = LocalClient::single("Random", source); let resp = client.init("Random".to_string()).await.unwrap(); let mut brkr = UistBrokerBuilder::new() @@ -988,7 +989,7 @@ mod tests { source.add_quote(100.00, 100.00, 101, "ABC"); source.add_quote(100.00, 100.00, 103, "ABC"); - let mut client = TestClient::single("Random", source); + let mut client = LocalClient::single("Random", source); let resp = client.init("Random".to_string()).await.unwrap(); let mut brkr = UistBrokerBuilder::new() @@ -1033,7 +1034,7 @@ mod tests { source.add_quote(75.00, 75.00, 104, "ABC"); source.add_quote(75.00, 75.00, 105, "ABC"); - let mut client = TestClient::single("Random", source); + let mut client = LocalClient::single("Random", source); let resp = client.init("Random".to_string()).await.unwrap(); let mut brkr = UistBrokerBuilder::new() diff --git a/rotala/src/http/mod.rs b/rotala-client/src/client/mod.rs similarity index 66% rename from rotala/src/http/mod.rs rename to rotala-client/src/client/mod.rs index a0c9ccd7..7d33118e 100644 --- a/rotala/src/http/mod.rs +++ b/rotala-client/src/client/mod.rs @@ -1,3 +1,2 @@ -pub mod jura_v1; pub mod uist_v1; pub mod uist_v2; diff --git a/rotala-client/src/client/uist_v1.rs b/rotala-client/src/client/uist_v1.rs new file mode 100644 index 00000000..0dce2230 --- /dev/null +++ b/rotala-client/src/client/uist_v1.rs @@ -0,0 +1,199 @@ +use anyhow::{Error, Result}; +use reqwest; +use rotala::exchange::uist_v1::{Order, OrderId}; +use rotala::input::penelope::Penelope; +use rotala_http::http::uist_v1::{ + AppState, BacktestId, Client, DeleteOrderRequest, FetchQuotesResponse, InfoResponse, + InitResponse, InsertOrderRequest, NowResponse, TickResponse, UistV1Error, +}; +use std::future::{self, Future}; + +#[derive(Debug)] +pub struct HttpClient { + pub path: String, + pub client: reqwest::Client, +} + +impl Client for HttpClient { + async fn tick(&mut self, backtest_id: BacktestId) -> Result { + Ok(self + .client + .get(self.path.clone() + format!("/backtest/{backtest_id}/tick").as_str()) + .send() + .await? + .json::() + .await?) + } + + async fn delete_order(&mut self, order_id: OrderId, backtest_id: BacktestId) -> Result<()> { + let req = DeleteOrderRequest { order_id }; + Ok(self + .client + .post(self.path.clone() + format!("/backtest/{backtest_id}/delete_order").as_str()) + .json(&req) + .send() + .await? + .json::<()>() + .await?) + } + + async fn insert_order(&mut self, order: Order, backtest_id: BacktestId) -> Result<()> { + let req = InsertOrderRequest { order }; + Ok(self + .client + .post(self.path.clone() + format!("/backtest/{backtest_id}/insert_order").as_str()) + .json(&req) + .send() + .await? + .json::<()>() + .await?) + } + + async fn fetch_quotes(&mut self, backtest_id: BacktestId) -> Result { + Ok(self + .client + .get(self.path.clone() + format!("/backtest/{backtest_id}/fetch_quotes").as_str()) + .send() + .await? + .json::() + .await?) + } + + async fn init(&mut self, dataset_name: String) -> Result { + Ok(self + .client + .get(self.path.clone() + format!("/init/{dataset_name}").as_str()) + .send() + .await? + .json::() + .await?) + } + + async fn info(&mut self, backtest_id: BacktestId) -> Result { + Ok(self + .client + .get(self.path.clone() + format!("/backtest/{backtest_id}/info").as_str()) + .send() + .await? + .json::() + .await?) + } + + async fn now(&mut self, backtest_id: BacktestId) -> Result { + Ok(self + .client + .get(self.path.clone() + format!("/backtest/{backtest_id}/now").as_str()) + .send() + .await? + .json::() + .await?) + } +} + +impl HttpClient { + pub fn new(path: String) -> Self { + Self { + path, + client: reqwest::Client::new(), + } + } +} + +pub struct LocalClient { + state: AppState, +} + +impl Client for LocalClient { + fn init(&mut self, dataset_name: String) -> impl Future> { + if let Some(id) = self.state.init(dataset_name) { + future::ready(Ok(InitResponse { backtest_id: id })) + } else { + future::ready(Err(Error::new(UistV1Error::UnknownDataset))) + } + } + + fn tick(&mut self, backtest_id: BacktestId) -> impl Future> { + if let Some(resp) = self.state.tick(backtest_id) { + future::ready(Ok(TickResponse { + inserted_orders: resp.2, + executed_trades: resp.1, + has_next: resp.0, + })) + } else { + future::ready(Err(Error::new(UistV1Error::UnknownBacktest))) + } + } + + fn insert_order( + &mut self, + order: Order, + backtest_id: BacktestId, + ) -> impl Future> { + if let Some(()) = self.state.insert_order(order, backtest_id) { + future::ready(Ok(())) + } else { + future::ready(Err(Error::new(UistV1Error::UnknownBacktest))) + } + } + + fn delete_order( + &mut self, + order_id: OrderId, + backtest_id: BacktestId, + ) -> impl Future> { + if let Some(()) = self.state.delete_order(order_id, backtest_id) { + future::ready(Ok(())) + } else { + future::ready(Err(Error::new(UistV1Error::UnknownBacktest))) + } + } + + fn fetch_quotes( + &mut self, + backtest_id: BacktestId, + ) -> impl Future> { + if let Some(quotes) = self.state.fetch_quotes(backtest_id) { + future::ready(Ok(FetchQuotesResponse { + quotes: quotes.to_owned(), + })) + } else { + future::ready(Err(Error::new(UistV1Error::UnknownBacktest))) + } + } + + fn info(&mut self, backtest_id: BacktestId) -> impl Future> { + if let Some(backtest) = self.state.backtests.get(&backtest_id) { + future::ready(Ok(InfoResponse { + version: "v1".to_string(), + dataset: backtest.dataset_name.clone(), + })) + } else { + future::ready(Err(Error::new(UistV1Error::UnknownBacktest))) + } + } + + fn now(&mut self, backtest_id: BacktestId) -> impl Future> { + if let Some(backtest) = self.state.backtests.get(&backtest_id) { + if let Some(dataset) = self.state.datasets.get(&backtest.dataset_name) { + let now = backtest.date; + let mut has_next = false; + if dataset.has_next(backtest.pos) { + has_next = true; + } + future::ready(Ok(NowResponse { now, has_next })) + } else { + future::ready(Err(Error::new(UistV1Error::UnknownDataset))) + } + } else { + future::ready(Err(Error::new(UistV1Error::UnknownBacktest))) + } + } +} + +impl LocalClient { + pub fn single(name: &str, data: Penelope) -> Self { + Self { + state: AppState::single(name, data), + } + } +} diff --git a/rotala-client/src/client/uist_v2.rs b/rotala-client/src/client/uist_v2.rs new file mode 100644 index 00000000..68fd7fa9 --- /dev/null +++ b/rotala-client/src/client/uist_v2.rs @@ -0,0 +1,164 @@ +use anyhow::{Error, Result}; +use reqwest; +use rotala::exchange::uist_v2::Order; +use rotala_http::http::uist_v2::{ + AppState, BacktestId, Client, DatasetInfoResponse, InfoResponse, InitRequest, InitResponse, + InsertOrderRequest, TickResponse, UistV2Error, +}; +use std::{ + future::{self, Future}, + mem, +}; + +#[derive(Debug)] +pub struct HttpClient { + pub path: String, + pub client: reqwest::Client, +} + +impl Client for HttpClient { + async fn tick(&self, backtest_id: BacktestId) -> Result { + Ok(self + .client + .get(self.path.clone() + format!("/backtest/{backtest_id}/tick").as_str()) + .send() + .await? + .json::() + .await?) + } + + async fn insert_orders(&self, orders: Vec, backtest_id: BacktestId) -> Result<()> { + let req = InsertOrderRequest { orders }; + Ok(self + .client + .post(self.path.clone() + format!("/backtest/{backtest_id}/insert_orders").as_str()) + .json(&req) + .send() + .await? + .json::<()>() + .await?) + } + + async fn init(&self, start_date: i64, end_date: i64, frequency: u64) -> Result { + let req = InitRequest { + start_date, + end_date, + frequency, + }; + Ok(self + .client + .post(self.path.clone() + "/init") + .json(&req) + .send() + .await? + .json::() + .await?) + } + + async fn info(&self, backtest_id: BacktestId) -> Result { + Ok(self + .client + .get(self.path.clone() + format!("/backtest/{backtest_id}/info").as_str()) + .send() + .await? + .json::() + .await?) + } + + async fn dataset_info(&self) -> Result { + Ok(self + .client + .get(self.path.clone() + "/dataset/info") + .send() + .await? + .json::() + .await?) + } +} + +impl HttpClient { + pub fn new(path: String) -> Self { + Self { + path, + client: reqwest::Client::new(), + } + } +} + +pub struct TestClient { + state: AppState, +} + +impl Client for TestClient { + fn init( + &self, + start_date: i64, + end_date: i64, + frequency: u64, + ) -> impl Future> { + if let Some((backtest_id, depth)) = + futures::executor::block_on(self.state.init(start_date, end_date, frequency)) + { + future::ready(Ok(InitResponse { backtest_id, depth })) + } else { + future::ready(Err(Error::new(UistV2Error::UnknownDataset))) + } + } + + fn tick(&self, backtest_id: BacktestId) -> impl Future> { + if let Some(resp) = futures::executor::block_on(self.state.tick(backtest_id)) { + future::ready(Ok(TickResponse { + depth: resp.3, + inserted_orders: resp.2, + executed_orders: resp.1, + has_next: resp.0, + now: resp.4, + taker_trades: resp.5, + })) + } else { + future::ready(Err(Error::new(UistV2Error::UnknownBacktest))) + } + } + + fn insert_orders( + &self, + mut orders: Vec, + backtest_id: BacktestId, + ) -> impl Future> { + let take_orders = mem::take(&mut orders); + if let Some(()) = self.state.insert_orders(take_orders, backtest_id) { + future::ready(Ok(())) + } else { + future::ready(Err(Error::new(UistV2Error::UnknownBacktest))) + } + } + + fn info(&self, backtest_id: BacktestId) -> impl Future> { + if let Some(_backtest) = self.state.backtests.get(&backtest_id) { + future::ready(Ok(InfoResponse { + version: "v1".to_string(), + })) + } else { + future::ready(Err(Error::new(UistV2Error::UnknownBacktest))) + } + } + + fn dataset_info(&self) -> impl Future> { + if let Some(dataset) = futures::executor::block_on(self.state.dataset_info()) { + future::ready(Ok(DatasetInfoResponse { + start_date: dataset.0, + end_date: dataset.1, + })) + } else { + future::ready(Err(Error::new(UistV2Error::UnknownDataset))) + } + } +} + +impl TestClient { + pub fn single(user: &str, dbname: &str, host: &str, password: &str) -> Self { + Self { + state: AppState::single(user, dbname, host, password), + } + } +} diff --git a/example_clients/alator/src/lib.rs b/rotala-client/src/lib.rs similarity index 99% rename from example_clients/alator/src/lib.rs rename to rotala-client/src/lib.rs index 9241d5c2..8566fbd6 100755 --- a/example_clients/alator/src/lib.rs +++ b/rotala-client/src/lib.rs @@ -53,6 +53,7 @@ #[allow(unused)] pub mod broker; +pub mod client; pub mod perf; pub mod schedule; pub mod strategy; diff --git a/example_clients/alator/src/perf/mod.rs b/rotala-client/src/perf/mod.rs similarity index 85% rename from example_clients/alator/src/perf/mod.rs rename to rotala-client/src/perf/mod.rs index 2cd207f7..382b9b83 100755 --- a/example_clients/alator/src/perf/mod.rs +++ b/rotala-client/src/perf/mod.rs @@ -281,43 +281,12 @@ impl PerformanceCalculator { #[cfg(test)] mod tests { - use std::collections::HashMap; - - use rotala::http::uist_v1::{Client, TestClient}; - use rotala::input::penelope::Penelope; - - use crate::broker::uist::UistBroker; - use crate::broker::uist::UistBrokerBuilder; - use crate::broker::BrokerCost; use crate::perf::StrategySnapshot; - use crate::strategy::staticweight::StaticWeightStrategyBuilder; use super::Frequency; use super::PerformanceCalculator; use super::PortfolioCalculations; - async fn setup() -> UistBroker { - let mut source = Penelope::new(); - source.add_quote(101.0, 102.0, 100, "ABC"); - source.add_quote(102.0, 103.0, 101, "ABC"); - source.add_quote(97.0, 98.0, 102, "ABC"); - source.add_quote(105.0, 106.0, 103, "ABC"); - - source.add_quote(501.0, 502.0, 100, "BCD"); - source.add_quote(503.0, 504.0, 101, "BCD"); - source.add_quote(498.0, 499.0, 102, "BCD"); - source.add_quote(495.0, 496.0, 103, "BCD"); - - let mut client = TestClient::single("Random", source); - let resp = client.init("Random".to_string()).await.unwrap(); - - UistBrokerBuilder::new() - .with_client(client, resp.backtest_id) - .with_trade_costs(vec![BrokerCost::PctOfValue(0.01)]) - .build() - .await - } - #[test] fn test_that_annualizations_calculate_correctly() { assert_eq!( @@ -337,40 +306,6 @@ mod tests { ); } - #[tokio::test] - async fn test_that_portfolio_calculates_performance_accurately() { - let brkr = setup().await; - //We use less than 100% because some bugs become possible when you are allocating the full - //portfolio which perturb the order of operations leading to different perf outputs. - let mut target_weights = HashMap::new(); - target_weights.insert("ABC".to_string(), 0.4); - target_weights.insert("BCD".to_string(), 0.4); - - let mut strat = StaticWeightStrategyBuilder::new() - .with_brkr(brkr) - .with_weights(target_weights) - .default(); - - strat.init(&100_000.0); - - strat.update().await; - - strat.update().await; - - strat.update().await; - - let output = strat.get_history(); - println!("{:?}", output); - let perf = PerformanceCalculator::calculate(Frequency::Daily, output); - println!("{:?}", perf.returns); - - let portfolio_return = perf.ret; - //We need to round up to cmp properly - let to_comp = (portfolio_return * 1000.0).round(); - println!("{:?}", to_comp); - assert_eq!(to_comp, 24.0); - } - #[test] fn test_that_returns_with_cash_flow_correct() { //Each period has a 10% return starting from the last period value + the value of the cash diff --git a/example_clients/alator/src/schedule/mod.rs b/rotala-client/src/schedule/mod.rs similarity index 100% rename from example_clients/alator/src/schedule/mod.rs rename to rotala-client/src/schedule/mod.rs diff --git a/example_clients/alator/src/strategy/mod.rs b/rotala-client/src/strategy/mod.rs similarity index 99% rename from example_clients/alator/src/strategy/mod.rs rename to rotala-client/src/strategy/mod.rs index afb8c5f8..153f8610 100755 --- a/example_clients/alator/src/strategy/mod.rs +++ b/rotala-client/src/strategy/mod.rs @@ -13,7 +13,6 @@ pub mod staticweight; #[allow(unused)] - /// Used to log cash flows which may be used in performance calculations. pub enum StrategyEvent { WithdrawSuccess(f64), diff --git a/example_clients/alator/src/strategy/staticweight.rs b/rotala-client/src/strategy/staticweight.rs similarity index 100% rename from example_clients/alator/src/strategy/staticweight.rs rename to rotala-client/src/strategy/staticweight.rs diff --git a/example_clients/alator/tests/staticweight_test.rs b/rotala-client/tests/staticweight_test.rs similarity index 69% rename from example_clients/alator/tests/staticweight_test.rs rename to rotala-client/tests/staticweight_test.rs index e63f6cef..59baa32c 100755 --- a/example_clients/alator/tests/staticweight_test.rs +++ b/rotala-client/tests/staticweight_test.rs @@ -1,11 +1,12 @@ use std::collections::HashMap; -use alator::broker::uist::UistBrokerBuilder; -use alator::broker::BrokerCost; +use rotala_client::broker::uist::UistBrokerBuilder; +use rotala_client::broker::BrokerCost; -use alator::strategy::staticweight::{PortfolioAllocation, StaticWeightStrategyBuilder}; -use rotala::http::uist_v1::{Client, TestClient}; use rotala::input::penelope::Penelope; +use rotala_client::client::uist_v1::LocalClient; +use rotala_client::strategy::staticweight::{PortfolioAllocation, StaticWeightStrategyBuilder}; +use rotala_http::http::uist_v1::Client; #[tokio::test] async fn staticweight_integration_test() { @@ -19,7 +20,7 @@ async fn staticweight_integration_test() { weights.insert("BCD".to_string(), 0.5); let source = Penelope::random(length_in_days, vec!["ABC", "BCD"]); - let mut client = TestClient::single("Random", source); + let mut client = LocalClient::single("Random", source); let resp = client.init("Random".to_string()).await.unwrap(); let brkr = UistBrokerBuilder::new() @@ -36,5 +37,5 @@ async fn staticweight_integration_test() { strat.init(&initial_cash); strat.run().await; - let _perf = strat.perf(alator::perf::Frequency::Daily); + let _perf = strat.perf(rotala_client::perf::Frequency::Daily); } diff --git a/rotala-http/Cargo.toml b/rotala-http/Cargo.toml new file mode 100644 index 00000000..7479ff08 --- /dev/null +++ b/rotala-http/Cargo.toml @@ -0,0 +1,40 @@ +[package] +name = "rotala-http" +version = "0.4.1" +edition = "2021" +authors = ["Calum Russell "] +license-file = "LICENCE" +description = "JSON server exchange and library for backtesting trading strategies" +repository = "https://github.com/calumrussell/alator" +readme = "README.md" +rust-version = "1.75" + +[dependencies] +actix-web = "4" +time = { version = "0.3.17", features = ["macros", "parsing"] } +rand = "0.8.4" +rand_distr = "0.4.1" +reqwest = { version = "0.12.5", features = ["blocking", "json"] } +zip = "2.1.3" +csv = "1.1.6" +serde = { version = "1.0.193", features = ["derive"] } +serde_json = "1.0.108" +tokio = { version = "1.35.1", features = ["full"] } +derive_more = { version = "1.0.0", features = ["full"] } +anyhow = "1.0.86" +rotala = { path = "../rotala/" } +dashmap = "6.1.0" +env_logger = "0.11.6" +tokio-postgres = "0.7.12" +deadpool-postgres = "0.14.0" + +[dev-dependencies] +criterion = { version = "0.5.1", features = ["async_tokio"] } + +[[bin]] +name = "uist_server_v1" +path = "./src/bin/uist_server_v1.rs" + +[[bin]] +name = "uist_server_v2" +path = "./src/bin/uist_server_v2.rs" diff --git a/rotala/src/bin/uist_server_v1.rs b/rotala-http/src/bin/uist_server_v1.rs similarity index 81% rename from rotala/src/bin/uist_server_v1.rs rename to rotala-http/src/bin/uist_server_v1.rs index 8dc68431..3283e008 100644 --- a/rotala/src/bin/uist_server_v1.rs +++ b/rotala-http/src/bin/uist_server_v1.rs @@ -2,16 +2,16 @@ use std::env; use std::sync::Mutex; use actix_web::{web, App, HttpServer}; -use rotala::{ - http::uist_v1::{ - server::{delete_order, fetch_quotes, info, init, insert_order, tick}, - AppState, - }, - input::penelope::Penelope, + +use rotala::input::penelope::Penelope; +use rotala_http::http::uist_v1::{ + server::{delete_order, fetch_quotes, info, init, insert_order, tick}, + AppState, }; #[actix_web::main] async fn main() -> std::io::Result<()> { + env_logger::init(); let args: Vec = env::args().collect(); let address: String = args[1].clone(); diff --git a/rotala-http/src/bin/uist_server_v2.rs b/rotala-http/src/bin/uist_server_v2.rs new file mode 100644 index 00000000..bc3febb1 --- /dev/null +++ b/rotala-http/src/bin/uist_server_v2.rs @@ -0,0 +1,34 @@ +use std::env; + +use actix_web::{web, App, HttpServer}; +use rotala_http::http::uist_v2::server::*; +use rotala_http::http::uist_v2::AppState; + +#[actix_web::main] +async fn main() -> std::io::Result<()> { + env_logger::init(); + let args: Vec = env::args().collect(); + + let address: String = args[1].clone(); + let port: u16 = args[2].parse().unwrap(); + let host: String = args[3].clone(); + let user: String = args[4].clone(); + let password: String = args[5].clone(); + let dbname: String = args[6].clone(); + + let app_state = AppState::single(&user, &dbname, &host, &password); + let uist_state = web::Data::new(app_state); + + HttpServer::new(move || { + App::new() + .app_data(uist_state.clone()) + .service(info) + .service(init) + .service(tick) + .service(insert_orders) + .service(dataset_info) + }) + .bind((address, port))? + .run() + .await +} diff --git a/rotala-http/src/http/mod.rs b/rotala-http/src/http/mod.rs new file mode 100644 index 00000000..7d33118e --- /dev/null +++ b/rotala-http/src/http/mod.rs @@ -0,0 +1,2 @@ +pub mod uist_v1; +pub mod uist_v2; diff --git a/rotala/src/http/uist_v1.rs b/rotala-http/src/http/uist_v1.rs similarity index 68% rename from rotala/src/http/uist_v1.rs rename to rotala-http/src/http/uist_v1.rs index ad84515c..c486f264 100644 --- a/rotala/src/http/uist_v1.rs +++ b/rotala-http/src/http/uist_v1.rs @@ -1,12 +1,12 @@ use std::collections::HashMap; -use std::future::{self, Future}; +use std::future::Future; use std::sync::Mutex; -use anyhow::{Error, Result}; +use anyhow::Result; use serde::{Deserialize, Serialize}; -use crate::exchange::uist_v1::{Order, OrderId, Trade, UistV1}; -use crate::input::penelope::{Penelope, PenelopeQuoteByDate}; +use rotala::exchange::uist_v1::{Order, OrderId, Trade, UistV1}; +use rotala::input::penelope::{Penelope, PenelopeQuoteByDate}; pub type BacktestId = u64; @@ -233,196 +233,6 @@ pub trait Client { fn now(&mut self, backtest_id: BacktestId) -> impl Future>; } -pub struct TestClient { - state: AppState, -} - -impl Client for TestClient { - fn init(&mut self, dataset_name: String) -> impl Future> { - if let Some(id) = self.state.init(dataset_name) { - future::ready(Ok(InitResponse { backtest_id: id })) - } else { - future::ready(Err(Error::new(UistV1Error::UnknownDataset))) - } - } - - fn tick(&mut self, backtest_id: BacktestId) -> impl Future> { - if let Some(resp) = self.state.tick(backtest_id) { - future::ready(Ok(TickResponse { - inserted_orders: resp.2, - executed_trades: resp.1, - has_next: resp.0, - })) - } else { - future::ready(Err(Error::new(UistV1Error::UnknownBacktest))) - } - } - - fn insert_order( - &mut self, - order: Order, - backtest_id: BacktestId, - ) -> impl Future> { - if let Some(()) = self.state.insert_order(order, backtest_id) { - future::ready(Ok(())) - } else { - future::ready(Err(Error::new(UistV1Error::UnknownBacktest))) - } - } - - fn delete_order( - &mut self, - order_id: OrderId, - backtest_id: BacktestId, - ) -> impl Future> { - if let Some(()) = self.state.delete_order(order_id, backtest_id) { - future::ready(Ok(())) - } else { - future::ready(Err(Error::new(UistV1Error::UnknownBacktest))) - } - } - - fn fetch_quotes( - &mut self, - backtest_id: BacktestId, - ) -> impl Future> { - if let Some(quotes) = self.state.fetch_quotes(backtest_id) { - future::ready(Ok(FetchQuotesResponse { - quotes: quotes.to_owned(), - })) - } else { - future::ready(Err(Error::new(UistV1Error::UnknownBacktest))) - } - } - - fn info(&mut self, backtest_id: BacktestId) -> impl Future> { - if let Some(backtest) = self.state.backtests.get(&backtest_id) { - future::ready(Ok(InfoResponse { - version: "v1".to_string(), - dataset: backtest.dataset_name.clone(), - })) - } else { - future::ready(Err(Error::new(UistV1Error::UnknownBacktest))) - } - } - - fn now(&mut self, backtest_id: BacktestId) -> impl Future> { - if let Some(backtest) = self.state.backtests.get(&backtest_id) { - if let Some(dataset) = self.state.datasets.get(&backtest.dataset_name) { - let now = backtest.date; - let mut has_next = false; - if dataset.has_next(backtest.pos) { - has_next = true; - } - future::ready(Ok(NowResponse { now, has_next })) - } else { - future::ready(Err(Error::new(UistV1Error::UnknownDataset))) - } - } else { - future::ready(Err(Error::new(UistV1Error::UnknownBacktest))) - } - } -} - -impl TestClient { - pub fn single(name: &str, data: Penelope) -> Self { - Self { - state: AppState::single(name, data), - } - } -} - -#[derive(Debug)] -pub struct HttpClient { - pub path: String, - pub client: reqwest::Client, -} - -impl Client for HttpClient { - async fn tick(&mut self, backtest_id: BacktestId) -> Result { - Ok(self - .client - .get(self.path.clone() + format!("/backtest/{backtest_id}/tick").as_str()) - .send() - .await? - .json::() - .await?) - } - - async fn delete_order(&mut self, order_id: OrderId, backtest_id: BacktestId) -> Result<()> { - let req = DeleteOrderRequest { order_id }; - Ok(self - .client - .post(self.path.clone() + format!("/backtest/{backtest_id}/delete_order").as_str()) - .json(&req) - .send() - .await? - .json::<()>() - .await?) - } - - async fn insert_order(&mut self, order: Order, backtest_id: BacktestId) -> Result<()> { - let req = InsertOrderRequest { order }; - Ok(self - .client - .post(self.path.clone() + format!("/backtest/{backtest_id}/insert_order").as_str()) - .json(&req) - .send() - .await? - .json::<()>() - .await?) - } - - async fn fetch_quotes(&mut self, backtest_id: BacktestId) -> Result { - Ok(self - .client - .get(self.path.clone() + format!("/backtest/{backtest_id}/fetch_quotes").as_str()) - .send() - .await? - .json::() - .await?) - } - - async fn init(&mut self, dataset_name: String) -> Result { - Ok(self - .client - .get(self.path.clone() + format!("/init/{dataset_name}").as_str()) - .send() - .await? - .json::() - .await?) - } - - async fn info(&mut self, backtest_id: BacktestId) -> Result { - Ok(self - .client - .get(self.path.clone() + format!("/backtest/{backtest_id}/info").as_str()) - .send() - .await? - .json::() - .await?) - } - - async fn now(&mut self, backtest_id: BacktestId) -> Result { - Ok(self - .client - .get(self.path.clone() + format!("/backtest/{backtest_id}/now").as_str()) - .send() - .await? - .json::() - .await?) - } -} - -impl HttpClient { - pub fn new(path: String) -> Self { - Self { - path, - client: reqwest::Client::new(), - } - } -} - type UistState = Mutex; pub mod server { @@ -562,8 +372,8 @@ pub mod server { mod tests { use actix_web::{test, web, App}; - use crate::exchange::uist_v1::Order; - use crate::input::penelope::Penelope; + use rotala::exchange::uist_v1::Order; + use rotala::input::penelope::Penelope; use super::server::*; use super::{AppState, FetchQuotesResponse, InitResponse, InsertOrderRequest, TickResponse}; diff --git a/rotala-http/src/http/uist_v2.rs b/rotala-http/src/http/uist_v2.rs new file mode 100644 index 00000000..7a74b66b --- /dev/null +++ b/rotala-http/src/http/uist_v2.rs @@ -0,0 +1,418 @@ +use std::collections::BTreeMap; +use std::future::Future; +use std::sync::atomic::AtomicU64; + +use anyhow::Result; +use dashmap::try_result::TryResult; +use dashmap::DashMap; +use deadpool_postgres::Pool; +use rotala::input::minerva::Minerva; +use serde::{Deserialize, Serialize}; + +use rotala::exchange::uist_v2::{InnerOrder, Order, OrderId, OrderResult, UistV2}; +use rotala::source::hyperliquid::{DateDepth, Trade}; + +pub type BacktestId = u64; +pub type TickResponseType = ( + bool, + Vec, + Vec, + DateDepth, + i64, + Vec, +); + +pub struct BacktestState { + pub id: BacktestId, + pub start_date: i64, + pub curr_date: i64, + pub frequency: u64, + pub end_date: i64, + pub exchange: UistV2, +} + +pub struct AppState { + pub backtests: DashMap, + pub datasets: DashMap, + pub pool: Pool, + pub last: AtomicU64, +} + +impl AppState { + const MAX_BACKTEST_LENGTH: i64 = 1_000_000; + + pub fn create_db_pool(user: &str, dbname: &str, host: &str, password: &str) -> Pool { + let mut pg_config = tokio_postgres::Config::new(); + pg_config.user(user); + pg_config.dbname(dbname); + pg_config.host(host); + pg_config.password(password); + + let mgr_config = deadpool_postgres::ManagerConfig { + recycling_method: deadpool_postgres::RecyclingMethod::Fast, + }; + let mgr = + deadpool_postgres::Manager::from_config(pg_config, tokio_postgres::NoTls, mgr_config); + Pool::builder(mgr).max_size(16).build().unwrap() + } + + pub fn create(user: &str, dbname: &str, host: &str, password: &str) -> Self { + Self { + backtests: DashMap::new(), + last: AtomicU64::new(0), + pool: Self::create_db_pool(user, dbname, host, password), + datasets: DashMap::new(), + } + } + + pub fn single(user: &str, dbname: &str, host: &str, password: &str) -> Self { + let minerva = Minerva::new(); + + let datasets = DashMap::new(); + datasets.insert(0, minerva); + + Self { + backtests: DashMap::new(), + last: AtomicU64::new(1), + pool: Self::create_db_pool(user, dbname, host, password), + datasets, + } + } + + pub async fn tick(&self, backtest_id: BacktestId) -> Option { + if let TryResult::Present(mut backtest) = self.backtests.try_get_mut(&backtest_id) { + if let Some(dataset) = self.datasets.get(&backtest_id) { + let mut executed_orders = Vec::new(); + let mut inserted_orders = Vec::new(); + + let curr_date = backtest.curr_date; + + let back_depth = dataset + .get_depth_between( + backtest.curr_date - backtest.frequency as i64..backtest.curr_date, + ) + .await; + if let Some((_date, back_depth_last)) = back_depth.last() { + let back_trades = dataset + .get_trades_between( + backtest.curr_date - backtest.frequency as i64..backtest.curr_date, + ) + .await; + let mut back_trades_last = BTreeMap::default(); + if let Some((date, back_trades_last_query)) = back_trades.last() { + back_trades_last.insert(*date, back_trades_last_query.to_vec()); + } + let mut res = + backtest + .exchange + .tick(back_depth_last, &back_trades_last, curr_date); + + executed_orders = std::mem::take(&mut res.0); + inserted_orders = std::mem::take(&mut res.1); + } + + let new_date = backtest.curr_date + backtest.frequency as i64; + if new_date >= backtest.end_date { + return Some(( + false, + Vec::new(), + Vec::new(), + BTreeMap::new(), + new_date, + Vec::new(), + )); + } else { + let depth = dataset + .get_depth_between(backtest.curr_date..new_date) + .await; + let mut last_depth = BTreeMap::default(); + if let Some((_date, queried_last_quotes)) = depth.last() { + //TODO: not great, as state is stored in DB it isn't clear why we need + //clone + last_depth = queried_last_quotes.clone(); + } + let trades = dataset + .get_trades_between(backtest.curr_date..new_date) + .await; + let mut last_trades = Vec::default(); + if let Some((_date, queried_last_trades)) = trades.last() { + //TODO: not great either + for trade in queried_last_trades { + last_trades.push(trade.clone()); + } + } + + backtest.curr_date = new_date; + return Some(( + true, + executed_orders, + inserted_orders, + last_depth, + new_date, + last_trades, + )); + } + } + } + None + } + + pub async fn init( + &self, + start_date: i64, + end_date: i64, + frequency: u64, + ) -> Option<(BacktestId, DateDepth)> { + let curr_id = self.last.load(std::sync::atomic::Ordering::SeqCst); + let exchange = UistV2::new(); + + let mut minerva = Minerva::new(); + minerva.init_cache(&self.pool, start_date..end_date).await; + + let dataset_date_bounds = minerva.get_date_bounds(&self.pool).await.unwrap(); + let mut end_date_backtest = if dataset_date_bounds.1 > end_date { + end_date + } else { + dataset_date_bounds.1 + }; + + let backtest_length = (end_date_backtest - start_date) / frequency as i64; + if backtest_length > Self::MAX_BACKTEST_LENGTH { + end_date_backtest = start_date + (frequency as i64 * Self::MAX_BACKTEST_LENGTH); + } + + let backtest = BacktestState { + id: curr_id, + start_date, + curr_date: start_date, + end_date: end_date_backtest, + frequency, + exchange, + }; + + let new_id = curr_id + 1; + //Attempt to increment the counter, if this is successful then we create new backtest + if let Ok(res) = self.last.compare_exchange( + curr_id, + new_id, + std::sync::atomic::Ordering::SeqCst, + std::sync::atomic::Ordering::SeqCst, + ) { + if res == curr_id { + self.backtests.insert(curr_id, backtest); + + let depth = minerva + .get_depth_between(start_date - frequency as i64..start_date) + .await; + let mut last_depth = BTreeMap::default(); + if let Some((_date, last_value)) = depth.last() { + //TODO: isn't clear why clone is required here, same as above somewhere + last_depth = last_value.clone(); + } + + self.datasets.insert(curr_id, minerva); + return Some((curr_id, last_depth)); + } + } + None + } + + pub fn insert_orders(&self, orders: Vec, backtest_id: BacktestId) -> Option<()> { + if let TryResult::Present(mut backtest) = self.backtests.try_get_mut(&backtest_id) { + backtest.exchange.insert_orders(orders); + return Some(()); + } + None + } + + pub async fn dataset_info(&self) -> Option<(i64, i64)> { + let minerva = Minerva::new(); + return minerva.get_date_bounds(&self.pool).await; + } +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct TickResponse { + pub has_next: bool, + pub executed_orders: Vec, + pub inserted_orders: Vec, + pub depth: DateDepth, + pub taker_trades: Vec, + pub now: i64, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct InsertOrderRequest { + pub orders: Vec, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct InitRequest { + pub start_date: i64, + pub end_date: i64, + pub frequency: u64, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct ModifyOrderRequest { + pub order_id: OrderId, + pub quantity_change: f64, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct CancelOrderRequest { + pub order_id: OrderId, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct InitResponse { + pub backtest_id: BacktestId, + pub depth: DateDepth, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct DatasetInfoResponse { + pub start_date: i64, + pub end_date: i64, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct InfoResponse { + pub version: String, +} + +#[derive(Debug)] +pub enum UistV2Error { + UnknownBacktest, + UnknownDataset, +} + +impl std::error::Error for UistV2Error {} + +impl core::fmt::Display for UistV2Error { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + UistV2Error::UnknownBacktest => write!(f, "UnknownBacktest"), + UistV2Error::UnknownDataset => write!(f, "UnknownDataset"), + } + } +} + +impl actix_web::ResponseError for UistV2Error { + fn status_code(&self) -> actix_web::http::StatusCode { + match self { + UistV2Error::UnknownBacktest => actix_web::http::StatusCode::BAD_REQUEST, + UistV2Error::UnknownDataset => actix_web::http::StatusCode::BAD_REQUEST, + } + } +} + +pub trait Client { + fn tick(&self, backtest_id: BacktestId) -> impl Future>; + fn insert_orders( + &self, + orders: Vec, + backtest_id: BacktestId, + ) -> impl Future>; + fn init( + &self, + start_date: i64, + end_date: i64, + frequency: u64, + ) -> impl Future>; + fn info(&self, backtest_id: BacktestId) -> impl Future>; + fn dataset_info(&self) -> impl Future>; +} + +type UistState = AppState; + +pub mod server { + use actix_web::{get, post, web}; + + use super::{ + BacktestId, DatasetInfoResponse, InfoResponse, InitRequest, InitResponse, + InsertOrderRequest, TickResponse, UistState, UistV2Error, + }; + + #[get("/backtest/{backtest_id}/tick")] + pub async fn tick( + app: web::Data, + path: web::Path<(BacktestId,)>, + ) -> Result, UistV2Error> { + let (backtest_id,) = path.into_inner(); + + if let Some(result) = app.tick(backtest_id).await { + Ok(web::Json(TickResponse { + depth: result.3, + inserted_orders: result.2, + executed_orders: result.1, + has_next: result.0, + now: result.4, + taker_trades: result.5, + })) + } else { + Err(UistV2Error::UnknownBacktest) + } + } + + #[post("/backtest/{backtest_id}/insert_orders")] + pub async fn insert_orders( + app: web::Data, + path: web::Path<(BacktestId,)>, + mut insert_order: web::Json, + ) -> Result, UistV2Error> { + let (backtest_id,) = path.into_inner(); + let take_orders = std::mem::take(&mut insert_order.orders); + if let Some(()) = app.insert_orders(take_orders, backtest_id) { + Ok(web::Json(())) + } else { + Err(UistV2Error::UnknownBacktest) + } + } + + #[post("/init")] + pub async fn init( + app: web::Data, + _path: web::Path<()>, + init: web::Json, + ) -> Result, UistV2Error> { + if let Some((backtest_id, depth)) = app + .init(init.start_date, init.end_date, init.frequency) + .await + { + Ok(web::Json(InitResponse { backtest_id, depth })) + } else { + Err(UistV2Error::UnknownDataset) + } + } + + #[get("/backtest/{backtest_id}/info")] + pub async fn info( + app: web::Data, + path: web::Path<(BacktestId,)>, + ) -> Result, UistV2Error> { + let (backtest_id,) = path.into_inner(); + + if let Some(_resp) = app.backtests.get(&backtest_id) { + Ok(web::Json(InfoResponse { + version: "v1".to_string(), + })) + } else { + Err(UistV2Error::UnknownBacktest) + } + } + + #[get("/dataset/info")] + pub async fn dataset_info( + app: web::Data, + ) -> Result, UistV2Error> { + if let Some(resp) = app.dataset_info().await { + Ok(web::Json(DatasetInfoResponse { + start_date: resp.0, + end_date: resp.1, + })) + } else { + Err(UistV2Error::UnknownDataset) + } + } +} diff --git a/rotala-http/src/lib.rs b/rotala-http/src/lib.rs new file mode 100644 index 00000000..3883215f --- /dev/null +++ b/rotala-http/src/lib.rs @@ -0,0 +1 @@ +pub mod http; diff --git a/example_clients/snek/README.md b/rotala-python/README.md similarity index 100% rename from example_clients/snek/README.md rename to rotala-python/README.md diff --git a/rotala-python/main.py b/rotala-python/main.py new file mode 100644 index 00000000..7d1e2da8 --- /dev/null +++ b/rotala-python/main.py @@ -0,0 +1,108 @@ +import logging + +from src.broker import BrokerBuilder, Order, OrderType +from src.http import HttpClient + + +def get_best_and_mid(depth): + bids = depth["SOL"]["bids"] + asks = depth["SOL"]["asks"] + + bid_levels = [b["price"] for b in bids] + ask_levels = [a["price"] for a in asks] + + best_bid = bid_levels[0] + best_ask = ask_levels[0] + mid_price = (best_bid + best_ask) / 2 + return best_bid, best_ask, mid_price + + +def create_grid(depth): + best_bid, best_ask, mid_price = get_best_and_mid(depth) + gap = round(mid_price * 0.0005, 2) + + bid_order_levels = [round(best_bid - (i * gap), 2) for i in range(1, 5)] + ask_order_levels = [round(best_ask + (i * gap), 2) for i in range(1, 5)] + return bid_order_levels, ask_order_levels + + +def risk_management(unexecuted_orders, total_value): + gross_value = 0 + for order_id in unexecuted_orders: + order = unexecuted_orders[order_id] + if order.price: + gross_value += order.qty * order.price + + if gross_value > total_value * 0.1: + return False + return True + + +def create_orders(bid_grid, ask_grid): + orders = [] + for level in bid_grid: + order = Order(OrderType.LimitBuy, "SOL", 10, level, None) + orders.append(order) + + for level in ask_grid: + order = Order(OrderType.LimitSell, "SOL", 10, level, None) + orders.append(order) + return orders + + +if __name__ == "__main__": + logging.basicConfig(level=logging.INFO) + + frequency = 1000 + sim_length = frequency * 10000 + http_client = HttpClient("http://127.0.0.1:3000") + dataset_info = http_client.dataset_info() + start_date = dataset_info["start_date"] + + builder = BrokerBuilder() + builder.init_cash(100000) + builder.init_http(http_client) + #Clear the first date so we have quotes always + builder.init_dates(start_date+frequency, start_date+frequency+sim_length) + builder.init_frequency(frequency) + brkr = builder.build() + + count = 0 + last_mid = -1 + while True: + brkr.tick() + + depth = brkr.latest_depth + if not depth: + continue + + if not depth.get("SOL"): + continue + + bid_grid, ask_grid = create_grid(depth) + best_bid, best_ask, mid_price = get_best_and_mid(depth) + + if last_mid == -1: + last_mid = mid_price + + mid_change = round(abs(last_mid - mid_price), 2) + last_mid = mid_price + + risk = risk_management(brkr.unexecuted_orders, brkr.get_current_value()) + if len(brkr.unexecuted_orders) == 0: + [brkr.insert_order(order) for order in create_orders(bid_grid, ask_grid)] + else: + if mid_change > 0.1: + # In practice, we want to look for overlapping levels so we don't need + # to clear whole book + for order_id in brkr.unexecuted_orders: + order = brkr.unexecuted_orders[order_id] + if order.is_transaction(): + brkr.insert_order( + Order(OrderType.Cancel, "SOL", 0, None, order_id) + ) + + [ + brkr.insert_order(order) + for order in create_orders(bid_grid, ask_grid) + ] diff --git a/example_clients/snek/requirements.txt b/rotala-python/requirements.txt similarity index 100% rename from example_clients/snek/requirements.txt rename to rotala-python/requirements.txt diff --git a/example_clients/snek/src/__init__.py b/rotala-python/src/__init__.py similarity index 100% rename from example_clients/snek/src/__init__.py rename to rotala-python/src/__init__.py diff --git a/rotala-python/src/broker.py b/rotala-python/src/broker.py new file mode 100644 index 00000000..83adacd0 --- /dev/null +++ b/rotala-python/src/broker.py @@ -0,0 +1,319 @@ +from enum import Enum +import json +import logging + +from src.http import HttpClient + +logger = logging.getLogger(__name__) + + +class BrokerBuilder: + def __init__(self): + self.initial_cash = None + self.http = None + self.start_date = None + self.end_date = None + self.frequency = None + + def init_cash(self, value: int): + self.initial_cash = value + + def init_http(self, http: HttpClient): + self.http = http + + def init_dates(self, start_date: int, end_date: int): + self.start_date = start_date + self.end_date = end_date + + def init_frequency(self, frequency: int): + self.frequency = frequency + + def build(self): + if not self.initial_cash: + raise ValueError("BrokerBuilder needs cash") + + if not self.http: + raise ValueError("BrokerBuilder needs http") + + return Broker(self) + + +class OrderType(Enum): + MarketSell = "MarketSell" + MarketBuy = "MarketBuy" + LimitBuy = "LimitBuy" + LimitSell = "LimitSell" + Modify = "Modify" + Cancel = "Cancel" + + +class Order: + def __init__( + self, + order_type: OrderType, + symbol: str, + qty: float, + price: float | None, + order_id_ref: float | None, + ): + if order_type == OrderType.MarketSell or order_type == OrderType.MarketBuy: + if price is not None: + raise ValueError("Order price must be None for Market order") + + self.order_type = order_type + self.symbol = symbol + self.qty = qty + self.price = price + self.order_id_ref = order_id_ref + + def __str__(self): + return f"{self.order_type} {self.symbol} {self.qty} {self.price}" + + def is_transaction(self) -> bool: + return ( + self.order_type == OrderType.LimitBuy + or self.order_type == OrderType.LimitSell + or self.order_type == OrderType.MarketBuy + or self.order_type == OrderType.MarketSell + ) + + def serialize(self): + base = f'{{"order_type": "{self.order_type.name}", "symbol": "{self.symbol}", "qty": {self.qty}' + if self.price: + base += f', "price": {self.price}' + if self.order_id_ref is not None: + base += f', "order_id_ref": {self.order_id_ref}' + base += "}" + return base + + @staticmethod + def from_dict(order): + order_type = OrderType(order["order_type"]) + return Order( + order_type, + order["symbol"], + order["qty"], + order["price"], + order["order_id_ref"], + ) + + @staticmethod + def from_json(json_str): + to_dict = json.loads(json_str) + return Order( + to_dict["order_type"], + to_dict["symbol"], + to_dict["qty"], + to_dict["price"], + to_dict["order_id_ref"], + ) + + +class OrderResultType(Enum): + Buy = "Buy" + Sell = "Sell" + Modify = "Modify" + Cancel = "Cancel" + + +class OrderResult: + def __init__( + self, + symbol: str, + value: float, + quantity: float, + date: int, + typ: OrderResultType, + order_id: int, + order_id_ref: int | None, + ): + self.symbol = symbol + self.value = value + self.quantity = quantity + self.date = date + self.typ = typ + self.order_id = order_id + self.order_id_ref = order_id_ref + + def __str__(self): + return ( + f"{self.typ} {self.order_id} - {self.quantity}/{self.value} {self.symbol}" + ) + + @staticmethod + def from_dict(from_dict: dict): + trade_type = OrderResultType(from_dict["typ"]) + return OrderResult( + from_dict["symbol"], + from_dict["value"], + from_dict["quantity"], + from_dict["date"], + trade_type, + from_dict["order_id"], + from_dict["order_id_ref"], + ) + + @staticmethod + def from_json(json_str: str): + to_dict = json.loads(json_str) + return OrderResult( + to_dict["symbol"], + to_dict["value"], + to_dict["quantity"], + to_dict["date"], + to_dict["typ"], + to_dict["order_id"], + to_dict["order_id_ref"], + ) + + +class Broker: + def __init__(self, builder: BrokerBuilder): + self.builder = builder + self.http = builder.http + self.cash = builder.initial_cash + self.holdings = {} + self.pending_orders = [] + self.trade_log = [] + self.order_inserted_on_last_tick = [] + self.unexecuted_orders = {} + self.portfolio_values = [] + self.backtest_id = None + self.ts = None + + # Initializes backtest_id, can ignore result + logger.info(f"{self.backtest_id}-{self.ts} INIT: {builder.start_date}, {builder.end_date}") + init_response = self.http.init(builder.start_date, builder.end_date, builder.frequency) + self.backtest_id = init_response["backtest_id"] + self.latest_depth = init_response["depth"] + self.latest_quotes = Broker._convert_depth_to_quotes(self.latest_depth) + self.cached_quotes = {} + + def _convert_depth_to_quotes(depth): + bbo = {} + for coin in depth: + coin_depth = depth[coin] + coin_bids = coin_depth["bids"] + coin_asks = coin_depth["asks"] + + tmp = {"bid": coin_bids[0]["price"], "ask": coin_asks[-1]["price"]} + bbo[coin] = tmp + return bbo + + def _update_holdings(self, position: str, chg: float): + if position not in self.holdings: + self.holdings[position] = 0 + + curr_position = self.holdings[position] + new_position = curr_position + chg + logger.debug( + f"{self.backtest_id}-{self.ts} POSITION CHG: {position} {curr_position} -> {new_position}" + ) + self.holdings[position] = new_position + + def _process_order_result(self, result: OrderResult): + logger.debug(f"{self.backtest_id}-{self.ts} EXECUTED: {result}") + + if result.typ == OrderResultType.Buy or result.typ == OrderResultType.Sell: + before_trade = self.cash + after_trade = ( + self.cash - result.value + if result.typ == OrderResultType.Buy + else self.cash + result.value + ) + + logger.debug( + f"{self.backtest_id}-{self.ts} CASH: {before_trade} -> {after_trade}" + ) + self.cash = after_trade + if self.cash < 0: + logger.critical("Run out of cash. Stopping sim.") + exit(1) + + signed_qty = ( + result.quantity + if result.typ == OrderResultType.Buy + else -result.quantity + ) + self._update_holdings(result.symbol, signed_qty) + + if result.order_id in self.unexecuted_orders: + order = self.unexecuted_orders[result.order_id] + if result.quantity > order.qty: + order["quantity"] -= result.quantity + else: + del self.unexecuted_orders[result.order_id] + else: + if result.typ == OrderResultType.Cancel: + del self.unexecuted_orders[result.order_id] + del self.unexecuted_orders[result.order_id_ref] + else: + logger.critical("Unsupported order modification type") + exit(1) + + def insert_order(self, order: Order): + # Orders are only flushed when we call tick + self.pending_orders.append(order) + + def get_quotes(self): + return self.latest_quotes + + def get_depth(self): + return self.latest_depth + + def get_position(self, symbol) -> float: + return self.holdings.get(symbol, 0) + + def get_current_value(self) -> float: + value = self.cash + for symbol in self.holdings: + quote = self.latest_quotes.get(symbol) + symbol_bid = -1 + if not quote: + quote = self.cached_quotes.get(symbol) + symbol_bid = quote["bid"] + if not symbol_bid: + raise ValueError("No cached values and no quotes so likely an application error somewhere") + else: + symbol_bid = quote["bid"] + + qty = self.holdings[symbol] + value += qty * symbol_bid + return value + + def tick(self): + logger.info(f"{self.backtest_id}-{self.ts} TICK") + + # Flush pending orders + logger.debug( + f"{self.backtest_id}-{self.ts} INSERTING {len(self.pending_orders)} ORDER" + ) + self.http.insert_orders(self.pending_orders) + self.pending_orders = [] + + # Tick, reconcile our state + self.order_inserted_on_last_tick = [] + tick_response = self.http.tick() + for order_result_json in tick_response["executed_orders"]: + order_result = OrderResult.from_dict(order_result_json) + self._process_order_result(order_result) + self.trade_log.append(order_result) + + for order in tick_response["inserted_orders"]: + self.unexecuted_orders[order["order_id"]] = Order.from_dict(order) + self.order_inserted_on_last_tick.append(order) + + if not tick_response["has_next"]: + logger.critical("Sim finished") + exit(0) + else: + self.latest_depth = tick_response["depth"] + if self.latest_depth: + self.latest_quotes = Broker._convert_depth_to_quotes(self.latest_depth) + self.cached_quotes = {**self.cached_quotes, **self.latest_quotes} + + self.ts = tick_response["now"] + + curr_value = self.get_current_value() + logger.debug(f"{self.backtest_id}-{self.ts} TOTAL VALUE: {curr_value}") + self.portfolio_values.append(curr_value) diff --git a/rotala-python/src/http.py b/rotala-python/src/http.py new file mode 100644 index 00000000..abb27f27 --- /dev/null +++ b/rotala-python/src/http.py @@ -0,0 +1,62 @@ +from urllib3.util import Retry +from requests import Session +from requests.adapters import HTTPAdapter + + +class HttpClient: + def __init__(self, base_url): + self.base_url = base_url + self.backtest_id = None + + s = Session() + retries = Retry( + total=3, + backoff_factor=0.1, + status_forcelist=[502, 503, 504], + allowed_methods={"POST"}, + ) + s.mount("https://", HTTPAdapter(max_retries=retries)) + self.s = s + return + + def init(self, start_date, end_date, frequency): + val = f'{{"start_date": {start_date}, "end_date": {end_date}, "frequency": {frequency}}}' + r = self.s.post( + f"{self.base_url}/init", + data=val, + headers={"Content-type": "application/json"}, + ) + json_response = r.json() + self.backtest_id = int(json_response["backtest_id"]) + return json_response + + def tick(self): + if self.backtest_id is None: + raise ValueError("Called before init") + + r = self.s.get(f"{self.base_url}/backtest/{self.backtest_id}/tick") + return r.json() + + def insert_orders(self, orders): + if self.backtest_id is None: + raise ValueError("Called before init") + + serialized_orders_str = ",".join([o.serialize() for o in orders]) + val = f'{{"orders": [{serialized_orders_str}]}}' + r = self.s.post( + f"{self.base_url}/backtest/{self.backtest_id}/insert_orders", + data=val, + headers={"Content-type": "application/json"}, + ) + return r.json() + + def info(self): + if self.backtest_id is None: + raise ValueError("Called before init") + + r = self.s.get(f"{self.base_url}/backtest/{self.backtest_id}/info") + return r.json() + + def dataset_info(self): + r = self.s.get(f"{self.base_url}/dataset/info") + return r.json() diff --git a/rotala-python/src/tests/__init__.py b/rotala-python/src/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/rotala-python/src/tests/test_broker.py b/rotala-python/src/tests/test_broker.py new file mode 100644 index 00000000..3ab2608b --- /dev/null +++ b/rotala-python/src/tests/test_broker.py @@ -0,0 +1,48 @@ +import unittest +from unittest.mock import MagicMock +import random + +from src.broker import BrokerBuilder, Order, OrderType + + +def generate_fake_quotes(symbols, date): + quotes = [] + for symbol in symbols: + price = random.randint(10, 20) + quote_dict = { + "bid": price, + "bid_volume": random.randint(100, 1000), + "ask": price + 1, + "ask_volume": random.randint(100, 1000), + "date": date, + "symbol": symbol, + } + quotes.append(quote_dict) + return {"quotes": quotes} + + +class TestBroker(unittest.TestCase): + def test_main_loop(self): + http_client = MagicMock() + + http_client.init.return_value = {"backtest_id": 0} + http_client.fetch_quotes.side_effect = [ + generate_fake_quotes(["ABC"], 100), + generate_fake_quotes(["ABC"], 101), + ] + http_client.tick.return_value = { + "has_next": False, + "executed_trades": [], + "inserted_orders": [], + } + + builder = BrokerBuilder() + builder.init_cash(1000) + builder.init_dataset_name("Test") + builder.init_http(http_client) + brkr = builder.build() + + order = Order(OrderType.MarketBuy, "ABC", 100, None) + brkr.insert_order(order) + + brkr.tick() diff --git a/rotala/Cargo.toml b/rotala/Cargo.toml index f50d19cd..545597da 100644 --- a/rotala/Cargo.toml +++ b/rotala/Cargo.toml @@ -10,7 +10,6 @@ readme = "README.md" rust-version = "1.75" [dependencies] -actix-web = "4" time = { version = "0.3.17", features = ["macros", "parsing"] } rand = "0.8.4" rand_distr = "0.4.1" @@ -19,22 +18,15 @@ zip = "2.1.3" csv = "1.1.6" serde = { version = "1.0.193", features = ["derive"] } serde_json = "1.0.108" -env_logger = "0.11.0" tokio = { version = "1.35.1", features = ["full"] } -derive_more = "0.99.17" -anyhow = "1.0.86" +anyhow = "1.0.91" +tokio-postgres = { version = "0.7.12", features = ["with-serde_json-1"] } +tokio-pg-mapper = { version = "0.2.0", features = ["derive"] } +deadpool-postgres = "0.14.0" [dev-dependencies] criterion = { version = "0.5.1", features = ["async_tokio"] } -[[bin]] -name = "uist_server_v1" -path = "./src/bin/uist_server_v1.rs" - -[[bin]] -name = "jura_server_v1" -path = "./src/bin/jura_server_v1.rs" - [lib] bench = false diff --git a/rotala/src/bin/jura_server_v1.rs b/rotala/src/bin/jura_server_v1.rs deleted file mode 100644 index 114fe221..00000000 --- a/rotala/src/bin/jura_server_v1.rs +++ /dev/null @@ -1,38 +0,0 @@ -use std::env; -use std::sync::Mutex; - -use actix_web::{web, App, HttpServer}; -use rotala::{ - http::jura_v1::{ - jurav1_server::{delete_order, fetch_quotes, info, init, insert_order, tick}, - AppState, - }, - input::penelope::Penelope, -}; - -#[actix_web::main] -async fn main() -> std::io::Result<()> { - let args: Vec = env::args().collect(); - - let address: String = args[1].clone(); - let port: u16 = args[2].parse().unwrap(); - - let source = Penelope::random(3000, vec!["0"]); - let app_state = AppState::single("RANDOM", source); - - let jura_state = web::Data::new(Mutex::new(app_state)); - - HttpServer::new(move || { - App::new() - .app_data(jura_state.clone()) - .service(info) - .service(init) - .service(fetch_quotes) - .service(tick) - .service(insert_order) - .service(delete_order) - }) - .bind((address, port))? - .run() - .await -} diff --git a/rotala/src/exchange/jura_v1.rs b/rotala/src/exchange/jura_v1.rs deleted file mode 100644 index 761c6cbf..00000000 --- a/rotala/src/exchange/jura_v1.rs +++ /dev/null @@ -1,808 +0,0 @@ -use std::collections::VecDeque; - -use serde::{Deserialize, Serialize}; - -use crate::input::penelope::{PenelopeQuote, PenelopeQuoteByDate}; - -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct JuraQuote { - bid: f64, - ask: f64, - date: i64, - symbol: String, -} - -impl From for JuraQuote { - fn from(value: PenelopeQuote) -> Self { - Self { - bid: value.bid, - ask: value.ask, - date: value.date, - symbol: value.symbol, - } - } -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -pub enum Side { - Ask, - Bid, -} - -impl From for String { - fn from(value: Side) -> Self { - match value { - Side::Bid => "B".to_string(), - Side::Ask => "A".to_string(), - } - } -} - -/// HL is a future exchanges so assumes some of the functions of a broker, this means that -/// functions that report on the client's overall position won't be implemented at this stage. -/// * closed_pnl, unimplemented because the exchange does not keep track of client pnl -/// * dir, unimplemented as this appears to track the overall position in a coin, will always -/// be set to false -/// * crossed, this is unclear and may relate to margin or the execution of previous trades, this -/// will always be set to false -/// * hash, will always be an empty string, as HL is on-chain a transaction hash is produced but -/// won't be in a test env, always set to false -/// * start_position, unimplemented as this relates to overall position which is untracked, will -/// always be set to false -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct Fill { - pub closed_pnl: String, - pub coin: String, - pub crossed: bool, - pub dir: bool, - pub hash: bool, - pub oid: u64, - pub px: String, - pub side: String, - pub start_position: bool, - pub sz: String, - pub time: i64, -} - -pub type OrderId = u64; - -#[derive(Clone, Debug, Deserialize, Serialize)] -enum TimeInForce { - Alo, - Ioc, - Gtc, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct LimitOrder { - tif: TimeInForce, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -pub enum TriggerType { - Tp, - Sl, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct TriggerOrder { - // Differs from limit_px as trigger_px is the price that triggers the order, limit_px is the - // price that the user wants to trade at but is subject to the same slippage limitations - // For some reason this is a number but limit_px is a String? - trigger_px: f64, - // If this is true then the order will execute immediately with max slippage of 10% - is_market: bool, - tpsl: TriggerType, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -pub enum OrderType { - Limit(LimitOrder), - Trigger(TriggerOrder), -} - -/// The assumed function of the Hyperliquid API is followed as far as possible. A major area of -/// uncertainty in the API docs concerned what the exchange does when it receives an order that -/// has some properties set like a market order and some set like a limit order. The assumption -/// made throughout the implementation is that the is_market field, set on [TriggerOrder] -/// , determines fully whether an order is a market order and everything else is limit. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct Order { - asset: u64, - is_buy: bool, - limit_px: String, - sz: String, - reduce_only: bool, - //This is client order id, need to check whether test impl should reasonably use this. - cloid: Option, - order_type: OrderType, -} - -impl Order { - pub fn market_buy(asset: u64, sz: &str, price: &str) -> Self { - Self { - asset, - is_buy: true, - limit_px: price.to_string(), - sz: sz.to_string(), - reduce_only: false, - cloid: None, - order_type: OrderType::Limit(LimitOrder { - tif: TimeInForce::Ioc, - }), - } - } - - pub fn market_sell( - asset: impl Into, - sz: impl Into, - price: impl Into, - ) -> Self { - Self { - asset: asset.into(), - is_buy: false, - limit_px: price.into(), - sz: sz.into(), - reduce_only: false, - cloid: None, - order_type: OrderType::Limit(LimitOrder { - tif: TimeInForce::Ioc, - }), - } - } - - pub fn limit_buy( - asset: impl Into, - sz: impl Into, - price: impl Into, - ) -> Self { - Self { - asset: asset.into(), - is_buy: true, - limit_px: price.into(), - sz: sz.into(), - reduce_only: false, - cloid: None, - order_type: OrderType::Limit(LimitOrder { - tif: TimeInForce::Gtc, - }), - } - } - - pub fn limit_sell( - asset: impl Into, - sz: impl Into, - price: impl Into, - ) -> Self { - Self { - asset: asset.into(), - is_buy: false, - limit_px: price.into(), - sz: sz.into(), - reduce_only: false, - cloid: None, - order_type: OrderType::Limit(LimitOrder { - tif: TimeInForce::Gtc, - }), - } - } - - pub fn stop_buy( - asset: impl Into, - sz: impl Into, - price: impl Into, - ) -> Self { - // It is possible for a stop to use a different trigger price, we guard against this with - // the default order type because it is unexpected behaviour in most applications. - let copy = price.into(); - let to_f64 = copy.parse::().unwrap(); - Self { - asset: asset.into(), - is_buy: true, - limit_px: copy.clone(), - sz: sz.into(), - reduce_only: false, - cloid: None, - order_type: OrderType::Trigger(TriggerOrder { - trigger_px: to_f64, - is_market: true, - tpsl: TriggerType::Sl, - }), - } - } - - pub fn stop_sell( - asset: impl Into, - sz: impl Into, - price: impl Into, - ) -> Self { - // It is possible for a stop to use a different trigger price, we guard against this with - // the default order type because it is unexpected behaviour in most applications. - let copy = price.into(); - let to_f64 = copy.parse::().unwrap(); - Self { - asset: asset.into(), - is_buy: false, - limit_px: copy.clone(), - sz: sz.into(), - reduce_only: false, - cloid: None, - order_type: OrderType::Trigger(TriggerOrder { - trigger_px: to_f64, - is_market: true, - tpsl: TriggerType::Sl, - }), - } - } - - pub fn takeprofit_buy( - asset: impl Into, - sz: impl Into, - price: impl Into, - ) -> Self { - // It is possible for a stop to use a different trigger price, we guard against this with - // the default order type because it is unexpected behaviour in most applications. - let copy = price.into(); - let to_f64 = copy.parse::().unwrap(); - Self { - asset: asset.into(), - is_buy: true, - limit_px: copy.clone(), - sz: sz.into(), - reduce_only: false, - cloid: None, - order_type: OrderType::Trigger(TriggerOrder { - trigger_px: to_f64, - is_market: true, - tpsl: TriggerType::Tp, - }), - } - } - - pub fn takeprofit_sell( - asset: impl Into, - sz: impl Into, - price: impl Into, - ) -> Self { - // It is possible for a stop to use a different trigger price, we guard against this with - // the default order type because it is unexpected behaviour in most applications. - let copy = price.into(); - let to_f64 = copy.parse::().unwrap(); - Self { - asset: asset.into(), - is_buy: false, - limit_px: copy.clone(), - sz: sz.into(), - reduce_only: false, - cloid: None, - order_type: OrderType::Trigger(TriggerOrder { - trigger_px: to_f64, - is_market: true, - tpsl: TriggerType::Tp, - }), - } - } -} - -#[derive(Clone, Debug)] -struct InnerOrder { - pub order_id: OrderId, - pub order: Order, - pub attempted_execution: bool, -} - -impl InnerOrder { - pub fn get_shares(&self) -> f64 { - // We unwrap immediately because we can't continue if the client is passing incorrectly - // sized orders - str::parse::(&self.order.sz).unwrap() - } -} - -#[derive(Clone, Debug)] -pub struct JuraV1 { - orderbook: OrderBook, - trade_log: Vec, - //This is cleared on every tick - order_buffer: Vec, -} - -impl JuraV1 { - pub fn new() -> Self { - Self { - orderbook: OrderBook::default(), - trade_log: Vec::new(), - order_buffer: Vec::new(), - } - } - - fn sort_order_buffer(&mut self) { - self.order_buffer.sort_by(|a, _b| { - if a.is_buy { - std::cmp::Ordering::Greater - } else { - std::cmp::Ordering::Less - } - }) - } - - pub fn insert_order(&mut self, order: Order) { - // Orders are only inserted into the book when tick is called, this is to ensure proper - // ordering of trades - // This impacts order_id where an order X can come in before order X+1 but the latter can - // have an order_id that is less than the former. - self.order_buffer.push(order); - } - - pub fn delete_order(&mut self, asset: u64, order_id: u64) { - self.orderbook.delete_order(asset, order_id); - } - - pub fn tick(&mut self, quotes: &PenelopeQuoteByDate) -> (Vec, Vec, Vec) { - //To eliminate lookahead bias, we only insert new orders after we have executed any orders - //that were on the stack first - let (fills, triggered_order_ids) = self.orderbook.execute_orders(quotes); - for fill in &fills { - self.trade_log.push(fill.clone()); - } - - self.sort_order_buffer(); - for order in self.order_buffer.iter_mut() { - self.orderbook.insert_order(order.clone()); - } - - println!("{:?}", self.orderbook); - - let inserted_orders = std::mem::take(&mut self.order_buffer); - (fills, inserted_orders, triggered_order_ids) - } -} - -impl Default for JuraV1 { - fn default() -> Self { - Self::new() - } -} - -/// OrderBook is an implementation of the Hyperliquid API running against a local server. This allows -/// testing of strategies using the same API/order types/etc. -/// -/// Hyperliquid is a derivatives exchange. In order to simplify the implementation it is assumed -/// that everything is cash/no margin/no leverage. -/// -/// Hyperliquid has two order types: limit and trigger. -/// -/// Limit orders have various [TimeInForce] settings, we currently only support [TimeInForce::Ioc] -/// and [TimeInForce::Gtc]. This is roughly equivalent to a market order that will execute on the -/// next tick after entry with maximum slippage of 10%. Slippage is constant in this -/// implementation as this is the default setting in production. If this doesn't execute on the -/// next tick then it is cancelled. -/// -/// Trigger orders are orders that turn into Limit orders when a trigger has been hit. The -/// trigger_px and limit_px are distinct so this works slightly differently to a normal TP/SL -/// order. If the trigger_px and limit_px are the same, it is possible for price to gap down -/// through your order such that it is never executed. -/// -/// The latency paid by this order in production is unclear. The assumption made in this -/// implementation is that it is impossible for orders to be queued instanteously on a on-chain -/// exchange. So when an order triggers, the triggered orders are queued onto the next tick. -/// These are, however, added onto the front of the queue so will have a queue advantage over -/// order inserted onto the next_tick. -/// -/// When an order is triggered, is_market is used to determine whether the [TimeInForce] is -/// [TimeInForce::Ioc] (if true) or [TimeInForce::Gtc] (if false). -/// -/// After a trade executes a fill is returned to the user, the data returned is substantially -/// different to the Hyperliquid API due to Hyperliquid performing functions like margin. -/// The differences are documented in [Fill]. -#[derive(Clone, Debug)] -struct OrderBook { - inner: VecDeque, - last_inserted: u64, - slippage: f64, -} - -impl Default for OrderBook { - fn default() -> Self { - Self::new() - } -} - -impl OrderBook { - pub fn new() -> Self { - Self { - inner: VecDeque::new(), - last_inserted: 0, - slippage: 0.1, - } - } - - // Hyperliquid returns an error if we try to cancel a non-existent order - pub fn delete_order(&mut self, asset: u64, order_id: u64) -> bool { - let mut delete_position: Option = None; - for (position, order) in self.inner.iter().enumerate() { - if order_id == order.order_id && asset == order.order.asset { - delete_position = Some(position); - break; - } - } - if let Some(position) = delete_position { - self.inner.remove(position); - return true; - } - false - } - - // Hyperliquid immediately returns an oid to the user whether the order is resting or filled on - // the next tick. Because we need to guard against lookahead bias, we cannot execute immediately - // but we have to return order id here. - pub fn insert_order(&mut self, order: Order) -> OrderId { - let order_id = self.last_inserted; - // We assume that orders are received instaneously. - // Latency can be added here when this is implemented. - let inner_order = InnerOrder { - order_id, - order, - attempted_execution: false, - }; - self.inner.push_back(inner_order); - self.last_inserted += 1; - order_id - } - - fn execute_buy(quote: JuraQuote, order: &InnerOrder, date: i64) -> Fill { - let trade_price = quote.ask; - Fill { - closed_pnl: "0.0".to_string(), - coin: order.order.asset.to_string(), - crossed: false, - dir: false, - hash: false, - oid: order.order_id, - px: trade_price.to_string(), - side: Side::Ask.into(), - start_position: false, - sz: order.get_shares().to_string(), - time: date, - } - } - - fn execute_sell(quote: JuraQuote, order: &InnerOrder, date: i64) -> Fill { - let trade_price = quote.bid; - Fill { - closed_pnl: "0.0".to_string(), - coin: order.order.asset.to_string(), - crossed: false, - dir: false, - hash: false, - oid: order.order_id, - px: trade_price.to_string(), - side: Side::Bid.into(), - start_position: false, - sz: order.get_shares().to_string(), - time: date, - } - } - - fn create_trigger(order: &InnerOrder, tif: TimeInForce) -> Order { - Order { - asset: order.order.asset, - is_buy: order.order.is_buy, - limit_px: order.order.limit_px.clone(), - sz: order.order.sz.clone(), - reduce_only: order.order.reduce_only, - cloid: order.order.cloid.clone(), - order_type: OrderType::Limit(LimitOrder { tif }), - } - } - - fn create_gtc_trigger(order: &InnerOrder) -> Order { - Self::create_trigger(order, TimeInForce::Gtc) - } - - fn create_ioc_trigger(order: &InnerOrder) -> Order { - Self::create_trigger(order, TimeInForce::Ioc) - } - - pub fn execute_orders(&mut self, quotes: &PenelopeQuoteByDate) -> (Vec, Vec) { - let mut fills: Vec = Vec::new(); - let mut should_delete: Vec<(u64, u64)> = Vec::new(); - // HyperLiquid execution can trigger more orders, we don't execute these immediately. - let mut should_insert: Vec = Vec::new(); - - // We have to have a mutable reference so we can update attempted_execution - for order in self.inner.iter_mut() { - let symbol = order.order.asset.to_string(); - if let Some(quote) = quotes.get(&symbol) { - let quote_copy: JuraQuote = quote.clone().into(); - let date = quote_copy.date; - let result = match &order.order.order_type { - OrderType::Limit(limit) => { - // A market order is a limit order with Ioc time-in-force. The px parameter - // on the order is taken from the order and seems to be used to calculate - // maximum slippage tolerated on the order. - // Market order code in Python SDK: - // https://github.com/hyperliquid-dex/hyperliquid-python-sdk/blob/67864cf979d3bbea2e964a99ecc0a1effb7bb911/hyperliquid/exchange.py#L209 - match limit.tif { - // Don't support Alo TimeInForce - TimeInForce::Ioc => { - // Market orders can only be executed on the next time step - if order.attempted_execution { - // We have tried to execute this before, return nothing - should_delete.push((order.order.asset, order.order_id)); - None - } else { - // For a market order, the limit price represents the maximum amount - // of slippage tolerated by the client - - // We unwrap here because if the client is sending us bad prices - // then we need to stop execution - let price = str::parse::(&order.order.limit_px).unwrap(); - order.attempted_execution = true; - if order.order.is_buy { - if price * (1.0 + self.slippage) >= quote_copy.ask { - should_delete.push((order.order.asset, order.order_id)); - Some(Self::execute_buy(quote_copy, order, date)) - } else { - None - } - } else if price * (1.0 - self.slippage) <= quote_copy.bid { - should_delete.push((order.order.asset, order.order_id)); - Some(Self::execute_sell(quote_copy, order, date)) - } else { - None - } - } - } - TimeInForce::Gtc => { - let price = str::parse::(&order.order.limit_px).unwrap(); - if order.order.is_buy { - if price >= quote_copy.ask { - should_delete.push((order.order.asset, order.order_id)); - Some(Self::execute_buy(quote_copy, order, date)) - } else { - None - } - } else if price <= quote_copy.bid { - should_delete.push((order.order.asset, order.order_id)); - Some(Self::execute_sell(quote_copy, order, date)) - } else { - None - } - } - _ => unimplemented!(), - } - } - OrderType::Trigger(trigger) => { - // If we trigger a market order, execute it here. If the trigger is for a - // limit order then we create another order add it to the queue and return - // the order_id to the client, execution cannot be immediate. - - // TP/SL market orders have slippage of 10% - // If the market price falls below trigger price of stop loss purchase then it - // is triggered - // https://hyperliquid.gitbook.io/hyperliquid-docs/trading/take-profit-and-stop-loss-orders-tp-sl - match trigger.tpsl { - TriggerType::Sl => { - // Closing a short as price goes up - if order.order.is_buy { - if quote_copy.ask >= trigger.trigger_px { - if trigger.is_market { - should_insert.push(Self::create_ioc_trigger(order)); - } else { - should_insert.push(Self::create_gtc_trigger(order)); - } - should_delete.push((order.order.asset, order.order_id)); - None - } else { - None - } - } else { - // Closing a long as price goes down - if quote_copy.bid >= trigger.trigger_px { - if trigger.is_market { - should_insert.push(Self::create_ioc_trigger(order)); - } else { - should_insert.push(Self::create_gtc_trigger(order)); - } - should_delete.push((order.order.asset, order.order_id)); - None - } else { - None - } - } - } - TriggerType::Tp => { - // Closing a short as price goes down - if order.order.is_buy { - if quote_copy.ask <= trigger.trigger_px { - if trigger.is_market { - should_insert.push(Self::create_ioc_trigger(order)) - } else { - should_insert.push(Self::create_gtc_trigger(order)) - } - should_delete.push((order.order.asset, order.order_id)); - None - } else { - None - } - } else { - // Closing a long as price goes up - if quote_copy.bid <= trigger.trigger_px { - if trigger.is_market { - should_insert.push(Self::create_ioc_trigger(order)) - } else { - should_insert.push(Self::create_gtc_trigger(order)) - } - should_delete.push((order.order.asset, order.order_id)); - None - } else { - None - } - } - } - } - } - }; - - if let Some(trade) = &result { - fills.push(trade.clone()); - } - } - } - - for (asset, order_id) in should_delete { - self.delete_order(asset, order_id); - } - - let mut triggered_order_ids = Vec::new(); - - for order in should_insert { - triggered_order_ids.push(self.insert_order(order)); - } - (fills, triggered_order_ids) - } -} - -#[cfg(test)] -mod tests { - use super::{JuraV1, Order}; - use crate::input::penelope::Penelope; - - fn setup() -> (Penelope, JuraV1) { - let mut source = Penelope::new(); - source.add_quote(101.00, 102.00, 100, "0".to_owned()); - source.add_quote(102.00, 103.00, 101, "0".to_owned()); - source.add_quote(105.00, 106.00, 102, "0".to_owned()); - - let exchange = JuraV1::new(); - - (source, exchange) - } - - #[test] - fn test_that_buy_market_executes_incrementing_trade_log() { - let (source, mut exchange) = setup(); - - exchange.insert_order(Order::market_buy(0_u64, "100.0", "102.00")); - exchange.tick(source.get_quotes_unchecked(&100)); - exchange.tick(source.get_quotes_unchecked(&101)); - - //TODO: no abstraction! - assert_eq!(exchange.trade_log.len(), 1); - } - - #[test] - fn test_that_multiple_orders_are_executed_on_same_tick() { - let (source, mut exchange) = setup(); - - exchange.insert_order(Order::market_buy(0_u64, "25.0", "102.00")); - - exchange.insert_order(Order::market_buy(0_u64, "25.0", "102.00")); - exchange.insert_order(Order::market_buy(0_u64, "25.0", "102.00")); - exchange.insert_order(Order::market_buy(0_u64, "25.0", "102.00")); - - exchange.tick(source.get_quotes_unchecked(&100)); - exchange.tick(source.get_quotes_unchecked(&101)); - assert_eq!(exchange.trade_log.len(), 4); - } - - #[test] - fn test_that_multiple_orders_are_executed_on_consecutive_tick() { - let (source, mut exchange) = setup(); - exchange.insert_order(Order::market_buy(0_u64, "25.0", "102.00")); - exchange.insert_order(Order::market_buy(0_u64, "25.0", "102.00")); - exchange.tick(source.get_quotes_unchecked(&100)); - - exchange.insert_order(Order::market_buy(0_u64, "25.0", "102.00")); - exchange.insert_order(Order::market_buy(0_u64, "25.0", "102.00")); - exchange.tick(source.get_quotes_unchecked(&101)); - exchange.tick(source.get_quotes_unchecked(&102)); - - assert_eq!(exchange.trade_log.len(), 4); - } - - #[test] - fn test_that_buy_market_executes_on_next_tick() { - //Verifies that trades do not execute instaneously removing lookahead bias - let (source, mut exchange) = setup(); - - exchange.insert_order(Order::market_buy(0_u64, "100.0", "102.00")); - exchange.tick(source.get_quotes_unchecked(&100)); - exchange.tick(source.get_quotes_unchecked(&101)); - - assert_eq!(exchange.trade_log.len(), 1); - let trade = exchange.trade_log.remove(0); - //Trade executes at 101 so trade price should be 103 - assert_eq!(trade.px, "103"); - assert_eq!(trade.time, 101); - } - - #[test] - fn test_that_sell_market_executes_on_next_tick() { - //Verifies that trades do not execute instaneously removing lookahead bias - let (source, mut exchange) = setup(); - - exchange.insert_order(Order::market_sell(0_u64, "100.0", "101.00")); - exchange.tick(source.get_quotes_unchecked(&100)); - exchange.tick(source.get_quotes_unchecked(&101)); - - assert_eq!(exchange.trade_log.len(), 1); - let trade = exchange.trade_log.remove(0); - //Trade executes at 101 so trade price should be 102 - assert_eq!(trade.px, "102"); - assert_eq!(trade.time, 101); - } - - #[test] - fn test_that_order_for_nonexistent_stock_fails_silently() { - let (source, mut exchange) = setup(); - - exchange.insert_order(Order::market_buy(99_u64, "100.0", "102.00")); - exchange.tick(source.get_quotes_unchecked(&100)); - - assert_eq!(exchange.trade_log.len(), 0); - } - - #[test] - fn test_that_order_buffer_clears() { - //Sounds redundant but accidentally removing the clear could cause unusual errors elsewhere - let (source, mut exchange) = setup(); - - exchange.insert_order(Order::market_buy(0_u64, "100.0", "102.00")); - exchange.tick(source.get_quotes_unchecked(&100)); - - assert!(exchange.order_buffer.is_empty()); - } - - #[test] - fn test_that_order_with_missing_price_executes_later() { - let mut source = Penelope::new(); - source.add_quote(101.00, 102.00, 100, "0".to_owned()); - source.add_quote(105.00, 106.00, 102, "0".to_owned()); - - let mut exchange = JuraV1::new(); - - exchange.insert_order(Order::market_buy(0_u64, "100.0", "102.00")); - exchange.tick(source.get_quotes_unchecked(&100)); - //Orderbook should have one order and trade log has no executed trades - assert_eq!(exchange.trade_log.len(), 0); - - exchange.tick(source.get_quotes_unchecked(&102)); - //Order should execute now - assert_eq!(exchange.trade_log.len(), 1); - } - - #[test] - fn test_that_sells_are_executed_before_buy() { - let (source, mut exchange) = setup(); - - exchange.insert_order(Order::market_buy(0_u64, "100.0", "102.00")); - exchange.insert_order(Order::market_buy(0_u64, "100.0", "102.00")); - exchange.insert_order(Order::market_sell(0_u64, "100.0", "102.00")); - let res = exchange.tick(source.get_quotes_unchecked(&100)); - - assert_eq!(res.1.len(), 3); - assert!(!(res.1.first().unwrap().is_buy)); - } -} diff --git a/rotala/src/exchange/mod.rs b/rotala/src/exchange/mod.rs index d80b913b..e023de26 100644 --- a/rotala/src/exchange/mod.rs +++ b/rotala/src/exchange/mod.rs @@ -3,6 +3,5 @@ //! to Orderbooks and the logic contained within the Exchange itself primarily relates to the //! orchestration of the backtest (for example, ticking forward or synchronizing state with clients //! ). -pub mod jura_v1; pub mod uist_v1; pub mod uist_v2; diff --git a/rotala/src/exchange/uist_v1.rs b/rotala/src/exchange/uist_v1.rs index 35a6389a..ed69c8ab 100644 --- a/rotala/src/exchange/uist_v1.rs +++ b/rotala/src/exchange/uist_v1.rs @@ -168,6 +168,10 @@ impl UistV1 { } } + pub fn executed_trade_count(&self) -> usize { + self.trade_log.len() + } + fn sort_order_buffer(&mut self) { self.order_buffer.sort_by(|a, _b| match a.get_order_type() { OrderType::LimitSell | OrderType::StopSell | OrderType::MarketSell => { @@ -372,8 +376,7 @@ mod tests { exchange.tick(source.get_quotes_unchecked(&100)); exchange.tick(source.get_quotes_unchecked(&101)); - //TODO: no abstraction! - assert_eq!(exchange.trade_log.len(), 1); + assert_eq!(exchange.executed_trade_count(), 1); } #[test] @@ -387,7 +390,7 @@ mod tests { exchange.tick(source.get_quotes_unchecked(&100)); exchange.tick(source.get_quotes_unchecked(&101)); - assert_eq!(exchange.trade_log.len(), 4); + assert_eq!(exchange.executed_trade_count(), 4); } #[test] @@ -402,7 +405,7 @@ mod tests { exchange.tick(source.get_quotes_unchecked(&101)); exchange.tick(source.get_quotes_unchecked(&102)); - assert_eq!(exchange.trade_log.len(), 4); + assert_eq!(exchange.executed_trade_count(), 4); } #[test] @@ -414,7 +417,7 @@ mod tests { exchange.tick(source.get_quotes_unchecked(&100)); exchange.tick(source.get_quotes_unchecked(&101)); - assert_eq!(exchange.trade_log.len(), 1); + assert_eq!(exchange.executed_trade_count(), 1); let trade = exchange.trade_log.remove(0); //Trade executes at 101 so trade price should be 103 assert_eq!(trade.value / trade.quantity, 103.00); @@ -430,7 +433,7 @@ mod tests { exchange.tick(source.get_quotes_unchecked(&100)); exchange.tick(source.get_quotes_unchecked(&101)); - assert_eq!(exchange.trade_log.len(), 1); + assert_eq!(exchange.executed_trade_count(), 1); let trade = exchange.trade_log.remove(0); //Trade executes at 101 so trade price should be 103 assert_eq!(trade.value / trade.quantity, 102.00); @@ -444,7 +447,7 @@ mod tests { exchange.insert_order(Order::market_buy("XYZ", 100.0)); exchange.tick(source.get_quotes_unchecked(&100)); - assert_eq!(exchange.trade_log.len(), 0); + assert_eq!(exchange.executed_trade_count(), 0); } #[test] @@ -468,11 +471,11 @@ mod tests { exchange.insert_order(Order::market_buy("ABC", 100.0)); exchange.tick(source.get_quotes_unchecked(&100)); //Orderbook should have one order and trade log has no executed trades - assert_eq!(exchange.trade_log.len(), 0); + assert_eq!(exchange.executed_trade_count(), 0); exchange.tick(source.get_quotes_unchecked(&102)); //Order should execute now - assert_eq!(exchange.trade_log.len(), 1); + assert_eq!(exchange.executed_trade_count(), 1); } #[test] diff --git a/rotala/src/exchange/uist_v2.rs b/rotala/src/exchange/uist_v2.rs index e505ce69..a9861afd 100644 --- a/rotala/src/exchange/uist_v2.rs +++ b/rotala/src/exchange/uist_v2.rs @@ -1,8 +1,15 @@ -use std::collections::{HashMap, VecDeque}; +use core::panic; +use std::{ + collections::{BTreeMap, HashMap}, + fmt::Display, + mem, +}; use serde::{Deserialize, Serialize}; -use crate::input::athena::{DateQuotes, Depth, Level}; +use crate::source::hyperliquid::{DateDepth, DateTrade, Depth, BBO}; + +pub type OrderId = u64; #[derive(Clone, Debug, Deserialize, Serialize)] pub struct Quote { @@ -14,8 +21,8 @@ pub struct Quote { pub symbol: String, } -impl From for Quote { - fn from(value: crate::input::athena::BBO) -> Self { +impl From for Quote { + fn from(value: BBO) -> Self { Self { bid: value.bid, bid_volume: value.bid_volume, @@ -33,6 +40,8 @@ pub enum OrderType { MarketBuy, LimitBuy, LimitSell, + Cancel, + Modify, } #[derive(Clone, Debug, Deserialize, Serialize)] @@ -41,17 +50,24 @@ pub struct Order { pub symbol: String, pub qty: f64, pub price: Option, - pub recieved: i64, + pub order_id_ref: Option, + pub exchange: String, } impl Order { - fn market(order_type: OrderType, symbol: impl Into, shares: f64, now: i64) -> Self { + fn market( + order_type: OrderType, + symbol: impl Into, + shares: f64, + exchange: impl Into, + ) -> Self { Self { order_type, symbol: symbol.into(), qty: shares, price: None, - recieved: now, + order_id_ref: None, + exchange: exchange.into(), } } @@ -60,53 +76,104 @@ impl Order { symbol: impl Into, shares: f64, price: f64, - now: i64, + exchange: impl Into, ) -> Self { Self { order_type, symbol: symbol.into(), qty: shares, price: Some(price), - recieved: now, + order_id_ref: None, + exchange: exchange.into(), } } - pub fn market_buy(symbol: impl Into, shares: f64, now: i64) -> Self { - Order::market(OrderType::MarketBuy, symbol, shares, now) + pub fn market_buy(symbol: impl Into, shares: f64, exchange: impl Into) -> Self { + Order::market(OrderType::MarketBuy, symbol, shares, exchange) } - pub fn market_sell(symbol: impl Into, shares: f64, now: i64) -> Self { - Order::market(OrderType::MarketSell, symbol, shares, now) + pub fn market_sell( + symbol: impl Into, + shares: f64, + exchange: impl Into, + ) -> Self { + Order::market(OrderType::MarketSell, symbol, shares, exchange) } - pub fn limit_buy(symbol: impl Into, shares: f64, price: f64, now: i64) -> Self { - Order::delayed(OrderType::LimitBuy, symbol, shares, price, now) + pub fn limit_buy( + symbol: impl Into, + shares: f64, + price: f64, + exchange: impl Into, + ) -> Self { + Order::delayed(OrderType::LimitBuy, symbol, shares, price, exchange) } - pub fn limit_sell(symbol: impl Into, shares: f64, price: f64, now: i64) -> Self { - Order::delayed(OrderType::LimitSell, symbol, shares, price, now) + pub fn limit_sell( + symbol: impl Into, + shares: f64, + price: f64, + exchange: impl Into, + ) -> Self { + Order::delayed(OrderType::LimitSell, symbol, shares, price, exchange) + } + + pub fn modify_order( + symbol: impl Into, + order_id: OrderId, + qty_change: f64, + exchange: impl Into, + ) -> Self { + Self { + order_id_ref: Some(order_id), + order_type: OrderType::Modify, + symbol: symbol.into(), + price: None, + qty: qty_change, + exchange: exchange.into(), + } + } + + pub fn cancel_order( + symbol: impl Into, + order_id: OrderId, + exchange: impl Into, + ) -> Self { + Self { + order_id_ref: Some(order_id), + order_type: OrderType::Cancel, + symbol: symbol.into(), + price: None, + qty: 0.0, + exchange: exchange.into(), + } } } #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] -pub enum TradeType { +pub enum OrderResultType { Buy, Sell, + Modify, + Cancel, } #[derive(Clone, Debug, Deserialize, Serialize)] -pub struct Trade { +pub struct OrderResult { pub symbol: String, pub value: f64, pub quantity: f64, pub date: i64, - pub typ: TradeType, + pub typ: OrderResultType, + pub order_id: OrderId, + pub order_id_ref: Option, + pub exchange: String, } #[derive(Debug)] pub struct UistV2 { orderbook: OrderBook, - trade_log: Vec, + order_result_log: Vec, //This is cleared on every tick order_buffer: Vec, } @@ -115,7 +182,7 @@ impl UistV2 { pub fn new() -> Self { Self { orderbook: OrderBook::default(), - trade_log: Vec::new(), + order_result_log: Vec::new(), order_buffer: Vec::new(), } } @@ -135,21 +202,33 @@ impl UistV2 { self.order_buffer.push(order); } - pub fn tick(&mut self, quotes: &DateQuotes, now: i64) -> (Vec, Vec) { + pub fn insert_orders(&mut self, mut orders: Vec) { + let mut orders = mem::take(&mut orders); + self.order_buffer.append(&mut orders); + } + + pub fn tick( + &mut self, + quotes: &DateDepth, + trades: &DateTrade, + now: i64, + ) -> (Vec, Vec) { //To eliminate lookahead bias, we only insert new orders after we have executed any orders //that were on the stack first - let executed_trades = self.orderbook.execute_orders(quotes, now); + let executed_trades = self.orderbook.execute_orders(quotes, trades, now); for executed_trade in &executed_trades { - self.trade_log.push(executed_trade.clone()); + self.order_result_log.push(executed_trade.clone()); } + let mut inserted_orders = Vec::new(); self.sort_order_buffer(); //TODO: remove this overhead, shouldn't need a clone here for order in self.order_buffer.iter() { - self.orderbook.insert_order(order.clone()); + let inner_order = self.orderbook.insert_order(order.clone(), now); + inserted_orders.push(inner_order); } - let inserted_orders = std::mem::take(&mut self.order_buffer); + self.order_buffer.clear(); (executed_trades, inserted_orders) } } @@ -170,10 +249,10 @@ struct FillTracker { } impl FillTracker { - fn get_fill(&self, symbol: &str, level: &Level) -> f64 { + fn get_fill(&self, symbol: &str, price: &f64) -> f64 { //Can default to zero instead of None if let Some(fills) = self.inner.get(symbol) { - let level_string = level.price.to_string(); + let level_string = price.to_string(); if let Some(val) = fills.get(&level_string) { return *val; } @@ -181,14 +260,14 @@ impl FillTracker { 0.0 } - fn insert_fill(&mut self, symbol: &str, level: &Level, filled: f64) { + fn insert_fill(&mut self, symbol: &str, price: &f64, filled: f64) { if !self.inner.contains_key(symbol) { self.inner .insert(symbol.to_string().clone(), HashMap::new()); } let fills = self.inner.get_mut(symbol).unwrap(); - let level_string = level.price.to_string(); + let level_string = price.to_string(); fills .entry(level_string) @@ -210,18 +289,55 @@ pub enum LatencyModel { } impl LatencyModel { - fn cmp_order(&self, now: i64, order: &Order) -> bool { + fn cmp_order(&self, now: i64, order: &InnerOrder) -> bool { match self { Self::None => true, - Self::FixedPeriod(period) => order.recieved + period < now, + Self::FixedPeriod(period) => order.recieved_timestamp + period < now, } } } +//Representation of order used internally, this is sent back to clients. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct InnerOrder { + pub order_type: OrderType, + pub symbol: String, + pub qty: f64, + pub price: Option, + pub recieved_timestamp: i64, + pub order_id: OrderId, + pub order_id_ref: Option, + pub exchange: String, +} + +#[derive(Debug)] +pub enum OrderBookOrderPriority { + AlwaysFirst, + TradeThrough, +} + +#[derive(Debug)] +pub enum OrderBookError { + OrderIdNotFound, +} + +impl Display for OrderBookError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!(f, "OrderBookError") + } +} + +impl std::error::Error for OrderBookError {} + +//The key is an f64 but we use a String because f64 does not impl Hash +type FilledTrades = HashMap; + #[derive(Debug)] pub struct OrderBook { - inner: VecDeque, + inner: BTreeMap, latency: LatencyModel, + last_order_id: u64, + priority_setting: OrderBookOrderPriority, } impl Default for OrderBook { @@ -233,87 +349,255 @@ impl Default for OrderBook { impl OrderBook { pub fn new() -> Self { Self { - inner: std::collections::VecDeque::new(), + inner: BTreeMap::new(), latency: LatencyModel::None, + last_order_id: 0, + priority_setting: OrderBookOrderPriority::AlwaysFirst, + } + } + + //Used for testing + pub fn get_total_order_qty_by_symbol(&self, symbol: &str) -> f64 { + let mut total = 0.0; + for order in self.inner.values() { + if order.symbol == symbol { + total += order.qty + } } + total } pub fn with_latency(latency: i64) -> Self { Self { - inner: std::collections::VecDeque::new(), + inner: BTreeMap::new(), latency: LatencyModel::FixedPeriod(latency), + last_order_id: 0, + priority_setting: OrderBookOrderPriority::AlwaysFirst, } } - pub fn insert_order(&mut self, order: Order) { - self.inner.push_back(order.clone()); + pub fn insert_order(&mut self, order: Order, now: i64) -> InnerOrder { + let inner_order = InnerOrder { + recieved_timestamp: now, + order_id: self.last_order_id, + order_type: order.order_type, + symbol: order.symbol.clone(), + qty: order.qty, + price: order.price, + order_id_ref: order.order_id_ref, + exchange: order.exchange, + }; + + self.inner.insert(self.last_order_id, inner_order.clone()); + self.last_order_id += 1; + inner_order } pub fn is_empty(&self) -> bool { self.inner.is_empty() } + // Only returns a single `OrderResult` but we return a `Vec` for empty condition + fn cancel_order( + now: i64, + cancel_order: &InnerOrder, + orderbook: &mut BTreeMap, + ) -> Vec { + let mut res = Vec::new(); + //Fails silently if you send garbage in + if let Some(order_to_cancel_id) = &cancel_order.order_id_ref { + if orderbook.remove(order_to_cancel_id).is_some() { + let order_result = OrderResult { + symbol: cancel_order.symbol.clone(), + value: 0.0, + quantity: 0.0, + date: now, + typ: OrderResultType::Cancel, + order_id: cancel_order.order_id, + order_id_ref: Some(*order_to_cancel_id), + exchange: cancel_order.exchange.clone(), + }; + res.push(order_result); + } + } + + res + } + + // Only returns a single `OrderResult` but we return a `Vec` for empty condition + fn modify_order( + now: i64, + modify_order: &InnerOrder, + orderbook: &mut BTreeMap, + ) -> Vec { + let mut res = Vec::new(); + + if let Some(order_to_modify) = orderbook.get_mut(&modify_order.order_id_ref.unwrap()) { + let qty_change = modify_order.qty; + + if qty_change > 0.0 { + order_to_modify.qty += qty_change; + } else { + let qty_left = order_to_modify.qty + qty_change; + if qty_left > 0.0 { + order_to_modify.qty += qty_change; + } else { + // we are trying to remove more than the total number of shares + // left on the order so will assume user wants to cancel + orderbook.remove(&modify_order.order_id); + } + } + + let order_result = OrderResult { + symbol: modify_order.symbol.clone(), + value: 0.0, + quantity: 0.0, + date: now, + typ: OrderResultType::Modify, + order_id: modify_order.order_id, + order_id_ref: Some(modify_order.order_id_ref.unwrap()), + exchange: modify_order.exchange.clone(), + }; + res.push(order_result); + } + res + } + fn fill_order( depth: &Depth, - order: &Order, - is_buy: bool, - price_check: f64, + order: &InnerOrder, filled: &mut FillTracker, - ) -> Vec { + taker_trades: &FilledTrades, + priority_setting: &OrderBookOrderPriority, + ) -> Vec { let mut to_fill = order.qty; let mut trades = Vec::new(); - if is_buy { - for ask in &depth.asks { - if ask.price > price_check { - break; + let is_buy = match order.order_type { + OrderType::MarketBuy | OrderType::LimitBuy => true, + OrderType::LimitSell | OrderType::MarketSell => false, + _ => panic!("Can't fill cancel or modify"), + }; + + let price_check = match order.order_type { + OrderType::LimitBuy | OrderType::LimitSell => order.price.unwrap(), + OrderType::MarketBuy => f64::MAX, + OrderType::MarketSell => f64::MIN, + _ => panic!("Can't fill cancel or modify"), + }; + + for bid in &depth.bids { + let filled_size = filled.get_fill(&order.symbol, &bid.price); + + if let OrderBookOrderPriority::AlwaysFirst = priority_setting { + if is_buy && bid.price == price_check { + if let Some((_buy_vol, sell_vol)) = taker_trades.get(&price_check.to_string()) { + let size = sell_vol - filled_size; + if size == 0.0 { + break; + } + + let qty = if size >= to_fill { to_fill } else { size }; + to_fill -= qty; + + let trade = OrderResult { + symbol: order.symbol.clone(), + value: bid.price * qty, + quantity: qty, + date: depth.date, + typ: OrderResultType::Buy, + order_id: order.order_id, + order_id_ref: None, + exchange: order.exchange.clone(), + }; + + trades.push(trade); + filled.insert_fill(&order.symbol, &bid.price, qty); + } } + } + + if !is_buy && bid.price >= price_check { + let size = bid.size - filled_size; - let filled_size = filled.get_fill(&order.symbol, ask); - let size = ask.size - filled_size; if size == 0.0 { break; } let qty = if size >= to_fill { to_fill } else { size }; to_fill -= qty; - let trade = Trade { + let trade = OrderResult { symbol: order.symbol.clone(), - value: ask.price * order.qty, + value: bid.price * qty, quantity: qty, date: depth.date, - typ: TradeType::Buy, + typ: OrderResultType::Sell, + order_id: order.order_id, + order_id_ref: None, + exchange: order.exchange.clone(), }; + trades.push(trade); - filled.insert_fill(&order.symbol, ask, qty); + filled.insert_fill(&order.symbol, &bid.price, qty); if to_fill == 0.0 { break; } } - } else { - for bid in &depth.bids { - if price_check > bid.price { - break; + } + + for ask in &depth.asks { + let filled_size = filled.get_fill(&order.symbol, &ask.price); + + if let OrderBookOrderPriority::AlwaysFirst = priority_setting { + if !is_buy && ask.price == price_check { + if let Some((buy_vol, _sell_vol)) = taker_trades.get(&price_check.to_string()) { + let size = buy_vol - filled_size; + if size == 0.0 { + break; + } + + let qty = if size >= to_fill { to_fill } else { size }; + to_fill -= qty; + + let trade = OrderResult { + symbol: order.symbol.clone(), + value: ask.price * qty, + quantity: qty, + date: depth.date, + typ: OrderResultType::Sell, + order_id: order.order_id, + order_id_ref: None, + exchange: order.exchange.clone(), + }; + + trades.push(trade); + filled.insert_fill(&order.symbol, &ask.price, qty); + } } + } - let filled_size = filled.get_fill(&order.symbol, bid); - let size = bid.size - filled_size; + if is_buy && ask.price <= price_check { + let filled_size = filled.get_fill(&order.symbol, &ask.price); + let size = ask.size - filled_size; if size == 0.0 { break; } let qty = if size >= to_fill { to_fill } else { size }; to_fill -= qty; - let trade = Trade { + let trade = OrderResult { symbol: order.symbol.clone(), - value: bid.price * order.qty, + value: ask.price * qty, quantity: qty, date: depth.date, - typ: TradeType::Sell, + typ: OrderResultType::Buy, + order_id: order.order_id, + order_id_ref: None, + exchange: order.exchange.clone(), }; trades.push(trade); - filled.insert_fill(&order.symbol, bid, qty); + filled.insert_fill(&order.symbol, &ask.price, qty); if to_fill == 0.0 { break; @@ -325,9 +609,10 @@ impl OrderBook { pub fn execute_orders( &mut self, - quotes: &crate::input::athena::DateQuotes, + quotes: &DateDepth, + trades: &DateTrade, now: i64, - ) -> Vec { + ) -> Vec { //Tracks liquidity that has been used at each level let mut filled: FillTracker = FillTracker::new(); @@ -336,46 +621,145 @@ impl OrderBook { return trade_results; } - for order in self.inner.iter() { - let security_id = &order.symbol; + let mut taker_trades: FilledTrades = HashMap::new(); + for date_trades in trades.values() { + for trade in date_trades { + taker_trades + .entry(trade.px.to_string()) + .or_insert_with(|| (0.0, 0.0)); + let volume = taker_trades.get_mut(&trade.px.to_string()).unwrap(); + match trade.side { + crate::source::hyperliquid::Side::Bid => volume.1 += trade.sz, + crate::source::hyperliquid::Side::Ask => volume.0 += trade.sz, + } + } + } - if !self.latency.cmp_order(now, order) { - continue; + // Split out cancel and modifies, and then implement on a copy of orderbook + let mut cancel_and_modify: Vec = Vec::new(); + let mut orders: BTreeMap = BTreeMap::new(); + while let Some((order_id, order)) = self.inner.pop_first() { + match order.order_type { + OrderType::Cancel | OrderType::Modify => { + cancel_and_modify.push(order); + } + _ => { + orders.insert(order_id, order); + } } + } - if let Some(depth) = quotes.get(security_id) { - let mut trades = match order.order_type { - OrderType::MarketBuy => { - Self::fill_order(depth, order, true, f64::MAX, &mut filled) - } - OrderType::MarketSell => { - Self::fill_order(depth, order, false, f64::MIN, &mut filled) + for order in cancel_and_modify { + match order.order_type { + OrderType::Cancel => { + let mut res = Self::cancel_order(now, &order, &mut orders); + if !res.is_empty() { + trade_results.append(&mut res); } - OrderType::LimitBuy => { - Self::fill_order(depth, order, true, order.price.unwrap(), &mut filled) + } + OrderType::Modify => { + let mut res = Self::modify_order(now, &order, &mut orders); + if !res.is_empty() { + trade_results.append(&mut res); } - OrderType::LimitSell => { - Self::fill_order(depth, order, false, order.price.unwrap(), &mut filled) + } + _ => {} + } + } + + let mut unexecuted_orders = BTreeMap::new(); + while let Some((order_id, order)) = orders.pop_first() { + let security_id = &order.symbol; + + if !self.latency.cmp_order(now, &order) { + unexecuted_orders.insert(order_id, order); + continue; + } + + if let Some(exchange) = quotes.get(&order.exchange) { + if let Some(depth) = exchange.get(security_id) { + let mut completed_trades = match order.order_type { + OrderType::MarketBuy => Self::fill_order( + depth, + &order, + &mut filled, + &taker_trades, + &self.priority_setting, + ), + OrderType::MarketSell => Self::fill_order( + depth, + &order, + &mut filled, + &taker_trades, + &self.priority_setting, + ), + OrderType::LimitBuy => Self::fill_order( + depth, + &order, + &mut filled, + &taker_trades, + &self.priority_setting, + ), + OrderType::LimitSell => Self::fill_order( + depth, + &order, + &mut filled, + &taker_trades, + &self.priority_setting, + ), + // There shouldn't be any cancel or modifies by this point + _ => vec![], + }; + + if completed_trades.is_empty() { + unexecuted_orders.insert(order_id, order); } - }; - trade_results.append(&mut trades) + + trade_results.append(&mut completed_trades) + } + } else { + unexecuted_orders.insert(order_id, order); } } + self.inner = unexecuted_orders; trade_results } } #[cfg(test)] mod tests { - use std::collections::HashMap; + use std::collections::BTreeMap; use crate::{ exchange::uist_v2::{Order, OrderBook}, - input::athena::{DateQuotes, Depth, Level}, + source::hyperliquid::{DateDepth, DateTrade, Depth, Level, Side, Trade}, }; - #[test] - fn test_that_buy_order_will_lift_all_volume_when_order_is_equal_to_depth_size() { + fn trades() -> DateTrade { + let bid_trade = Trade { + coin: "ABC".to_string(), + side: Side::Bid, + px: 100.0, + sz: 100.0, + time: 100, + exchange: "exchange".to_string(), + }; + let ask_trade = Trade { + coin: "ABC".to_string(), + side: Side::Ask, + px: 102.0, + sz: 100.0, + time: 100, + exchange: "exchange".to_string(), + }; + + let mut trades: DateTrade = BTreeMap::new(); + trades.insert(100, vec![bid_trade, ask_trade]); + + trades + } + + fn quotes() -> DateDepth { let bid_level = Level { price: 100.0, size: 100.0, @@ -386,78 +770,151 @@ mod tests { size: 100.0, }; - let mut depth = Depth::new(100, "ABC"); - depth.add_level(bid_level, crate::input::athena::Side::Bid); - depth.add_level(ask_level, crate::input::athena::Side::Ask); + let mut depth = Depth::new(100, "ABC", "exchange"); + depth.add_level(bid_level, Side::Bid); + depth.add_level(ask_level, Side::Ask); + + let mut quotes: DateDepth = BTreeMap::new(); + quotes.insert("exchange".to_string(), BTreeMap::new()); + quotes + .get_mut("exchange") + .unwrap() + .insert("ABC".to_string(), depth); + quotes + } - let mut quotes: DateQuotes = HashMap::new(); - quotes.insert("ABC".to_string(), depth); + fn trades1() -> DateTrade { + let bid_trade = Trade { + coin: "ABC".to_string(), + side: Side::Bid, + px: 98.0, + sz: 20.0, + time: 100, + exchange: "exchange".to_string(), + }; + let ask_trade = Trade { + coin: "ABC".to_string(), + side: Side::Ask, + px: 102.0, + sz: 20.0, + time: 100, + exchange: "exchange".to_string(), + }; - let mut orderbook = OrderBook::new(); - let order = Order::market_buy("ABC", 100.0, 100); - orderbook.insert_order(order); + let mut trades: DateTrade = BTreeMap::new(); + trades.insert(100, vec![bid_trade, ask_trade]); - let res = orderbook.execute_orders("es, 100); - assert!(res.len() == 1); - let trade = res.first().unwrap(); - assert!(trade.quantity == 100.00); - assert!(trade.value / trade.quantity == 102.00); + trades } - #[test] - fn test_that_sell_order_will_lift_all_volume_when_order_is_equal_to_depth_size() { + fn quotes1() -> DateDepth { let bid_level = Level { - price: 100.0, - size: 100.0, + price: 98.0, + size: 20.0, }; let ask_level = Level { price: 102.0, - size: 100.0, + size: 20.0, }; - let mut depth = Depth::new(100, "ABC"); - depth.add_level(bid_level, crate::input::athena::Side::Bid); - depth.add_level(ask_level, crate::input::athena::Side::Ask); + let mut depth = Depth::new(100, "ABC", "exchange"); + depth.add_level(bid_level, Side::Bid); + depth.add_level(ask_level, Side::Ask); + + let mut quotes: DateDepth = BTreeMap::new(); + quotes.insert("exchange".to_string(), BTreeMap::new()); + quotes + .get_mut("exchange") + .unwrap() + .insert("ABC".to_string(), depth); + quotes + } - let mut quotes: DateQuotes = HashMap::new(); - quotes.insert("ABC".to_string(), depth); + #[test] + fn test_that_nonexistent_buy_order_cancel_produces_empty_result() { + let quotes = quotes(); + let trades = trades(); + let mut orderbook = OrderBook::new(); + orderbook.insert_order(Order::cancel_order("ABC", 10, "exchange"), 100); + let res = orderbook.execute_orders("es, &trades, 100); + assert!(res.is_empty()) + } + #[test] + fn test_that_nonexistent_buy_order_modify_throws_error() { + let quotes = quotes(); + let trades = trades(); let mut orderbook = OrderBook::new(); - let order = Order::market_sell("ABC", 100.0, 100); - orderbook.insert_order(order); + orderbook.insert_order(Order::modify_order("ABC", 10, 100.0, "exchange"), 100); + let res = orderbook.execute_orders("es, &trades, 100); + assert!(res.is_empty()) + } - let res = orderbook.execute_orders("es, 100); + #[test] + fn test_that_buy_order_can_be_cancelled_and_modified() { + let quotes = quotes(); + let trades = trades(); + + let mut orderbook = OrderBook::new(); + let oid = orderbook + .insert_order(Order::limit_buy("ABC", 100.0, 1.0, "exchange"), 100) + .order_id; + + orderbook.insert_order(Order::cancel_order("ABC", oid, "exchange"), 100); + let res = orderbook.execute_orders("es, &trades, 100); + println!("{:?}", res); + assert!(res.len() == 1); + + let oid1 = orderbook + .insert_order(Order::limit_buy("ABC", 200.0, 1.0, "exchange"), 100) + .order_id; + orderbook.insert_order(Order::modify_order("ABC", oid1, 100.0, "exchange"), 100); + let res = orderbook.execute_orders("es, &trades, 100); + assert!(res.len() == 1); + } + + #[test] + fn test_that_buy_order_will_lift_all_volume_when_order_is_equal_to_depth_size() { + let quotes = quotes(); + let trades = trades(); + + let mut orderbook = OrderBook::new(); + let order = Order::market_buy("ABC", 100.0, "exchange"); + orderbook.insert_order(order, 100); + + let res = orderbook.execute_orders("es, &trades, 100); assert!(res.len() == 1); let trade = res.first().unwrap(); assert!(trade.quantity == 100.00); - assert!(trade.value / trade.quantity == 100.00); + assert!(trade.value / trade.quantity == 102.00); } #[test] - fn test_that_order_will_lift_order_qty_when_order_is_less_than_depth_size() { - let bid_level = Level { - price: 100.0, - size: 100.0, - }; - - let ask_level = Level { - price: 102.0, - size: 100.0, - }; + fn test_that_sell_order_will_lift_all_volume_when_order_is_equal_to_depth_size() { + let quotes = quotes(); + let trades = trades(); - let mut depth = Depth::new(100, "ABC"); - depth.add_level(bid_level, crate::input::athena::Side::Bid); - depth.add_level(ask_level, crate::input::athena::Side::Ask); + let mut orderbook = OrderBook::new(); + let order = Order::market_sell("ABC", 100.0, "exchange"); + orderbook.insert_order(order, 100); - let mut quotes: DateQuotes = HashMap::new(); - quotes.insert("ABC".to_string(), depth); + let res = orderbook.execute_orders("es, &trades, 100); + assert!(res.len() == 1); + let trade = res.first().unwrap(); + assert!(trade.quantity == 100.00); + assert!(trade.value / trade.quantity == 100.00); + } + #[test] + fn test_that_order_will_lift_order_qty_when_order_is_less_than_depth_size() { + let quotes = quotes(); + let trades = trades(); let mut orderbook = OrderBook::new(); - let order = Order::market_buy("ABC", 50.0, 100); - orderbook.insert_order(order); + let order = Order::market_buy("ABC", 50.0, "exchange"); + orderbook.insert_order(order, 100); - let res = orderbook.execute_orders("es, 100); + let res = orderbook.execute_orders("es, &trades, 100); assert!(res.len() == 1); let trade = res.first().unwrap(); assert!(trade.quantity == 50.00); @@ -481,19 +938,43 @@ mod tests { size: 20.0, }; - let mut depth = Depth::new(100, "ABC"); - depth.add_level(bid_level, crate::input::athena::Side::Bid); - depth.add_level(ask_level, crate::input::athena::Side::Ask); - depth.add_level(ask_level_1, crate::input::athena::Side::Ask); + let mut depth = Depth::new(100, "ABC", "exchange"); + depth.add_level(bid_level, Side::Bid); + depth.add_level(ask_level, Side::Ask); + depth.add_level(ask_level_1, Side::Ask); + + let mut quotes: DateDepth = BTreeMap::new(); + quotes.insert("exchange".to_string(), BTreeMap::new()); + quotes + .get_mut("exchange") + .unwrap() + .insert("ABC".to_string(), depth); + + let bid_trade = Trade { + coin: "ABC".to_string(), + side: Side::Bid, + px: 100.0, + sz: 100.0, + time: 100, + exchange: "exchange".to_string(), + }; + let ask_trade = Trade { + coin: "ABC".to_string(), + side: Side::Ask, + px: 102.0, + sz: 80.0, + time: 100, + exchange: "exchange".to_string(), + }; - let mut quotes: DateQuotes = HashMap::new(); - quotes.insert("ABC".to_string(), depth); + let mut trades: DateTrade = BTreeMap::new(); + trades.insert(100, vec![bid_trade, ask_trade]); let mut orderbook = OrderBook::new(); - let order = Order::market_buy("ABC", 100.0, 100); - orderbook.insert_order(order); + let order = Order::market_buy("ABC", 100.0, "exchange"); + orderbook.insert_order(order, 100); - let res = orderbook.execute_orders("es, 100); + let res = orderbook.execute_orders("es, &trades, 100); assert!(res.len() == 2); let first_trade = res.first().unwrap(); let second_trade = res.get(1).unwrap(); @@ -526,20 +1007,44 @@ mod tests { size: 20.0, }; - let mut depth = Depth::new(100, "ABC"); - depth.add_level(bid_level, crate::input::athena::Side::Bid); - depth.add_level(ask_level, crate::input::athena::Side::Ask); - depth.add_level(ask_level_1, crate::input::athena::Side::Ask); - depth.add_level(ask_level_2, crate::input::athena::Side::Ask); + let mut depth = Depth::new(100, "ABC", "exchange"); + depth.add_level(bid_level, Side::Bid); + depth.add_level(ask_level, Side::Ask); + depth.add_level(ask_level_1, Side::Ask); + depth.add_level(ask_level_2, Side::Ask); + + let mut quotes: DateDepth = BTreeMap::new(); + quotes.insert("exchange".to_string(), BTreeMap::new()); + quotes + .get_mut("exchange") + .unwrap() + .insert("ABC".to_string(), depth); + + let bid_trade = Trade { + coin: "ABC".to_string(), + side: Side::Bid, + px: 100.0, + sz: 100.0, + time: 100, + exchange: "exchange".to_string(), + }; + let ask_trade = Trade { + coin: "ABC".to_string(), + side: Side::Ask, + px: 102.0, + sz: 80.0, + time: 100, + exchange: "exchange".to_string(), + }; - let mut quotes: DateQuotes = HashMap::new(); - quotes.insert("ABC".to_string(), depth); + let mut trades: DateTrade = BTreeMap::new(); + trades.insert(100, vec![bid_trade, ask_trade]); let mut orderbook = OrderBook::new(); - let order = Order::limit_buy("ABC", 120.0, 103.00, 100); - orderbook.insert_order(order); + let order = Order::limit_buy("ABC", 120.0, 103.00, "exchange"); + orderbook.insert_order(order, 100); - let res = orderbook.execute_orders("es, 100); + let res = orderbook.execute_orders("es, &trades, 100); println!("{:?}", res); assert!(res.len() == 2); let first_trade = res.first().unwrap(); @@ -573,20 +1078,44 @@ mod tests { size: 80.0, }; - let mut depth = Depth::new(100, "ABC"); - depth.add_level(bid_level_0, crate::input::athena::Side::Bid); - depth.add_level(bid_level_1, crate::input::athena::Side::Bid); - depth.add_level(bid_level_2, crate::input::athena::Side::Bid); - depth.add_level(ask_level, crate::input::athena::Side::Ask); + let mut depth = Depth::new(100, "ABC", "exchange"); + depth.add_level(bid_level_0, Side::Bid); + depth.add_level(bid_level_1, Side::Bid); + depth.add_level(bid_level_2, Side::Bid); + depth.add_level(ask_level, Side::Ask); + + let mut quotes: DateDepth = BTreeMap::new(); + quotes.insert("exchange".to_string(), BTreeMap::new()); + quotes + .get_mut("exchange") + .unwrap() + .insert("ABC".to_string(), depth); + + let bid_trade = Trade { + coin: "ABC".to_string(), + side: Side::Bid, + px: 100.0, + sz: 80.0, + time: 100, + exchange: "exchange".to_string(), + }; + let ask_trade = Trade { + coin: "ABC".to_string(), + side: Side::Ask, + px: 102.0, + sz: 80.0, + time: 100, + exchange: "exchange".to_string(), + }; - let mut quotes: DateQuotes = HashMap::new(); - quotes.insert("ABC".to_string(), depth); + let mut trades: DateTrade = BTreeMap::new(); + trades.insert(100, vec![bid_trade, ask_trade]); let mut orderbook = OrderBook::new(); - let order = Order::limit_sell("ABC", 120.0, 99.00, 100); - orderbook.insert_order(order); + let order = Order::limit_sell("ABC", 120.0, 99.00, "exchange"); + orderbook.insert_order(order, 100); - let res = orderbook.execute_orders("es, 100); + let res = orderbook.execute_orders("es, &trades, 100); println!("{:?}", res); assert!(res.len() == 2); let first_trade = res.first().unwrap(); @@ -600,6 +1129,21 @@ mod tests { #[test] fn test_that_repeated_orders_do_not_use_same_liquidty() { + let quotes = quotes1(); + let trades = trades1(); + let mut orderbook = OrderBook::new(); + let first_order = Order::limit_buy("ABC", 20.0, 103.00, "exchange"); + orderbook.insert_order(first_order, 100); + let second_order = Order::limit_buy("ABC", 20.0, 103.00, "exchange"); + orderbook.insert_order(second_order, 100); + + let res = orderbook.execute_orders("es, &trades, 100); + println!("{:?}", res); + assert!(res.len() == 1); + } + + #[test] + fn test_that_latency_model_filters_orders() { let bid_level = Level { price: 98.0, size: 20.0, @@ -610,26 +1154,77 @@ mod tests { size: 20.0, }; - let mut depth = Depth::new(100, "ABC"); - depth.add_level(bid_level, crate::input::athena::Side::Bid); - depth.add_level(ask_level, crate::input::athena::Side::Ask); + let mut depth = Depth::new(100, "ABC", "exchange"); + depth.add_level(bid_level.clone(), Side::Bid); + depth.add_level(ask_level.clone(), Side::Ask); + + let mut depth_101 = Depth::new(101, "ABC", "exchange"); + depth_101.add_level(bid_level.clone(), Side::Bid); + depth_101.add_level(ask_level.clone(), Side::Ask); + + let mut depth_102 = Depth::new(102, "ABC", "exchange"); + depth_102.add_level(bid_level, Side::Bid); + depth_102.add_level(ask_level, Side::Ask); + + let mut quotes: DateDepth = BTreeMap::new(); + quotes.insert("exchange".to_string(), BTreeMap::new()); + let exchange = quotes.get_mut("exchange").unwrap(); + exchange.insert("ABC".to_string(), depth); + exchange.insert("ABC".to_string(), depth_101); + exchange.insert("ABC".to_string(), depth_102); + + let bid_trade = Trade { + coin: "ABC".to_string(), + side: Side::Bid, + px: 98.0, + sz: 20.0, + time: 100, + exchange: "exchange".to_string(), + }; + let ask_trade = Trade { + coin: "ABC".to_string(), + side: Side::Ask, + px: 102.0, + sz: 20.0, + time: 100, + exchange: "exchange".to_string(), + }; - let mut quotes: DateQuotes = HashMap::new(); - quotes.insert("ABC".to_string(), depth); + let mut trades: DateTrade = BTreeMap::new(); + trades.insert(100, vec![bid_trade, ask_trade]); + let mut orderbook = OrderBook::with_latency(1); + let order = Order::limit_buy("ABC", 20.0, 103.00, "exchange"); + orderbook.insert_order(order, 100); + + let trades_100 = orderbook.execute_orders("es, &trades, 100); + let trades_101 = orderbook.execute_orders("es, &trades, 101); + let trades_102 = orderbook.execute_orders("es, &trades, 102); + + println!("{:?}", trades_101); + + assert!(trades_100.is_empty()); + assert!(trades_101.is_empty()); + assert!(trades_102.len() == 1); + } + + #[test] + fn test_that_orderbook_clears_after_execution() { + let quotes = quotes1(); + let trades = trades1(); let mut orderbook = OrderBook::new(); - let first_order = Order::limit_buy("ABC", 20.0, 103.00, 100); - orderbook.insert_order(first_order); - let second_order = Order::limit_buy("ABC", 20.0, 103.00, 100); - orderbook.insert_order(second_order); + let order = Order::market_buy("ABC", 20.0, "exchange"); + orderbook.insert_order(order, 100); - let res = orderbook.execute_orders("es, 100); - println!("{:?}", res); - assert!(res.len() == 1); + let completed_trades = orderbook.execute_orders("es, &trades, 100); + let completed_trades1 = orderbook.execute_orders("es, &trades, 101); + + assert!(completed_trades.len() == 1); + assert!(completed_trades1.is_empty()); } #[test] - fn test_that_latency_model_filters_orders() { + fn test_that_order_id_is_incrementing_and_unique() { let bid_level = Level { price: 98.0, size: 20.0, @@ -640,32 +1235,167 @@ mod tests { size: 20.0, }; - let mut depth = Depth::new(100, "ABC"); - depth.add_level(bid_level.clone(), crate::input::athena::Side::Bid); - depth.add_level(ask_level.clone(), crate::input::athena::Side::Ask); + let mut depth = Depth::new(100, "ABC", "exchange"); + depth.add_level(bid_level.clone(), Side::Bid); + depth.add_level(ask_level.clone(), Side::Ask); + + let mut depth_101 = Depth::new(101, "ABC", "exchange"); + depth_101.add_level(bid_level.clone(), Side::Bid); + depth_101.add_level(ask_level.clone(), Side::Ask); + + let mut depth_102 = Depth::new(102, "ABC", "exchange"); + depth_102.add_level(bid_level, Side::Bid); + depth_102.add_level(ask_level, Side::Ask); + + let mut quotes: DateDepth = BTreeMap::new(); + quotes.insert("exchange".to_string(), BTreeMap::new()); + let exchange = quotes.get_mut("exchange").unwrap(); + exchange.insert("ABC".to_string(), depth); + exchange.insert("ABC".to_string(), depth_101); + exchange.insert("ABC".to_string(), depth_102); + + let bid_trade = Trade { + coin: "ABC".to_string(), + side: Side::Bid, + px: 98.0, + sz: 20.0, + time: 100, + exchange: "exchange".to_string(), + }; + let ask_trade = Trade { + coin: "ABC".to_string(), + side: Side::Ask, + px: 102.0, + sz: 20.0, + time: 100, + exchange: "exchange".to_string(), + }; - let mut depth_101 = Depth::new(101, "ABC"); - depth_101.add_level(bid_level.clone(), crate::input::athena::Side::Bid); - depth_101.add_level(ask_level.clone(), crate::input::athena::Side::Ask); + let mut trades: DateTrade = BTreeMap::new(); + trades.insert(100, vec![bid_trade, ask_trade]); - let mut depth_102 = Depth::new(102, "ABC"); - depth_102.add_level(bid_level, crate::input::athena::Side::Bid); - depth_102.add_level(ask_level, crate::input::athena::Side::Ask); + let mut orderbook = OrderBook::new(); + let order = Order::limit_buy("ABC", 20.0, 103.00, "exchange"); + let order1 = Order::limit_buy("ABC", 20.0, 103.00, "exchange"); + let order2 = Order::limit_buy("ABC", 20.0, 103.00, "exchange"); - let mut quotes: DateQuotes = HashMap::new(); - quotes.insert("ABC".to_string(), depth); - quotes.insert("ABC".to_string(), depth_101); - quotes.insert("ABC".to_string(), depth_102); + let res = orderbook.insert_order(order, 100); + let res1 = orderbook.insert_order(order1, 100); + let _ = orderbook.execute_orders("es, &trades, 100); - let mut orderbook = OrderBook::with_latency(1); - let order = Order::limit_buy("ABC", 20.0, 103.00, 100); - orderbook.insert_order(order); + let res2 = orderbook.insert_order(order2, 101); + let _ = orderbook.execute_orders("es, &trades, 101); - let trades_100 = orderbook.execute_orders("es, 100); - let trades_101 = orderbook.execute_orders("es, 101); - let trades_102 = orderbook.execute_orders("es, 102); - assert!(trades_100.is_empty()); - assert!(trades_101.is_empty()); - assert!(trades_102.len() == 1); + assert!(res.order_id == 0); + assert!(res1.order_id == 1); + assert!(res2.order_id == 2); + } + + #[test] + fn test_that_volume_lifts_with_trades_inside() { + let bid_level = Level { + price: 98.0, + size: 100.0, + }; + + let ask_level = Level { + price: 102.0, + size: 100.0, + }; + + let mut depth = Depth::new(100, "ABC", "exchange"); + depth.add_level(bid_level.clone(), Side::Bid); + depth.add_level(ask_level.clone(), Side::Ask); + + let mut quotes: DateDepth = BTreeMap::new(); + quotes.insert("exchange".to_string(), BTreeMap::new()); + quotes + .get_mut("exchange") + .unwrap() + .insert("ABC".to_string(), depth); + + let bid_trade = Trade { + coin: "ABC".to_string(), + side: Side::Bid, + px: 98.0, + sz: 20.0, + time: 100, + exchange: "exchange".to_string(), + }; + let ask_trade = Trade { + coin: "ABC".to_string(), + side: Side::Ask, + px: 102.0, + sz: 20.0, + time: 100, + exchange: "exchange".to_string(), + }; + + let mut trades: DateTrade = BTreeMap::new(); + trades.insert(100, vec![bid_trade, ask_trade]); + + let mut orderbook = OrderBook::new(); + let buy_order = Order::limit_buy("ABC", 10.0, 98.00, "exchange"); + orderbook.insert_order(buy_order, 99); + let sell_order = Order::limit_sell("ABC", 10.0, 102.00, "exchange"); + orderbook.insert_order(sell_order, 99); + + let res = orderbook.execute_orders("es, &trades, 100); + assert!(res.len() == 2); + } + + #[test] + fn test_that_fills_only_traded_volume_on_inside() { + let bid_level = Level { + price: 98.0, + size: 100.0, + }; + + let ask_level = Level { + price: 102.0, + size: 100.0, + }; + + let mut depth = Depth::new(100, "ABC", "exchange"); + depth.add_level(bid_level.clone(), Side::Bid); + depth.add_level(ask_level.clone(), Side::Ask); + + let mut quotes: DateDepth = BTreeMap::new(); + quotes.insert("exchange".to_string(), BTreeMap::new()); + quotes + .get_mut("exchange") + .unwrap() + .insert("ABC".to_string(), depth); + + let bid_trade = Trade { + coin: "ABC".to_string(), + side: Side::Bid, + px: 98.0, + sz: 20.0, + time: 100, + exchange: "exchange".to_string(), + }; + let ask_trade = Trade { + coin: "ABC".to_string(), + side: Side::Ask, + px: 102.0, + sz: 20.0, + time: 100, + exchange: "exchange".to_string(), + }; + + let mut trades: DateTrade = BTreeMap::new(); + trades.insert(100, vec![bid_trade, ask_trade]); + + let mut orderbook = OrderBook::new(); + let buy_order = Order::limit_buy("ABC", 40.0, 98.00, "exchange"); + orderbook.insert_order(buy_order, 99); + let sell_order = Order::limit_sell("ABC", 40.0, 102.00, "exchange"); + orderbook.insert_order(sell_order, 99); + + let res = orderbook.execute_orders("es, &trades, 100); + assert!(res.len() == 2); + assert!(res.first().unwrap().quantity == 20.0); + assert!(res.get(1).unwrap().quantity == 20.0); } } diff --git a/rotala/src/http/jura_v1.rs b/rotala/src/http/jura_v1.rs deleted file mode 100644 index 0fb5b938..00000000 --- a/rotala/src/http/jura_v1.rs +++ /dev/null @@ -1,518 +0,0 @@ -use std::collections::HashMap; - -use crate::{ - exchange::jura_v1::{Fill, JuraV1, Order, OrderId}, - input::penelope::{Penelope, PenelopeQuoteByDate}, -}; - -type BacktestId = u64; - -pub struct BacktestState { - pub id: BacktestId, - pub date: i64, - pub pos: usize, - pub exchange: JuraV1, - pub dataset_name: String, -} - -pub struct AppState { - pub backtests: HashMap, - pub last: BacktestId, - pub datasets: HashMap, -} - -pub type TickResult = (bool, Vec, Vec, Vec); - -impl AppState { - pub fn create(datasets: &mut HashMap) -> Self { - Self { - backtests: HashMap::new(), - last: 0, - datasets: std::mem::take(datasets), - } - } - - pub fn single(name: &str, data: Penelope) -> Self { - let exchange = JuraV1::new(); - let backtest = BacktestState { - id: 0, - date: *data.get_date(0).unwrap(), - pos: 0, - exchange, - dataset_name: name.into(), - }; - - let mut datasets = HashMap::new(); - datasets.insert(name.into(), data); - - let mut backtests = HashMap::new(); - backtests.insert(0, backtest); - - Self { - backtests, - last: 1, - datasets, - } - } - - pub fn tick(&mut self, backtest_id: BacktestId) -> Option { - if let Some(backtest) = self.backtests.get_mut(&backtest_id) { - if let Some(dataset) = self.datasets.get(&backtest.dataset_name) { - let mut has_next = false; - let mut fills = Vec::new(); - let mut orders = Vec::new(); - let mut order_ids = Vec::new(); - - if let Some(quotes) = dataset.get_quotes(&backtest.date) { - let mut res = backtest.exchange.tick(quotes); - fills.append(&mut res.0); - orders.append(&mut res.1); - order_ids.append(&mut res.2); - } - - let new_pos = backtest.pos + 1; - if dataset.has_next(new_pos) { - has_next = true; - backtest.date = *dataset.get_date(new_pos).unwrap(); - } - - return Some((has_next, fills, orders, order_ids)); - } - } - None - } - - pub fn fetch_quotes(&self, backtest_id: BacktestId) -> Option<&PenelopeQuoteByDate> { - if let Some(backtest) = self.backtests.get(&backtest_id) { - if let Some(dataset) = self.datasets.get(&backtest.dataset_name) { - return dataset.get_quotes(&backtest.date); - } - } - None - } - - pub fn init(&mut self, dataset_name: String) -> Option { - if let Some(dataset) = self.datasets.get(&dataset_name) { - let new_id = self.last + 1; - let exchange = JuraV1::new(); - let backtest = BacktestState { - id: new_id, - date: *dataset.get_date(0).unwrap(), - pos: 0, - exchange, - dataset_name, - }; - self.backtests.insert(new_id, backtest); - return Some(new_id); - } - None - } - - pub fn insert_order(&mut self, order: Order, backtest_id: BacktestId) -> Option<()> { - if let Some(backtest) = self.backtests.get_mut(&backtest_id) { - backtest.exchange.insert_order(order); - return Some(()); - } - None - } - - pub fn delete_order( - &mut self, - asset: u64, - order_id: OrderId, - backtest_id: BacktestId, - ) -> Option<()> { - if let Some(backtest) = self.backtests.get_mut(&backtest_id) { - backtest.exchange.delete_order(asset, order_id); - return Some(()); - } - None - } - - pub fn new_backtest(&mut self, dataset_name: &str) -> Option { - let new_id = self.last + 1; - - // Check that dataset exists - if let Some(dataset) = self.datasets.get(dataset_name) { - let exchange = JuraV1::new(); - - let backtest = BacktestState { - id: new_id, - date: *dataset.get_date(0).unwrap(), - pos: 0, - exchange, - dataset_name: dataset_name.into(), - }; - - self.backtests.insert(new_id, backtest); - - self.last = new_id; - return Some(new_id); - } - None - } -} - -pub mod jurav1_client { - - use std::future::Future; - - use anyhow::Result; - - use super::{ - jurav1_server::{ - DeleteOrderRequest, FetchQuotesResponse, InfoResponse, InitResponse, - InsertOrderRequest, TickResponse, - }, - BacktestId, - }; - - use crate::exchange::jura_v1::{Order, OrderId}; - - pub trait JuraClient { - fn tick(&mut self, backtest_id: BacktestId) -> impl Future>; - fn delete_order( - &mut self, - asset: u64, - order_id: OrderId, - backtest_id: BacktestId, - ) -> impl Future>; - fn insert_order( - &mut self, - order: Order, - backtest_id: BacktestId, - ) -> impl Future>; - fn fetch_quotes( - &mut self, - backtest_id: BacktestId, - ) -> impl Future>; - fn init(&mut self, dataset_name: String) -> impl Future>; - fn info(&mut self, backtest_id: BacktestId) -> impl Future>; - } - - pub struct Client { - pub path: String, - pub client: reqwest::Client, - } - - impl JuraClient for Client { - async fn tick(&mut self, backtest_id: BacktestId) -> Result { - Ok(self - .client - .get(self.path.clone() + format!("/backtest/{backtest_id}/tick").as_str()) - .send() - .await? - .json::() - .await?) - } - - async fn delete_order( - &mut self, - asset: u64, - order_id: OrderId, - backtest_id: BacktestId, - ) -> Result<()> { - let req = DeleteOrderRequest { asset, order_id }; - Ok(self - .client - .post(self.path.clone() + format!("/backtest/{backtest_id}/delete_order").as_str()) - .json(&req) - .send() - .await? - .json::<()>() - .await?) - } - - async fn insert_order(&mut self, order: Order, backtest_id: BacktestId) -> Result<()> { - let req = InsertOrderRequest { order }; - Ok(self - .client - .post(self.path.clone() + format!("/backtest/{backtest_id}/insert_order").as_str()) - .json(&req) - .send() - .await? - .json::<()>() - .await?) - } - - async fn fetch_quotes(&mut self, backtest_id: BacktestId) -> Result { - Ok(self - .client - .get(self.path.clone() + format!("/backtest/{backtest_id}/fetch_quotes").as_str()) - .send() - .await? - .json::() - .await?) - } - - async fn init(&mut self, dataset_name: String) -> Result { - Ok(self - .client - .get(self.path.clone() + format!("/init/{dataset_name}").as_str()) - .send() - .await? - .json::() - .await?) - } - - async fn info(&mut self, backtest_id: BacktestId) -> Result { - Ok(self - .client - .get(self.path.clone() + format!("/backtest/{backtest_id}/info").as_str()) - .send() - .await? - .json::() - .await?) - } - } - - impl Client { - pub fn new(path: String) -> Self { - Self { - path, - client: reqwest::Client::new(), - } - } - } -} - -pub mod jurav1_server { - use serde::{Deserialize, Serialize}; - use std::sync::Mutex; - - use crate::exchange::jura_v1::{Fill, Order, OrderId}; - use crate::input::penelope::PenelopeQuoteByDate; - use actix_web::{ - error, get, post, - web::{self, Path}, - Result, - }; - use derive_more::{Display, Error}; - - use super::AppState; - - type BacktestId = u64; - pub type JuraState = Mutex; - - #[derive(Debug, Display, Error)] - pub enum JuraV1Error { - UnknownBacktest, - UnknownDataset, - } - - impl error::ResponseError for JuraV1Error { - fn status_code(&self) -> actix_web::http::StatusCode { - match self { - JuraV1Error::UnknownBacktest => actix_web::http::StatusCode::BAD_REQUEST, - JuraV1Error::UnknownDataset => actix_web::http::StatusCode::BAD_REQUEST, - } - } - } - - #[derive(Debug, Deserialize, Serialize)] - pub struct TickResponse { - pub has_next: bool, - pub executed_trades: Vec, - pub inserted_orders: Vec, - } - - #[get("/backtest/{backtest_id}/tick")] - pub async fn tick( - app: web::Data, - path: web::Path<(BacktestId,)>, - ) -> Result, JuraV1Error> { - let mut jura = app.lock().unwrap(); - let (backtest_id,) = path.into_inner(); - - if let Some(result) = jura.tick(backtest_id) { - Ok(web::Json(TickResponse { - inserted_orders: result.2, - executed_trades: result.1, - has_next: result.0, - })) - } else { - Err(JuraV1Error::UnknownBacktest) - } - } - - #[derive(Debug, Deserialize, Serialize)] - pub struct DeleteOrderRequest { - pub asset: u64, - pub order_id: OrderId, - } - - #[post("/backtest/{backtest_id}/delete_order")] - pub async fn delete_order( - app: web::Data, - path: web::Path<(BacktestId,)>, - delete_order: web::Json, - ) -> Result, JuraV1Error> { - let mut jura = app.lock().unwrap(); - let (backtest_id,) = path.into_inner(); - - if let Some(()) = jura.delete_order(delete_order.asset, delete_order.order_id, backtest_id) - { - Ok(web::Json(())) - } else { - Err(JuraV1Error::UnknownBacktest) - } - } - - #[derive(Debug, Deserialize, Serialize)] - pub struct InsertOrderRequest { - pub order: Order, - } - - #[post("/backtest/{backtest_id}/insert_order")] - pub async fn insert_order( - app: web::Data, - path: Path<(BacktestId,)>, - insert_order: web::Json, - ) -> Result, JuraV1Error> { - let mut jura = app.lock().unwrap(); - let (backtest_id,) = path.into_inner(); - if let Some(()) = jura.insert_order(insert_order.order.clone(), backtest_id) { - Ok(web::Json(())) - } else { - Err(JuraV1Error::UnknownBacktest) - } - } - - #[derive(Debug, Deserialize, Serialize)] - pub struct FetchQuotesResponse { - pub quotes: PenelopeQuoteByDate, - } - - #[get("/backtest/{backtest_id}/fetch_quotes")] - pub async fn fetch_quotes( - app: web::Data, - path: Path<(BacktestId,)>, - ) -> Result, JuraV1Error> { - let jura = app.lock().unwrap(); - let (backtest_id,) = path.into_inner(); - - if let Some(quotes) = jura.fetch_quotes(backtest_id) { - Ok(web::Json(FetchQuotesResponse { - quotes: quotes.clone(), - })) - } else { - Err(JuraV1Error::UnknownBacktest) - } - } - - #[derive(Debug, Deserialize, Serialize)] - pub struct InitResponse { - pub backtest_id: BacktestId, - } - - #[get("/init/{dataset_name}")] - pub async fn init( - app: web::Data, - path: Path<(String,)>, - ) -> Result, JuraV1Error> { - let mut jura = app.lock().unwrap(); - let (dataset_name,) = path.into_inner(); - - if let Some(backtest_id) = jura.init(dataset_name) { - Ok(web::Json(InitResponse { backtest_id })) - } else { - Err(JuraV1Error::UnknownDataset) - } - } - - #[derive(Debug, Deserialize, Serialize)] - pub struct InfoResponse { - pub version: String, - pub dataset: String, - } - - #[get("/backtest/{backtest_id}/info")] - pub async fn info( - app: web::Data, - path: Path<(BacktestId,)>, - ) -> Result, JuraV1Error> { - let jura = app.lock().unwrap(); - let (backtest_id,) = path.into_inner(); - - if let Some(resp) = jura.backtests.get(&backtest_id) { - Ok(web::Json(InfoResponse { - version: "v1".to_string(), - dataset: resp.dataset_name.clone(), - })) - } else { - Err(JuraV1Error::UnknownBacktest) - } - } -} - -#[cfg(test)] -mod tests { - use actix_web::{test, web, App}; - - use super::jurav1_server::*; - use super::AppState; - use crate::exchange::jura_v1::Order; - use crate::input::penelope::Penelope; - - use std::sync::Mutex; - - #[actix_web::test] - async fn test_single_trade_loop() { - let jura = Penelope::random(100, vec!["0"]); - let dataset_name = "fake"; - let state = AppState::single(dataset_name, jura); - - let app_state = Mutex::new(state); - let jura_state = web::Data::new(app_state); - - let app = test::init_service( - App::new() - .app_data(jura_state) - .service(info) - .service(init) - .service(fetch_quotes) - .service(tick) - .service(insert_order) - .service(delete_order), - ) - .await; - - let req = test::TestRequest::get() - .uri(format!("/init/{dataset_name}").as_str()) - .to_request(); - let resp: InitResponse = test::call_and_read_body_json(&app, req).await; - - let backtest_id = resp.backtest_id; - - let req1 = test::TestRequest::get() - .uri(format!("/backtest/{backtest_id}/fetch_quotes").as_str()) - .to_request(); - let _resp1: FetchQuotesResponse = test::call_and_read_body_json(&app, req1).await; - - let req2 = test::TestRequest::get() - .uri(format!("/backtest/{backtest_id}/tick").as_str()) - .to_request(); - let _resp2: TickResponse = test::call_and_read_body_json(&app, req2).await; - - let req3 = test::TestRequest::post() - .set_json(InsertOrderRequest { - order: Order::market_buy(0, "100.0", "90.00"), - }) - .uri(format!("/backtest/{backtest_id}/insert_order").as_str()) - .to_request(); - test::call_and_read_body(&app, req3).await; - - let req4 = test::TestRequest::get() - .uri(format!("/backtest/{backtest_id}/tick").as_str()) - .to_request(); - let _resp4: TickResponse = test::call_and_read_body_json(&app, req4).await; - - let req5 = test::TestRequest::get() - .uri(format!("/backtest/{backtest_id}/tick").as_str()) - .to_request(); - let resp5: TickResponse = test::call_and_read_body_json(&app, req5).await; - - assert!(resp5.executed_trades.len() == 1); - assert!(resp5.executed_trades.first().unwrap().coin == "0") - } -} diff --git a/rotala/src/http/uist_v2.rs b/rotala/src/http/uist_v2.rs deleted file mode 100644 index 4bb3859b..00000000 --- a/rotala/src/http/uist_v2.rs +++ /dev/null @@ -1,579 +0,0 @@ -use std::collections::HashMap; -use std::future::{self, Future}; -use std::sync::Mutex; - -use anyhow::{Error, Result}; -use serde::{Deserialize, Serialize}; - -use crate::exchange::uist_v2::{Order, Trade, UistV2}; -use crate::input::athena::{Athena, DateQuotes}; - -type BacktestId = u64; - -pub struct BacktestState { - pub id: BacktestId, - pub date: i64, - pub pos: usize, - pub exchange: UistV2, - pub dataset_name: String, -} - -pub struct AppState { - pub backtests: HashMap, - pub last: BacktestId, - pub datasets: HashMap, -} - -impl AppState { - pub fn create(datasets: &mut HashMap) -> Self { - Self { - backtests: HashMap::new(), - last: 0, - datasets: std::mem::take(datasets), - } - } - - pub fn single(name: &str, data: Athena) -> Self { - let exchange = UistV2::new(); - let backtest = BacktestState { - id: 0, - date: *data.get_date(0).unwrap(), - pos: 0, - exchange, - dataset_name: name.into(), - }; - - let mut datasets = HashMap::new(); - datasets.insert(name.into(), data); - - let mut backtests = HashMap::new(); - backtests.insert(0, backtest); - - Self { - backtests, - last: 1, - datasets, - } - } - - pub fn tick(&mut self, backtest_id: BacktestId) -> Option<(bool, Vec, Vec)> { - if let Some(backtest) = self.backtests.get_mut(&backtest_id) { - if let Some(dataset) = self.datasets.get(&backtest.dataset_name) { - let mut has_next = false; - let mut executed_trades = Vec::new(); - let mut inserted_orders = Vec::new(); - - if let Some(quotes) = dataset.get_quotes(&backtest.date) { - let mut res = backtest.exchange.tick(quotes, backtest.date); - executed_trades.append(&mut res.0); - inserted_orders.append(&mut res.1); - } - - let new_pos = backtest.pos + 1; - if dataset.has_next(new_pos) { - has_next = true; - backtest.date = *dataset.get_date(new_pos).unwrap(); - } - backtest.pos = new_pos; - return Some((has_next, executed_trades, inserted_orders)); - } - } - None - } - - pub fn fetch_quotes(&self, backtest_id: BacktestId) -> Option<&DateQuotes> { - if let Some(backtest) = self.backtests.get(&backtest_id) { - if let Some(dataset) = self.datasets.get(&backtest.dataset_name) { - return dataset.get_quotes(&backtest.date); - } - } - None - } - - pub fn init(&mut self, dataset_name: String) -> Option { - if let Some(dataset) = self.datasets.get(&dataset_name) { - let new_id = self.last + 1; - let exchange = UistV2::new(); - let backtest = BacktestState { - id: new_id, - date: *dataset.get_date(0).unwrap(), - pos: 0, - exchange, - dataset_name, - }; - self.backtests.insert(new_id, backtest); - return Some(new_id); - } - None - } - - pub fn insert_order(&mut self, order: Order, backtest_id: BacktestId) -> Option<()> { - if let Some(backtest) = self.backtests.get_mut(&backtest_id) { - backtest.exchange.insert_order(order); - return Some(()); - } - None - } - - pub fn new_backtest(&mut self, dataset_name: &str) -> Option { - let new_id = self.last + 1; - - // Check that dataset exists - if let Some(dataset) = self.datasets.get(dataset_name) { - let exchange = UistV2::new(); - - let backtest = BacktestState { - id: new_id, - date: *dataset.get_date(0).unwrap(), - pos: 0, - exchange, - dataset_name: dataset_name.into(), - }; - - self.backtests.insert(new_id, backtest); - - self.last = new_id; - return Some(new_id); - } - None - } -} - -#[derive(Debug, Deserialize, Serialize)] -pub struct TickResponse { - pub has_next: bool, - pub executed_trades: Vec, - pub inserted_orders: Vec, -} - -#[derive(Debug, Deserialize, Serialize)] -pub struct InsertOrderRequest { - pub order: Order, -} - -#[derive(Debug, Deserialize, Serialize)] -pub struct FetchQuotesResponse { - pub quotes: DateQuotes, -} - -#[derive(Debug, Deserialize, Serialize)] -pub struct InitResponse { - pub backtest_id: BacktestId, -} - -#[derive(Debug, Deserialize, Serialize)] -pub struct InfoResponse { - pub version: String, - pub dataset: String, -} - -#[derive(Debug, Deserialize, Serialize)] -pub struct NowResponse { - pub now: i64, - pub has_next: bool, -} - -#[derive(Debug)] -pub enum UistV2Error { - UnknownBacktest, - UnknownDataset, -} - -impl std::error::Error for UistV2Error {} - -impl core::fmt::Display for UistV2Error { - fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { - match self { - UistV2Error::UnknownBacktest => write!(f, "UnknownBacktest"), - UistV2Error::UnknownDataset => write!(f, "UnknownDataset"), - } - } -} - -impl actix_web::ResponseError for UistV2Error { - fn status_code(&self) -> actix_web::http::StatusCode { - match self { - UistV2Error::UnknownBacktest => actix_web::http::StatusCode::BAD_REQUEST, - UistV2Error::UnknownDataset => actix_web::http::StatusCode::BAD_REQUEST, - } - } -} - -pub trait Client { - fn tick(&mut self, backtest_id: BacktestId) -> impl Future>; - fn insert_order( - &mut self, - order: Order, - backtest_id: BacktestId, - ) -> impl Future>; - fn fetch_quotes( - &mut self, - backtest_id: BacktestId, - ) -> impl Future>; - fn init(&mut self, dataset_name: String) -> impl Future>; - fn info(&mut self, backtest_id: BacktestId) -> impl Future>; - fn now(&mut self, backtest_id: BacktestId) -> impl Future>; -} - -pub struct TestClient { - state: AppState, -} - -impl Client for TestClient { - fn init(&mut self, dataset_name: String) -> impl Future> { - if let Some(id) = self.state.init(dataset_name) { - future::ready(Ok(InitResponse { backtest_id: id })) - } else { - future::ready(Err(Error::new(UistV2Error::UnknownDataset))) - } - } - - fn tick(&mut self, backtest_id: BacktestId) -> impl Future> { - if let Some(resp) = self.state.tick(backtest_id) { - future::ready(Ok(TickResponse { - inserted_orders: resp.2, - executed_trades: resp.1, - has_next: resp.0, - })) - } else { - future::ready(Err(Error::new(UistV2Error::UnknownBacktest))) - } - } - - fn insert_order( - &mut self, - order: Order, - backtest_id: BacktestId, - ) -> impl Future> { - if let Some(()) = self.state.insert_order(order, backtest_id) { - future::ready(Ok(())) - } else { - future::ready(Err(Error::new(UistV2Error::UnknownBacktest))) - } - } - - fn fetch_quotes( - &mut self, - backtest_id: BacktestId, - ) -> impl Future> { - if let Some(quotes) = self.state.fetch_quotes(backtest_id) { - future::ready(Ok(FetchQuotesResponse { - quotes: quotes.to_owned(), - })) - } else { - future::ready(Err(Error::new(UistV2Error::UnknownBacktest))) - } - } - - fn info(&mut self, backtest_id: BacktestId) -> impl Future> { - if let Some(backtest) = self.state.backtests.get(&backtest_id) { - future::ready(Ok(InfoResponse { - version: "v1".to_string(), - dataset: backtest.dataset_name.clone(), - })) - } else { - future::ready(Err(Error::new(UistV2Error::UnknownBacktest))) - } - } - - fn now(&mut self, backtest_id: BacktestId) -> impl Future> { - if let Some(backtest) = self.state.backtests.get(&backtest_id) { - if let Some(dataset) = self.state.datasets.get(&backtest.dataset_name) { - let now = backtest.date; - let mut has_next = false; - if dataset.has_next(backtest.pos) { - has_next = true; - } - future::ready(Ok(NowResponse { now, has_next })) - } else { - future::ready(Err(Error::new(UistV2Error::UnknownDataset))) - } - } else { - future::ready(Err(Error::new(UistV2Error::UnknownBacktest))) - } - } -} - -impl TestClient { - pub fn single(name: &str, data: Athena) -> Self { - Self { - state: AppState::single(name, data), - } - } -} - -#[derive(Debug)] -pub struct HttpClient { - pub path: String, - pub client: reqwest::Client, -} - -impl Client for HttpClient { - async fn tick(&mut self, backtest_id: BacktestId) -> Result { - Ok(self - .client - .get(self.path.clone() + format!("/backtest/{backtest_id}/tick").as_str()) - .send() - .await? - .json::() - .await?) - } - - async fn insert_order(&mut self, order: Order, backtest_id: BacktestId) -> Result<()> { - let req = InsertOrderRequest { order }; - Ok(self - .client - .post(self.path.clone() + format!("/backtest/{backtest_id}/insert_order").as_str()) - .json(&req) - .send() - .await? - .json::<()>() - .await?) - } - - async fn fetch_quotes(&mut self, backtest_id: BacktestId) -> Result { - Ok(self - .client - .get(self.path.clone() + format!("/backtest/{backtest_id}/fetch_quotes").as_str()) - .send() - .await? - .json::() - .await?) - } - - async fn init(&mut self, dataset_name: String) -> Result { - Ok(self - .client - .get(self.path.clone() + format!("/init/{dataset_name}").as_str()) - .send() - .await? - .json::() - .await?) - } - - async fn info(&mut self, backtest_id: BacktestId) -> Result { - Ok(self - .client - .get(self.path.clone() + format!("/backtest/{backtest_id}/info").as_str()) - .send() - .await? - .json::() - .await?) - } - - async fn now(&mut self, backtest_id: BacktestId) -> Result { - Ok(self - .client - .get(self.path.clone() + format!("/backtest/{backtest_id}/now").as_str()) - .send() - .await? - .json::() - .await?) - } -} - -impl HttpClient { - pub fn new(path: String) -> Self { - Self { - path, - client: reqwest::Client::new(), - } - } -} - -type UistState = Mutex; - -mod server { - use actix_web::{get, post, web}; - - use super::{ - BacktestId, FetchQuotesResponse, InfoResponse, InitResponse, InsertOrderRequest, - NowResponse, TickResponse, UistState, UistV2Error, - }; - - #[get("/backtest/{backtest_id}/tick")] - pub async fn tick( - app: web::Data, - path: web::Path<(BacktestId,)>, - ) -> Result, UistV2Error> { - let mut uist = app.lock().unwrap(); - let (backtest_id,) = path.into_inner(); - - if let Some(result) = uist.tick(backtest_id) { - Ok(web::Json(TickResponse { - inserted_orders: result.2, - executed_trades: result.1, - has_next: result.0, - })) - } else { - Err(UistV2Error::UnknownBacktest) - } - } - - #[post("/backtest/{backtest_id}/insert_order")] - pub async fn insert_order( - app: web::Data, - path: web::Path<(BacktestId,)>, - insert_order: web::Json, - ) -> Result, UistV2Error> { - let mut uist = app.lock().unwrap(); - let (backtest_id,) = path.into_inner(); - if let Some(()) = uist.insert_order(insert_order.order.clone(), backtest_id) { - Ok(web::Json(())) - } else { - Err(UistV2Error::UnknownBacktest) - } - } - - #[get("/backtest/{backtest_id}/fetch_quotes")] - pub async fn fetch_quotes( - app: web::Data, - path: web::Path<(BacktestId,)>, - ) -> Result, UistV2Error> { - let uist = app.lock().unwrap(); - let (backtest_id,) = path.into_inner(); - - if let Some(quotes) = uist.fetch_quotes(backtest_id) { - Ok(web::Json(FetchQuotesResponse { - quotes: quotes.clone(), - })) - } else { - Err(UistV2Error::UnknownBacktest) - } - } - - #[get("/init/{dataset_name}")] - pub async fn init( - app: web::Data, - path: web::Path<(String,)>, - ) -> Result, UistV2Error> { - let mut uist = app.lock().unwrap(); - let (dataset_name,) = path.into_inner(); - - if let Some(backtest_id) = uist.init(dataset_name) { - Ok(web::Json(InitResponse { backtest_id })) - } else { - Err(UistV2Error::UnknownDataset) - } - } - - #[get("/backtest/{backtest_id}/info")] - pub async fn info( - app: web::Data, - path: web::Path<(BacktestId,)>, - ) -> Result, UistV2Error> { - let uist = app.lock().unwrap(); - let (backtest_id,) = path.into_inner(); - - if let Some(resp) = uist.backtests.get(&backtest_id) { - Ok(web::Json(InfoResponse { - version: "v1".to_string(), - dataset: resp.dataset_name.clone(), - })) - } else { - Err(UistV2Error::UnknownBacktest) - } - } - - #[get("/backtest/{backtest_id}/now")] - pub async fn now( - app: web::Data, - path: web::Path<(BacktestId,)>, - ) -> Result, UistV2Error> { - let uist = app.lock().unwrap(); - let (backtest_id,) = path.into_inner(); - - if let Some(backtest) = uist.backtests.get(&backtest_id) { - let now = backtest.date; - if let Some(dataset) = uist.datasets.get(&backtest.dataset_name) { - let mut has_next = false; - if dataset.has_next(backtest.pos) { - has_next = true; - } - Ok(web::Json(NowResponse { now, has_next })) - } else { - Err(UistV2Error::UnknownDataset) - } - } else { - Err(UistV2Error::UnknownBacktest) - } - } -} - -#[cfg(test)] -mod tests { - use actix_web::{test, web, App}; - - use crate::exchange::uist_v2::Order; - use crate::http::uist_v1::NowResponse; - use crate::input::athena::Athena; - - use super::server::*; - use super::{AppState, FetchQuotesResponse, InitResponse, InsertOrderRequest, TickResponse}; - use std::sync::Mutex; - - #[actix_web::test] - async fn test_single_trade_loop() { - let uist = Athena::random(100, vec!["ABC", "BCD"]); - let dataset_name = "fake"; - let state = AppState::single(dataset_name, uist); - - let app_state = Mutex::new(state); - let uist_state = web::Data::new(app_state); - - let app = test::init_service( - App::new() - .app_data(uist_state) - .service(info) - .service(init) - .service(fetch_quotes) - .service(tick) - .service(insert_order) - .service(now), - ) - .await; - - let req = test::TestRequest::get() - .uri(format!("/init/{dataset_name}").as_str()) - .to_request(); - let resp: InitResponse = test::call_and_read_body_json(&app, req).await; - - let backtest_id = resp.backtest_id; - - let req1 = test::TestRequest::get() - .uri(format!("/backtest/{backtest_id}/fetch_quotes").as_str()) - .to_request(); - let _resp1: FetchQuotesResponse = test::call_and_read_body_json(&app, req1).await; - - let req2 = test::TestRequest::get() - .uri(format!("/backtest/{backtest_id}/tick").as_str()) - .to_request(); - let _resp2: TickResponse = test::call_and_read_body_json(&app, req2).await; - - let now_request = test::TestRequest::get() - .uri(format!("/backtest/{backtest_id}/now").as_str()) - .to_request(); - let now_response: NowResponse = test::call_and_read_body_json(&app, now_request).await; - - let req3 = test::TestRequest::post() - .set_json(InsertOrderRequest { - order: Order::market_buy("ABC", 100.0, now_response.now), - }) - .uri(format!("/backtest/{backtest_id}/insert_order").as_str()) - .to_request(); - test::call_and_read_body(&app, req3).await; - - let req4 = test::TestRequest::get() - .uri(format!("/backtest/{backtest_id}/tick").as_str()) - .to_request(); - let _resp4: TickResponse = test::call_and_read_body_json(&app, req4).await; - - let req5 = test::TestRequest::get() - .uri(format!("/backtest/{backtest_id}/tick").as_str()) - .to_request(); - let resp5: TickResponse = test::call_and_read_body_json(&app, req5).await; - - assert!(resp5.executed_trades.len() == 1); - assert!(resp5.executed_trades.first().unwrap().symbol == "ABC") - } -} diff --git a/rotala/src/input/athena.rs b/rotala/src/input/athena.rs index 966da1bd..0ad1b2e3 100644 --- a/rotala/src/input/athena.rs +++ b/rotala/src/input/athena.rs @@ -1,143 +1,104 @@ #![allow(dead_code)] -use std::collections::HashMap; -use std::{borrow::Borrow, collections::HashSet}; +use std::collections::btree_map::Range; +use std::collections::BTreeMap; +use std::path::Path; use rand::thread_rng; use rand_distr::{Distribution, Uniform}; -use serde::{Deserialize, Serialize}; -#[derive(Clone, Debug, Deserialize, Serialize)] -pub enum Side { - Bid, - Ask, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct Level { - pub price: f64, - pub size: f64, -} +use crate::source::hyperliquid::{get_hyperliquid_l2, DateBBO, DateDepth, Depth, Level, Side}; -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct Depth { - pub bids: Vec, - pub asks: Vec, - pub date: i64, - pub symbol: String, +pub struct Athena { + inner: BTreeMap, } -impl Depth { - pub fn add_level(&mut self, level: Level, side: Side) { - match side { - Side::Bid => { - self.bids.push(level); - self.bids - .sort_by(|x, y| x.price.partial_cmp(&y.price).unwrap().reverse()); +impl Athena { + pub fn get_date_bounds(&self) -> Option<(i64, i64)> { + let first_date = *self.inner.first_key_value().unwrap().0; + let last_date = *self.inner.last_key_value().unwrap().0; + Some((first_date, last_date)) + } + + pub fn get_quotes_between(&self, dates: std::ops::Range) -> Range { + self.inner.range(dates) + } + + pub fn get_best_bid( + &self, + dates: std::ops::Range, + symbol: &str, + exchange: &str, + ) -> Option<&Level> { + let depth_between = self.get_quotes_between(dates); + if let Some(last_depth) = depth_between.last() { + if let Some(exchange_depth) = last_depth.1.get(exchange) { + if let Some(coin_depth) = exchange_depth.get(symbol) { + return coin_depth.get_best_bid(); + } } - Side::Ask => { - self.asks.push(level); - self.asks - .sort_by(|x, y| x.price.partial_cmp(&y.price).unwrap()); + } + None + } + + pub fn get_best_ask( + &self, + dates: std::ops::Range, + symbol: &str, + exchange: &str, + ) -> Option<&Level> { + let depth_between = self.get_quotes_between(dates); + if let Some(last_depth) = depth_between.last() { + if let Some(exchange_depth) = last_depth.1.get(exchange) { + if let Some(coin_depth) = exchange_depth.get(symbol) { + return coin_depth.get_best_ask(); + } } } + None } - pub fn get_best_bid(&self) -> Option<&Level> { - self.bids.first() - } - - pub fn get_best_ask(&self) -> Option<&Level> { - self.asks.first() - } - - pub fn get_bbo(&self) -> Option { - let best_bid = self.get_best_bid()?; - let best_ask = self.get_best_ask()?; - - Some(BBO { - bid: best_bid.price, - bid_volume: best_bid.size, - ask: best_ask.price, - ask_volume: best_ask.size, - symbol: self.symbol.clone(), - date: self.date, - }) - } + pub fn get_bbo(&self, dates: std::ops::Range, exchange: &str) -> Option { + let mut res = BTreeMap::new(); - pub fn new(date: i64, symbol: impl Into) -> Self { - Self { - bids: vec![], - asks: vec![], - date, - symbol: symbol.into(), + let depth_between = self.get_quotes_between(dates); + if let Some(last_depth) = depth_between.last() { + if let Some(exchange_depth) = last_depth.1.get(exchange) { + for (symbol, depth) in exchange_depth { + res.insert(symbol.clone(), depth.get_bbo()?); + } + } } + Some(res) } -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct BBO { - pub bid: f64, - pub bid_volume: f64, - pub ask: f64, - pub ask_volume: f64, - pub symbol: String, - pub date: i64, -} -pub type DateQuotes = HashMap; + pub fn add_depth(&mut self, depth: Depth) { + let date = depth.date; + let symbol = depth.symbol.clone(); + let exchange = depth.exchange.clone(); -pub struct Athena { - dates: Vec, - //TODO: this is not great, added because the dates weren't being added at all, not sure if this - //is really ideal path - dates_seen: HashSet, - inner: HashMap, -} - -impl Athena { - pub fn get_quotes(&self, date: &i64) -> Option<&DateQuotes> { - self.inner.get(date) - } - - fn get_quotes_unchecked(&self, date: &i64) -> &DateQuotes { - self.get_quotes(date).unwrap() - } - - pub fn get_date(&self, pos: usize) -> Option<&i64> { - self.dates.get(pos) - } - - pub fn has_next(&self, pos: usize) -> bool { - self.dates.len() > pos - } - - pub fn get_best_bid(&self, date: impl Borrow, symbol: &str) -> Option<&Level> { - let date_levels = self.inner.get(date.borrow())?; - let depth = date_levels.get(symbol)?; - depth.get_best_bid() - } - - pub fn get_best_ask(&self, date: impl Borrow, symbol: &str) -> Option<&Level> { - let date_levels = self.inner.get(date.borrow())?; - let depth = date_levels.get(symbol)?; - depth.get_best_ask() - } + self.inner.entry(date).or_default(); - pub fn get_bbo(&self, date: impl Borrow, symbol: &str) -> Option { - let date_levels = self.inner.get(date.borrow())?; - let depth = date_levels.get(symbol)?; - depth.get_bbo() + let date_map = self.inner.get_mut(&date).unwrap(); + date_map.entry(exchange.to_string()).or_default(); + date_map.get_mut(&exchange).unwrap().insert(symbol, depth); } - pub fn add_price_level(&mut self, date: i64, symbol: &str, level: Level, side: Side) { + pub fn add_price_level( + &mut self, + date: i64, + symbol: &str, + level: Level, + side: Side, + exchange: &str, + ) { self.inner.entry(date).or_default(); - let symbol_string = symbol.into(); + let date_map = self.inner.get_mut(&date).unwrap(); + date_map.entry(exchange.to_string()).or_default(); - //We will always have a value due to the above block so can unwrap safely - let date_levels = self.inner.get_mut(&date).unwrap(); - if let Some(depth) = date_levels.get_mut(&symbol_string) { + let date_levels = date_map.get_mut(&exchange.to_string()).unwrap(); + if let Some(depth) = date_levels.get_mut(symbol) { depth.add_level(level, side) } else { let depth = match side { @@ -146,21 +107,18 @@ impl Athena { asks: vec![], symbol: symbol.to_string(), date, + exchange: exchange.to_string(), }, Side::Ask => Depth { bids: vec![], asks: vec![level], symbol: symbol.to_string(), date, + exchange: exchange.to_string(), }, }; - date_levels.insert(symbol_string, depth); - } - - if !self.dates_seen.contains(&date) { - self.dates.push(date); - self.dates_seen.insert(date); + date_levels.insert(symbol.to_string(), depth); } } @@ -186,18 +144,27 @@ impl Athena { size: random_size, }; - source.add_price_level(date, symbol, bid_level, Side::Bid); - source.add_price_level(date, symbol, ask_level, Side::Ask); + source.add_price_level(date, symbol, bid_level, Side::Bid, "exchange"); + source.add_price_level(date, symbol, ask_level, Side::Ask, "exchange"); } } source } + pub fn from_file(path: &Path) -> Self { + let hl_source = get_hyperliquid_l2(path); + + let mut athena = Self::new(); + for (_key, value) in hl_source { + let into_depth: Depth = value.into(); + athena.add_depth(into_depth); + } + athena + } + pub fn new() -> Self { Self { - dates: Vec::new(), - inner: HashMap::new(), - dates_seen: HashSet::new(), + inner: BTreeMap::new(), } } } @@ -210,7 +177,9 @@ impl Default for Athena { #[cfg(test)] mod tests { - use super::{Athena, Level, Side}; + use crate::source::hyperliquid::{Level, Side}; + + use super::Athena; #[test] fn test_that_insertions_are_sorted() { @@ -236,13 +205,29 @@ mod tests { size: 100.0, }; - athena.add_price_level(100, "ABC", bid0, Side::Bid); - athena.add_price_level(100, "ABC", ask0, Side::Ask); - - athena.add_price_level(100, "ABC", bid1, Side::Bid); - athena.add_price_level(100, "ABC", ask1, Side::Ask); - - assert_eq!(athena.get_best_bid(100, "ABC").unwrap().price, 101.0); - assert_eq!(athena.get_best_ask(100, "ABC").unwrap().price, 102.0); + athena.add_price_level(100, "ABC", bid0, Side::Bid, "exchange"); + athena.add_price_level(100, "ABC", ask0, Side::Ask, "exchange"); + + athena.add_price_level(100, "ABC", bid1, Side::Bid, "exchange"); + athena.add_price_level(100, "ABC", ask1, Side::Ask, "exchange"); + + println!( + "{:?}", + athena.get_best_bid(100..101, "ABC", "exchange").unwrap() + ); + assert_eq!( + athena + .get_best_bid(100..101, "ABC", "exchange") + .unwrap() + .price, + 101.0 + ); + assert_eq!( + athena + .get_best_ask(100..101, "ABC", "exchange") + .unwrap() + .price, + 102.0 + ); } } diff --git a/rotala/src/input/minerva.rs b/rotala/src/input/minerva.rs new file mode 100644 index 00000000..c46f2707 --- /dev/null +++ b/rotala/src/input/minerva.rs @@ -0,0 +1,213 @@ +#![allow(dead_code)] +use std::collections::{btree_map::Range, BTreeMap, HashMap}; + +use deadpool_postgres::Pool; +use serde_json::Value; +use tokio_pg_mapper::FromTokioPostgresRow; + +use crate::source::hyperliquid::{DateDepth, DateTrade, Depth, Level, Side}; + +#[derive(tokio_pg_mapper::PostgresMapper, Clone, Debug)] +#[pg_mapper(table = "depth")] +pub struct L2Book { + coin: String, + side: bool, + px: String, + sz: String, + time: i64, + exchange: String, + meta: Value, +} + +#[derive(tokio_pg_mapper::PostgresMapper, Clone, Debug)] +#[pg_mapper(table = "trade")] +pub struct Trade { + pub coin: String, + pub side: bool, + pub px: String, + pub sz: String, + pub time: i64, + pub exchange: String, + pub meta: Value, +} + +impl From for crate::source::hyperliquid::Trade { + fn from(value: Trade) -> Self { + let side = if !value.side { Side::Bid } else { Side::Ask }; + + Self { + coin: value.coin, + side, + px: str::parse::(&value.px).unwrap(), + sz: str::parse::(&value.sz).unwrap(), + time: value.time, + exchange: value.exchange, + } + } +} + +impl From> for Depth { + fn from(values: Vec) -> Self { + let mut bids = Vec::with_capacity(5); + let mut asks = Vec::with_capacity(5); + + let date = values.first().unwrap().time; + let symbol = values.first().unwrap().coin.clone(); + let exchange = values.first().unwrap().exchange.clone(); + + for row in values { + match row.side { + false => bids.push(Level { + price: str::parse::(&row.px).unwrap(), + size: str::parse::(&row.sz).unwrap(), + }), + true => asks.push(Level { + price: str::parse::(&row.px).unwrap(), + size: str::parse::(&row.sz).unwrap(), + }), + } + } + + Depth { + bids, + asks, + date, + symbol, + exchange, + } + } +} + +pub struct Minerva { + trades: DateTrade, + depths: BTreeMap, +} + +impl Default for Minerva { + fn default() -> Self { + Self::new() + } +} + +impl Minerva { + pub fn new() -> Self { + Self { + trades: BTreeMap::new(), + depths: BTreeMap::new(), + } + } + + async fn init_depth_between(&mut self, pool: &Pool, dates: &std::ops::Range) { + //Looks weird right now but we need this to work with BTreeMap because we will want to + //cache values rather than send every request to DB + let start_date = dates.start; + let end_date = dates.end; + + if let Ok(client) = pool.get().await { + let query_result = client + .query( + "select * from depth where time between $1 and $2", + &[&start_date, &end_date], + ) + .await; + + let mut sort_into_dates: HashMap>>> = + HashMap::new(); + if let Ok(rows) = query_result { + for row in rows { + if let Ok(book) = L2Book::from_row(row) { + sort_into_dates.entry(book.time).or_default(); + + let date = sort_into_dates.get_mut(&book.time).unwrap(); + + if !date.contains_key(&book.exchange) { + date.insert(book.exchange.clone(), HashMap::new()); + } + + let exchange = date.get_mut(&book.exchange).unwrap(); + + if !exchange.contains_key(&book.coin) { + exchange.insert(book.coin.clone(), Vec::new()); + } + + let coin_date: &mut Vec = exchange.get_mut(&book.coin).unwrap(); + coin_date.push(book); + } + } + } + + for (date, exchange_map) in sort_into_dates.iter_mut() { + for (exchange, coin_map) in exchange_map.iter_mut() { + for (coin, book) in coin_map.iter_mut() { + let depth: Depth = std::mem::take(book).into(); + self.depths.entry(*date).or_default(); + + let date_map = self.depths.get_mut(date).unwrap(); + date_map.entry(exchange.to_string()).or_default(); + date_map + .get_mut(exchange) + .unwrap() + .insert(coin.to_string(), depth); + } + } + } + } + } + + async fn init_trades(&mut self, pool: &Pool, dates: &std::ops::Range) { + let start_date = dates.start; + let end_date = dates.end; + + if let Ok(client) = pool.get().await { + let query_result = client + .query( + "select * from trade where time between $1 and $2", + &[&start_date, &end_date], + ) + .await; + + if let Ok(rows) = query_result { + for row in rows { + if let Ok(trade) = Trade::from_row(row) { + let hl_trade: crate::source::hyperliquid::Trade = trade.into(); + + self.trades.entry(hl_trade.time).or_default(); + + let date_trades = self.trades.get_mut(&hl_trade.time).unwrap(); + date_trades.push(hl_trade); + } + } + } + } + } + + pub async fn get_date_bounds(&self, pool: &Pool) -> Option<(i64, i64)> { + if let Ok(client) = pool.get().await { + let query_result = client + .query("select min(time), max(time) from trade", &[]) + .await; + + if let Ok(rows) = query_result { + let first = rows.first().unwrap(); + return Some((first.get(0), first.get(1))); + }; + } + None + } + + pub async fn get_trades_between( + &self, + dates: std::ops::Range, + ) -> Range> { + self.trades.range(dates) + } + + pub async fn get_depth_between(&self, dates: std::ops::Range) -> Range { + self.depths.range(dates) + } + + pub async fn init_cache(&mut self, pool: &Pool, dates: std::ops::Range) { + self.init_trades(pool, &dates).await; + self.init_depth_between(pool, &dates).await; + } +} diff --git a/rotala/src/input/mod.rs b/rotala/src/input/mod.rs index 9a6bd11d..0836a168 100644 --- a/rotala/src/input/mod.rs +++ b/rotala/src/input/mod.rs @@ -7,4 +7,5 @@ //! Sources should be called through inputs so that clients do not have to marshall data into internal //! types. pub mod athena; +pub mod minerva; pub mod penelope; diff --git a/rotala/src/lib.rs b/rotala/src/lib.rs index 8021bdbc..42f44b73 100644 --- a/rotala/src/lib.rs +++ b/rotala/src/lib.rs @@ -74,6 +74,5 @@ //! Long-term: //! - Add orderbook with L2 data, this is going to require L2 sources pub mod exchange; -pub mod http; pub mod input; pub mod source; diff --git a/rotala/src/source/hyperliquid.rs b/rotala/src/source/hyperliquid.rs index f099370b..2dca0036 100644 --- a/rotala/src/source/hyperliquid.rs +++ b/rotala/src/source/hyperliquid.rs @@ -1,3 +1,4 @@ +use std::collections::BTreeMap; use std::path::Path; use std::{collections::HashMap, fs::read_to_string}; @@ -5,21 +6,30 @@ use serde::{Deserialize, Serialize}; use serde_json::from_str; #[derive(Debug, Clone, Deserialize, Serialize)] -pub struct Level { +pub struct HyperLiquidLevel { pub px: String, pub sz: String, pub n: i8, } +impl From for Level { + fn from(value: HyperLiquidLevel) -> Self { + Self { + price: value.px.parse().unwrap(), + size: value.sz.parse().unwrap(), + } + } +} + #[derive(Debug, Clone, Deserialize, Serialize)] pub struct PointInTime { pub coin: String, pub time: u64, - pub levels: Vec>, + pub levels: Vec>, } #[derive(Debug, Clone, Deserialize, Serialize)] -pub struct Data { +pub struct PointInTimeWrapper { pub channel: String, pub data: PointInTime, } @@ -28,21 +38,155 @@ pub struct Data { pub struct L2Book { pub time: String, pub ver_num: u64, - pub raw: Data, + pub raw: PointInTimeWrapper, +} + +impl From for Depth { + fn from(value: L2Book) -> Depth { + let date = value.raw.data.time as i64; + let symbol = value.raw.data.coin; + let exchange = "hl".to_string(); + + let mut bids_depth: Vec = Vec::new(); + let mut asks_depth: Vec = Vec::new(); + + if let Some(bids) = value.raw.data.levels.first() { + let bids_depth_tmp: Vec = + bids.iter().map(|v| -> Level { v.clone().into() }).collect(); + bids_depth.extend(bids_depth_tmp); + } + + if let Some(asks) = value.raw.data.levels.get(1) { + let asks_depth_tmp: Vec = + asks.iter().map(|v| -> Level { v.clone().into() }).collect(); + asks_depth.extend(asks_depth_tmp); + } + + Depth { + bids: bids_depth, + asks: asks_depth, + date, + symbol, + exchange, + } + } } pub fn get_hyperliquid_l2(path: &Path) -> HashMap { let mut result = HashMap::new(); - if let Ok(file_contents) = read_to_string(path) { - for line in file_contents.split('\n') { - if line.is_empty() { - continue; - } - let val: L2Book = from_str(line).unwrap(); - let time = val.raw.data.time; - result.insert(time, val); + if let Ok(dir_contents) = path.read_dir() { + for coin in dir_contents.flatten() { + if let Ok(coin_dir_contents) = coin.path().read_dir() { + for period in coin_dir_contents.flatten() { + if let Ok(file_contents) = read_to_string(period.path()) { + for line in file_contents.split('\n') { + if line.is_empty() { + continue; + } + + let val: L2Book = from_str(line).unwrap(); + let time = val.raw.data.time; + result.insert(time, val); + } + } + } + } } } result } + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub enum Side { + Bid, + Ask, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct Level { + pub price: f64, + pub size: f64, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct Depth { + pub bids: Vec, + pub asks: Vec, + pub date: i64, + pub symbol: String, + pub exchange: String, +} + +impl Depth { + pub fn add_level(&mut self, level: Level, side: Side) { + match side { + Side::Bid => { + self.bids.push(level); + self.bids + .sort_by(|x, y| x.price.partial_cmp(&y.price).unwrap().reverse()); + } + Side::Ask => { + self.asks.push(level); + self.asks + .sort_by(|x, y| x.price.partial_cmp(&y.price).unwrap()); + } + } + } + + pub fn get_best_bid(&self) -> Option<&Level> { + self.bids.first() + } + + pub fn get_best_ask(&self) -> Option<&Level> { + self.asks.first() + } + + pub fn get_bbo(&self) -> Option { + let best_bid = self.get_best_bid()?; + let best_ask = self.get_best_ask()?; + + Some(BBO { + bid: best_bid.price, + bid_volume: best_bid.size, + ask: best_ask.price, + ask_volume: best_ask.size, + symbol: self.symbol.clone(), + date: self.date, + }) + } + + pub fn new(date: i64, symbol: impl Into, exchange: impl Into) -> Self { + Self { + bids: vec![], + asks: vec![], + date, + symbol: symbol.into(), + exchange: exchange.into(), + } + } +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct BBO { + pub bid: f64, + pub bid_volume: f64, + pub ask: f64, + pub ask_volume: f64, + pub symbol: String, + pub date: i64, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct Trade { + pub coin: String, + pub side: Side, + pub px: f64, + pub sz: f64, + pub time: i64, + pub exchange: String, +} + +pub type DateDepth = BTreeMap>; +pub type DateBBO = BTreeMap; +pub type DateTrade = BTreeMap>; diff --git a/rotala/tests/jura_test.rs b/rotala/tests/jura_test.rs deleted file mode 100644 index 4987379d..00000000 --- a/rotala/tests/jura_test.rs +++ /dev/null @@ -1,13 +0,0 @@ -use rotala::exchange::jura_v1::{JuraV1, Order}; -use rotala::input::penelope::Penelope; - -#[test] -fn test_that_uist_works() { - let source = Penelope::random(1000, vec!["0"]); - let mut exchange = JuraV1::new(); - - let order = Order::market_buy(0, "100.0", "97.00"); - exchange.insert_order(order); - - exchange.tick(source.get_quotes_unchecked(&100)); -}