commit
ff280584d5
8 changed files with 2515 additions and 0 deletions
@ -0,0 +1 @@ |
|||||||
|
/target |
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,19 @@ |
|||||||
|
[package] |
||||||
|
name = "poloniex_dump" |
||||||
|
version = "0.1.0" |
||||||
|
edition = "2021" |
||||||
|
|
||||||
|
[dependencies] |
||||||
|
async-stream = "0.3.6" |
||||||
|
chrono = { version = "0.4.39", features = ["serde"] } |
||||||
|
futures-util = "0.3.31" |
||||||
|
reqwest = { version = "0.12.12", features = ["json"] } |
||||||
|
reqwest-websocket = "0.4.4" |
||||||
|
rusqlite = "0.33.0" |
||||||
|
serde = { version = "1.0.217", features = ["derive"] } |
||||||
|
serde_json = "1.0.138" |
||||||
|
serde_tuple = "1.1.0" |
||||||
|
strum = { version = "0.26.3", features = ["derive"] } |
||||||
|
thiserror = "2.0.11" |
||||||
|
tokio = { version = "1.43.0", features = ["rt-multi-thread", "macros"] } |
||||||
|
url = "2.5.4" |
@ -0,0 +1,21 @@ |
|||||||
|
use url::ParseError; |
||||||
|
|
||||||
|
pub type AppResult<R> = Result<R, AppError>; |
||||||
|
|
||||||
|
#[derive(thiserror::Error, Debug)] |
||||||
|
pub enum AppError { |
||||||
|
#[error(transparent)] |
||||||
|
UrlParseError(#[from] ParseError), |
||||||
|
|
||||||
|
#[error(transparent)] |
||||||
|
ReqwestError(#[from] reqwest::Error), |
||||||
|
|
||||||
|
#[error(transparent)] |
||||||
|
ReqwestWsError(#[from] reqwest_websocket::Error), |
||||||
|
|
||||||
|
#[error(transparent)] |
||||||
|
SerdeError(#[from] serde_json::Error), |
||||||
|
|
||||||
|
#[error(transparent)] |
||||||
|
DbError(#[from] rusqlite::Error), |
||||||
|
} |
@ -0,0 +1,95 @@ |
|||||||
|
use chrono::{NaiveDate, NaiveDateTime, TimeDelta, Utc}; |
||||||
|
use error::AppResult; |
||||||
|
use futures_util::StreamExt; |
||||||
|
use models::{Candle, CandleInterval, Pair}; |
||||||
|
use poloniex::PoloniexClient; |
||||||
|
use repo::Repo; |
||||||
|
|
||||||
|
mod error; |
||||||
|
mod models; |
||||||
|
mod poloniex; |
||||||
|
mod repo; |
||||||
|
|
||||||
|
async fn fetch_candles_until_now( |
||||||
|
poloniex_client: &PoloniexClient, |
||||||
|
pair: &Pair, |
||||||
|
interval: CandleInterval, |
||||||
|
mut start_time: NaiveDateTime, |
||||||
|
) -> AppResult<Vec<Candle>> { |
||||||
|
let mut result = vec![]; |
||||||
|
|
||||||
|
loop { |
||||||
|
let candles = poloniex_client |
||||||
|
.get_historical_candles(pair, interval, start_time, Utc::now().naive_utc()) |
||||||
|
.await?; |
||||||
|
|
||||||
|
let Some(last_candle) = candles.last() else { |
||||||
|
// больше нет свечей, скачали все возможные
|
||||||
|
break; |
||||||
|
}; |
||||||
|
|
||||||
|
let last_candle_ts = last_candle.ts.and_utc().timestamp(); |
||||||
|
let now = Utc::now().timestamp(); |
||||||
|
|
||||||
|
dbg!(last_candle_ts, now); |
||||||
|
if last_candle_ts < now { |
||||||
|
// если какие-то свечки недополучили из-за лимитов,
|
||||||
|
// смещаем запрашиваемый временной отрезок вправо
|
||||||
|
start_time = last_candle.ts + TimeDelta::seconds(1); |
||||||
|
|
||||||
|
if start_time.and_utc().timestamp() >= now { |
||||||
|
break; |
||||||
|
} |
||||||
|
} else { |
||||||
|
break; |
||||||
|
} |
||||||
|
|
||||||
|
result.extend(candles); |
||||||
|
} |
||||||
|
|
||||||
|
Ok(result) |
||||||
|
} |
||||||
|
|
||||||
|
async fn _main() -> AppResult<()> { |
||||||
|
let poloniex_client = PoloniexClient::new( |
||||||
|
"https://api.poloniex.com", |
||||||
|
"wss://ws.poloniex.com/ws/public", |
||||||
|
)?; |
||||||
|
let repo = Repo::new_init("poloniex_data.db")?; |
||||||
|
|
||||||
|
let start_time = NaiveDate::from_ymd_opt(2024, 12, 1) |
||||||
|
.unwrap() |
||||||
|
.and_hms_opt(0, 0, 0) |
||||||
|
.unwrap(); |
||||||
|
|
||||||
|
let pair = Pair::new("BTC", "USDT"); |
||||||
|
let candles = |
||||||
|
fetch_candles_until_now(&poloniex_client, &pair, CandleInterval::M1, start_time).await?; |
||||||
|
|
||||||
|
println!("fetched {} candles", candles.len()); |
||||||
|
|
||||||
|
// нельзя так делать, нужно использовать транзакцию
|
||||||
|
// и батч-вставку для уменьшения количества обращений к бд,
|
||||||
|
// но в контексте тестового и так сойдёт
|
||||||
|
for candle in candles { |
||||||
|
repo.insert_candle(&candle)?; |
||||||
|
} |
||||||
|
|
||||||
|
let mut trades = poloniex_client.recent_trades_stream(&pair).await?; |
||||||
|
|
||||||
|
while let Some(t) = trades.next().await { |
||||||
|
println!("{t:?}"); |
||||||
|
|
||||||
|
if let Ok(trade) = t { |
||||||
|
repo.insert_trade(&trade)?; |
||||||
|
} |
||||||
|
} |
||||||
|
Ok(()) |
||||||
|
} |
||||||
|
|
||||||
|
#[tokio::main] |
||||||
|
async fn main() { |
||||||
|
if let Err(e) = _main().await { |
||||||
|
eprintln!("{e}"); |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,102 @@ |
|||||||
|
use std::fmt::{self, Display}; |
||||||
|
|
||||||
|
use chrono::{DateTime, NaiveDateTime}; |
||||||
|
use serde::{Deserialize, Deserializer, Serialize}; |
||||||
|
use serde_tuple::Deserialize_tuple; |
||||||
|
|
||||||
|
pub struct Pair { |
||||||
|
pub base: String, |
||||||
|
pub quote: String, |
||||||
|
} |
||||||
|
|
||||||
|
impl Pair { |
||||||
|
pub fn new(base: &str, quote: &str) -> Self { |
||||||
|
Self { |
||||||
|
base: base.to_string(), |
||||||
|
quote: quote.to_string(), |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
impl Display for Pair { |
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { |
||||||
|
write!(f, "{}_{}", self.base, self.quote) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
#[derive(strum::AsRefStr, Clone, Copy)] |
||||||
|
pub enum CandleInterval { |
||||||
|
#[strum(serialize = "MINUTE_1")] |
||||||
|
M1, |
||||||
|
#[strum(serialize = "MINUTE_15")] |
||||||
|
M15, |
||||||
|
#[strum(serialize = "HOUR_1")] |
||||||
|
H1, |
||||||
|
#[strum(serialize = "DAY_1")] |
||||||
|
D1, |
||||||
|
} |
||||||
|
|
||||||
|
#[derive(Debug, Deserialize_tuple)] |
||||||
|
pub struct Candle { |
||||||
|
pub low: String, |
||||||
|
pub high: String, |
||||||
|
pub open: String, |
||||||
|
pub close: String, |
||||||
|
pub amount: String, |
||||||
|
pub quantity: String, |
||||||
|
pub buy_taker_amount: String, |
||||||
|
pub buy_taker_quantity: String, |
||||||
|
pub trade_count: i64, |
||||||
|
#[serde(deserialize_with = "deser_naive_dt")] |
||||||
|
pub ts: NaiveDateTime, |
||||||
|
pub weighted_average: String, |
||||||
|
pub interval: String, |
||||||
|
#[serde(deserialize_with = "deser_naive_dt")] |
||||||
|
pub start_time: NaiveDateTime, |
||||||
|
#[serde(deserialize_with = "deser_naive_dt")] |
||||||
|
pub close_time: NaiveDateTime, |
||||||
|
} |
||||||
|
|
||||||
|
fn deser_naive_dt<'de, D: Deserializer<'de>>(deserialize: D) -> Result<NaiveDateTime, D::Error> { |
||||||
|
let ts = Deserialize::deserialize(deserialize)?; |
||||||
|
DateTime::from_timestamp_millis(ts) |
||||||
|
.ok_or(serde::de::Error::custom(format!("wrong timestamp '{ts}'"))) |
||||||
|
.map(|dt| dt.naive_utc()) |
||||||
|
} |
||||||
|
|
||||||
|
#[derive(Deserialize, Debug)] |
||||||
|
#[serde(rename_all = "camelCase")] |
||||||
|
pub struct Trade { |
||||||
|
pub symbol: String, |
||||||
|
pub amount: String, |
||||||
|
pub taker_side: String, |
||||||
|
pub quantity: String, |
||||||
|
#[serde(deserialize_with = "deser_naive_dt")] |
||||||
|
pub create_time: NaiveDateTime, |
||||||
|
pub price: String, |
||||||
|
pub id: String, |
||||||
|
#[serde(deserialize_with = "deser_naive_dt")] |
||||||
|
pub ts: NaiveDateTime, |
||||||
|
} |
||||||
|
|
||||||
|
#[derive(Serialize)] |
||||||
|
pub struct SubscriptionRequest { |
||||||
|
pub event: String, |
||||||
|
pub channel: Vec<String>, |
||||||
|
pub symbols: Vec<String>, |
||||||
|
} |
||||||
|
|
||||||
|
#[derive(Deserialize, Debug)] |
||||||
|
#[allow(dead_code)] |
||||||
|
pub struct SubscriptionResponse { |
||||||
|
pub event: String, |
||||||
|
pub channel: String, |
||||||
|
pub symbols: Vec<String>, |
||||||
|
} |
||||||
|
|
||||||
|
#[derive(Deserialize, Debug)] |
||||||
|
#[allow(dead_code)] |
||||||
|
pub struct SubscriptionResponseData<T> { |
||||||
|
pub channel: String, |
||||||
|
pub data: Vec<T>, |
||||||
|
} |
@ -0,0 +1,103 @@ |
|||||||
|
use std::pin::Pin; |
||||||
|
|
||||||
|
use chrono::{NaiveDate, NaiveDateTime}; |
||||||
|
use futures_util::{SinkExt, Stream, TryStreamExt as _}; |
||||||
|
use reqwest::Url; |
||||||
|
use reqwest_websocket::Message; |
||||||
|
use serde::{Deserialize, Serialize}; |
||||||
|
|
||||||
|
use crate::{ |
||||||
|
error::{AppError, AppResult}, |
||||||
|
models::{ |
||||||
|
Candle, CandleInterval, Pair, SubscriptionRequest, SubscriptionResponse, |
||||||
|
SubscriptionResponseData, Trade, |
||||||
|
}, |
||||||
|
}; |
||||||
|
|
||||||
|
pub struct PoloniexClient { |
||||||
|
rest_base_url: Url, |
||||||
|
ws_base_url: Url, |
||||||
|
} |
||||||
|
|
||||||
|
impl PoloniexClient { |
||||||
|
pub fn new(rest_base_url: &str, ws_base_url: &str) -> AppResult<Self> { |
||||||
|
let rest_base_url = rest_base_url.parse()?; |
||||||
|
let ws_base_url = ws_base_url.parse()?; |
||||||
|
|
||||||
|
Ok(Self { |
||||||
|
rest_base_url, |
||||||
|
ws_base_url, |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
pub async fn get_historical_candles( |
||||||
|
&self, |
||||||
|
pair: &Pair, |
||||||
|
interval: CandleInterval, |
||||||
|
start_date: NaiveDateTime, |
||||||
|
end_date: NaiveDateTime, |
||||||
|
) -> AppResult<Vec<Candle>> { |
||||||
|
let mut req = self |
||||||
|
.rest_base_url |
||||||
|
.join(&format!("/markets/{}/candles", pair.to_string()))?; |
||||||
|
|
||||||
|
req.query_pairs_mut() |
||||||
|
.append_pair( |
||||||
|
"startTime", |
||||||
|
&start_date.and_utc().timestamp_millis().to_string(), |
||||||
|
) |
||||||
|
.append_pair( |
||||||
|
"endTime", |
||||||
|
&end_date.and_utc().timestamp_millis().to_string(), |
||||||
|
) |
||||||
|
.append_pair("interval", interval.as_ref()) |
||||||
|
.append_pair("limit", "500"); |
||||||
|
|
||||||
|
let result = reqwest::get(req).await?; |
||||||
|
|
||||||
|
if let Err(e) = result.error_for_status_ref() { |
||||||
|
println!("{:?}", result.text().await); |
||||||
|
return Err(e.into()); |
||||||
|
}; |
||||||
|
result.json().await.map_err(AppError::from) |
||||||
|
} |
||||||
|
|
||||||
|
pub async fn recent_trades_stream( |
||||||
|
&self, |
||||||
|
pair: &Pair, |
||||||
|
) -> AppResult<Pin<Box<dyn Stream<Item = AppResult<Trade>>>>> { |
||||||
|
let mut ws = reqwest_websocket::websocket(self.ws_base_url.clone()).await?; |
||||||
|
|
||||||
|
let req = SubscriptionRequest { |
||||||
|
event: "subscribe".to_string(), |
||||||
|
channel: vec!["trades".to_string()], |
||||||
|
symbols: vec![pair.to_string()], |
||||||
|
}; |
||||||
|
|
||||||
|
ws.send(Message::Text(serde_json::to_string(&req)?)).await?; |
||||||
|
|
||||||
|
Ok(Box::pin(async_stream::stream! { |
||||||
|
while let Some(message) = ws.try_next().await.unwrap() { |
||||||
|
match message { |
||||||
|
Message::Text(text) => { |
||||||
|
if let Ok(sub) = serde_json::from_str::<SubscriptionResponse>(&text) { |
||||||
|
println!("{sub:?}"); |
||||||
|
continue
|
||||||
|
} |
||||||
|
|
||||||
|
dbg!(&text); |
||||||
|
let trades = serde_json::from_str::<SubscriptionResponseData<Trade>>(&text)?; |
||||||
|
for trade in trades.data { |
||||||
|
yield Ok(trade) |
||||||
|
} |
||||||
|
}, |
||||||
|
Message::Close {..} => { |
||||||
|
eprintln!("trades stream closed: {message:?}"); |
||||||
|
break
|
||||||
|
}, |
||||||
|
m => eprintln!("unknown message {m:?}") |
||||||
|
} |
||||||
|
} |
||||||
|
})) |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,159 @@ |
|||||||
|
use std::{fs, path::Path}; |
||||||
|
|
||||||
|
use rusqlite::{self, params, Connection}; |
||||||
|
|
||||||
|
use crate::{ |
||||||
|
error::{AppError, AppResult}, |
||||||
|
models::{Candle, Trade}, |
||||||
|
}; |
||||||
|
|
||||||
|
pub struct Repo { |
||||||
|
conn: Connection, |
||||||
|
} |
||||||
|
|
||||||
|
impl Repo { |
||||||
|
pub fn new_init(db_path: impl AsRef<Path>) -> AppResult<Self> { |
||||||
|
let path = db_path.as_ref(); |
||||||
|
|
||||||
|
// постоянно создаём новую бд для упрощения тестирования
|
||||||
|
fs::remove_file(path).ok(); |
||||||
|
|
||||||
|
let conn = Connection::open(path)?; |
||||||
|
|
||||||
|
conn.execute( |
||||||
|
" |
||||||
|
CREATE TABLE IF NOT EXISTS trades( |
||||||
|
symbol TEXT NOT NULL, |
||||||
|
amount TEXT NOT NULL, |
||||||
|
taker_side TEXT NOT NULL, |
||||||
|
quantity TEXT NOT NULL, |
||||||
|
create_time INT NOT NULL, |
||||||
|
price TEXT NOT NULL, |
||||||
|
id TEXT NOT NULL, |
||||||
|
ts INT NOT NULL |
||||||
|
); |
||||||
|
", |
||||||
|
[], |
||||||
|
)?; |
||||||
|
conn.execute( |
||||||
|
" |
||||||
|
CREATE TABLE IF NOT EXISTS candles( |
||||||
|
low TEXT NOT NULL, |
||||||
|
high TEXT NOT NULL, |
||||||
|
open TEXT NOT NULL, |
||||||
|
close TEXT NOT NULL, |
||||||
|
amount TEXT NOT NULL, |
||||||
|
quantity TEXT NOT NULL, |
||||||
|
buy_taker_amount TEXT NOT NULL, |
||||||
|
buy_taker_quantity TEXT NOT NULL, |
||||||
|
trade_count INT NOT NULL, |
||||||
|
ts INT NOT NULL, |
||||||
|
weighted_average TEXT NOT NULL, |
||||||
|
interval TEXT NOT NULL, |
||||||
|
start_time INT NOT NULL, |
||||||
|
close_time INT NOT NULL |
||||||
|
); |
||||||
|
", |
||||||
|
[], |
||||||
|
)?; |
||||||
|
|
||||||
|
Ok(Self { conn }) |
||||||
|
} |
||||||
|
|
||||||
|
pub fn insert_candle(&self, candle: &Candle) -> AppResult<usize> { |
||||||
|
let q = " |
||||||
|
INSERT INTO candles( |
||||||
|
low, |
||||||
|
high, |
||||||
|
open, |
||||||
|
close, |
||||||
|
amount, |
||||||
|
quantity, |
||||||
|
buy_taker_amount, |
||||||
|
buy_taker_quantity, |
||||||
|
trade_count, |
||||||
|
ts, |
||||||
|
weighted_average, |
||||||
|
interval, |
||||||
|
start_time, |
||||||
|
close_time |
||||||
|
) VALUES ( |
||||||
|
?1, |
||||||
|
?2, |
||||||
|
?3, |
||||||
|
?4, |
||||||
|
?5, |
||||||
|
?6, |
||||||
|
?7, |
||||||
|
?8, |
||||||
|
?9, |
||||||
|
?10, |
||||||
|
?11, |
||||||
|
?12, |
||||||
|
?13, |
||||||
|
?14 |
||||||
|
) |
||||||
|
"; |
||||||
|
self.conn |
||||||
|
.execute( |
||||||
|
q, |
||||||
|
params![ |
||||||
|
&candle.low, |
||||||
|
&candle.high, |
||||||
|
&candle.open, |
||||||
|
&candle.close, |
||||||
|
&candle.amount, |
||||||
|
&candle.quantity, |
||||||
|
&candle.buy_taker_amount, |
||||||
|
&candle.buy_taker_quantity, |
||||||
|
&candle.trade_count, |
||||||
|
&candle.ts.and_utc().timestamp_millis(), |
||||||
|
&candle.weighted_average, |
||||||
|
&candle.interval, |
||||||
|
&candle.start_time.and_utc().timestamp_millis(), |
||||||
|
&candle.close_time.and_utc().timestamp_millis(), |
||||||
|
], |
||||||
|
) |
||||||
|
.map_err(AppError::from) |
||||||
|
} |
||||||
|
|
||||||
|
pub fn insert_trade(&self, trade: &Trade) -> AppResult<usize> { |
||||||
|
let q = " |
||||||
|
INSERT INTO trades( |
||||||
|
symbol, |
||||||
|
amount, |
||||||
|
taker_side, |
||||||
|
quantity, |
||||||
|
create_time, |
||||||
|
price, |
||||||
|
id, |
||||||
|
ts |
||||||
|
) VALUES ( |
||||||
|
?1, |
||||||
|
?2, |
||||||
|
?3, |
||||||
|
?4, |
||||||
|
?5, |
||||||
|
?6, |
||||||
|
?7, |
||||||
|
?8 |
||||||
|
); |
||||||
|
"; |
||||||
|
|
||||||
|
self.conn |
||||||
|
.execute( |
||||||
|
&q, |
||||||
|
params![ |
||||||
|
&trade.symbol, |
||||||
|
&trade.amount, |
||||||
|
&trade.taker_side, |
||||||
|
&trade.quantity, |
||||||
|
&trade.create_time.and_utc().timestamp_millis(), |
||||||
|
&trade.price, |
||||||
|
&trade.id, |
||||||
|
&trade.ts.and_utc().timestamp_millis() |
||||||
|
], |
||||||
|
) |
||||||
|
.map_err(AppError::from) |
||||||
|
} |
||||||
|
} |
Loading…
Reference in new issue