Prevent race conditions

- This is a massive cope, I don't know how to code

Signed-off-by: Nikolaos Karaolidis <nick@karaolidis.com>
This commit is contained in:
2024-01-17 17:16:37 +00:00
parent 36ee6030ce
commit 7200447bc5
23 changed files with 510 additions and 311 deletions

View File

@@ -22,7 +22,7 @@ build:
cache: cache:
<<: *global_cache <<: *global_cache
script: script:
- cargo build --verbose - cargo build
test: test:
image: registry.karaolidis.com/karaolidis/qrust/rust image: registry.karaolidis.com/karaolidis/qrust/rust
@@ -30,7 +30,7 @@ test:
cache: cache:
<<: *global_cache <<: *global_cache
script: script:
- cargo test --verbose - cargo test
lint: lint:
image: registry.karaolidis.com/karaolidis/qrust/rust image: registry.karaolidis.com/karaolidis/qrust/rust
@@ -38,6 +38,7 @@ lint:
cache: cache:
<<: *global_cache <<: *global_cache
script: script:
- cargo fmt --all -- --check
- cargo clippy --all-targets --all-features - cargo clippy --all-targets --all-features
depcheck: depcheck:
@@ -55,7 +56,7 @@ build-release:
cache: cache:
<<: *global_cache <<: *global_cache
script: script:
- cargo build --release --verbose - cargo build --release
after_script: after_script:
- echo "JOB_ID=$CI_JOB_ID" >> job.env - echo "JOB_ID=$CI_JOB_ID" >> job.env
artifacts: artifacts:

16
Cargo.lock generated
View File

@@ -145,9 +145,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]] [[package]]
name = "bitflags" name = "bitflags"
version = "2.4.1" version = "2.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "327762f6e5a765692301e5bb513e0d9fef63be86bbc14528052b1cd3e6f03e07" checksum = "ed570934406eb16438a4e976b1b4500774099c13b8cb96eec99f620f05090ddf"
[[package]] [[package]]
name = "block-buffer" name = "block-buffer"
@@ -872,9 +872,9 @@ checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f"
[[package]] [[package]]
name = "linux-raw-sys" name = "linux-raw-sys"
version = "0.4.12" version = "0.4.13"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4cd1a83af159aa67994778be9070f0ae1bd732942279cabb14f86f986a21456" checksum = "01cda141df6706de531b6c46c3a33ecca755538219bd484262fa09410c13539c"
[[package]] [[package]]
name = "lock_api" name = "lock_api"
@@ -1064,7 +1064,7 @@ version = "0.10.62"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8cde4d2d9200ad5909f8dac647e29482e07c3a35de8a13fce7c9c7747ad9f671" checksum = "8cde4d2d9200ad5909f8dac647e29482e07c3a35de8a13fce7c9c7747ad9f671"
dependencies = [ dependencies = [
"bitflags 2.4.1", "bitflags 2.4.2",
"cfg-if", "cfg-if",
"foreign-types", "foreign-types",
"libc", "libc",
@@ -1174,9 +1174,9 @@ checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
[[package]] [[package]]
name = "pkg-config" name = "pkg-config"
version = "0.3.28" version = "0.3.29"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69d3587f8a9e599cc7ec2c00e331f71c4e69a5f9a4b8a6efd5b07466b9736f9a" checksum = "2900ede94e305130c13ddd391e0ab7cbaeb783945ae07a279c268cb05109c6cb"
[[package]] [[package]]
name = "powerfmt" name = "powerfmt"
@@ -1344,7 +1344,7 @@ version = "0.38.30"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "322394588aaf33c24007e8bb3238ee3e4c5c09c084ab32bc73890b99ff326bca" checksum = "322394588aaf33c24007e8bb3238ee3e4c5c09c084ab32bc73890b99ff326bca"
dependencies = [ dependencies = [
"bitflags 2.4.1", "bitflags 2.4.2",
"errno", "errno",
"libc", "libc",
"linux-raw-sys", "linux-raw-sys",

View File

@@ -5,10 +5,10 @@ use crate::{
}, },
data::authenticate_websocket, data::authenticate_websocket,
database, database,
state::{self, BroadcastMessage},
types::{ types::{
alpaca::{api, websocket, Source}, alpaca::{api, websocket, Source},
asset::{self, Asset}, Asset, Backfill, Bar, Class,
Bar, BarValidity, BroadcastMessage, Class,
}, },
utils::{duration_until, last_minute, FIFTEEN_MINUTES, ONE_MINUTE}, utils::{duration_until, last_minute, FIFTEEN_MINUTES, ONE_MINUTE},
}; };
@@ -18,11 +18,12 @@ use futures_util::{
SinkExt, StreamExt, SinkExt, StreamExt,
}; };
use log::{error, info, warn}; use log::{error, info, warn};
use serde_json::from_str; use serde_json::{from_str, to_string};
use std::{ use std::{
collections::{HashMap, HashSet}, collections::{HashMap, HashSet},
sync::Arc, sync::Arc,
}; };
use time::OffsetDateTime;
use tokio::{ use tokio::{
net::TcpStream, net::TcpStream,
spawn, spawn,
@@ -30,14 +31,22 @@ use tokio::{
broadcast::{Receiver, Sender}, broadcast::{Receiver, Sender},
RwLock, RwLock,
}, },
task::JoinHandle,
time::sleep, time::sleep,
}; };
use tokio_tungstenite::{connect_async, tungstenite::Message, MaybeTlsStream, WebSocketStream}; use tokio_tungstenite::{connect_async, tungstenite::Message, MaybeTlsStream, WebSocketStream};
pub struct Guard {
symbols: HashSet<String>,
backfill_jobs: HashMap<String, JoinHandle<()>>,
pending_subscriptions: HashMap<String, Asset>,
pending_unsubscriptions: HashMap<String, Asset>,
}
pub async fn run( pub async fn run(
app_config: Arc<Config>, app_config: Arc<Config>,
class: Class, class: Class,
broadcast_sender: Sender<BroadcastMessage>, broadcast_bus_sender: Sender<BroadcastMessage>,
) { ) {
info!("Running live data threads for {:?}.", class); info!("Running live data threads for {:?}.", class);
@@ -54,208 +63,204 @@ pub async fn run(
authenticate_websocket(&app_config, &mut stream, &mut sink).await; authenticate_websocket(&app_config, &mut stream, &mut sink).await;
let sink = Arc::new(RwLock::new(sink)); let sink = Arc::new(RwLock::new(sink));
spawn(broadcast_handler( let guard = Arc::new(RwLock::new(Guard {
symbols: HashSet::new(),
backfill_jobs: HashMap::new(),
pending_subscriptions: HashMap::new(),
pending_unsubscriptions: HashMap::new(),
}));
spawn(broadcast_bus_handler(
app_config.clone(),
class, class,
sink.clone(), sink.clone(),
broadcast_sender.subscribe(), broadcast_bus_sender.subscribe(),
guard.clone(),
)); ));
let assets = database::assets::select_where_class(&app_config.clickhouse_client, class).await; spawn(websocket_handler(
broadcast_sender app_config.clone(),
stream,
sink,
broadcast_bus_sender.clone(),
guard.clone(),
));
spawn(clock_handler(
app_config.clone(),
class,
broadcast_bus_sender.clone(),
));
let assets = database::assets::select_where_class(&app_config.clickhouse_client, &class).await;
broadcast_bus_sender
.send(BroadcastMessage::Asset(( .send(BroadcastMessage::Asset((
asset::BroadcastMessage::Added, state::asset::BroadcastMessage::Add,
assets, assets,
))) )))
.unwrap(); .unwrap();
websocket_handler(app_config, class, stream, sink).await;
unreachable!()
} }
async fn broadcast_handler( #[allow(clippy::too_many_lines)]
#[allow(clippy::significant_drop_tightening)]
pub async fn broadcast_bus_handler(
app_config: Arc<Config>,
class: Class, class: Class,
sink: Arc<RwLock<SplitSink<WebSocketStream<MaybeTlsStream<TcpStream>>, Message>>>, sink: Arc<RwLock<SplitSink<WebSocketStream<MaybeTlsStream<TcpStream>>, Message>>>,
mut broadcast_receiver: Receiver<BroadcastMessage>, mut broadcast_bus_receiver: Receiver<BroadcastMessage>,
guard: Arc<RwLock<Guard>>,
) { ) {
loop { loop {
match broadcast_receiver.recv().await.unwrap() { match broadcast_bus_receiver.recv().await.unwrap() {
BroadcastMessage::Asset((action, assets)) => { BroadcastMessage::Asset((action, assets)) => {
let symbols = assets let assets = assets
.into_iter() .into_iter()
.filter(|asset| asset.class == class) .filter(|asset| asset.class == class)
.map(|asset| asset.symbol)
.collect::<Vec<_>>(); .collect::<Vec<_>>();
if symbols.is_empty() { if assets.is_empty() {
continue; continue;
} }
let symbols = assets
.iter()
.map(|asset| asset.symbol.clone())
.collect::<Vec<_>>();
match action {
state::asset::BroadcastMessage::Add => {
database::assets::upsert_batch(&app_config.clickhouse_client, &assets)
.await;
info!("Added {:?}.", symbols);
let mut guard = guard.write().await;
guard.pending_subscriptions.extend(
assets
.into_iter()
.map(|asset| (asset.symbol.clone(), asset)),
);
guard.symbols.extend(symbols.clone());
sink.write() sink.write()
.await .await
.send(Message::Text( .send(Message::Text(
serde_json::to_string(&match action { to_string(&websocket::data::outgoing::Message::Subscribe(
asset::BroadcastMessage::Added => { websocket::data::outgoing::subscribe::Message::new(symbols),
websocket::data::outgoing::Message::Subscribe( ))
websocket::data::outgoing::subscribe::Message::new(
symbols.clone(),
),
)
}
asset::BroadcastMessage::Deleted => {
websocket::data::outgoing::Message::Unsubscribe(
websocket::data::outgoing::subscribe::Message::new(
symbols.clone(),
),
)
}
})
.unwrap(), .unwrap(),
)) ))
.await .await
.unwrap(); .unwrap();
} }
} state::asset::BroadcastMessage::Delete => {
} database::assets::delete_where_symbols(
} &app_config.clickhouse_client,
&symbols,
)
.await;
async fn websocket_handler( info!("Deleted {:?}.", symbols);
app_config: Arc<Config>,
class: Class,
mut stream: SplitStream<WebSocketStream<MaybeTlsStream<TcpStream>>>,
sink: Arc<RwLock<SplitSink<WebSocketStream<MaybeTlsStream<TcpStream>>, Message>>>,
) {
let backfilled = Arc::new(RwLock::new(HashMap::new()));
loop { let mut guard = guard.write().await;
match stream.next().await {
Some(Ok(Message::Text(data))) => { guard.pending_unsubscriptions.extend(
let parsed_data = from_str::<Vec<websocket::data::incoming::Message>>(&data); assets
if let Err(e) = &parsed_data { .into_iter()
warn!( .map(|asset| (asset.symbol.clone(), asset)),
"Unparsed websocket::data::incoming message: {:?}: {}",
data, e
); );
}
for message in parsed_data.unwrap_or_default() { guard.symbols = guard
spawn(websocket_handle_message( .symbols
app_config.clone(), .clone()
class, .into_iter()
backfilled.clone(), .filter(|symbol| !symbols.contains(symbol))
message, .collect::<HashSet<_, _>>();
));
} sink.write()
}
Some(Ok(Message::Ping(_))) => sink
.write()
.await
.send(Message::Pong(vec![]))
.await .await
.send(Message::Text(
to_string(&websocket::data::outgoing::Message::Unsubscribe(
websocket::data::outgoing::subscribe::Message::new(symbols),
))
.unwrap(), .unwrap(),
Some(unknown) => error!("Unknown websocket::data::incoming message: {:?}", unknown), ))
None => panic!(), .await
.unwrap();
}
state::asset::BroadcastMessage::Backfill => {
info!("Creating backfill jobs for {:?}.", symbols);
let guard_clone = guard.clone();
let mut guard = guard.write().await;
for asset in assets {
let mut handles = Vec::new();
if let Some(backfill_job) = guard.backfill_jobs.remove(&asset.symbol) {
backfill_job.abort();
handles.push(backfill_job);
}
for handle in handles {
handle.await.unwrap_err();
}
guard.backfill_jobs.insert(asset.symbol.clone(), {
let guard = guard_clone.clone();
let app_config = app_config.clone();
spawn(async move {
backfill(app_config, class, asset.clone()).await;
let mut guard = guard.write().await;
guard.backfill_jobs.remove(&asset.symbol);
})
});
}
}
state::asset::BroadcastMessage::Purge => {
let mut guard = guard.write().await;
info!("Purging {:?}.", symbols);
for asset in assets {
let mut handles = Vec::new();
if let Some(backfill_job) = guard.backfill_jobs.remove(&asset.symbol) {
backfill_job.abort();
handles.push(backfill_job);
}
for handle in handles {
handle.await.unwrap_err();
}
}
database::backfills::delete_where_symbols(
&app_config.clickhouse_client,
&symbols,
)
.await;
database::bars::delete_where_symbols(
&app_config.clickhouse_client,
&symbols,
)
.await;
}
}
}
} }
} }
} }
async fn websocket_handle_message( pub async fn clock_handler(
app_config: Arc<Config>, app_config: Arc<Config>,
class: Class, class: Class,
backfilled: Arc<RwLock<HashMap<String, bool>>>, broadcast_bus_sender: Sender<BroadcastMessage>,
message: websocket::data::incoming::Message,
) { ) {
match message { loop {
websocket::data::incoming::Message::Subscription(message) => {
let added_asset_symbols;
let deleted_asset_symbols;
{
let mut backfilled = backfilled.write().await;
let old_asset_sybols = backfilled.keys().cloned().collect::<HashSet<_>>();
let new_asset_symbols = message.bars.into_iter().collect::<HashSet<_>>();
added_asset_symbols = new_asset_symbols
.difference(&old_asset_sybols)
.cloned()
.collect::<HashSet<_>>();
for asset_symbol in &added_asset_symbols {
backfilled.insert(asset_symbol.clone(), false);
}
deleted_asset_symbols = old_asset_sybols
.difference(&new_asset_symbols)
.cloned()
.collect::<HashSet<_>>();
for asset_symbol in &deleted_asset_symbols {
backfilled.remove(asset_symbol);
}
drop(backfilled);
info!(
"Subscription update for {:?}: {:?} added, {:?} deleted.",
class, added_asset_symbols, deleted_asset_symbols
);
}
for asset_symbol in added_asset_symbols {
let asset = database::assets::select_where_symbol(
&app_config.clickhouse_client,
&asset_symbol,
)
.await
.unwrap();
database::bars::insert_validity_if_not_exists(
&app_config.clickhouse_client,
&BarValidity::none(asset.symbol.clone()),
)
.await;
spawn(backfill(app_config.clone(), backfilled.clone(), asset));
}
for asset_symbol in deleted_asset_symbols {
database::bars::delete_validity_where_symbol(
&app_config.clickhouse_client,
&asset_symbol,
)
.await;
database::bars::delete_where_symbol(&app_config.clickhouse_client, &asset_symbol)
.await;
}
}
websocket::data::incoming::Message::Bars(bar_message)
| websocket::data::incoming::Message::UpdatedBars(bar_message) => {
let bar = Bar::from(bar_message);
info!("websocket::Incoming bar for {}: {}", bar.symbol, bar.time);
database::bars::upsert(&app_config.clickhouse_client, &bar).await;
if *backfilled.read().await.get(&bar.symbol).unwrap() {
database::bars::upsert_validity(&app_config.clickhouse_client, &bar.into()).await;
}
}
websocket::data::incoming::Message::Success(_) => {}
}
}
pub async fn backfill(
app_config: Arc<Config>,
backfilled: Arc<RwLock<HashMap<String, bool>>>,
asset: Asset,
) {
let bar_validity =
database::bars::select_validity_where_symbol(&app_config.clickhouse_client, &asset.symbol)
.await
.unwrap();
let fetch_from = bar_validity.time_last + ONE_MINUTE;
let fetch_until = if app_config.alpaca_source == Source::Iex {
app_config.alpaca_rate_limit.until_ready().await; app_config.alpaca_rate_limit.until_ready().await;
let clock = app_config let clock = app_config
.alpaca_client .alpaca_client
@@ -267,15 +272,177 @@ pub async fn backfill(
.await .await
.unwrap(); .unwrap();
if clock.is_open { let sleep_until = duration_until(if clock.is_open {
last_minute() if class == Class::UsEquity {
info!("Market is open, will close at {}.", clock.next_close);
}
clock.next_close
} else { } else {
if class == Class::UsEquity {
info!("Market is closed, will reopen at {}.", clock.next_open);
}
clock.next_open clock.next_open
});
sleep(sleep_until).await;
let assets = database::assets::select(&app_config.clickhouse_client).await;
broadcast_bus_sender
.send(BroadcastMessage::Asset((
state::asset::BroadcastMessage::Backfill,
assets,
)))
.unwrap();
}
}
async fn websocket_handler(
app_config: Arc<Config>,
mut stream: SplitStream<WebSocketStream<MaybeTlsStream<TcpStream>>>,
sink: Arc<RwLock<SplitSink<WebSocketStream<MaybeTlsStream<TcpStream>>, Message>>>,
broadcast_bus_sender: Sender<BroadcastMessage>,
guard: Arc<RwLock<Guard>>,
) {
loop {
let app_config = app_config.clone();
let sink = sink.clone();
let broadcast_bus_sender = broadcast_bus_sender.clone();
let guard = guard.clone();
let message = stream.next().await;
spawn(async move {
match message {
Some(Ok(Message::Text(data))) => {
let parsed_data = from_str::<Vec<websocket::data::incoming::Message>>(&data);
if let Ok(messages) = parsed_data {
for message in messages {
websocket_handle_message(
app_config.clone(),
broadcast_bus_sender.clone(),
guard.clone(),
message,
)
.await;
} }
} else { } else {
last_minute() error!(
"Unparsed websocket::data::incoming message: {:?}: {}",
data,
parsed_data.err().unwrap()
);
}
}
Some(Ok(Message::Ping(_))) => sink
.write()
.await
.send(Message::Pong(vec![]))
.await
.unwrap(),
Some(unknown) => error!("Unknown websocket::data::incoming message: {:?}", unknown),
_ => panic!(),
}
});
}
}
#[allow(clippy::significant_drop_tightening)]
async fn websocket_handle_message(
app_config: Arc<Config>,
broadcast_bus_sender: Sender<BroadcastMessage>,
guard: Arc<RwLock<Guard>>,
message: websocket::data::incoming::Message,
) {
match message {
websocket::data::incoming::Message::Subscription(message) => {
let symbols = message.bars.into_iter().collect::<HashSet<_>>();
let mut guard = guard.write().await;
let newly_subscribed_assets = guard
.pending_subscriptions
.drain()
.filter(|(symbol, _)| symbols.contains(symbol))
.map(|(_, asset)| asset)
.collect::<Vec<_>>();
if !newly_subscribed_assets.is_empty() {
info!(
"Subscribed to {:?}.",
newly_subscribed_assets
.iter()
.map(|asset| asset.symbol.clone())
.collect::<Vec<_>>()
);
broadcast_bus_sender
.send(BroadcastMessage::Asset((
state::asset::BroadcastMessage::Backfill,
newly_subscribed_assets,
)))
.unwrap();
}
let newly_unsubscribed_assets = guard
.pending_unsubscriptions
.drain()
.filter(|(symbol, _)| !symbols.contains(symbol))
.map(|(_, asset)| asset)
.collect::<Vec<_>>();
if !newly_unsubscribed_assets.is_empty() {
info!(
"Unsubscribed from {:?}.",
newly_unsubscribed_assets
.iter()
.map(|asset| asset.symbol.clone())
.collect::<Vec<_>>()
);
broadcast_bus_sender
.send(BroadcastMessage::Asset((
state::asset::BroadcastMessage::Purge,
newly_unsubscribed_assets,
)))
.unwrap();
}
}
websocket::data::incoming::Message::Bars(bar_message)
| websocket::data::incoming::Message::UpdatedBars(bar_message) => {
let bar = Bar::from(bar_message);
let guard = guard.read().await;
let symbol_status = guard.symbols.get(&bar.symbol);
if symbol_status.is_none() {
warn!(
"Race condition: received bar for unsubscribed symbol: {:?}.",
bar.symbol
);
return;
}
info!("Received bar for {}: {}", bar.symbol, bar.time);
database::bars::upsert(&app_config.clickhouse_client, &bar).await;
}
websocket::data::incoming::Message::Success(_) => {}
}
}
pub async fn backfill(app_config: Arc<Config>, class: Class, asset: Asset) {
let latest_backfill = database::backfills::select_latest_where_symbol(
&app_config.clickhouse_client,
&asset.symbol,
)
.await;
let fetch_from = if let Some(backfill) = latest_backfill {
backfill.time + ONE_MINUTE
} else {
OffsetDateTime::UNIX_EPOCH
}; };
let fetch_until = last_minute();
if fetch_from > fetch_until { if fetch_from > fetch_until {
return; return;
} }
@@ -289,7 +456,7 @@ pub async fn backfill(
sleep(task_run_delay).await; sleep(task_run_delay).await;
} }
info!("Running historical data backfill for {}...", asset.symbol); info!("Running historical data backfill for {}.", asset.symbol);
let mut bars = Vec::new(); let mut bars = Vec::new();
let mut next_page_token = None; let mut next_page_token = None;
@@ -298,7 +465,7 @@ pub async fn backfill(
app_config.alpaca_rate_limit.until_ready().await; app_config.alpaca_rate_limit.until_ready().await;
let message = app_config let message = app_config
.alpaca_client .alpaca_client
.get(match asset.class { .get(match class {
Class::UsEquity => ALPACA_STOCK_DATA_URL, Class::UsEquity => ALPACA_STOCK_DATA_URL,
Class::Crypto => ALPACA_CRYPTO_DATA_URL, Class::Crypto => ALPACA_CRYPTO_DATA_URL,
}) })
@@ -330,12 +497,11 @@ pub async fn backfill(
} }
database::bars::upsert_batch(&app_config.clickhouse_client, &bars).await; database::bars::upsert_batch(&app_config.clickhouse_client, &bars).await;
if let Some(last_bar) = bars.last() { database::backfills::upsert(
database::bars::upsert_validity(&app_config.clickhouse_client, &last_bar.clone().into()) &app_config.clickhouse_client,
&Backfill::new(asset.symbol.clone(), fetch_until),
)
.await; .await;
}
backfilled.write().await.insert(asset.symbol.clone(), true);
info!("Backfilled historical data for {}.", asset.symbol); info!("Backfilled historical data for {}.", asset.symbol);
} }

View File

@@ -3,15 +3,15 @@ use clickhouse::Client;
pub async fn select(clickhouse_client: &Client) -> Vec<Asset> { pub async fn select(clickhouse_client: &Client) -> Vec<Asset> {
clickhouse_client clickhouse_client
.query("SELECT ?fields FROM assets") .query("SELECT ?fields FROM assets FINAL")
.fetch_all::<Asset>() .fetch_all::<Asset>()
.await .await
.unwrap() .unwrap()
} }
pub async fn select_where_class(clickhouse_client: &Client, class: Class) -> Vec<Asset> { pub async fn select_where_class(clickhouse_client: &Client, class: &Class) -> Vec<Asset> {
clickhouse_client clickhouse_client
.query("SELECT ?fields FROM assets WHERE class = ?") .query("SELECT ?fields FROM assets FINAL WHERE class = ?")
.bind(class) .bind(class)
.fetch_all::<Asset>() .fetch_all::<Asset>()
.await .await
@@ -20,23 +20,25 @@ pub async fn select_where_class(clickhouse_client: &Client, class: Class) -> Vec
pub async fn select_where_symbol(clickhouse_client: &Client, symbol: &str) -> Option<Asset> { pub async fn select_where_symbol(clickhouse_client: &Client, symbol: &str) -> Option<Asset> {
clickhouse_client clickhouse_client
.query("SELECT ?fields FROM assets WHERE symbol = ?") .query("SELECT ?fields FROM assets FINAL WHERE symbol = ?")
.bind(symbol) .bind(symbol)
.fetch_optional::<Asset>() .fetch_optional::<Asset>()
.await .await
.unwrap() .unwrap()
} }
pub async fn upsert(clickhouse_client: &Client, asset: &Asset) { pub async fn upsert_batch(clickhouse_client: &Client, assets: &Vec<Asset>) {
let mut insert = clickhouse_client.insert("assets").unwrap(); let mut insert = clickhouse_client.insert("assets").unwrap();
for asset in assets {
insert.write(asset).await.unwrap(); insert.write(asset).await.unwrap();
}
insert.end().await.unwrap(); insert.end().await.unwrap();
} }
pub async fn delete_where_symbol(clickhouse_client: &Client, symbol: &str) { pub async fn delete_where_symbols(clickhouse_client: &Client, symbols: &Vec<String>) {
clickhouse_client clickhouse_client
.query("DELETE FROM assets WHERE symbol = ?") .query("DELETE FROM assets WHERE symbol IN ?")
.bind(symbol) .bind(symbols)
.execute() .execute()
.await .await
.unwrap(); .unwrap();

38
src/database/backfills.rs Normal file
View File

@@ -0,0 +1,38 @@
use crate::types::Backfill;
use clickhouse::Client;
pub async fn select_latest_where_symbol(
clickhouse_client: &Client,
symbol: &str,
) -> Option<Backfill> {
clickhouse_client
.query("SELECT ?fields FROM backfills FINAL WHERE symbol = ? ORDER BY time DESC LIMIT 1")
.bind(symbol)
.fetch_optional::<Backfill>()
.await
.unwrap()
}
pub async fn upsert(clickhouse_client: &Client, backfill: &Backfill) {
let mut insert = clickhouse_client.insert("backfills").unwrap();
insert.write(backfill).await.unwrap();
insert.end().await.unwrap();
}
pub async fn delete_where_symbols(clickhouse_client: &Client, symbols: &Vec<String>) {
clickhouse_client
.query("DELETE FROM backfills WHERE symbol IN ?")
.bind(symbols)
.execute()
.await
.unwrap();
}
pub async fn delete_where_not_symbols(clickhouse_client: &Client, symbols: &Vec<String>) {
clickhouse_client
.query("DELETE FROM backfills WHERE symbol NOT IN ?")
.bind(symbols)
.execute()
.await
.unwrap();
}

View File

@@ -1,4 +1,4 @@
use crate::types::{Bar, BarValidity}; use crate::types::Bar;
use clickhouse::Client; use clickhouse::Client;
pub async fn upsert(clickhouse_client: &Client, bar: &Bar) { pub async fn upsert(clickhouse_client: &Client, bar: &Bar) {
@@ -15,46 +15,19 @@ pub async fn upsert_batch(clickhouse_client: &Client, bars: &[Bar]) {
insert.end().await.unwrap(); insert.end().await.unwrap();
} }
pub async fn delete_where_symbol(clickhouse_client: &Client, symbol: &str) { pub async fn delete_where_symbols(clickhouse_client: &Client, symbols: &Vec<String>) {
clickhouse_client clickhouse_client
.query("DELETE FROM bars WHERE symbol = ?") .query("DELETE FROM bars WHERE symbol IN ?")
.bind(symbol) .bind(symbols)
.execute() .execute()
.await .await
.unwrap(); .unwrap();
} }
pub async fn select_validity_where_symbol( pub async fn delete_where_not_symbols(clickhouse_client: &Client, symbols: &Vec<String>) {
clickhouse_client: &Client,
symbol: &str,
) -> Option<BarValidity> {
clickhouse_client clickhouse_client
.query("SELECT ?fields FROM bars_validity FINAL WHERE symbol = ?") .query("DELETE FROM bars WHERE symbol NOT IN ?")
.bind(symbol) .bind(symbols)
.fetch_optional::<BarValidity>()
.await
.unwrap()
}
pub async fn upsert_validity(clickhouse_client: &Client, bar_validity: &BarValidity) {
let mut insert = clickhouse_client.insert("bars_validity").unwrap();
insert.write(bar_validity).await.unwrap();
insert.end().await.unwrap();
}
pub async fn insert_validity_if_not_exists(clickhouse_client: &Client, bar_validity: &BarValidity) {
if select_validity_where_symbol(clickhouse_client, &bar_validity.symbol)
.await
.is_none()
{
upsert_validity(clickhouse_client, bar_validity).await;
}
}
pub async fn delete_validity_where_symbol(clickhouse_client: &Client, symbol: &str) {
clickhouse_client
.query("DELETE FROM bars_validity WHERE symbol = ?")
.bind(symbol)
.execute() .execute()
.await .await
.unwrap(); .unwrap();

View File

@@ -1,2 +1,3 @@
pub mod assets; pub mod assets;
pub mod backfills;
pub mod bars; pub mod bars;

View File

@@ -5,15 +5,18 @@ mod config;
mod data; mod data;
mod database; mod database;
mod routes; mod routes;
mod state;
mod types; mod types;
mod utils; mod utils;
use crate::utils::cleanup;
use config::Config; use config::Config;
use dotenv::dotenv; use dotenv::dotenv;
use log4rs::config::Deserializers; use log4rs::config::Deserializers;
use state::BroadcastMessage;
use std::error::Error; use std::error::Error;
use tokio::{spawn, sync::broadcast}; use tokio::{spawn, sync::broadcast};
use types::{BroadcastMessage, Class}; use types::Class;
#[tokio::main] #[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> { async fn main() -> Result<(), Box<dyn Error>> {
@@ -22,21 +25,23 @@ async fn main() -> Result<(), Box<dyn Error>> {
let app_config = Config::arc_from_env(); let app_config = Config::arc_from_env();
let mut threads = Vec::new(); let mut threads = Vec::new();
let (broadcast_sender, _) = broadcast::channel::<BroadcastMessage>(100); cleanup(&app_config.clickhouse_client).await;
let (broadcast_bus, _) = broadcast::channel::<BroadcastMessage>(100);
threads.push(spawn(data::market::run( threads.push(spawn(data::market::run(
app_config.clone(), app_config.clone(),
Class::UsEquity, Class::UsEquity,
broadcast_sender.clone(), broadcast_bus.clone(),
))); )));
threads.push(spawn(data::market::run( threads.push(spawn(data::market::run(
app_config.clone(), app_config.clone(),
Class::Crypto, Class::Crypto,
broadcast_sender.clone(), broadcast_bus.clone(),
))); )));
threads.push(spawn(routes::run(app_config.clone(), broadcast_sender))); threads.push(spawn(routes::run(app_config.clone(), broadcast_bus)));
for thread in threads { for thread in threads {
thread.await?; thread.await?;

View File

@@ -1,12 +1,14 @@
use crate::config::{Config, ALPACA_ASSET_API_URL}; use crate::{
use crate::database; config::{Config, ALPACA_ASSET_API_URL},
use crate::types::{ database,
state::{self, BroadcastMessage},
types::{
alpaca::api::incoming::{self, asset::Status}, alpaca::api::incoming::{self, asset::Status},
asset, Asset, BroadcastMessage, Asset,
},
}; };
use axum::{extract::Path, Extension, Json}; use axum::{extract::Path, Extension, Json};
use http::StatusCode; use http::StatusCode;
use log::info;
use serde::Deserialize; use serde::Deserialize;
use std::sync::Arc; use std::sync::Arc;
use tokio::sync::broadcast::Sender; use tokio::sync::broadcast::Sender;
@@ -35,7 +37,7 @@ pub struct AddAssetRequest {
pub async fn add( pub async fn add(
Extension(app_config): Extension<Arc<Config>>, Extension(app_config): Extension<Arc<Config>>,
Extension(broadcast_sender): Extension<Sender<BroadcastMessage>>, Extension(broadcast_bus_sender): Extension<Sender<BroadcastMessage>>,
Json(request): Json<AddAssetRequest>, Json(request): Json<AddAssetRequest>,
) -> Result<(StatusCode, Json<Asset>), StatusCode> { ) -> Result<(StatusCode, Json<Asset>), StatusCode> {
if database::assets::select_where_symbol(&app_config.clickhouse_client, &request.symbol) if database::assets::select_where_symbol(&app_config.clickhouse_client, &request.symbol)
@@ -66,22 +68,19 @@ pub async fn add(
} }
let asset = Asset::from(asset); let asset = Asset::from(asset);
database::assets::upsert(&app_config.clickhouse_client, &asset).await; broadcast_bus_sender
broadcast_sender
.send(BroadcastMessage::Asset(( .send(BroadcastMessage::Asset((
asset::BroadcastMessage::Added, state::asset::BroadcastMessage::Add,
vec![asset.clone()], vec![asset.clone()],
))) )))
.unwrap(); .unwrap();
info!("Added asset {}.", asset.symbol);
Ok((StatusCode::CREATED, Json(asset))) Ok((StatusCode::CREATED, Json(asset)))
} }
pub async fn delete( pub async fn delete(
Extension(app_config): Extension<Arc<Config>>, Extension(app_config): Extension<Arc<Config>>,
Extension(broadcast_sender): Extension<Sender<BroadcastMessage>>, Extension(broadcast_bus_sender): Extension<Sender<BroadcastMessage>>,
Path(symbol): Path<String>, Path(symbol): Path<String>,
) -> Result<StatusCode, StatusCode> { ) -> Result<StatusCode, StatusCode> {
let asset = database::assets::select_where_symbol(&app_config.clickhouse_client, &symbol) let asset = database::assets::select_where_symbol(&app_config.clickhouse_client, &symbol)
@@ -89,15 +88,12 @@ pub async fn delete(
.ok_or(StatusCode::NOT_FOUND) .ok_or(StatusCode::NOT_FOUND)
.unwrap(); .unwrap();
broadcast_sender broadcast_bus_sender
.send(BroadcastMessage::Asset(( .send(BroadcastMessage::Asset((
asset::BroadcastMessage::Deleted, state::asset::BroadcastMessage::Delete,
vec![asset.clone()], vec![asset],
))) )))
.unwrap(); .unwrap();
database::assets::delete_where_symbol(&app_config.clickhouse_client, &symbol).await;
info!("Deleted asset {}.", symbol);
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }

View File

@@ -1,4 +1,4 @@
use crate::{config::Config, types::BroadcastMessage}; use crate::{config::Config, state::BroadcastMessage};
use axum::{ use axum::{
routing::{delete, get, post}, routing::{delete, get, post},
serve, Extension, Router, serve, Extension, Router,

7
src/state/asset.rs Normal file
View File

@@ -0,0 +1,7 @@
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum BroadcastMessage {
Add,
Backfill,
Delete,
Purge,
}

8
src/state/mod.rs Normal file
View File

@@ -0,0 +1,8 @@
use crate::types::Asset;
pub mod asset;
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum BroadcastMessage {
Asset((asset::BroadcastMessage, Vec<Asset>)),
}

View File

@@ -1,5 +1,3 @@
#![allow(clippy::struct_excessive_bools)]
use crate::types::alpaca::api::impl_from_enum; use crate::types::alpaca::api::impl_from_enum;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -45,6 +43,7 @@ pub enum Status {
Inactive, Inactive,
} }
#[allow(clippy::struct_excessive_bools)]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Asset { pub struct Asset {
pub id: String, pub id: String,

View File

@@ -16,7 +16,7 @@ pub struct Bar {
#[serde(rename = "c")] #[serde(rename = "c")]
pub close: f64, pub close: f64,
#[serde(rename = "v")] #[serde(rename = "v")]
pub volume: i64, pub volume: f64,
#[serde(rename = "n")] #[serde(rename = "n")]
pub trades: i64, pub trades: i64,
#[serde(rename = "vw")] #[serde(rename = "vw")]

View File

@@ -17,7 +17,7 @@ pub struct Message {
#[serde(rename = "c")] #[serde(rename = "c")]
pub close: f64, pub close: f64,
#[serde(rename = "v")] #[serde(rename = "v")]
pub volume: i64, pub volume: f64,
#[serde(rename = "n")] #[serde(rename = "n")]
pub trades: i64, pub trades: i64,
#[serde(rename = "vw")] #[serde(rename = "vw")]

View File

@@ -31,9 +31,3 @@ pub struct Asset {
#[serde(with = "clickhouse::serde::time::datetime")] #[serde(with = "clickhouse::serde::time::datetime")]
pub time_added: OffsetDateTime, pub time_added: OffsetDateTime,
} }
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum BroadcastMessage {
Added,
Deleted,
}

23
src/types/backfill.rs Normal file
View File

@@ -0,0 +1,23 @@
use super::Bar;
use clickhouse::Row;
use serde::{Deserialize, Serialize};
use time::OffsetDateTime;
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Row)]
pub struct Backfill {
pub symbol: String,
#[serde(with = "clickhouse::serde::time::datetime")]
pub time: OffsetDateTime,
}
impl Backfill {
pub const fn new(symbol: String, time: OffsetDateTime) -> Self {
Self { symbol, time }
}
}
impl From<Bar> for Backfill {
fn from(bar: Bar) -> Self {
Self::new(bar.symbol, bar.time)
}
}

View File

@@ -1,5 +1,3 @@
#![allow(clippy::module_name_repetitions)]
use clickhouse::Row; use clickhouse::Row;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use time::OffsetDateTime; use time::OffsetDateTime;
@@ -13,32 +11,7 @@ pub struct Bar {
pub high: f64, pub high: f64,
pub low: f64, pub low: f64,
pub close: f64, pub close: f64,
pub volume: i64, pub volume: f64,
pub trades: i64, pub trades: i64,
pub vwap: f64, pub vwap: f64,
} }
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Row)]
pub struct BarValidity {
pub symbol: String,
#[serde(with = "clickhouse::serde::time::datetime")]
pub time_last: OffsetDateTime,
}
impl BarValidity {
pub const fn none(symbol: String) -> Self {
Self {
symbol,
time_last: OffsetDateTime::UNIX_EPOCH,
}
}
}
impl From<Bar> for BarValidity {
fn from(bar: Bar) -> Self {
Self {
symbol: bar.symbol,
time_last: bar.time,
}
}
}

View File

@@ -1,11 +1,8 @@
pub mod alpaca; pub mod alpaca;
pub mod asset; pub mod asset;
pub mod backfill;
pub mod bar; pub mod bar;
pub use asset::{Asset, Class, Exchange}; pub use asset::{Asset, Class, Exchange};
pub use bar::{Bar, BarValidity}; pub use backfill::Backfill;
pub use bar::Bar;
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum BroadcastMessage {
Asset((asset::BroadcastMessage, Vec<Asset>)),
}

14
src/utils/cleanup.rs Normal file
View File

@@ -0,0 +1,14 @@
use crate::database;
use clickhouse::Client;
pub async fn cleanup(clickhouse_client: &Client) {
let assets = database::assets::select(clickhouse_client).await;
let symbols = assets
.iter()
.map(|asset| asset.symbol.clone())
.collect::<Vec<_>>();
database::bars::delete_where_not_symbols(clickhouse_client, &symbols).await;
database::backfills::delete_where_not_symbols(clickhouse_client, &symbols).await;
}

View File

@@ -1,3 +1,5 @@
pub mod cleanup;
pub mod time; pub mod time;
pub use cleanup::cleanup;
pub use time::{duration_until, last_minute, FIFTEEN_MINUTES, ONE_MINUTE}; pub use time::{duration_until, last_minute, FIFTEEN_MINUTES, ONE_MINUTE};

View File

@@ -1,5 +1,5 @@
FROM rust FROM rust
RUN rustup install nightly RUN rustup install nightly
RUN rustup component add clippy RUN rustup component add rustfmt clippy
RUN cargo install cargo-udeps cargo-outdated RUN cargo install cargo-udeps cargo-outdated

View File

@@ -23,7 +23,7 @@ CREATE TABLE IF NOT EXISTS qrust.bars (
high Float64, high Float64,
low Float64, low Float64,
close Float64, close Float64,
volume Int64, volume Float64,
trades Int64, trades Int64,
vwap Float64 vwap Float64
) )
@@ -31,9 +31,9 @@ ENGINE = ReplacingMergeTree()
PRIMARY KEY (symbol, time) PRIMARY KEY (symbol, time)
PARTITION BY toYYYYMM(time); PARTITION BY toYYYYMM(time);
CREATE TABLE IF NOT EXISTS qrust.bars_validity ( CREATE TABLE IF NOT EXISTS qrust.backfills (
symbol String, symbol String,
time_last DateTime time DateTime
) )
ENGINE = ReplacingMergeTree() ENGINE = ReplacingMergeTree()
PRIMARY KEY symbol; PRIMARY KEY symbol;