paracas downloader 🚧

This commit is contained in:
Andreas Bigger
2025-12-29 13:21:48 -05:00
parent ab0afbc1d2
commit 7be4eded90
46 changed files with 4570 additions and 22 deletions
+25
View File
@@ -0,0 +1,25 @@
[package]
name = "paracas-aggregate"
description = "OHLCV aggregation for paracas tick data downloader"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
homepage.workspace = true
repository.workspace = true
documentation.workspace = true
authors.workspace = true
keywords.workspace = true
categories.workspace = true
[lints]
workspace = true
[package.metadata.docs.rs]
all-features = true
rustdoc-args = ["--cfg", "docsrs"]
[dependencies]
paracas-types = { workspace = true }
chrono = { workspace = true }
serde = { workspace = true }
+35
View File
@@ -0,0 +1,35 @@
# paracas-aggregate
OHLCV aggregation for the paracas tick data downloader.
## Features
- Tick-to-OHLCV aggregation
- Multiple timeframes (1s, 1m, 5m, 15m, 30m, 1h, 4h, 1d)
- Streaming aggregation for memory efficiency
## Usage
```rust,ignore
use paracas_aggregate::{Ohlcv, TickAggregator};
use paracas_types::Timeframe;
// Create an aggregator for 1-hour bars
let mut aggregator = TickAggregator::new(Timeframe::Hour1);
// Process ticks
for tick in ticks {
if let Some(bar) = aggregator.process(tick) {
println!("Completed bar: {:?}", bar);
}
}
// Get any remaining partial bar
if let Some(bar) = aggregator.finish() {
println!("Final bar: {:?}", bar);
}
```
## License
MIT License - see [LICENSE](../../LICENSE) for details.
+240
View File
@@ -0,0 +1,240 @@
//! Streaming tick-to-OHLCV aggregation.
use chrono::{DateTime, Datelike, TimeZone, Timelike, Utc};
use paracas_types::{Tick, Timeframe};
use crate::Ohlcv;
/// Streaming tick aggregator.
///
/// Aggregates ticks into OHLCV bars based on the configured timeframe.
#[derive(Debug)]
pub struct TickAggregator {
timeframe: Timeframe,
current_bar: Option<OhlcvBuilder>,
}
impl TickAggregator {
/// Creates a new aggregator for the given timeframe.
#[must_use]
pub const fn new(timeframe: Timeframe) -> Self {
Self {
timeframe,
current_bar: None,
}
}
/// Returns the timeframe being aggregated to.
#[must_use]
pub const fn timeframe(&self) -> Timeframe {
self.timeframe
}
/// Processes a tick, potentially emitting a completed bar.
///
/// Returns `Some(bar)` when a bar is completed by this tick,
/// `None` otherwise.
pub fn process(&mut self, tick: Tick) -> Option<Ohlcv> {
let bar_start = self.bar_start_for(tick.timestamp);
match self.current_bar.take() {
Some(mut builder) if builder.timestamp == bar_start => {
// Same bar, update it
builder.update(&tick);
self.current_bar = Some(builder);
None
}
Some(builder) => {
// New bar started, finish the old one
let completed = builder.finish();
self.current_bar = Some(OhlcvBuilder::new(bar_start, &tick));
Some(completed)
}
None => {
// First tick
self.current_bar = Some(OhlcvBuilder::new(bar_start, &tick));
None
}
}
}
/// Finishes aggregation, returning any remaining partial bar.
#[must_use]
pub fn finish(self) -> Option<Ohlcv> {
self.current_bar.map(|b| b.finish())
}
/// Calculates the bar start time for a given timestamp.
fn bar_start_for(&self, timestamp: DateTime<Utc>) -> DateTime<Utc> {
match self.timeframe {
Timeframe::Tick => timestamp,
Timeframe::Second1 => truncate_to_seconds(timestamp, 1),
Timeframe::Minute1 => truncate_to_minutes(timestamp, 1),
Timeframe::Minute5 => truncate_to_minutes(timestamp, 5),
Timeframe::Minute15 => truncate_to_minutes(timestamp, 15),
Timeframe::Minute30 => truncate_to_minutes(timestamp, 30),
Timeframe::Hour1 => truncate_to_hours(timestamp, 1),
Timeframe::Hour4 => truncate_to_hours(timestamp, 4),
Timeframe::Day1 => truncate_to_day(timestamp),
}
}
}
/// Builder for OHLCV bars.
#[derive(Debug)]
struct OhlcvBuilder {
timestamp: DateTime<Utc>,
open: f64,
high: f64,
low: f64,
close: f64,
volume: f64,
tick_count: u32,
}
impl OhlcvBuilder {
/// Creates a new builder from the first tick.
fn new(timestamp: DateTime<Utc>, tick: &Tick) -> Self {
let mid = tick.mid();
let volume = f64::from(tick.total_volume());
Self {
timestamp,
open: mid,
high: mid,
low: mid,
close: mid,
volume,
tick_count: 1,
}
}
/// Updates the builder with a new tick.
fn update(&mut self, tick: &Tick) {
let mid = tick.mid();
self.high = self.high.max(mid);
self.low = self.low.min(mid);
self.close = mid;
self.volume += f64::from(tick.total_volume());
self.tick_count += 1;
}
/// Finishes building and returns the OHLCV bar.
const fn finish(self) -> Ohlcv {
Ohlcv::new(
self.timestamp,
self.open,
self.high,
self.low,
self.close,
self.volume,
self.tick_count,
)
}
}
/// Truncates a timestamp to the start of a second boundary.
fn truncate_to_seconds(dt: DateTime<Utc>, interval: u32) -> DateTime<Utc> {
let second = dt.second() / interval * interval;
Utc.with_ymd_and_hms(
dt.year(),
dt.month(),
dt.day(),
dt.hour(),
dt.minute(),
second,
)
.unwrap()
}
/// Truncates a timestamp to the start of a minute boundary.
fn truncate_to_minutes(dt: DateTime<Utc>, interval: u32) -> DateTime<Utc> {
let minute = dt.minute() / interval * interval;
Utc.with_ymd_and_hms(dt.year(), dt.month(), dt.day(), dt.hour(), minute, 0)
.unwrap()
}
/// Truncates a timestamp to the start of an hour boundary.
fn truncate_to_hours(dt: DateTime<Utc>, interval: u32) -> DateTime<Utc> {
let hour = dt.hour() / interval * interval;
Utc.with_ymd_and_hms(dt.year(), dt.month(), dt.day(), hour, 0, 0)
.unwrap()
}
/// Truncates a timestamp to the start of the day.
fn truncate_to_day(dt: DateTime<Utc>) -> DateTime<Utc> {
Utc.with_ymd_and_hms(dt.year(), dt.month(), dt.day(), 0, 0, 0)
.unwrap()
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeDelta;
fn make_tick(hour: u32, minute: u32, second: u32, millis: u32, ask: f64, bid: f64) -> Tick {
let timestamp = Utc
.with_ymd_and_hms(2024, 1, 1, hour, minute, second)
.unwrap()
+ TimeDelta::milliseconds(i64::from(millis));
Tick::new(timestamp, ask, bid, 100.0, 100.0)
}
#[test]
fn test_minute_aggregation() {
let mut agg = TickAggregator::new(Timeframe::Minute1);
// First tick at 12:00:00
let tick1 = make_tick(12, 0, 0, 0, 1.1001, 1.1000);
assert!(agg.process(tick1).is_none());
// Second tick at 12:00:30 (same minute)
let tick2 = make_tick(12, 0, 30, 0, 1.1010, 1.1005);
assert!(agg.process(tick2).is_none());
// Third tick at 12:01:00 (new minute, completes first bar)
let tick3 = make_tick(12, 1, 0, 0, 1.0990, 1.0985);
let bar = agg.process(tick3).unwrap();
assert_eq!(bar.tick_count, 2);
assert!((bar.open - 1.10005).abs() < 1e-10); // mid of first tick
assert!((bar.close - 1.10075).abs() < 1e-10); // mid of second tick
}
#[test]
fn test_hour_aggregation() {
let mut agg = TickAggregator::new(Timeframe::Hour1);
let tick1 = make_tick(12, 0, 0, 0, 1.1001, 1.1000);
assert!(agg.process(tick1).is_none());
let tick2 = make_tick(12, 30, 0, 0, 1.1050, 1.1045);
assert!(agg.process(tick2).is_none());
let tick3 = make_tick(13, 0, 0, 0, 1.0990, 1.0985);
let bar = agg.process(tick3).unwrap();
assert_eq!(bar.tick_count, 2);
assert_eq!(bar.timestamp.hour(), 12);
}
#[test]
fn test_finish() {
let mut agg = TickAggregator::new(Timeframe::Hour1);
let tick1 = make_tick(12, 0, 0, 0, 1.1001, 1.1000);
agg.process(tick1);
let bar = agg.finish().unwrap();
assert_eq!(bar.tick_count, 1);
}
#[test]
fn test_truncate_functions() {
let dt = Utc.with_ymd_and_hms(2024, 1, 15, 14, 37, 45).unwrap();
assert_eq!(truncate_to_minutes(dt, 5).minute(), 35);
assert_eq!(truncate_to_minutes(dt, 15).minute(), 30);
assert_eq!(truncate_to_hours(dt, 4).hour(), 12);
assert_eq!(truncate_to_day(dt).hour(), 0);
}
}
+18
View File
@@ -0,0 +1,18 @@
//! OHLCV aggregation for paracas tick data downloader.
//!
//! This crate provides tick-to-OHLCV (candlestick) aggregation:
//!
//! - [`Ohlcv`] - OHLCV bar data structure
//! - [`TickAggregator`] - Streaming tick aggregator
#![doc = include_str!("../README.md")]
#![doc(issue_tracker_base_url = "https://github.com/factordynamics/paracas/issues/")]
#![cfg_attr(docsrs, feature(doc_cfg, doc_auto_cfg))]
#![warn(missing_docs)]
#![forbid(unsafe_code)]
mod aggregator;
mod ohlcv;
pub use aggregator::TickAggregator;
pub use ohlcv::Ohlcv;
+128
View File
@@ -0,0 +1,128 @@
//! OHLCV (candlestick) data structure.
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
/// OHLCV bar (candlestick) data.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct Ohlcv {
/// Bar open time (start of the period).
pub timestamp: DateTime<Utc>,
/// Opening price (first tick's mid price).
pub open: f64,
/// Highest price during the period.
pub high: f64,
/// Lowest price during the period.
pub low: f64,
/// Closing price (last tick's mid price).
pub close: f64,
/// Total volume (sum of ask + bid volumes).
pub volume: f64,
/// Number of ticks in the bar.
pub tick_count: u32,
}
impl Ohlcv {
/// Creates a new OHLCV bar.
#[must_use]
pub const fn new(
timestamp: DateTime<Utc>,
open: f64,
high: f64,
low: f64,
close: f64,
volume: f64,
tick_count: u32,
) -> Self {
Self {
timestamp,
open,
high,
low,
close,
volume,
tick_count,
}
}
/// Returns the price range (high - low).
#[must_use]
pub fn range(&self) -> f64 {
self.high - self.low
}
/// Returns the body size (|close - open|).
#[must_use]
pub fn body(&self) -> f64 {
(self.close - self.open).abs()
}
/// Returns true if this is a bullish (green) bar.
#[must_use]
pub fn is_bullish(&self) -> bool {
self.close > self.open
}
/// Returns true if this is a bearish (red) bar.
#[must_use]
pub fn is_bearish(&self) -> bool {
self.close < self.open
}
/// Returns the typical price ((high + low + close) / 3).
#[must_use]
pub fn typical_price(&self) -> f64 {
(self.high + self.low + self.close) / 3.0
}
/// Returns the weighted close ((high + low + 2*close) / 4).
#[must_use]
pub fn weighted_close(&self) -> f64 {
(self.high + self.low + 2.0 * self.close) / 4.0
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
fn create_test_bar() -> Ohlcv {
let timestamp = Utc.with_ymd_and_hms(2024, 1, 1, 12, 0, 0).unwrap();
Ohlcv::new(timestamp, 1.1000, 1.1050, 1.0980, 1.1020, 1000.0, 500)
}
#[test]
fn test_range() {
let bar = create_test_bar();
assert!((bar.range() - 0.0070).abs() < 1e-10);
}
#[test]
fn test_body() {
let bar = create_test_bar();
assert!((bar.body() - 0.0020).abs() < 1e-10);
}
#[test]
fn test_bullish() {
let bar = create_test_bar();
assert!(bar.is_bullish());
assert!(!bar.is_bearish());
}
#[test]
fn test_bearish() {
let timestamp = Utc.with_ymd_and_hms(2024, 1, 1, 12, 0, 0).unwrap();
let bar = Ohlcv::new(timestamp, 1.1020, 1.1050, 1.0980, 1.1000, 1000.0, 500);
assert!(!bar.is_bullish());
assert!(bar.is_bearish());
}
#[test]
fn test_typical_price() {
let bar = create_test_bar();
let expected = (1.1050 + 1.0980 + 1.1020) / 3.0;
assert!((bar.typical_price() - expected).abs() < 1e-10);
}
}
+34
View File
@@ -0,0 +1,34 @@
[package]
name = "paracas-fetch"
description = "HTTP client and data fetching for paracas tick data downloader"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
homepage.workspace = true
repository.workspace = true
documentation.workspace = true
authors.workspace = true
keywords.workspace = true
categories.workspace = true
[lints]
workspace = true
[package.metadata.docs.rs]
all-features = true
rustdoc-args = ["--cfg", "docsrs"]
[dependencies]
paracas-types = { workspace = true }
tokio = { workspace = true }
futures = { workspace = true }
reqwest = { workspace = true }
bytes = { workspace = true }
lzma-rs = { workspace = true }
byteorder = { workspace = true }
chrono = { workspace = true }
thiserror = { workspace = true }
[dev-dependencies]
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
+52
View File
@@ -0,0 +1,52 @@
# paracas-fetch
HTTP client and data fetching for the paracas tick data downloader.
## Features
- Concurrent HTTP downloads with connection pooling
- LZMA decompression for bi5 files
- Binary tick data parsing
- Streaming async API with backpressure
## Architecture
The fetch pipeline consists of:
1. **URL Builder** - Constructs Dukascopy data URLs
2. **HTTP Client** - Downloads bi5 files with retries
3. **Decompressor** - LZMA decompression
4. **Parser** - Binary tick data parsing
## Usage
```rust,ignore
use paracas_fetch::{DownloadClient, ClientConfig, tick_stream};
use paracas_instruments::InstrumentRegistry;
use paracas_types::DateRange;
use futures::StreamExt;
#[tokio::main]
async fn main() {
let client = DownloadClient::new(ClientConfig::default()).unwrap();
let registry = InstrumentRegistry::global();
let instrument = registry.get("eurusd").unwrap();
let range = DateRange::new(
chrono::NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
chrono::NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
).unwrap();
let mut stream = tick_stream(&client, instrument, range);
while let Some(result) = stream.next().await {
match result {
Ok(tick) => println!("{:?}", tick),
Err(e) => eprintln!("Error: {}", e),
}
}
}
```
## License
MIT License - see [LICENSE](../../LICENSE) for details.
+214
View File
@@ -0,0 +1,214 @@
//! HTTP client for downloading bi5 files.
use bytes::Bytes;
use reqwest::Client;
use std::time::Duration;
use thiserror::Error;
/// Configuration for the download client.
#[derive(Debug, Clone)]
pub struct ClientConfig {
/// Maximum concurrent downloads.
pub concurrency: usize,
/// Request timeout.
pub timeout: Duration,
/// Maximum retry attempts for failed requests.
pub max_retries: u32,
/// Base delay for exponential backoff (in milliseconds).
pub base_delay_ms: u64,
/// Maximum delay between retries (in milliseconds).
pub max_delay_ms: u64,
/// User agent string.
pub user_agent: String,
}
impl Default for ClientConfig {
fn default() -> Self {
Self {
concurrency: 10, // Lower concurrency to avoid overwhelming the server
timeout: Duration::from_secs(60),
max_retries: 10, // More retries for transient failures
base_delay_ms: 500, // Start with 500ms delay
max_delay_ms: 30_000, // Max 30 seconds between retries
user_agent: format!("paracas/{}", env!("CARGO_PKG_VERSION")),
}
}
}
/// Errors that can occur during downloads.
#[derive(Error, Debug)]
pub enum DownloadError {
/// HTTP request failed.
#[error("HTTP error: {0}")]
Http(#[from] reqwest::Error),
/// Request timed out.
#[error("Request timed out after {0} attempts")]
Timeout(u32),
/// Server returned an error status.
#[error("Server error: {status}")]
ServerError {
/// HTTP status code.
status: u16,
},
}
/// HTTP client with connection pooling and retry logic.
#[derive(Debug, Clone)]
pub struct DownloadClient {
client: Client,
config: ClientConfig,
}
impl DownloadClient {
/// Creates a new download client with the given configuration.
///
/// # Errors
///
/// Returns an error if the HTTP client cannot be created.
pub fn new(config: ClientConfig) -> Result<Self, reqwest::Error> {
let client = Client::builder()
.pool_max_idle_per_host(config.concurrency)
.timeout(config.timeout)
.user_agent(&config.user_agent)
.gzip(true)
.build()?;
Ok(Self { client, config })
}
/// Creates a client with default configuration.
///
/// # Errors
///
/// Returns an error if the HTTP client cannot be created.
pub fn with_defaults() -> Result<Self, reqwest::Error> {
Self::new(ClientConfig::default())
}
/// Returns the client configuration.
#[must_use]
pub const fn config(&self) -> &ClientConfig {
&self.config
}
/// Downloads a single bi5 file, returning the compressed bytes.
///
/// Returns `Ok(None)` if the file does not exist (404).
///
/// # Errors
///
/// Returns an error if the download fails after all retries.
pub async fn download(&self, url: &str) -> Result<Option<Bytes>, DownloadError> {
let mut attempts = 0;
loop {
match self.client.get(url).send().await {
Ok(response) => {
if response.status() == reqwest::StatusCode::NOT_FOUND {
return Ok(None); // No data for this hour
}
// Retry on server errors (5xx) and rate limiting (429)
if response.status().is_server_error()
|| response.status() == reqwest::StatusCode::TOO_MANY_REQUESTS
{
if attempts < self.config.max_retries {
attempts += 1;
let delay = self.calculate_backoff_delay(attempts);
tokio::time::sleep(delay).await;
continue;
}
return Err(DownloadError::ServerError {
status: response.status().as_u16(),
});
}
response.error_for_status_ref()?;
return Ok(Some(response.bytes().await?));
}
Err(e) if self.is_retryable_error(&e) && attempts < self.config.max_retries => {
attempts += 1;
let delay = self.calculate_backoff_delay(attempts);
tokio::time::sleep(delay).await;
}
Err(e) => return Err(e.into()),
}
}
}
/// Calculates the backoff delay with exponential backoff and jitter.
fn calculate_backoff_delay(&self, attempt: u32) -> Duration {
// Exponential backoff: base_delay * 2^attempt
let exp_delay = self
.config
.base_delay_ms
.saturating_mul(1u64 << attempt.min(10));
// Cap at max delay
let capped_delay = exp_delay.min(self.config.max_delay_ms);
// Add jitter (±25%)
let jitter_range = capped_delay / 4;
let jitter = if jitter_range > 0 {
// Simple deterministic jitter based on attempt number
// This avoids needing a random number generator
let jitter_offset = (attempt as u64 * 17) % (jitter_range * 2);
jitter_offset.saturating_sub(jitter_range)
} else {
0
};
let final_delay = (capped_delay as i64 + jitter as i64).max(100) as u64;
Duration::from_millis(final_delay)
}
/// Determines if an error is retryable.
fn is_retryable_error(&self, error: &reqwest::Error) -> bool {
// Don't retry builder errors (configuration issues)
if error.is_builder() {
return false;
}
// Retry on timeouts, connection errors, and request errors
error.is_timeout() || error.is_connect() || error.is_request()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_client_config_default() {
let config = ClientConfig::default();
assert_eq!(config.concurrency, 10);
assert_eq!(config.max_retries, 10);
assert_eq!(config.timeout, Duration::from_secs(60));
assert_eq!(config.base_delay_ms, 500);
assert_eq!(config.max_delay_ms, 30_000);
}
#[tokio::test]
async fn test_client_creation() {
let client = DownloadClient::with_defaults();
assert!(client.is_ok());
}
#[test]
fn test_backoff_delay_calculation() {
let client = DownloadClient::with_defaults().unwrap();
// First attempt: base_delay * 2 = 1000ms (plus jitter)
let delay1 = client.calculate_backoff_delay(1);
assert!(delay1.as_millis() >= 750 && delay1.as_millis() <= 1250);
// Second attempt: base_delay * 4 = 2000ms (plus jitter)
let delay2 = client.calculate_backoff_delay(2);
assert!(delay2.as_millis() >= 1500 && delay2.as_millis() <= 2500);
// High attempt should be capped at max_delay
let delay_high = client.calculate_backoff_delay(20);
assert!(delay_high.as_millis() <= 37500); // max_delay + 25% jitter
}
}
+64
View File
@@ -0,0 +1,64 @@
//! LZMA decompression for bi5 files.
use lzma_rs::lzma_decompress;
use std::io::{BufReader, Cursor};
use thiserror::Error;
/// Errors that can occur during decompression.
#[derive(Error, Debug)]
pub enum DecompressError {
/// LZMA decompression failed.
#[error("LZMA decompression failed: {0}")]
LzmaError(String),
/// Empty input data.
#[error("Empty input data")]
EmptyInput,
}
/// Decompresses LZMA-compressed bi5 data.
///
/// Dukascopy bi5 files are LZMA-compressed binary data containing tick records.
///
/// # Errors
///
/// Returns an error if decompression fails.
///
/// # Example
///
/// ```ignore
/// use paracas_fetch::decompress_bi5;
///
/// let compressed = /* bi5 data from HTTP */;
/// let decompressed = decompress_bi5(&compressed)?;
/// ```
pub fn decompress_bi5(compressed: &[u8]) -> Result<Vec<u8>, DecompressError> {
if compressed.is_empty() {
return Err(DecompressError::EmptyInput);
}
let mut decompressed = Vec::new();
let mut reader = BufReader::new(Cursor::new(compressed));
lzma_decompress(&mut reader, &mut decompressed)
.map_err(|e| DecompressError::LzmaError(e.to_string()))?;
Ok(decompressed)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_empty_input() {
let result = decompress_bi5(&[]);
assert!(matches!(result, Err(DecompressError::EmptyInput)));
}
#[test]
fn test_invalid_lzma() {
let result = decompress_bi5(&[0x00, 0x01, 0x02, 0x03]);
assert!(matches!(result, Err(DecompressError::LzmaError(_))));
}
}
+26
View File
@@ -0,0 +1,26 @@
//! HTTP client and data fetching for paracas tick data downloader.
//!
//! This crate provides the data download pipeline:
//!
//! - [`url::tick_url`] - Constructs Dukascopy data URLs
//! - [`DownloadClient`] - HTTP client with connection pooling and retries
//! - [`decompress::decompress_bi5`] - LZMA decompression
//! - [`parse::parse_ticks`] - Binary tick data parsing
//! - [`tick_stream`] - Async streaming tick download
#![doc = include_str!("../README.md")]
#![doc(issue_tracker_base_url = "https://github.com/factordynamics/paracas/issues/")]
#![cfg_attr(docsrs, feature(doc_cfg, doc_auto_cfg))]
#![warn(missing_docs)]
#![forbid(unsafe_code)]
mod client;
mod decompress;
mod parse;
mod stream;
pub mod url;
pub use client::{ClientConfig, DownloadClient, DownloadError};
pub use decompress::{DecompressError, decompress_bi5};
pub use parse::{ParseError, parse_ticks, tick_count};
pub use stream::{TickBatch, flatten_ticks, tick_stream, tick_stream_resilient};
+122
View File
@@ -0,0 +1,122 @@
//! Binary tick parsing from bi5 format.
use byteorder::{BigEndian, ByteOrder};
use paracas_types::RawTick;
use thiserror::Error;
/// Errors that can occur during tick parsing.
#[derive(Error, Debug, Clone, PartialEq, Eq)]
pub enum ParseError {
/// Invalid data length (not a multiple of tick size).
#[error("Invalid data length: {0} bytes (expected multiple of {1})")]
InvalidLength(usize, usize),
/// Incomplete tick record.
#[error("Incomplete tick record at offset {0}")]
IncompleteRecord(usize),
}
/// Parses raw ticks from decompressed bi5 data.
///
/// The bi5 format stores ticks as 20 bytes in big-endian order:
/// - `u32`: milliseconds offset from hour start (bytes 0-3)
/// - `u32`: ask price raw (bytes 4-7)
/// - `u32`: bid price raw (bytes 8-11)
/// - `f32`: ask volume (bytes 12-15)
/// - `f32`: bid volume (bytes 16-19)
///
/// # Arguments
///
/// * `data` - Decompressed bi5 data
///
/// # Returns
///
/// An iterator over parsed raw ticks.
///
/// # Errors
///
/// Returns an error if the data length is invalid.
pub fn parse_ticks(data: &[u8]) -> Result<impl Iterator<Item = RawTick> + '_, ParseError> {
if !data.len().is_multiple_of(RawTick::SIZE) {
return Err(ParseError::InvalidLength(data.len(), RawTick::SIZE));
}
Ok(data.chunks_exact(RawTick::SIZE).map(parse_single_tick))
}
/// Parses a single tick from a 20-byte chunk.
#[inline]
fn parse_single_tick(data: &[u8]) -> RawTick {
RawTick::new(
BigEndian::read_u32(&data[0..4]),
BigEndian::read_u32(&data[4..8]),
BigEndian::read_u32(&data[8..12]),
BigEndian::read_f32(&data[12..16]),
BigEndian::read_f32(&data[16..20]),
)
}
/// Returns the number of ticks in the given data.
#[must_use]
pub const fn tick_count(data_len: usize) -> usize {
data_len / RawTick::SIZE
}
#[cfg(test)]
mod tests {
use super::*;
fn create_test_tick_bytes(ms: u32, ask: u32, bid: u32, ask_vol: f32, bid_vol: f32) -> Vec<u8> {
let mut bytes = vec![0u8; 20];
BigEndian::write_u32(&mut bytes[0..4], ms);
BigEndian::write_u32(&mut bytes[4..8], ask);
BigEndian::write_u32(&mut bytes[8..12], bid);
BigEndian::write_f32(&mut bytes[12..16], ask_vol);
BigEndian::write_f32(&mut bytes[16..20], bid_vol);
bytes
}
#[test]
fn test_parse_single_tick() {
let bytes = create_test_tick_bytes(1000, 112345, 112340, 100.0, 200.0);
let tick = parse_single_tick(&bytes);
assert_eq!(tick.ms_offset, 1000);
assert_eq!(tick.ask_raw, 112345);
assert_eq!(tick.bid_raw, 112340);
assert!((tick.ask_volume - 100.0).abs() < 0.001);
assert!((tick.bid_volume - 200.0).abs() < 0.001);
}
#[test]
fn test_parse_multiple_ticks() {
let mut data = create_test_tick_bytes(0, 100, 99, 10.0, 20.0);
data.extend(create_test_tick_bytes(1000, 101, 100, 15.0, 25.0));
let ticks: Vec<_> = parse_ticks(&data).unwrap().collect();
assert_eq!(ticks.len(), 2);
assert_eq!(ticks[0].ms_offset, 0);
assert_eq!(ticks[1].ms_offset, 1000);
}
#[test]
fn test_invalid_length() {
let data = vec![0u8; 25]; // Not a multiple of 20
let result = parse_ticks(&data);
assert!(matches!(result, Err(ParseError::InvalidLength(25, 20))));
}
#[test]
fn test_empty_data() {
let data: Vec<u8> = vec![];
let ticks: Vec<_> = parse_ticks(&data).unwrap().collect();
assert!(ticks.is_empty());
}
#[test]
fn test_tick_count() {
assert_eq!(tick_count(0), 0);
assert_eq!(tick_count(20), 1);
assert_eq!(tick_count(200), 10);
}
}
+225
View File
@@ -0,0 +1,225 @@
//! Streaming tick download pipeline.
use chrono::{DateTime, Utc};
use futures::stream::{self, Stream, StreamExt};
use paracas_types::{DateRange, Instrument, ParacasError, Tick};
use crate::{DownloadClient, decompress_bi5, parse_ticks, url::tick_url};
/// A batch of ticks from a single hour.
#[derive(Debug, Clone)]
pub struct TickBatch {
/// The hour start timestamp.
pub hour: DateTime<Utc>,
/// The ticks in this batch.
pub ticks: Vec<Tick>,
/// Whether this batch had an error that was skipped.
pub had_error: bool,
}
impl TickBatch {
/// Creates a new tick batch.
#[must_use]
pub const fn new(hour: DateTime<Utc>, ticks: Vec<Tick>) -> Self {
Self {
hour,
ticks,
had_error: false,
}
}
/// Creates a new tick batch that represents a skipped error.
#[must_use]
pub const fn skipped_error(hour: DateTime<Utc>) -> Self {
Self {
hour,
ticks: Vec::new(),
had_error: true,
}
}
/// Returns true if the batch is empty.
#[must_use]
pub const fn is_empty(&self) -> bool {
self.ticks.is_empty()
}
/// Returns the number of ticks in the batch.
#[must_use]
pub const fn len(&self) -> usize {
self.ticks.len()
}
/// Returns true if this batch had an error that was skipped.
#[must_use]
pub const fn had_error(&self) -> bool {
self.had_error
}
}
/// Creates an async stream of tick batches for the given instrument and date range.
///
/// This function downloads, decompresses, and parses tick data concurrently
/// using the configured number of parallel connections.
///
/// # Arguments
///
/// * `client` - The HTTP client to use for downloads
/// * `instrument` - The instrument to download data for
/// * `range` - The date range to download
///
/// # Returns
///
/// An async stream of tick batches, one per hour.
pub fn tick_stream<'a>(
client: &'a DownloadClient,
instrument: &'a Instrument,
range: DateRange,
) -> impl Stream<Item = Result<TickBatch, ParacasError>> + 'a {
let decimal_factor = instrument.decimal_factor_f64();
let instrument_id = instrument.id().to_string();
let concurrency = client.config().concurrency;
stream::iter(range.hours())
.map(move |hour| {
let url = tick_url(&instrument_id, hour);
let client = client.clone();
async move { (hour, client.download(&url).await) }
})
.buffer_unordered(concurrency)
.map(move |(hour, result)| process_download_result(hour, result, decimal_factor))
}
/// Processes a download result into a tick batch.
fn process_download_result(
hour: DateTime<Utc>,
result: Result<Option<bytes::Bytes>, crate::DownloadError>,
decimal_factor: f64,
) -> Result<TickBatch, ParacasError> {
match result {
Ok(Some(compressed)) => {
let decompressed =
decompress_bi5(&compressed).map_err(|e| ParacasError::Decompress(e.to_string()))?;
let ticks: Vec<Tick> = parse_ticks(&decompressed)
.map_err(|e| ParacasError::Parse(e.to_string()))?
.map(|raw| raw.normalize(hour, decimal_factor))
.collect();
Ok(TickBatch::new(hour, ticks))
}
Ok(None) => {
// No data for this hour
Ok(TickBatch::new(hour, Vec::new()))
}
Err(e) => Err(ParacasError::Http(e.to_string())),
}
}
/// Creates a resilient async stream that skips failed hours instead of failing entirely.
///
/// This is useful for long-running downloads where occasional server errors
/// should not abort the entire operation.
///
/// # Arguments
///
/// * `client` - The HTTP client to use for downloads
/// * `instrument` - The instrument to download data for
/// * `range` - The date range to download
///
/// # Returns
///
/// An async stream of tick batches. Failed hours are returned as empty batches
/// with `had_error` set to true.
pub fn tick_stream_resilient<'a>(
client: &'a DownloadClient,
instrument: &'a Instrument,
range: DateRange,
) -> impl Stream<Item = TickBatch> + 'a {
let decimal_factor = instrument.decimal_factor_f64();
let instrument_id = instrument.id().to_string();
let concurrency = client.config().concurrency;
stream::iter(range.hours())
.map(move |hour| {
let url = tick_url(&instrument_id, hour);
let client = client.clone();
async move { (hour, client.download(&url).await) }
})
.buffer_unordered(concurrency)
.map(move |(hour, result)| process_download_result_resilient(hour, result, decimal_factor))
}
/// Processes a download result into a tick batch, skipping errors.
#[allow(clippy::option_if_let_else)] // Nested matches are more readable here
fn process_download_result_resilient(
hour: DateTime<Utc>,
result: Result<Option<bytes::Bytes>, crate::DownloadError>,
decimal_factor: f64,
) -> TickBatch {
match result {
Ok(Some(compressed)) => {
match decompress_bi5(&compressed) {
Ok(decompressed) => match parse_ticks(&decompressed) {
Ok(raw_ticks) => {
let ticks: Vec<Tick> = raw_ticks
.map(|raw| raw.normalize(hour, decimal_factor))
.collect();
TickBatch::new(hour, ticks)
}
Err(_) => {
// Parse error - return empty batch with error flag
TickBatch::skipped_error(hour)
}
},
Err(_) => {
// Decompression error - return empty batch with error flag
TickBatch::skipped_error(hour)
}
}
}
Ok(None) => {
// No data for this hour
TickBatch::new(hour, Vec::new())
}
Err(_) => {
// HTTP error - return empty batch with error flag
TickBatch::skipped_error(hour)
}
}
}
/// Flattens a tick batch stream into individual ticks.
///
/// This is useful when you want to process ticks one at a time rather than
/// in batches.
pub fn flatten_ticks(
batch_stream: impl Stream<Item = Result<TickBatch, ParacasError>>,
) -> impl Stream<Item = Result<Tick, ParacasError>> {
batch_stream.flat_map(|result| match result {
Ok(batch) => stream::iter(batch.ticks.into_iter().map(Ok)).left_stream(),
Err(e) => stream::once(async move { Err(e) }).right_stream(),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_tick_batch_new() {
let hour = Utc::now();
let batch = TickBatch::new(hour, vec![]);
assert!(batch.is_empty());
assert_eq!(batch.len(), 0);
assert!(!batch.had_error());
}
#[test]
fn test_tick_batch_skipped_error() {
let hour = Utc::now();
let batch = TickBatch::skipped_error(hour);
assert!(batch.is_empty());
assert!(batch.had_error());
}
}
+68
View File
@@ -0,0 +1,68 @@
//! Dukascopy URL construction.
use chrono::{DateTime, Datelike, Timelike, Utc};
/// Base URL for Dukascopy data feed.
pub const BASE_URL: &str = "https://datafeed.dukascopy.com/datafeed";
/// Builds the URL for a specific hour's tick data.
///
/// URL format: `{BASE_URL}/{INSTRUMENT}/{YEAR}/{MONTH}/{DAY}/{HOUR}h_ticks.bi5`
///
/// Note: Dukascopy uses 0-indexed months (January = 00).
///
/// # Example
///
/// ```
/// use paracas_fetch::url::tick_url;
/// use chrono::{TimeZone, Utc};
///
/// let hour = Utc.with_ymd_and_hms(2024, 1, 15, 12, 0, 0).unwrap();
/// let url = tick_url("eurusd", hour);
/// assert_eq!(url, "https://datafeed.dukascopy.com/datafeed/EURUSD/2024/00/15/12h_ticks.bi5");
/// ```
#[must_use]
pub fn tick_url(instrument: &str, hour: DateTime<Utc>) -> String {
format!(
"{}/{}/{}/{:02}/{:02}/{:02}h_ticks.bi5",
BASE_URL,
instrument.to_uppercase(),
hour.year(),
hour.month() - 1, // Dukascopy uses 0-indexed months
hour.day(),
hour.hour()
)
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
#[test]
fn test_tick_url_january() {
let hour = Utc.with_ymd_and_hms(2024, 1, 15, 12, 0, 0).unwrap();
let url = tick_url("eurusd", hour);
assert_eq!(
url,
"https://datafeed.dukascopy.com/datafeed/EURUSD/2024/00/15/12h_ticks.bi5"
);
}
#[test]
fn test_tick_url_december() {
let hour = Utc.with_ymd_and_hms(2024, 12, 31, 23, 0, 0).unwrap();
let url = tick_url("btcusd", hour);
assert_eq!(
url,
"https://datafeed.dukascopy.com/datafeed/BTCUSD/2024/11/31/23h_ticks.bi5"
);
}
#[test]
fn test_tick_url_uppercase() {
let hour = Utc.with_ymd_and_hms(2024, 6, 1, 0, 0, 0).unwrap();
let url = tick_url("GBPJPY", hour);
assert!(url.contains("GBPJPY"));
}
}
+39
View File
@@ -0,0 +1,39 @@
[package]
name = "paracas-format"
description = "Output formatters for paracas tick data downloader"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
homepage.workspace = true
repository.workspace = true
documentation.workspace = true
authors.workspace = true
keywords.workspace = true
categories.workspace = true
[lints]
workspace = true
[package.metadata.docs.rs]
all-features = true
rustdoc-args = ["--cfg", "docsrs"]
[features]
default = ["csv", "json", "parquet"]
csv = []
json = []
parquet = ["dep:arrow", "dep:parquet"]
[dependencies]
paracas-types = { workspace = true }
paracas-aggregate = { workspace = true }
chrono = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
arrow = { workspace = true, optional = true }
parquet = { workspace = true, optional = true }
[dev-dependencies]
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
+36
View File
@@ -0,0 +1,36 @@
# paracas-format
Output formatters for the paracas tick data downloader.
## Supported Formats
- **CSV** - Comma-separated values
- **JSON** - JSON array or newline-delimited JSON (NDJSON)
- **Parquet** - Apache Parquet columnar format (requires `parquet` feature)
## Usage
```rust,no_run
use paracas_format::{CsvFormatter, Formatter, OutputFormat};
use paracas_types::Tick;
use std::io::Cursor;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let ticks: Vec<Tick> = vec![];
let mut output = Cursor::new(Vec::new());
let formatter = CsvFormatter::new();
formatter.write_ticks(&ticks, &mut output)?;
Ok(())
}
```
## Features
- `csv` - CSV format support (default)
- `json` - JSON format support (default)
- `parquet` - Parquet format support (default)
## License
MIT License - see [LICENSE](../../LICENSE) for details.
+163
View File
@@ -0,0 +1,163 @@
//! CSV output format.
use paracas_aggregate::Ohlcv;
use paracas_types::Tick;
use std::io::Write;
use crate::{FormatError, Formatter};
/// CSV formatter.
#[derive(Debug, Clone, Default)]
pub struct CsvFormatter {
/// Field delimiter (default: comma).
delimiter: char,
/// Whether to include header row.
include_header: bool,
}
impl CsvFormatter {
/// Creates a new CSV formatter with default settings.
#[must_use]
pub const fn new() -> Self {
Self {
delimiter: ',',
include_header: true,
}
}
/// Sets the field delimiter.
#[must_use]
pub const fn with_delimiter(mut self, delimiter: char) -> Self {
self.delimiter = delimiter;
self
}
/// Sets whether to include a header row.
#[must_use]
pub const fn with_header(mut self, include: bool) -> Self {
self.include_header = include;
self
}
/// Creates a tab-separated values (TSV) formatter.
#[must_use]
pub const fn tsv() -> Self {
Self {
delimiter: '\t',
include_header: true,
}
}
}
impl Formatter for CsvFormatter {
fn write_ticks<W: Write + Send>(
&self,
ticks: &[Tick],
mut writer: W,
) -> Result<(), FormatError> {
let d = self.delimiter;
if self.include_header {
writeln!(writer, "timestamp{d}ask{d}bid{d}ask_volume{d}bid_volume")?;
}
for tick in ticks {
writeln!(
writer,
"{}{d}{}{d}{}{d}{}{d}{}",
tick.timestamp.format("%Y-%m-%dT%H:%M:%S%.3fZ"),
tick.ask,
tick.bid,
tick.ask_volume,
tick.bid_volume
)?;
}
Ok(())
}
fn write_ohlcv<W: Write + Send>(
&self,
bars: &[Ohlcv],
mut writer: W,
) -> Result<(), FormatError> {
let d = self.delimiter;
if self.include_header {
writeln!(
writer,
"timestamp{d}open{d}high{d}low{d}close{d}volume{d}tick_count"
)?;
}
for bar in bars {
writeln!(
writer,
"{}{d}{}{d}{}{d}{}{d}{}{d}{}{d}{}",
bar.timestamp.format("%Y-%m-%dT%H:%M:%SZ"),
bar.open,
bar.high,
bar.low,
bar.close,
bar.volume,
bar.tick_count
)?;
}
Ok(())
}
fn extension(&self) -> &str {
"csv"
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::{TimeZone, Utc};
use std::io::Cursor;
fn create_test_tick() -> Tick {
let timestamp = Utc.with_ymd_and_hms(2024, 1, 15, 12, 30, 45).unwrap();
Tick::new(timestamp, 1.1001, 1.1000, 100.0, 200.0)
}
#[test]
fn test_csv_ticks() {
let formatter = CsvFormatter::new();
let ticks = vec![create_test_tick()];
let mut output = Cursor::new(Vec::new());
formatter.write_ticks(&ticks, &mut output).unwrap();
let result = String::from_utf8(output.into_inner()).unwrap();
assert!(result.contains("timestamp,ask,bid,ask_volume,bid_volume"));
assert!(result.contains("2024-01-15T12:30:45.000Z"));
assert!(result.contains("1.1001"));
}
#[test]
fn test_csv_no_header() {
let formatter = CsvFormatter::new().with_header(false);
let ticks = vec![create_test_tick()];
let mut output = Cursor::new(Vec::new());
formatter.write_ticks(&ticks, &mut output).unwrap();
let result = String::from_utf8(output.into_inner()).unwrap();
assert!(!result.contains("timestamp,ask"));
}
#[test]
fn test_tsv() {
let formatter = CsvFormatter::tsv();
let ticks = vec![create_test_tick()];
let mut output = Cursor::new(Vec::new());
formatter.write_ticks(&ticks, &mut output).unwrap();
let result = String::from_utf8(output.into_inner()).unwrap();
assert!(result.contains("timestamp\task\tbid"));
}
}
+99
View File
@@ -0,0 +1,99 @@
//! Output format abstraction.
use paracas_aggregate::Ohlcv;
use paracas_types::Tick;
use std::io::Write;
use thiserror::Error;
/// Output format identifier.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub enum OutputFormat {
/// CSV format.
#[default]
Csv,
/// JSON array format.
Json,
/// Newline-delimited JSON format.
Ndjson,
/// Apache Parquet format.
Parquet,
}
impl OutputFormat {
/// Returns the file extension for this format.
#[must_use]
pub const fn extension(&self) -> &'static str {
match self {
Self::Csv => "csv",
Self::Json => "json",
Self::Ndjson => "ndjson",
Self::Parquet => "parquet",
}
}
/// Returns all available formats.
#[must_use]
pub const fn all() -> &'static [Self] {
&[Self::Csv, Self::Json, Self::Ndjson, Self::Parquet]
}
}
impl std::fmt::Display for OutputFormat {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.extension())
}
}
impl std::str::FromStr for OutputFormat {
type Err = FormatError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"csv" => Ok(Self::Csv),
"json" => Ok(Self::Json),
"ndjson" | "jsonl" => Ok(Self::Ndjson),
"parquet" | "pq" => Ok(Self::Parquet),
_ => Err(FormatError::UnknownFormat(s.to_string())),
}
}
}
/// Errors that can occur during formatting.
#[derive(Error, Debug)]
pub enum FormatError {
/// Unknown output format.
#[error("Unknown format: {0}")]
UnknownFormat(String),
/// I/O error.
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
/// JSON serialization error.
#[error("JSON error: {0}")]
Json(#[from] serde_json::Error),
/// Arrow/Parquet error.
#[error("Parquet error: {0}")]
Parquet(String),
}
/// Trait for output formatters.
pub trait Formatter: Send + Sync {
/// Writes tick data to the output.
///
/// # Errors
///
/// Returns an error if writing fails.
fn write_ticks<W: Write + Send>(&self, ticks: &[Tick], writer: W) -> Result<(), FormatError>;
/// Writes OHLCV data to the output.
///
/// # Errors
///
/// Returns an error if writing fails.
fn write_ohlcv<W: Write + Send>(&self, bars: &[Ohlcv], writer: W) -> Result<(), FormatError>;
/// Returns the file extension for this format.
fn extension(&self) -> &str;
}
+169
View File
@@ -0,0 +1,169 @@
//! JSON output format.
use paracas_aggregate::Ohlcv;
use paracas_types::Tick;
use std::io::Write;
use crate::{FormatError, Formatter};
/// JSON output style.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum JsonStyle {
/// JSON array (standard JSON).
#[default]
Array,
/// Newline-delimited JSON (NDJSON/JSONL).
Ndjson,
}
/// JSON formatter.
#[derive(Debug, Clone, Default)]
pub struct JsonFormatter {
/// Output style.
style: JsonStyle,
/// Whether to pretty-print (only for array style).
pretty: bool,
}
impl JsonFormatter {
/// Creates a new JSON formatter with default settings (array style).
#[must_use]
pub const fn new() -> Self {
Self {
style: JsonStyle::Array,
pretty: false,
}
}
/// Creates a new NDJSON formatter.
#[must_use]
pub const fn ndjson() -> Self {
Self {
style: JsonStyle::Ndjson,
pretty: false,
}
}
/// Sets whether to pretty-print output (array style only).
#[must_use]
pub const fn with_pretty(mut self, pretty: bool) -> Self {
self.pretty = pretty;
self
}
/// Sets the output style.
#[must_use]
pub const fn with_style(mut self, style: JsonStyle) -> Self {
self.style = style;
self
}
}
impl Formatter for JsonFormatter {
fn write_ticks<W: Write + Send>(
&self,
ticks: &[Tick],
mut writer: W,
) -> Result<(), FormatError> {
match self.style {
JsonStyle::Array => {
if self.pretty {
serde_json::to_writer_pretty(&mut writer, ticks)?;
} else {
serde_json::to_writer(&mut writer, ticks)?;
}
writeln!(writer)?;
}
JsonStyle::Ndjson => {
for tick in ticks {
serde_json::to_writer(&mut writer, tick)?;
writeln!(writer)?;
}
}
}
Ok(())
}
fn write_ohlcv<W: Write + Send>(
&self,
bars: &[Ohlcv],
mut writer: W,
) -> Result<(), FormatError> {
match self.style {
JsonStyle::Array => {
if self.pretty {
serde_json::to_writer_pretty(&mut writer, bars)?;
} else {
serde_json::to_writer(&mut writer, bars)?;
}
writeln!(writer)?;
}
JsonStyle::Ndjson => {
for bar in bars {
serde_json::to_writer(&mut writer, bar)?;
writeln!(writer)?;
}
}
}
Ok(())
}
fn extension(&self) -> &str {
match self.style {
JsonStyle::Array => "json",
JsonStyle::Ndjson => "ndjson",
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::{TimeZone, Utc};
use std::io::Cursor;
fn create_test_tick() -> Tick {
let timestamp = Utc.with_ymd_and_hms(2024, 1, 15, 12, 30, 45).unwrap();
Tick::new(timestamp, 1.1001, 1.1000, 100.0, 200.0)
}
#[test]
fn test_json_array() {
let formatter = JsonFormatter::new();
let ticks = vec![create_test_tick()];
let mut output = Cursor::new(Vec::new());
formatter.write_ticks(&ticks, &mut output).unwrap();
let result = String::from_utf8(output.into_inner()).unwrap();
assert!(result.starts_with('['));
assert!(result.contains("\"ask\":1.1001"));
}
#[test]
fn test_ndjson() {
let formatter = JsonFormatter::ndjson();
let ticks = vec![create_test_tick(), create_test_tick()];
let mut output = Cursor::new(Vec::new());
formatter.write_ticks(&ticks, &mut output).unwrap();
let result = String::from_utf8(output.into_inner()).unwrap();
let lines: Vec<_> = result.lines().collect();
assert_eq!(lines.len(), 2);
assert!(lines[0].starts_with('{'));
}
#[test]
fn test_pretty_json() {
let formatter = JsonFormatter::new().with_pretty(true);
let ticks = vec![create_test_tick()];
let mut output = Cursor::new(Vec::new());
formatter.write_ticks(&ticks, &mut output).unwrap();
let result = String::from_utf8(output.into_inner()).unwrap();
assert!(result.contains('\n'));
assert!(result.contains(" ")); // Indentation
}
}
+28
View File
@@ -0,0 +1,28 @@
//! Output formatters for paracas tick data downloader.
//!
//! This crate provides formatters for writing tick and OHLCV data
//! to various output formats:
//!
//! - [`CsvFormatter`] - CSV format
//! - [`JsonFormatter`] - JSON array or NDJSON format
//! - [`ParquetFormatter`] - Apache Parquet columnar format
#![doc = include_str!("../README.md")]
#![doc(issue_tracker_base_url = "https://github.com/factordynamics/paracas/issues/")]
#![cfg_attr(docsrs, feature(doc_cfg, doc_auto_cfg))]
#![warn(missing_docs)]
#![forbid(unsafe_code)]
mod csv;
mod formatter;
mod json;
#[cfg(feature = "parquet")]
mod parquet;
pub use crate::csv::CsvFormatter;
pub use formatter::{FormatError, Formatter, OutputFormat};
pub use json::{JsonFormatter, JsonStyle};
#[cfg(feature = "parquet")]
pub use crate::parquet::ParquetFormatter;
+236
View File
@@ -0,0 +1,236 @@
//! Apache Parquet output format.
use arrow::array::{Float32Array, Float64Array, TimestampMicrosecondArray, UInt32Array};
use arrow::datatypes::{DataType, Field, Schema, TimeUnit};
use arrow::record_batch::RecordBatch;
use paracas_aggregate::Ohlcv;
use paracas_types::Tick;
use parquet::arrow::ArrowWriter;
use parquet::basic::Compression;
use parquet::file::properties::WriterProperties;
use std::io::Write;
use std::sync::Arc;
use crate::{FormatError, Formatter};
/// Parquet formatter.
#[derive(Debug, Clone)]
pub struct ParquetFormatter {
/// Row group size (number of rows per group).
row_group_size: usize,
/// Compression codec.
compression: Compression,
}
impl Default for ParquetFormatter {
fn default() -> Self {
Self {
row_group_size: 100_000,
compression: Compression::SNAPPY,
}
}
}
impl ParquetFormatter {
/// Creates a new Parquet formatter with default settings.
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// Sets the row group size.
#[must_use]
pub const fn with_row_group_size(mut self, size: usize) -> Self {
self.row_group_size = size;
self
}
/// Sets the compression codec.
#[must_use]
pub const fn with_compression(mut self, compression: Compression) -> Self {
self.compression = compression;
self
}
/// Creates the Arrow schema for tick data.
fn tick_schema() -> Schema {
Schema::new(vec![
Field::new(
"timestamp",
DataType::Timestamp(TimeUnit::Microsecond, Some("UTC".into())),
false,
),
Field::new("ask", DataType::Float64, false),
Field::new("bid", DataType::Float64, false),
Field::new("ask_volume", DataType::Float32, false),
Field::new("bid_volume", DataType::Float32, false),
])
}
/// Creates the Arrow schema for OHLCV data.
fn ohlcv_schema() -> Schema {
Schema::new(vec![
Field::new(
"timestamp",
DataType::Timestamp(TimeUnit::Microsecond, Some("UTC".into())),
false,
),
Field::new("open", DataType::Float64, false),
Field::new("high", DataType::Float64, false),
Field::new("low", DataType::Float64, false),
Field::new("close", DataType::Float64, false),
Field::new("volume", DataType::Float64, false),
Field::new("tick_count", DataType::UInt32, false),
])
}
/// Converts ticks to Arrow RecordBatch.
fn ticks_to_batch(ticks: &[Tick]) -> Result<RecordBatch, FormatError> {
let timestamps: Vec<_> = ticks
.iter()
.map(|t| t.timestamp.timestamp_micros())
.collect();
let asks: Vec<_> = ticks.iter().map(|t| t.ask).collect();
let bids: Vec<_> = ticks.iter().map(|t| t.bid).collect();
let ask_vols: Vec<_> = ticks.iter().map(|t| t.ask_volume).collect();
let bid_vols: Vec<_> = ticks.iter().map(|t| t.bid_volume).collect();
RecordBatch::try_new(
Arc::new(Self::tick_schema()),
vec![
Arc::new(TimestampMicrosecondArray::from(timestamps).with_timezone("UTC")),
Arc::new(Float64Array::from(asks)),
Arc::new(Float64Array::from(bids)),
Arc::new(Float32Array::from(ask_vols)),
Arc::new(Float32Array::from(bid_vols)),
],
)
.map_err(|e| FormatError::Parquet(e.to_string()))
}
/// Converts OHLCV bars to Arrow RecordBatch.
fn ohlcv_to_batch(bars: &[Ohlcv]) -> Result<RecordBatch, FormatError> {
let timestamps: Vec<_> = bars
.iter()
.map(|b| b.timestamp.timestamp_micros())
.collect();
let opens: Vec<_> = bars.iter().map(|b| b.open).collect();
let highs: Vec<_> = bars.iter().map(|b| b.high).collect();
let lows: Vec<_> = bars.iter().map(|b| b.low).collect();
let closes: Vec<_> = bars.iter().map(|b| b.close).collect();
let volumes: Vec<_> = bars.iter().map(|b| b.volume).collect();
let tick_counts: Vec<_> = bars.iter().map(|b| b.tick_count).collect();
RecordBatch::try_new(
Arc::new(Self::ohlcv_schema()),
vec![
Arc::new(TimestampMicrosecondArray::from(timestamps).with_timezone("UTC")),
Arc::new(Float64Array::from(opens)),
Arc::new(Float64Array::from(highs)),
Arc::new(Float64Array::from(lows)),
Arc::new(Float64Array::from(closes)),
Arc::new(Float64Array::from(volumes)),
Arc::new(UInt32Array::from(tick_counts)),
],
)
.map_err(|e| FormatError::Parquet(e.to_string()))
}
}
impl Formatter for ParquetFormatter {
fn write_ticks<W: Write + Send>(&self, ticks: &[Tick], writer: W) -> Result<(), FormatError> {
let schema = Arc::new(Self::tick_schema());
let props = WriterProperties::builder()
.set_compression(self.compression)
.set_max_row_group_size(self.row_group_size)
.build();
let mut arrow_writer = ArrowWriter::try_new(writer, schema, Some(props))
.map_err(|e| FormatError::Parquet(e.to_string()))?;
// Write in batches
for chunk in ticks.chunks(self.row_group_size) {
let batch = Self::ticks_to_batch(chunk)?;
arrow_writer
.write(&batch)
.map_err(|e| FormatError::Parquet(e.to_string()))?;
}
arrow_writer
.close()
.map_err(|e| FormatError::Parquet(e.to_string()))?;
Ok(())
}
fn write_ohlcv<W: Write + Send>(&self, bars: &[Ohlcv], writer: W) -> Result<(), FormatError> {
let schema = Arc::new(Self::ohlcv_schema());
let props = WriterProperties::builder()
.set_compression(self.compression)
.set_max_row_group_size(self.row_group_size)
.build();
let mut arrow_writer = ArrowWriter::try_new(writer, schema, Some(props))
.map_err(|e| FormatError::Parquet(e.to_string()))?;
// Write in batches
for chunk in bars.chunks(self.row_group_size) {
let batch = Self::ohlcv_to_batch(chunk)?;
arrow_writer
.write(&batch)
.map_err(|e| FormatError::Parquet(e.to_string()))?;
}
arrow_writer
.close()
.map_err(|e| FormatError::Parquet(e.to_string()))?;
Ok(())
}
fn extension(&self) -> &str {
"parquet"
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::{TimeZone, Utc};
use std::io::Cursor;
fn create_test_tick() -> Tick {
let timestamp = Utc.with_ymd_and_hms(2024, 1, 15, 12, 30, 45).unwrap();
Tick::new(timestamp, 1.1001, 1.1000, 100.0, 200.0)
}
#[test]
fn test_parquet_ticks() {
let formatter = ParquetFormatter::new();
let ticks = vec![create_test_tick()];
let mut output = Cursor::new(Vec::new());
formatter.write_ticks(&ticks, &mut output).unwrap();
// Parquet files start with "PAR1" magic bytes
let data = output.into_inner();
assert!(data.len() > 4);
assert_eq!(&data[0..4], b"PAR1");
}
#[test]
fn test_tick_schema() {
let schema = ParquetFormatter::tick_schema();
assert_eq!(schema.fields().len(), 5);
assert!(schema.field_with_name("timestamp").is_ok());
assert!(schema.field_with_name("ask").is_ok());
}
#[test]
fn test_ohlcv_schema() {
let schema = ParquetFormatter::ohlcv_schema();
assert_eq!(schema.fields().len(), 7);
assert!(schema.field_with_name("open").is_ok());
assert!(schema.field_with_name("close").is_ok());
}
}
+26
View File
@@ -0,0 +1,26 @@
[package]
name = "paracas-instruments"
description = "Instrument registry for paracas tick data downloader"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
homepage.workspace = true
repository.workspace = true
documentation.workspace = true
authors.workspace = true
keywords.workspace = true
categories.workspace = true
[lints]
workspace = true
[package.metadata.docs.rs]
all-features = true
rustdoc-args = ["--cfg", "docsrs"]
[dependencies]
paracas-types = { workspace = true }
chrono = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
+37
View File
@@ -0,0 +1,37 @@
# paracas-instruments
Instrument registry for the paracas tick data downloader.
## Features
- Registry of 1000+ Dukascopy instruments
- Lookup by ID (case-insensitive)
- Filter by category (forex, crypto, stocks, etc.)
- Search by name pattern
## Usage
```rust
use paracas_instruments::InstrumentRegistry;
let registry = InstrumentRegistry::global();
// Lookup by ID
if let Some(instrument) = registry.get("eurusd") {
println!("{}: decimal_factor = {}", instrument.name(), instrument.decimal_factor());
}
// Filter by category
for instrument in registry.forex() {
println!("{}", instrument.id());
}
// Search
for instrument in registry.search("btc") {
println!("{}", instrument.name());
}
```
## License
MIT License - see [LICENSE](../../LICENSE) for details.
@@ -0,0 +1,354 @@
{
"eurusd": {
"id": "eurusd",
"name": "EUR/USD",
"description": "Euro vs US Dollar",
"category": "forex",
"decimal_factor": 100000,
"start_tick_date": "2003-05-05T00:00:00Z"
},
"gbpusd": {
"id": "gbpusd",
"name": "GBP/USD",
"description": "British Pound vs US Dollar",
"category": "forex",
"decimal_factor": 100000,
"start_tick_date": "2003-05-05T00:00:00Z"
},
"usdjpy": {
"id": "usdjpy",
"name": "USD/JPY",
"description": "US Dollar vs Japanese Yen",
"category": "forex",
"decimal_factor": 1000,
"start_tick_date": "2003-05-05T00:00:00Z"
},
"usdchf": {
"id": "usdchf",
"name": "USD/CHF",
"description": "US Dollar vs Swiss Franc",
"category": "forex",
"decimal_factor": 100000,
"start_tick_date": "2003-05-05T00:00:00Z"
},
"audusd": {
"id": "audusd",
"name": "AUD/USD",
"description": "Australian Dollar vs US Dollar",
"category": "forex",
"decimal_factor": 100000,
"start_tick_date": "2003-08-03T00:00:00Z"
},
"usdcad": {
"id": "usdcad",
"name": "USD/CAD",
"description": "US Dollar vs Canadian Dollar",
"category": "forex",
"decimal_factor": 100000,
"start_tick_date": "2003-08-03T00:00:00Z"
},
"nzdusd": {
"id": "nzdusd",
"name": "NZD/USD",
"description": "New Zealand Dollar vs US Dollar",
"category": "forex",
"decimal_factor": 100000,
"start_tick_date": "2003-08-03T00:00:00Z"
},
"eurgbp": {
"id": "eurgbp",
"name": "EUR/GBP",
"description": "Euro vs British Pound",
"category": "forex",
"decimal_factor": 100000,
"start_tick_date": "2003-08-03T00:00:00Z"
},
"eurjpy": {
"id": "eurjpy",
"name": "EUR/JPY",
"description": "Euro vs Japanese Yen",
"category": "forex",
"decimal_factor": 1000,
"start_tick_date": "2003-08-03T00:00:00Z"
},
"eurchf": {
"id": "eurchf",
"name": "EUR/CHF",
"description": "Euro vs Swiss Franc",
"category": "forex",
"decimal_factor": 100000,
"start_tick_date": "2003-08-03T00:00:00Z"
},
"gbpjpy": {
"id": "gbpjpy",
"name": "GBP/JPY",
"description": "British Pound vs Japanese Yen",
"category": "forex",
"decimal_factor": 1000,
"start_tick_date": "2003-08-03T00:00:00Z"
},
"gbpchf": {
"id": "gbpchf",
"name": "GBP/CHF",
"description": "British Pound vs Swiss Franc",
"category": "forex",
"decimal_factor": 100000,
"start_tick_date": "2003-08-03T00:00:00Z"
},
"chfjpy": {
"id": "chfjpy",
"name": "CHF/JPY",
"description": "Swiss Franc vs Japanese Yen",
"category": "forex",
"decimal_factor": 1000,
"start_tick_date": "2003-08-03T00:00:00Z"
},
"euraud": {
"id": "euraud",
"name": "EUR/AUD",
"description": "Euro vs Australian Dollar",
"category": "forex",
"decimal_factor": 100000,
"start_tick_date": "2005-10-30T00:00:00Z"
},
"eurcad": {
"id": "eurcad",
"name": "EUR/CAD",
"description": "Euro vs Canadian Dollar",
"category": "forex",
"decimal_factor": 100000,
"start_tick_date": "2005-10-30T00:00:00Z"
},
"eurnzd": {
"id": "eurnzd",
"name": "EUR/NZD",
"description": "Euro vs New Zealand Dollar",
"category": "forex",
"decimal_factor": 100000,
"start_tick_date": "2005-10-30T00:00:00Z"
},
"gbpaud": {
"id": "gbpaud",
"name": "GBP/AUD",
"description": "British Pound vs Australian Dollar",
"category": "forex",
"decimal_factor": 100000,
"start_tick_date": "2005-10-30T00:00:00Z"
},
"gbpcad": {
"id": "gbpcad",
"name": "GBP/CAD",
"description": "British Pound vs Canadian Dollar",
"category": "forex",
"decimal_factor": 100000,
"start_tick_date": "2005-10-30T00:00:00Z"
},
"gbpnzd": {
"id": "gbpnzd",
"name": "GBP/NZD",
"description": "British Pound vs New Zealand Dollar",
"category": "forex",
"decimal_factor": 100000,
"start_tick_date": "2005-10-30T00:00:00Z"
},
"audcad": {
"id": "audcad",
"name": "AUD/CAD",
"description": "Australian Dollar vs Canadian Dollar",
"category": "forex",
"decimal_factor": 100000,
"start_tick_date": "2005-10-30T00:00:00Z"
},
"audchf": {
"id": "audchf",
"name": "AUD/CHF",
"description": "Australian Dollar vs Swiss Franc",
"category": "forex",
"decimal_factor": 100000,
"start_tick_date": "2005-10-30T00:00:00Z"
},
"audjpy": {
"id": "audjpy",
"name": "AUD/JPY",
"description": "Australian Dollar vs Japanese Yen",
"category": "forex",
"decimal_factor": 1000,
"start_tick_date": "2005-10-30T00:00:00Z"
},
"audnzd": {
"id": "audnzd",
"name": "AUD/NZD",
"description": "Australian Dollar vs New Zealand Dollar",
"category": "forex",
"decimal_factor": 100000,
"start_tick_date": "2005-10-30T00:00:00Z"
},
"cadjpy": {
"id": "cadjpy",
"name": "CAD/JPY",
"description": "Canadian Dollar vs Japanese Yen",
"category": "forex",
"decimal_factor": 1000,
"start_tick_date": "2005-10-30T00:00:00Z"
},
"cadchf": {
"id": "cadchf",
"name": "CAD/CHF",
"description": "Canadian Dollar vs Swiss Franc",
"category": "forex",
"decimal_factor": 100000,
"start_tick_date": "2005-10-30T00:00:00Z"
},
"nzdjpy": {
"id": "nzdjpy",
"name": "NZD/JPY",
"description": "New Zealand Dollar vs Japanese Yen",
"category": "forex",
"decimal_factor": 1000,
"start_tick_date": "2005-10-30T00:00:00Z"
},
"nzdcad": {
"id": "nzdcad",
"name": "NZD/CAD",
"description": "New Zealand Dollar vs Canadian Dollar",
"category": "forex",
"decimal_factor": 100000,
"start_tick_date": "2005-10-30T00:00:00Z"
},
"nzdchf": {
"id": "nzdchf",
"name": "NZD/CHF",
"description": "New Zealand Dollar vs Swiss Franc",
"category": "forex",
"decimal_factor": 100000,
"start_tick_date": "2005-10-30T00:00:00Z"
},
"xauusd": {
"id": "xauusd",
"name": "XAU/USD",
"description": "Gold vs US Dollar",
"category": "commodity",
"decimal_factor": 1000,
"start_tick_date": "2004-05-05T00:00:00Z"
},
"xagusd": {
"id": "xagusd",
"name": "XAG/USD",
"description": "Silver vs US Dollar",
"category": "commodity",
"decimal_factor": 1000,
"start_tick_date": "2004-05-05T00:00:00Z"
},
"btcusd": {
"id": "btcusd",
"name": "BTC/USD",
"description": "Bitcoin vs US Dollar",
"category": "crypto",
"decimal_factor": 100,
"start_tick_date": "2017-01-01T00:00:00Z"
},
"ethusd": {
"id": "ethusd",
"name": "ETH/USD",
"description": "Ethereum vs US Dollar",
"category": "crypto",
"decimal_factor": 100,
"start_tick_date": "2017-06-01T00:00:00Z"
},
"ltcusd": {
"id": "ltcusd",
"name": "LTC/USD",
"description": "Litecoin vs US Dollar",
"category": "crypto",
"decimal_factor": 100,
"start_tick_date": "2017-07-01T00:00:00Z"
},
"xrpusd": {
"id": "xrpusd",
"name": "XRP/USD",
"description": "Ripple vs US Dollar",
"category": "crypto",
"decimal_factor": 100000,
"start_tick_date": "2017-09-01T00:00:00Z"
},
"usa500idxusd": {
"id": "usa500idxusd",
"name": "US 500",
"description": "S&P 500 Index",
"category": "index",
"decimal_factor": 10,
"start_tick_date": "2013-05-05T00:00:00Z"
},
"usa30idxusd": {
"id": "usa30idxusd",
"name": "US 30",
"description": "Dow Jones Industrial Average",
"category": "index",
"decimal_factor": 10,
"start_tick_date": "2013-05-05T00:00:00Z"
},
"usaborrowusd": {
"id": "usatechidxusd",
"name": "US Tech 100",
"description": "NASDAQ 100 Index",
"category": "index",
"decimal_factor": 10,
"start_tick_date": "2013-05-05T00:00:00Z"
},
"deuidxeur": {
"id": "deuidxeur",
"name": "Germany 40",
"description": "DAX 40 Index",
"category": "index",
"decimal_factor": 10,
"start_tick_date": "2013-05-05T00:00:00Z"
},
"gbridxgbp": {
"id": "gbridxgbp",
"name": "UK 100",
"description": "FTSE 100 Index",
"category": "index",
"decimal_factor": 10,
"start_tick_date": "2013-05-05T00:00:00Z"
},
"jpnidxjpy": {
"id": "jpnidxjpy",
"name": "Japan 225",
"description": "Nikkei 225 Index",
"category": "index",
"decimal_factor": 1,
"start_tick_date": "2013-05-05T00:00:00Z"
},
"uscrude": {
"id": "uscrude",
"name": "WTI Crude Oil",
"description": "West Texas Intermediate Crude Oil",
"category": "commodity",
"decimal_factor": 1000,
"start_tick_date": "2013-05-05T00:00:00Z"
},
"ukcrude": {
"id": "ukcrude",
"name": "Brent Crude Oil",
"description": "Brent Crude Oil",
"category": "commodity",
"decimal_factor": 1000,
"start_tick_date": "2013-05-05T00:00:00Z"
},
"natgas": {
"id": "natgas",
"name": "Natural Gas",
"description": "Natural Gas",
"category": "commodity",
"decimal_factor": 1000,
"start_tick_date": "2013-05-05T00:00:00Z"
},
"copper": {
"id": "copper",
"name": "Copper",
"description": "Copper",
"category": "commodity",
"decimal_factor": 10000,
"start_tick_date": "2013-05-05T00:00:00Z"
}
}
+173
View File
@@ -0,0 +1,173 @@
//! Instrument registry for paracas tick data downloader.
//!
//! This crate provides access to the full list of Dukascopy instruments
//! with their metadata including decimal factors for price normalization.
//!
//! # Example
//!
//! ```
//! use paracas_instruments::InstrumentRegistry;
//!
//! let registry = InstrumentRegistry::global();
//!
//! // Lookup by ID
//! if let Some(instrument) = registry.get("eurusd") {
//! println!("{}: {}", instrument.name(), instrument.decimal_factor());
//! }
//! ```
#![doc = include_str!("../README.md")]
#![doc(issue_tracker_base_url = "https://github.com/factordynamics/paracas/issues/")]
#![cfg_attr(docsrs, feature(doc_cfg, doc_auto_cfg))]
#![warn(missing_docs)]
#![forbid(unsafe_code)]
use std::collections::HashMap;
use std::sync::OnceLock;
use paracas_types::{Category, Instrument};
/// The instrument metadata JSON embedded at compile time.
const INSTRUMENTS_JSON: &str = include_str!("../data/instruments.json");
/// Global instrument registry instance.
static REGISTRY: OnceLock<InstrumentRegistry> = OnceLock::new();
/// Registry of all supported Dukascopy instruments.
#[derive(Debug)]
pub struct InstrumentRegistry {
instruments: HashMap<String, Instrument>,
}
impl InstrumentRegistry {
/// Returns the global instrument registry.
///
/// The registry is initialized lazily on first access.
#[must_use]
pub fn global() -> &'static Self {
REGISTRY.get_or_init(Self::load)
}
/// Loads instruments from the embedded JSON data.
fn load() -> Self {
let instruments: HashMap<String, Instrument> =
serde_json::from_str(INSTRUMENTS_JSON).expect("Invalid instruments.json");
Self { instruments }
}
/// Looks up an instrument by ID (case-insensitive).
#[must_use]
pub fn get(&self, id: &str) -> Option<&Instrument> {
self.instruments.get(&id.to_lowercase())
}
/// Returns all instruments as an iterator.
pub fn all(&self) -> impl Iterator<Item = &Instrument> {
self.instruments.values()
}
/// Returns the total number of instruments.
#[must_use]
pub fn len(&self) -> usize {
self.instruments.len()
}
/// Returns true if the registry is empty.
#[must_use]
pub fn is_empty(&self) -> bool {
self.instruments.is_empty()
}
/// Returns all forex instruments.
pub fn forex(&self) -> impl Iterator<Item = &Instrument> {
self.instruments.values().filter(|i| i.is_forex())
}
/// Returns all cryptocurrency instruments.
pub fn crypto(&self) -> impl Iterator<Item = &Instrument> {
self.instruments.values().filter(|i| i.is_crypto())
}
/// Returns all index instruments.
pub fn indices(&self) -> impl Iterator<Item = &Instrument> {
self.instruments.values().filter(|i| i.is_index())
}
/// Returns all stock instruments.
pub fn stocks(&self) -> impl Iterator<Item = &Instrument> {
self.instruments.values().filter(|i| i.is_stock())
}
/// Returns all commodity instruments.
pub fn commodities(&self) -> impl Iterator<Item = &Instrument> {
self.instruments.values().filter(|i| i.is_commodity())
}
/// Returns instruments matching the given category.
pub fn by_category(&self, category: Category) -> impl Iterator<Item = &Instrument> {
self.instruments
.values()
.filter(move |i| i.category() == category)
}
/// Searches instruments by name or ID pattern (case-insensitive).
pub fn search(&self, pattern: &str) -> Vec<&Instrument> {
let pattern = pattern.to_lowercase();
self.instruments
.values()
.filter(|i| {
i.id().to_lowercase().contains(&pattern)
|| i.name().to_lowercase().contains(&pattern)
})
.collect()
}
/// Returns all instrument IDs sorted alphabetically.
pub fn ids(&self) -> Vec<&str> {
let mut ids: Vec<&str> = self.instruments.keys().map(String::as_str).collect();
ids.sort();
ids
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_registry_loads() {
let registry = InstrumentRegistry::global();
assert!(!registry.is_empty());
}
#[test]
fn test_get_eurusd() {
let registry = InstrumentRegistry::global();
let eurusd = registry.get("eurusd").expect("EURUSD should exist");
assert_eq!(eurusd.id(), "eurusd");
assert_eq!(eurusd.decimal_factor(), 100_000);
}
#[test]
fn test_get_case_insensitive() {
let registry = InstrumentRegistry::global();
assert!(registry.get("EURUSD").is_some());
assert!(registry.get("EurUsd").is_some());
assert!(registry.get("eurusd").is_some());
}
#[test]
fn test_forex_filter() {
let registry = InstrumentRegistry::global();
let forex: Vec<_> = registry.forex().collect();
assert!(!forex.is_empty());
assert!(forex.iter().all(|i| i.is_forex()));
}
#[test]
fn test_search() {
let registry = InstrumentRegistry::global();
let results = registry.search("eur");
assert!(!results.is_empty());
}
}
+35
View File
@@ -0,0 +1,35 @@
[package]
name = "paracas-lib"
description = "High-performance Rust library for downloading Dukascopy tick data"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
homepage.workspace = true
repository.workspace = true
documentation.workspace = true
authors.workspace = true
keywords.workspace = true
categories.workspace = true
[lints]
workspace = true
[package.metadata.docs.rs]
all-features = true
rustdoc-args = ["--cfg", "docsrs"]
[features]
default = ["full"]
full = ["fetch", "aggregate", "format", "parquet"]
fetch = ["dep:paracas-fetch"]
aggregate = ["dep:paracas-aggregate"]
format = ["dep:paracas-format"]
parquet = ["format", "paracas-format/parquet"]
[dependencies]
paracas-types = { workspace = true }
paracas-instruments = { workspace = true }
paracas-fetch = { workspace = true, optional = true }
paracas-aggregate = { workspace = true, optional = true }
paracas-format = { workspace = true, optional = true }
+56
View File
@@ -0,0 +1,56 @@
# paracas-lib
High-performance Rust library for downloading historical tick data from Dukascopy.
## Features
- **Fast**: Concurrent downloads with connection pooling
- **Flexible**: CSV, JSON, and Parquet output formats
- **Complete**: All 1000+ Dukascopy instruments supported
- **Aggregation**: Built-in OHLCV aggregation
## Quick Start
```rust,ignore
use paracas_lib::prelude::*;
use futures::StreamExt;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Get instrument
let registry = InstrumentRegistry::global();
let instrument = registry.get("eurusd").unwrap();
// Create client
let client = DownloadClient::with_defaults()?;
// Define date range
let range = DateRange::new(
chrono::NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
chrono::NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
)?;
// Stream ticks
let mut stream = tick_stream(&client, instrument, range);
while let Some(batch) = stream.next().await {
let batch = batch?;
println!("Downloaded {} ticks for {:?}", batch.len(), batch.hour);
}
Ok(())
}
```
## Crates
This is a facade crate that re-exports functionality from:
- `paracas-types` - Core types (Tick, Instrument, DateRange)
- `paracas-instruments` - Instrument registry
- `paracas-fetch` - HTTP client and data fetching
- `paracas-aggregate` - OHLCV aggregation
- `paracas-format` - Output formatters
## License
MIT License - see [LICENSE](../../LICENSE) for details.
+88
View File
@@ -0,0 +1,88 @@
//! High-performance Rust library for downloading Dukascopy tick data.
//!
//! This is a facade crate that re-exports functionality from the paracas
//! workspace crates for convenient access.
//!
//! # Quick Start
//!
//! ```ignore
//! use paracas_lib::prelude::*;
//! use futures::StreamExt;
//!
//! #[tokio::main]
//! async fn main() -> Result<(), Box<dyn std::error::Error>> {
//! let registry = InstrumentRegistry::global();
//! let instrument = registry.get("eurusd").unwrap();
//! let client = DownloadClient::with_defaults()?;
//!
//! let range = DateRange::new(
//! chrono::NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
//! chrono::NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
//! )?;
//!
//! let mut stream = tick_stream(&client, instrument, range);
//! while let Some(batch) = stream.next().await {
//! println!("Downloaded {} ticks", batch?.len());
//! }
//!
//! Ok(())
//! }
//! ```
#![doc = include_str!("../README.md")]
#![doc(issue_tracker_base_url = "https://github.com/factordynamics/paracas/issues/")]
#![cfg_attr(docsrs, feature(doc_cfg, doc_auto_cfg))]
#![warn(missing_docs)]
#![forbid(unsafe_code)]
// Re-export core types
pub use paracas_types::*;
// Re-export instrument registry
pub use paracas_instruments::InstrumentRegistry;
// Re-export fetch functionality
#[cfg(feature = "fetch")]
pub use paracas_fetch::{
ClientConfig, DecompressError, DownloadClient, DownloadError, ParseError, TickBatch,
tick_stream, tick_stream_resilient,
};
// Re-export aggregation
#[cfg(feature = "aggregate")]
pub use paracas_aggregate::{Ohlcv, TickAggregator};
// Re-export formatters
#[cfg(feature = "format")]
pub use paracas_format::{CsvFormatter, FormatError, Formatter, JsonFormatter, OutputFormat};
#[cfg(all(feature = "format", feature = "parquet"))]
pub use paracas_format::ParquetFormatter;
/// Prelude module for convenient imports.
///
/// ```
/// use paracas_lib::prelude::*;
/// ```
pub mod prelude {
pub use paracas_types::{
Category, DateRange, DateRangeError, Instrument, ParacasError, RawTick, Result, Tick,
Timeframe,
};
pub use paracas_instruments::InstrumentRegistry;
#[cfg(feature = "fetch")]
pub use paracas_fetch::{
ClientConfig, DownloadClient, TickBatch, tick_stream, tick_stream_resilient,
};
#[cfg(feature = "aggregate")]
pub use paracas_aggregate::{Ohlcv, TickAggregator};
#[cfg(feature = "format")]
pub use paracas_format::{CsvFormatter, Formatter, JsonFormatter, OutputFormat};
#[cfg(all(feature = "format", feature = "parquet"))]
pub use paracas_format::ParquetFormatter;
}
+26
View File
@@ -0,0 +1,26 @@
[package]
name = "paracas-types"
description = "Core types for paracas tick data downloader"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
homepage.workspace = true
repository.workspace = true
documentation.workspace = true
authors.workspace = true
keywords.workspace = true
categories.workspace = true
[lints]
workspace = true
[package.metadata.docs.rs]
all-features = true
rustdoc-args = ["--cfg", "docsrs"]
[dependencies]
chrono = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
+16
View File
@@ -0,0 +1,16 @@
# paracas-types
Core types for the paracas tick data downloader.
## Types
- `Tick` - A single tick with timestamp, ask, bid, and volumes
- `RawTick` - Raw tick from bi5 binary format (before normalization)
- `Instrument` - Financial instrument with metadata
- `Timeframe` - OHLCV aggregation timeframe
- `DateRange` - Date range for data retrieval
- `ParacasError` - Error types
## License
MIT License - see [LICENSE](../../LICENSE) for details.
+191
View File
@@ -0,0 +1,191 @@
//! Date range and hour iteration.
use chrono::{DateTime, NaiveDate, NaiveTime, TimeZone, Utc};
use crate::DateRangeError;
/// A range of dates for data retrieval.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct DateRange {
/// Start date (inclusive).
pub start: NaiveDate,
/// End date (inclusive).
pub end: NaiveDate,
}
impl DateRange {
/// Creates a new date range, validating that start <= end.
///
/// # Errors
///
/// Returns an error if start > end.
pub fn new(start: NaiveDate, end: NaiveDate) -> Result<Self, DateRangeError> {
if start > end {
return Err(DateRangeError::InvalidRange { start, end });
}
Ok(Self { start, end })
}
/// Creates a date range for a single day.
#[must_use]
pub const fn single_day(date: NaiveDate) -> Self {
Self {
start: date,
end: date,
}
}
/// Returns an iterator over all hours in the date range.
pub fn hours(&self) -> HourIterator {
HourIterator::new(self.start, self.end)
}
/// Returns the total number of hours in the range.
#[must_use]
pub fn total_hours(&self) -> usize {
let days = (self.end - self.start).num_days() + 1;
(days * 24) as usize
}
/// Returns the total number of days in the range.
#[must_use]
pub fn total_days(&self) -> usize {
((self.end - self.start).num_days() + 1) as usize
}
/// Returns true if the range contains the given date.
#[must_use]
pub fn contains(&self, date: NaiveDate) -> bool {
date >= self.start && date <= self.end
}
}
impl std::fmt::Display for DateRange {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{} to {}", self.start, self.end)
}
}
/// Iterator over all hours in a date range.
#[derive(Debug, Clone)]
pub struct HourIterator {
current: DateTime<Utc>,
end: DateTime<Utc>,
}
impl HourIterator {
/// Creates a new hour iterator for the given date range.
fn new(start: NaiveDate, end: NaiveDate) -> Self {
let start_dt =
Utc.from_utc_datetime(&start.and_time(NaiveTime::from_hms_opt(0, 0, 0).unwrap()));
// End at 23:00 of the end date (last hour of the day)
let end_dt =
Utc.from_utc_datetime(&end.and_time(NaiveTime::from_hms_opt(23, 0, 0).unwrap()));
Self {
current: start_dt,
end: end_dt,
}
}
}
impl Iterator for HourIterator {
type Item = DateTime<Utc>;
fn next(&mut self) -> Option<Self::Item> {
if self.current > self.end {
return None;
}
let result = self.current;
self.current += chrono::TimeDelta::hours(1);
Some(result)
}
fn size_hint(&self) -> (usize, Option<usize>) {
if self.current > self.end {
return (0, Some(0));
}
let hours = (self.end - self.current).num_hours() as usize + 1;
(hours, Some(hours))
}
}
impl ExactSizeIterator for HourIterator {}
/// Extracts the hour start timestamp from a Dukascopy URL.
///
/// URL format: `https://datafeed.dukascopy.com/datafeed/{INSTRUMENT}/{YEAR}/{MONTH}/{DAY}/{HOUR}h_ticks.bi5`
/// Note: Month in URL is 0-indexed (January = 00).
#[must_use]
pub fn hour_from_url(url: &str) -> Option<DateTime<Utc>> {
let parts: Vec<&str> = url.split('/').collect();
if parts.len() < 5 {
return None;
}
// Parse from the end: .../{YEAR}/{MONTH}/{DAY}/{HOUR}h_ticks.bi5
let hour_part = parts.last()?;
let hour: u32 = hour_part.strip_suffix("h_ticks.bi5")?.parse().ok()?;
let day: u32 = parts.get(parts.len() - 2)?.parse().ok()?;
let month: u32 = parts.get(parts.len() - 3)?.parse().ok()?;
let year: i32 = parts.get(parts.len() - 4)?.parse().ok()?;
// Month in URL is 0-indexed
Utc.with_ymd_and_hms(year, month + 1, day, hour, 0, 0)
.single()
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::{Datelike, Timelike};
#[test]
fn test_date_range_new() {
let start = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
let end = NaiveDate::from_ymd_opt(2024, 1, 31).unwrap();
let range = DateRange::new(start, end).unwrap();
assert_eq!(range.start, start);
assert_eq!(range.end, end);
}
#[test]
fn test_date_range_invalid() {
let start = NaiveDate::from_ymd_opt(2024, 1, 31).unwrap();
let end = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
assert!(DateRange::new(start, end).is_err());
}
#[test]
fn test_total_hours() {
let start = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
let end = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
let range = DateRange::new(start, end).unwrap();
assert_eq!(range.total_hours(), 24);
}
#[test]
fn test_hour_iterator() {
let start = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
let range = DateRange::single_day(start);
let hours: Vec<_> = range.hours().collect();
assert_eq!(hours.len(), 24);
assert_eq!(hours[0].hour(), 0);
assert_eq!(hours[23].hour(), 23);
}
#[test]
fn test_hour_from_url() {
let url = "https://datafeed.dukascopy.com/datafeed/EURUSD/2024/00/15/12h_ticks.bi5";
let hour = hour_from_url(url).unwrap();
assert_eq!(hour.year(), 2024);
assert_eq!(hour.month(), 1); // 00 -> January
assert_eq!(hour.day(), 15);
assert_eq!(hour.hour(), 12);
}
}
+63
View File
@@ -0,0 +1,63 @@
//! Error types for paracas.
use chrono::NaiveDate;
use thiserror::Error;
/// Result type alias for paracas operations.
pub type Result<T> = std::result::Result<T, ParacasError>;
/// Errors that can occur during data download and processing.
#[derive(Error, Debug)]
pub enum ParacasError {
/// HTTP request failed.
#[error("HTTP error: {0}")]
Http(String),
/// LZMA decompression failed.
#[error("Decompression error: {0}")]
Decompress(String),
/// Invalid data format.
#[error("Parse error: {0}")]
Parse(String),
/// Instrument not found.
#[error("Unknown instrument: {0}")]
UnknownInstrument(String),
/// Invalid date range.
#[error(transparent)]
DateRange(#[from] DateRangeError),
/// No data available for the requested period.
#[error("No data available for {instrument} in requested range")]
NoDataAvailable {
/// The instrument that had no data.
instrument: String,
},
/// I/O error.
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
/// Output format error.
#[error("Format error: {0}")]
Format(String),
/// JSON serialization error.
#[error("JSON error: {0}")]
Json(#[from] serde_json::Error),
}
/// Error for invalid date ranges.
#[derive(Error, Debug, Clone, PartialEq, Eq)]
pub enum DateRangeError {
/// Start date is after end date.
#[error("Invalid date range: {start} > {end}")]
InvalidRange {
/// The start date.
start: NaiveDate,
/// The end date.
end: NaiveDate,
},
}
+213
View File
@@ -0,0 +1,213 @@
//! Financial instrument definitions.
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
/// Instrument category.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Category {
/// Foreign exchange currency pairs.
Forex,
/// Cryptocurrencies.
Crypto,
/// Stock indices.
Index,
/// Individual stocks.
Stock,
/// Commodities (metals, energy, agriculture).
Commodity,
/// Exchange-traded funds.
Etf,
/// Government bonds.
Bond,
}
impl Category {
/// Returns the category as a string slice.
#[must_use]
pub const fn as_str(&self) -> &'static str {
match self {
Self::Forex => "forex",
Self::Crypto => "crypto",
Self::Index => "index",
Self::Stock => "stock",
Self::Commodity => "commodity",
Self::Etf => "etf",
Self::Bond => "bond",
}
}
}
impl std::fmt::Display for Category {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.as_str())
}
}
/// Represents a tradable financial instrument.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Instrument {
/// Unique identifier (e.g., "eurusd", "btcusd").
id: String,
/// Human-readable name (e.g., "EUR/USD").
name: String,
/// Description of the instrument.
description: String,
/// Instrument category.
category: Category,
/// Decimal factor for price normalization.
decimal_factor: u32,
/// Earliest available tick data timestamp.
start_tick_date: Option<DateTime<Utc>>,
}
impl Instrument {
/// Creates a new instrument.
#[must_use]
pub fn new(
id: impl Into<String>,
name: impl Into<String>,
description: impl Into<String>,
category: Category,
decimal_factor: u32,
start_tick_date: Option<DateTime<Utc>>,
) -> Self {
Self {
id: id.into(),
name: name.into(),
description: description.into(),
category,
decimal_factor,
start_tick_date,
}
}
/// Returns the instrument identifier.
#[must_use]
pub fn id(&self) -> &str {
&self.id
}
/// Returns the human-readable name.
#[must_use]
pub fn name(&self) -> &str {
&self.name
}
/// Returns the description.
#[must_use]
pub fn description(&self) -> &str {
&self.description
}
/// Returns the instrument category.
#[must_use]
pub const fn category(&self) -> Category {
self.category
}
/// Returns the decimal factor for price normalization.
#[must_use]
pub const fn decimal_factor(&self) -> u32 {
self.decimal_factor
}
/// Returns the decimal factor as f64 for price calculations.
#[must_use]
pub fn decimal_factor_f64(&self) -> f64 {
f64::from(self.decimal_factor)
}
/// Returns the earliest available tick data timestamp.
#[must_use]
pub const fn start_tick_date(&self) -> Option<DateTime<Utc>> {
self.start_tick_date
}
/// Returns true if tick data is available for the given date.
#[must_use]
pub fn has_data_for(&self, date: DateTime<Utc>) -> bool {
self.start_tick_date.is_some_and(|start| date >= start)
}
/// Returns true if this is a forex instrument.
#[must_use]
pub const fn is_forex(&self) -> bool {
matches!(self.category, Category::Forex)
}
/// Returns true if this is a cryptocurrency instrument.
#[must_use]
pub const fn is_crypto(&self) -> bool {
matches!(self.category, Category::Crypto)
}
/// Returns true if this is an index instrument.
#[must_use]
pub const fn is_index(&self) -> bool {
matches!(self.category, Category::Index)
}
/// Returns true if this is a stock instrument.
#[must_use]
pub const fn is_stock(&self) -> bool {
matches!(self.category, Category::Stock)
}
/// Returns true if this is a commodity instrument.
#[must_use]
pub const fn is_commodity(&self) -> bool {
matches!(self.category, Category::Commodity)
}
}
impl std::fmt::Display for Instrument {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{} ({})", self.name, self.id)
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
#[test]
fn test_instrument_creation() {
let start = Utc.with_ymd_and_hms(2003, 5, 5, 0, 0, 0).unwrap();
let instrument = Instrument::new(
"eurusd",
"EUR/USD",
"Euro vs US Dollar",
Category::Forex,
100_000,
Some(start),
);
assert_eq!(instrument.id(), "eurusd");
assert_eq!(instrument.name(), "EUR/USD");
assert_eq!(instrument.decimal_factor(), 100_000);
assert!(instrument.is_forex());
assert!(!instrument.is_crypto());
}
#[test]
fn test_has_data_for() {
let start = Utc.with_ymd_and_hms(2003, 5, 5, 0, 0, 0).unwrap();
let instrument = Instrument::new(
"eurusd",
"EUR/USD",
"Euro vs US Dollar",
Category::Forex,
100_000,
Some(start),
);
let before = Utc.with_ymd_and_hms(2003, 1, 1, 0, 0, 0).unwrap();
let after = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap();
assert!(!instrument.has_data_for(before));
assert!(instrument.has_data_for(after));
}
}
+27
View File
@@ -0,0 +1,27 @@
//! Core types for paracas tick data downloader.
//!
//! This crate provides the fundamental data structures used throughout paracas:
//!
//! - [`Tick`] - A single price tick with timestamp, ask, bid, and volumes
//! - [`RawTick`] - Raw tick from bi5 binary format before price normalization
//! - [`Instrument`] - Financial instrument with metadata
//! - [`Timeframe`] - OHLCV aggregation timeframe
//! - [`DateRange`] - Date range for data retrieval
#![doc = include_str!("../README.md")]
#![doc(issue_tracker_base_url = "https://github.com/factordynamics/paracas/issues/")]
#![cfg_attr(docsrs, feature(doc_cfg, doc_auto_cfg))]
#![warn(missing_docs)]
#![forbid(unsafe_code)]
mod date_range;
mod error;
mod instrument;
mod tick;
mod timeframe;
pub use date_range::{DateRange, HourIterator, hour_from_url};
pub use error::{DateRangeError, ParacasError, Result};
pub use instrument::{Category, Instrument};
pub use tick::{RawTick, Tick};
pub use timeframe::{Timeframe, TimeframeParseError};
+150
View File
@@ -0,0 +1,150 @@
//! Tick data representation.
use chrono::{DateTime, TimeDelta, Utc};
use serde::{Deserialize, Serialize};
/// A single tick representing a price update.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct Tick {
/// Timestamp of the tick (UTC).
pub timestamp: DateTime<Utc>,
/// Ask (offer) price.
pub ask: f64,
/// Bid price.
pub bid: f64,
/// Volume available at the ask price.
pub ask_volume: f32,
/// Volume available at the bid price.
pub bid_volume: f32,
}
impl Tick {
/// Creates a new tick.
#[must_use]
pub const fn new(
timestamp: DateTime<Utc>,
ask: f64,
bid: f64,
ask_volume: f32,
bid_volume: f32,
) -> Self {
Self {
timestamp,
ask,
bid,
ask_volume,
bid_volume,
}
}
/// Returns the mid price (average of ask and bid).
#[must_use]
pub fn mid(&self) -> f64 {
(self.ask + self.bid) / 2.0
}
/// Returns the spread (ask - bid).
#[must_use]
pub fn spread(&self) -> f64 {
self.ask - self.bid
}
/// Returns the total volume (ask + bid volume).
#[must_use]
pub fn total_volume(&self) -> f32 {
self.ask_volume + self.bid_volume
}
}
/// Raw tick as read from bi5 file (before price normalization).
///
/// The bi5 format stores ticks as 20 bytes in big-endian order:
/// - `u32`: milliseconds offset from hour start
/// - `u32`: ask price (raw, needs division by decimal factor)
/// - `u32`: bid price (raw, needs division by decimal factor)
/// - `f32`: ask volume
/// - `f32`: bid volume
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct RawTick {
/// Milliseconds offset from the hour start.
pub ms_offset: u32,
/// Raw ask price (needs division by decimal factor).
pub ask_raw: u32,
/// Raw bid price (needs division by decimal factor).
pub bid_raw: u32,
/// Ask volume.
pub ask_volume: f32,
/// Bid volume.
pub bid_volume: f32,
}
impl RawTick {
/// Size in bytes of a raw tick record.
pub const SIZE: usize = 20;
/// Creates a new raw tick.
#[must_use]
pub const fn new(
ms_offset: u32,
ask_raw: u32,
bid_raw: u32,
ask_volume: f32,
bid_volume: f32,
) -> Self {
Self {
ms_offset,
ask_raw,
bid_raw,
ask_volume,
bid_volume,
}
}
/// Normalizes the raw tick using the instrument's decimal factor.
///
/// The decimal factor converts the raw integer prices to floating-point
/// prices. For example, EUR/USD has a decimal factor of 100,000, so a
/// raw price of 112345 becomes 1.12345.
#[must_use]
pub fn normalize(self, hour_start: DateTime<Utc>, decimal_factor: f64) -> Tick {
let timestamp = hour_start + TimeDelta::milliseconds(i64::from(self.ms_offset));
Tick {
timestamp,
ask: f64::from(self.ask_raw) / decimal_factor,
bid: f64::from(self.bid_raw) / decimal_factor,
ask_volume: self.ask_volume,
bid_volume: self.bid_volume,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
#[test]
fn test_tick_mid_price() {
let tick = Tick::new(Utc::now(), 1.1001, 1.1000, 100.0, 200.0);
assert!((tick.mid() - 1.10005).abs() < 1e-10);
}
#[test]
fn test_tick_spread() {
let tick = Tick::new(Utc::now(), 1.1001, 1.1000, 100.0, 200.0);
assert!((tick.spread() - 0.0001).abs() < 1e-10);
}
#[test]
fn test_raw_tick_normalize() {
let hour_start = Utc.with_ymd_and_hms(2024, 1, 1, 12, 0, 0).unwrap();
let raw = RawTick::new(1000, 110010, 110000, 100.0, 200.0);
let tick = raw.normalize(hour_start, 100_000.0);
assert_eq!(tick.timestamp, hour_start + TimeDelta::milliseconds(1000));
assert!((tick.ask - 1.1001).abs() < 1e-10);
assert!((tick.bid - 1.1000).abs() < 1e-10);
assert!((tick.ask_volume - 100.0).abs() < 1e-10);
assert!((tick.bid_volume - 200.0).abs() < 1e-10);
}
}
+164
View File
@@ -0,0 +1,164 @@
//! OHLCV aggregation timeframe definitions.
use serde::{Deserialize, Serialize};
use std::str::FromStr;
/// OHLCV aggregation timeframe.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum Timeframe {
/// Tick-by-tick (no aggregation).
#[default]
Tick,
/// 1-second bars.
#[serde(rename = "s1")]
Second1,
/// 1-minute bars.
#[serde(rename = "m1")]
Minute1,
/// 5-minute bars.
#[serde(rename = "m5")]
Minute5,
/// 15-minute bars.
#[serde(rename = "m15")]
Minute15,
/// 30-minute bars.
#[serde(rename = "m30")]
Minute30,
/// 1-hour bars.
#[serde(rename = "h1")]
Hour1,
/// 4-hour bars.
#[serde(rename = "h4")]
Hour4,
/// Daily bars.
#[serde(rename = "d1")]
Day1,
}
impl Timeframe {
/// Returns the duration in seconds, or None for tick data.
#[must_use]
pub const fn seconds(&self) -> Option<u64> {
match self {
Self::Tick => None,
Self::Second1 => Some(1),
Self::Minute1 => Some(60),
Self::Minute5 => Some(300),
Self::Minute15 => Some(900),
Self::Minute30 => Some(1800),
Self::Hour1 => Some(3600),
Self::Hour4 => Some(14400),
Self::Day1 => Some(86400),
}
}
/// Returns the duration in milliseconds, or None for tick data.
#[must_use]
pub const fn milliseconds(&self) -> Option<u64> {
match self.seconds() {
Some(s) => Some(s * 1000),
None => None,
}
}
/// Returns true if this is tick data (no aggregation).
#[must_use]
pub const fn is_tick(&self) -> bool {
matches!(self, Self::Tick)
}
/// Returns the timeframe as a string identifier.
#[must_use]
pub const fn as_str(&self) -> &'static str {
match self {
Self::Tick => "tick",
Self::Second1 => "s1",
Self::Minute1 => "m1",
Self::Minute5 => "m5",
Self::Minute15 => "m15",
Self::Minute30 => "m30",
Self::Hour1 => "h1",
Self::Hour4 => "h4",
Self::Day1 => "d1",
}
}
/// Returns all available timeframes.
#[must_use]
pub const fn all() -> &'static [Self] {
&[
Self::Tick,
Self::Second1,
Self::Minute1,
Self::Minute5,
Self::Minute15,
Self::Minute30,
Self::Hour1,
Self::Hour4,
Self::Day1,
]
}
}
impl std::fmt::Display for Timeframe {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.as_str())
}
}
impl FromStr for Timeframe {
type Err = TimeframeParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"tick" => Ok(Self::Tick),
"s1" | "1s" | "second" | "second1" => Ok(Self::Second1),
"m1" | "1m" | "minute" | "minute1" => Ok(Self::Minute1),
"m5" | "5m" | "minute5" => Ok(Self::Minute5),
"m15" | "15m" | "minute15" => Ok(Self::Minute15),
"m30" | "30m" | "minute30" => Ok(Self::Minute30),
"h1" | "1h" | "hour" | "hour1" => Ok(Self::Hour1),
"h4" | "4h" | "hour4" => Ok(Self::Hour4),
"d1" | "1d" | "day" | "day1" | "daily" => Ok(Self::Day1),
_ => Err(TimeframeParseError(s.to_string())),
}
}
}
/// Error returned when parsing an invalid timeframe string.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TimeframeParseError(String);
impl std::fmt::Display for TimeframeParseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"invalid timeframe '{}', expected one of: tick, s1, m1, m5, m15, m30, h1, h4, d1",
self.0
)
}
}
impl std::error::Error for TimeframeParseError {}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_timeframe_seconds() {
assert_eq!(Timeframe::Tick.seconds(), None);
assert_eq!(Timeframe::Minute1.seconds(), Some(60));
assert_eq!(Timeframe::Hour1.seconds(), Some(3600));
assert_eq!(Timeframe::Day1.seconds(), Some(86400));
}
#[test]
fn test_timeframe_parse() {
assert_eq!("m1".parse::<Timeframe>().unwrap(), Timeframe::Minute1);
assert_eq!("1h".parse::<Timeframe>().unwrap(), Timeframe::Hour1);
assert_eq!("H4".parse::<Timeframe>().unwrap(), Timeframe::Hour4);
assert!("invalid".parse::<Timeframe>().is_err());
}
}