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
+390
View File
@@ -0,0 +1,390 @@
//! paracas CLI - High-performance Dukascopy tick data downloader.
use anyhow::{Context, Result, bail};
use chrono::NaiveDate;
use clap::{Parser, Subcommand, ValueEnum};
use futures::StreamExt;
use indicatif::{ProgressBar, ProgressStyle};
use paracas_lib::prelude::*;
use std::fs::File;
use std::io::BufWriter;
use std::path::PathBuf;
#[derive(Parser)]
#[command(name = "paracas")]
#[command(about = "High-performance Dukascopy tick data downloader", long_about = None)]
#[command(version)]
struct Cli {
#[command(subcommand)]
command: Commands,
/// Verbosity level (-v, -vv, -vvv)
#[arg(short, long, action = clap::ArgAction::Count, global = true)]
verbose: u8,
/// Quiet mode (suppress progress output)
#[arg(short, long, global = true)]
quiet: bool,
}
#[derive(Subcommand)]
enum Commands {
/// Download tick data
Download {
/// Instrument identifier (e.g., eurusd, btcusd)
instrument: String,
/// Start date (YYYY-MM-DD). Defaults to instrument's earliest available data.
#[arg(short, long)]
start: Option<String>,
/// End date (YYYY-MM-DD). Defaults to today.
#[arg(short, long)]
end: Option<String>,
/// Output file path. Defaults to <instrument>.<format>
#[arg(short, long)]
output: Option<PathBuf>,
/// Output format
#[arg(short, long, value_enum, default_value = "csv")]
format: Format,
/// OHLCV aggregation timeframe (omit for raw ticks)
#[arg(short, long)]
timeframe: Option<String>,
/// Maximum concurrent downloads
#[arg(long, default_value = "32")]
concurrency: usize,
},
/// List available instruments
List {
/// Filter by category (forex, crypto, index, stock, commodity, etf, bond)
#[arg(short, long)]
category: Option<String>,
/// Search pattern
#[arg(short, long)]
search: Option<String>,
},
/// Show instrument details
Info {
/// Instrument identifier
instrument: String,
},
}
#[derive(Clone, Copy, ValueEnum)]
enum Format {
Csv,
Json,
Ndjson,
Parquet,
}
#[tokio::main]
async fn main() -> Result<()> {
let cli = Cli::parse();
match cli.command {
Commands::Download {
instrument,
start,
end,
output,
format,
timeframe,
concurrency,
} => {
download(
&instrument,
start.as_deref(),
end.as_deref(),
output,
format,
timeframe.as_deref(),
concurrency,
cli.quiet,
)
.await
}
Commands::List { category, search } => {
list_instruments(category.as_deref(), search.as_deref())
}
Commands::Info { instrument } => show_info(&instrument),
}
}
#[allow(clippy::too_many_arguments)]
async fn download(
instrument_id: &str,
start_str: Option<&str>,
end_str: Option<&str>,
output: Option<PathBuf>,
format: Format,
timeframe_str: Option<&str>,
concurrency: usize,
quiet: bool,
) -> Result<()> {
// Lookup instrument
let registry = InstrumentRegistry::global();
let instrument = registry
.get(instrument_id)
.with_context(|| format!("Unknown instrument: {instrument_id}"))?;
// Parse start date (default to instrument's earliest available data)
let start = match start_str {
Some(s) => NaiveDate::parse_from_str(s, "%Y-%m-%d")
.with_context(|| format!("Invalid start date: {s}"))?,
None => instrument
.start_tick_date()
.map(|dt| dt.date_naive())
.unwrap_or_else(|| NaiveDate::from_ymd_opt(2003, 5, 5).expect("valid date")),
};
// Parse end date (default to today)
let end = match end_str {
Some(s) => NaiveDate::parse_from_str(s, "%Y-%m-%d")
.with_context(|| format!("Invalid end date: {s}"))?,
None => chrono::Utc::now().date_naive(),
};
let range = DateRange::new(start, end)?;
// Determine output path (default to <instrument>.<format>)
let output = output.unwrap_or_else(|| {
let ext = match format {
Format::Csv => "csv",
Format::Json => "json",
Format::Ndjson => "ndjson",
Format::Parquet => "parquet",
};
PathBuf::from(format!("{}.{}", instrument_id, ext))
});
// Parse timeframe
let timeframe = match timeframe_str {
Some(tf) => tf
.parse::<Timeframe>()
.map_err(|e| anyhow::anyhow!("{e}"))?,
None => Timeframe::Tick,
};
// Create client
let config = ClientConfig {
concurrency,
..Default::default()
};
let client = DownloadClient::new(config)?;
// Setup progress bar
let total_hours = range.total_hours() as u64;
let progress = if quiet {
ProgressBar::hidden()
} else {
let pb = ProgressBar::new(total_hours);
pb.set_style(
ProgressStyle::default_bar()
.template("{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] {pos}/{len} hours ({percent}%) {msg}")
.expect("Invalid progress template")
.progress_chars("=>-"),
);
pb.set_message(format!("{} {} -> {}", instrument.id(), start, end));
pb
};
// Download and collect ticks using the resilient stream
// This will retry on transient errors and skip hours that fail after retries
let mut all_ticks: Vec<Tick> = Vec::new();
let mut skipped_hours = 0u64;
let mut stream = paracas_lib::tick_stream_resilient(&client, instrument, range);
while let Some(batch) = stream.next().await {
if batch.had_error() {
skipped_hours += 1;
}
all_ticks.extend(batch.ticks);
progress.inc(1);
}
let finish_msg = if skipped_hours > 0 {
format!(
"Downloaded {} ticks ({} hours skipped due to errors)",
all_ticks.len(),
skipped_hours
)
} else {
format!("Downloaded {} ticks", all_ticks.len())
};
progress.finish_with_message(finish_msg);
// Aggregate if needed
if timeframe.is_tick() {
// Write raw ticks
write_ticks(&all_ticks, &output, format)?;
} else {
// Aggregate to OHLCV
let bars = aggregate_ticks(&all_ticks, timeframe);
write_ohlcv(&bars, &output, format)?;
}
if !quiet {
println!("Output written to: {}", output.display());
}
Ok(())
}
fn aggregate_ticks(ticks: &[Tick], timeframe: Timeframe) -> Vec<Ohlcv> {
let mut aggregator = TickAggregator::new(timeframe);
let mut bars = Vec::new();
for tick in ticks {
if let Some(bar) = aggregator.process(*tick) {
bars.push(bar);
}
}
if let Some(bar) = aggregator.finish() {
bars.push(bar);
}
bars
}
fn write_ticks(ticks: &[Tick], output: &PathBuf, format: Format) -> Result<()> {
let file = File::create(output)?;
let writer = BufWriter::new(file);
match format {
Format::Csv => {
let formatter = CsvFormatter::new();
formatter.write_ticks(ticks, writer)?;
}
Format::Json => {
let formatter = JsonFormatter::new();
formatter.write_ticks(ticks, writer)?;
}
Format::Ndjson => {
let formatter = JsonFormatter::ndjson();
formatter.write_ticks(ticks, writer)?;
}
Format::Parquet => {
#[cfg(feature = "parquet")]
{
let formatter = ParquetFormatter::new();
formatter.write_ticks(ticks, writer)?;
}
#[cfg(not(feature = "parquet"))]
{
bail!("Parquet support not compiled in");
}
}
}
Ok(())
}
fn write_ohlcv(bars: &[Ohlcv], output: &PathBuf, format: Format) -> Result<()> {
let file = File::create(output)?;
let writer = BufWriter::new(file);
match format {
Format::Csv => {
let formatter = CsvFormatter::new();
formatter.write_ohlcv(bars, writer)?;
}
Format::Json => {
let formatter = JsonFormatter::new();
formatter.write_ohlcv(bars, writer)?;
}
Format::Ndjson => {
let formatter = JsonFormatter::ndjson();
formatter.write_ohlcv(bars, writer)?;
}
Format::Parquet => {
#[cfg(feature = "parquet")]
{
let formatter = ParquetFormatter::new();
formatter.write_ohlcv(bars, writer)?;
}
#[cfg(not(feature = "parquet"))]
{
bail!("Parquet support not compiled in");
}
}
}
Ok(())
}
fn list_instruments(category: Option<&str>, search: Option<&str>) -> Result<()> {
let registry = InstrumentRegistry::global();
let instruments: Vec<_> = match (category, search) {
(Some(cat), _) => {
let category = parse_category(cat)?;
registry.by_category(category).collect()
}
(_, Some(pattern)) => registry.search(pattern),
(None, None) => registry.all().collect(),
};
if instruments.is_empty() {
println!("No instruments found.");
return Ok(());
}
println!("{:<15} {:<20} {:<10}", "ID", "NAME", "CATEGORY");
println!("{}", "-".repeat(50));
for instrument in &instruments {
println!(
"{:<15} {:<20} {:<10}",
instrument.id(),
instrument.name(),
instrument.category()
);
}
println!("\nTotal: {} instruments", instruments.len());
Ok(())
}
fn show_info(instrument_id: &str) -> Result<()> {
let registry = InstrumentRegistry::global();
let instrument = registry
.get(instrument_id)
.with_context(|| format!("Unknown instrument: {instrument_id}"))?;
println!("Instrument: {}", instrument.name());
println!("ID: {}", instrument.id());
println!("Category: {}", instrument.category());
println!("Description: {}", instrument.description());
println!("Decimal Factor: {}", instrument.decimal_factor());
if let Some(start) = instrument.start_tick_date() {
println!("Data Available From: {}", start.format("%Y-%m-%d"));
}
Ok(())
}
fn parse_category(s: &str) -> Result<Category> {
match s.to_lowercase().as_str() {
"forex" => Ok(Category::Forex),
"crypto" => Ok(Category::Crypto),
"index" => Ok(Category::Index),
"stock" => Ok(Category::Stock),
"commodity" => Ok(Category::Commodity),
"etf" => Ok(Category::Etf),
"bond" => Ok(Category::Bond),
_ => bail!(
"Unknown category: {}. Valid options: forex, crypto, index, stock, commodity, etf, bond",
s
),
}
}