feat: background downloading ⚡
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
//! Hidden daemon entry point for background downloads.
|
||||
//!
|
||||
//! This module provides the entry point for daemon processes spawned
|
||||
//! with `--daemon-run <job_id>`. It loads the job from disk and executes
|
||||
//! the download tasks.
|
||||
|
||||
use crate::display::{Format, aggregate_ticks, write_ohlcv, write_ticks};
|
||||
use anyhow::{Context, Result, bail};
|
||||
use futures::StreamExt;
|
||||
use paracas_daemon::{DaemonProgress, JobId, JobStatus, StateManager};
|
||||
use paracas_lib::prelude::*;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Execute a background download job.
|
||||
///
|
||||
/// This is called when paracas is spawned with `--daemon-run <job_id>`.
|
||||
/// The function loads the job from disk, executes all pending tasks,
|
||||
/// and saves progress periodically.
|
||||
pub(crate) async fn daemon_run(job_id_str: &str) -> Result<()> {
|
||||
let job_id: JobId = job_id_str.parse().context("Invalid job ID")?;
|
||||
|
||||
let state_manager =
|
||||
StateManager::with_default_path().context("Failed to initialize state manager")?;
|
||||
|
||||
let job = state_manager.load_job(job_id).context("Job not found")?;
|
||||
|
||||
if !matches!(job.status, JobStatus::Pending | JobStatus::Running) {
|
||||
bail!("Job is not in a runnable state: {:?}", job.status);
|
||||
}
|
||||
|
||||
let progress = DaemonProgress::new(state_manager.clone(), job);
|
||||
|
||||
// Mark job as running
|
||||
{
|
||||
let mut job = progress.job().await;
|
||||
job.mark_started(std::process::id());
|
||||
state_manager.save_job(&job)?;
|
||||
}
|
||||
|
||||
// Process each task
|
||||
let job = progress.job().await;
|
||||
for (task_idx, task) in job.tasks.iter().enumerate() {
|
||||
if matches!(task.status, JobStatus::Completed) {
|
||||
continue; // Skip already completed tasks
|
||||
}
|
||||
|
||||
if let Err(e) = execute_task(&progress, task_idx).await {
|
||||
progress.mark_task_failed(task_idx, &e.to_string()).await;
|
||||
}
|
||||
|
||||
progress.save_checkpoint().await?;
|
||||
}
|
||||
|
||||
// Mark job as completed or failed based on task results
|
||||
if progress.all_tasks_finished().await {
|
||||
if progress.failed_tasks().await == 0 {
|
||||
progress.mark_job_completed().await;
|
||||
} else {
|
||||
let failed_count = progress.failed_tasks().await;
|
||||
let msg = format!("{} tasks failed", failed_count);
|
||||
progress.mark_job_failed(&msg).await;
|
||||
}
|
||||
}
|
||||
|
||||
progress.save_checkpoint().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Execute a single download task.
|
||||
async fn execute_task(progress: &DaemonProgress, task_idx: usize) -> Result<()> {
|
||||
progress.mark_task_running(task_idx).await;
|
||||
|
||||
let job = progress.job().await;
|
||||
let task = &job.tasks[task_idx];
|
||||
|
||||
// Get instrument
|
||||
let registry = InstrumentRegistry::global();
|
||||
let instrument = registry
|
||||
.get(&task.instrument_id)
|
||||
.context("Unknown instrument")?;
|
||||
|
||||
// Parse date range
|
||||
let start = chrono::NaiveDate::parse_from_str(&task.start_date, "%Y-%m-%d")?;
|
||||
let end = chrono::NaiveDate::parse_from_str(&task.end_date, "%Y-%m-%d")?;
|
||||
let range = DateRange::new(start, end)?;
|
||||
|
||||
// Create client
|
||||
let config = ClientConfig {
|
||||
concurrency: job.concurrency,
|
||||
..Default::default()
|
||||
};
|
||||
let client = DownloadClient::new(config)?;
|
||||
|
||||
// Download ticks
|
||||
let mut all_ticks: Vec<Tick> = Vec::new();
|
||||
let mut stream = paracas_lib::tick_stream_resilient(&client, instrument, range);
|
||||
let mut hours_completed = 0u64;
|
||||
|
||||
while let Some(batch) = stream.next().await {
|
||||
all_ticks.extend(batch.ticks);
|
||||
hours_completed += 1;
|
||||
|
||||
// Update progress periodically (every 10 hours)
|
||||
if hours_completed.is_multiple_of(10) {
|
||||
progress
|
||||
.update_task_progress(task_idx, hours_completed, all_ticks.len() as u64)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
// Parse timeframe and aggregate if needed
|
||||
let timeframe = task
|
||||
.timeframe
|
||||
.parse::<Timeframe>()
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
|
||||
// Parse format
|
||||
let format = parse_format(&task.format)?;
|
||||
|
||||
// Write output
|
||||
let output_path = task.output_path.clone();
|
||||
write_output(&all_ticks, &output_path, format, timeframe)?;
|
||||
|
||||
let bytes_written = std::fs::metadata(&output_path)
|
||||
.map(|m| m.len())
|
||||
.unwrap_or(0);
|
||||
|
||||
progress.mark_task_completed(task_idx, bytes_written).await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Parse a format string into a Format enum.
|
||||
fn parse_format(format: &str) -> Result<Format> {
|
||||
match format.to_lowercase().as_str() {
|
||||
"csv" => Ok(Format::Csv),
|
||||
"json" => Ok(Format::Json),
|
||||
"ndjson" => Ok(Format::Ndjson),
|
||||
"parquet" => Ok(Format::Parquet),
|
||||
_ => bail!("Unknown format: {}", format),
|
||||
}
|
||||
}
|
||||
|
||||
/// Write ticks or OHLCV data to the output file.
|
||||
fn write_output(
|
||||
ticks: &[Tick],
|
||||
output: &PathBuf,
|
||||
format: Format,
|
||||
timeframe: Timeframe,
|
||||
) -> Result<()> {
|
||||
if timeframe.is_tick() {
|
||||
write_ticks(ticks, output, format)?;
|
||||
} else {
|
||||
let bars = aggregate_ticks(ticks, timeframe);
|
||||
write_ohlcv(&bars, output, format)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
//! Download command implementation.
|
||||
//!
|
||||
//! This module handles downloading tick data from Dukascopy and writing it to various output formats.
|
||||
|
||||
use crate::display::{Format, aggregate_ticks, write_ohlcv, write_ticks};
|
||||
use anyhow::{Context, Result};
|
||||
use chrono::NaiveDate;
|
||||
use futures::StreamExt;
|
||||
use indicatif::{ProgressBar, ProgressStyle};
|
||||
use paracas_daemon::{DaemonSpawner, DownloadJob, InstrumentTask, StateManager};
|
||||
use paracas_lib::prelude::*;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Download tick data for an instrument.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) 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,
|
||||
background: bool,
|
||||
_yes: bool,
|
||||
quiet: bool,
|
||||
) -> Result<()> {
|
||||
// Handle background mode
|
||||
if background {
|
||||
return spawn_background_download(
|
||||
instrument_id,
|
||||
start_str,
|
||||
end_str,
|
||||
output,
|
||||
format,
|
||||
timeframe_str,
|
||||
concurrency,
|
||||
);
|
||||
}
|
||||
|
||||
// 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(|| PathBuf::from(format!("{}.{}", instrument_id, format.extension())));
|
||||
|
||||
// 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(())
|
||||
}
|
||||
|
||||
/// Spawn a background download job for a single instrument.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn spawn_background_download(
|
||||
instrument_id: &str,
|
||||
start_str: Option<&str>,
|
||||
end_str: Option<&str>,
|
||||
output: Option<PathBuf>,
|
||||
format: Format,
|
||||
timeframe_str: Option<&str>,
|
||||
concurrency: usize,
|
||||
) -> Result<()> {
|
||||
let registry = InstrumentRegistry::global();
|
||||
let instrument = registry
|
||||
.get(instrument_id)
|
||||
.with_context(|| format!("Unknown instrument: {instrument_id}"))?;
|
||||
|
||||
// Determine start date
|
||||
let start = start_str
|
||||
.map(|s| s.to_string())
|
||||
.or_else(|| {
|
||||
instrument
|
||||
.start_tick_date()
|
||||
.map(|d| d.format("%Y-%m-%d").to_string())
|
||||
})
|
||||
.unwrap_or_else(|| "2003-05-05".to_string());
|
||||
|
||||
// Determine end date
|
||||
let end = end_str
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| chrono::Utc::now().format("%Y-%m-%d").to_string());
|
||||
|
||||
// Determine output path
|
||||
let output_path = output
|
||||
.unwrap_or_else(|| PathBuf::from(format!("{}.{}", instrument_id, format.extension())));
|
||||
|
||||
// Make output path absolute
|
||||
let output_path = if output_path.is_absolute() {
|
||||
output_path
|
||||
} else {
|
||||
std::env::current_dir()
|
||||
.unwrap_or_else(|_| PathBuf::from("."))
|
||||
.join(output_path)
|
||||
};
|
||||
|
||||
// Calculate total hours for progress tracking
|
||||
let start_date = NaiveDate::parse_from_str(&start, "%Y-%m-%d")?;
|
||||
let end_date = NaiveDate::parse_from_str(&end, "%Y-%m-%d")?;
|
||||
let range = DateRange::new(start_date, end_date)?;
|
||||
|
||||
// Determine timeframe string (default to "tick")
|
||||
let timeframe = timeframe_str
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| "tick".to_string());
|
||||
|
||||
let task = InstrumentTask::new(
|
||||
instrument_id.to_string(),
|
||||
start,
|
||||
end,
|
||||
output_path,
|
||||
format.to_string(),
|
||||
timeframe,
|
||||
range.total_hours() as u32,
|
||||
);
|
||||
|
||||
let mut job = DownloadJob::new(vec![task], concurrency);
|
||||
|
||||
let state_manager =
|
||||
StateManager::with_default_path().context("Failed to initialize state manager")?;
|
||||
let spawner = DaemonSpawner::new(state_manager).context("Failed to create daemon spawner")?;
|
||||
let job_id = spawner
|
||||
.spawn(&mut job)
|
||||
.context("Failed to spawn background job")?;
|
||||
|
||||
println!("Background download started.");
|
||||
println!("Job ID: {}", job_id);
|
||||
println!("Check status with: paracas status {}", job_id);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
//! Download all instruments command.
|
||||
//!
|
||||
//! This module handles batch downloading of multiple instruments, with support for
|
||||
//! category filtering, parallel downloads, and download estimation.
|
||||
|
||||
use crate::display::{Format, aggregate_ticks, parse_category, write_ohlcv, write_ticks};
|
||||
use anyhow::{Context, Result};
|
||||
use chrono::NaiveDate;
|
||||
use futures::stream::{self, StreamExt};
|
||||
use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
|
||||
use paracas_daemon::{DaemonSpawner, DownloadJob, InstrumentTask, StateManager};
|
||||
use paracas_estimate::Estimator;
|
||||
use paracas_lib::prelude::*;
|
||||
use std::io::Write as _;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Execute the download-all command.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) async fn download_all(
|
||||
category: Option<&str>,
|
||||
start_str: Option<&str>,
|
||||
end_str: Option<&str>,
|
||||
output_dir: PathBuf,
|
||||
format: Format,
|
||||
timeframe_str: Option<&str>,
|
||||
parallel_instruments: usize,
|
||||
concurrency: usize,
|
||||
background: bool,
|
||||
yes: bool,
|
||||
quiet: bool,
|
||||
) -> Result<()> {
|
||||
// 1. Get instruments based on category filter (or all)
|
||||
let registry = InstrumentRegistry::global();
|
||||
let instruments: Vec<_> = match category {
|
||||
Some(cat) => {
|
||||
let category = parse_category(cat)?;
|
||||
registry.by_category(category).collect()
|
||||
}
|
||||
None => registry.all().collect(),
|
||||
};
|
||||
|
||||
if instruments.is_empty() {
|
||||
anyhow::bail!("No instruments found matching criteria");
|
||||
}
|
||||
|
||||
// Parse end date (default to today)
|
||||
let today = chrono::Utc::now().date_naive();
|
||||
let end = match end_str {
|
||||
Some(s) => NaiveDate::parse_from_str(s, "%Y-%m-%d")
|
||||
.with_context(|| format!("Invalid end date: {s}"))?,
|
||||
None => today,
|
||||
};
|
||||
|
||||
// Parse start date or use earliest instrument date
|
||||
let start = match start_str {
|
||||
Some(s) => NaiveDate::parse_from_str(s, "%Y-%m-%d")
|
||||
.with_context(|| format!("Invalid start date: {s}"))?,
|
||||
None => {
|
||||
// Use the earliest start date among all selected instruments
|
||||
instruments
|
||||
.iter()
|
||||
.filter_map(|i| i.start_tick_date())
|
||||
.map(|dt| dt.date_naive())
|
||||
.min()
|
||||
.unwrap_or_else(|| NaiveDate::from_ymd_opt(2003, 5, 5).expect("valid date"))
|
||||
}
|
||||
};
|
||||
|
||||
let range = DateRange::new(start, end)?;
|
||||
|
||||
// 2. Show estimate and get confirmation
|
||||
let estimator = Estimator::global();
|
||||
let estimate = estimator.estimate_batch(&instruments, &range);
|
||||
|
||||
if !yes && !quiet {
|
||||
println!("Download plan:");
|
||||
println!(" Instruments: {}", instruments.len());
|
||||
println!(" Date range: {} to {}", start, end);
|
||||
println!(
|
||||
" Estimated download size: {}",
|
||||
Estimator::format_bytes(estimate.estimated_compressed_bytes)
|
||||
);
|
||||
println!(
|
||||
" Estimated output size: {}",
|
||||
Estimator::format_bytes(estimate.estimated_output_bytes)
|
||||
);
|
||||
println!(
|
||||
" Estimated time: {}",
|
||||
Estimator::format_duration(estimate.estimated_duration)
|
||||
);
|
||||
println!();
|
||||
|
||||
// Simple y/n confirmation
|
||||
print!("Proceed with download? [y/N] ");
|
||||
std::io::stdout().flush()?;
|
||||
let mut input = String::new();
|
||||
std::io::stdin().read_line(&mut input)?;
|
||||
if !input.trim().eq_ignore_ascii_case("y") {
|
||||
println!("Cancelled.");
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
// 3. If background mode, spawn daemon
|
||||
if background {
|
||||
return spawn_background_download_all(
|
||||
&instruments,
|
||||
start,
|
||||
end,
|
||||
&output_dir,
|
||||
format,
|
||||
timeframe_str,
|
||||
concurrency,
|
||||
);
|
||||
}
|
||||
|
||||
// 4. Create output directory if needed
|
||||
std::fs::create_dir_all(&output_dir)?;
|
||||
|
||||
// 5. Parse timeframe
|
||||
let timeframe = match timeframe_str {
|
||||
Some(tf) => tf
|
||||
.parse::<Timeframe>()
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))?,
|
||||
None => Timeframe::Tick,
|
||||
};
|
||||
|
||||
// 6. Download instruments in parallel
|
||||
let multi_progress = MultiProgress::new();
|
||||
|
||||
let results: Vec<_> = stream::iter(instruments.into_iter())
|
||||
.map(|instrument| {
|
||||
let pb = multi_progress.add(ProgressBar::new(100));
|
||||
pb.set_style(
|
||||
ProgressStyle::default_bar()
|
||||
.template("{prefix:.bold} [{bar:30.cyan/blue}] {percent}% {msg}")
|
||||
.unwrap()
|
||||
.progress_chars("=>-"),
|
||||
);
|
||||
pb.set_prefix(format!("{:>12}", instrument.id()));
|
||||
|
||||
download_single_instrument(
|
||||
instrument,
|
||||
start,
|
||||
end,
|
||||
output_dir.clone(),
|
||||
format,
|
||||
timeframe,
|
||||
concurrency,
|
||||
pb,
|
||||
quiet,
|
||||
)
|
||||
})
|
||||
.buffer_unordered(parallel_instruments)
|
||||
.collect()
|
||||
.await;
|
||||
|
||||
// 7. Report summary
|
||||
let (successes, failures): (Vec<_>, Vec<_>) = results.iter().partition(|r| r.is_ok());
|
||||
|
||||
if !quiet {
|
||||
println!("\nDownload complete:");
|
||||
println!(" Successful: {}", successes.len());
|
||||
if !failures.is_empty() {
|
||||
println!(" Failed: {}", failures.len());
|
||||
for (i, err) in failures.iter().enumerate() {
|
||||
if let Err(e) = err {
|
||||
println!(" {}: {}", i + 1, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Return error if any downloads failed
|
||||
if !failures.is_empty() {
|
||||
anyhow::bail!(
|
||||
"{} out of {} downloads failed",
|
||||
failures.len(),
|
||||
successes.len() + failures.len()
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Download a single instrument with progress tracking.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn download_single_instrument(
|
||||
instrument: &Instrument,
|
||||
start: NaiveDate,
|
||||
end: NaiveDate,
|
||||
output_dir: PathBuf,
|
||||
format: Format,
|
||||
timeframe: Timeframe,
|
||||
concurrency: usize,
|
||||
progress: ProgressBar,
|
||||
quiet: bool,
|
||||
) -> Result<()> {
|
||||
// Adjust start date based on instrument's available data
|
||||
let effective_start = instrument
|
||||
.start_tick_date()
|
||||
.map_or(start, |instrument_start| {
|
||||
let instrument_start_date = instrument_start.date_naive();
|
||||
if start < instrument_start_date {
|
||||
instrument_start_date
|
||||
} else {
|
||||
start
|
||||
}
|
||||
});
|
||||
|
||||
// Skip if the instrument has no data in the requested range
|
||||
if effective_start > end {
|
||||
progress.finish_with_message("skipped (no data)");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let range = DateRange::new(effective_start, end)?;
|
||||
let total_hours = range.total_hours() as u64;
|
||||
progress.set_length(total_hours);
|
||||
|
||||
// Create client
|
||||
let config = ClientConfig {
|
||||
concurrency,
|
||||
..Default::default()
|
||||
};
|
||||
let client = DownloadClient::new(config)?;
|
||||
|
||||
// Download and collect ticks
|
||||
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 tick_count = all_ticks.len();
|
||||
let finish_msg = if skipped_hours > 0 {
|
||||
format!("{} ticks ({} hrs skipped)", tick_count, skipped_hours)
|
||||
} else {
|
||||
format!("{} ticks", tick_count)
|
||||
};
|
||||
progress.finish_with_message(finish_msg);
|
||||
|
||||
// Determine output path
|
||||
let output_path = output_dir.join(format!("{}.{}", instrument.id(), format.extension()));
|
||||
|
||||
// Aggregate if needed
|
||||
if timeframe.is_tick() {
|
||||
write_ticks(&all_ticks, &output_path, format)?;
|
||||
} else {
|
||||
let bars = aggregate_ticks(&all_ticks, timeframe);
|
||||
write_ohlcv(&bars, &output_path, format)?;
|
||||
}
|
||||
|
||||
if !quiet {
|
||||
progress.println(format!(" Written: {}", output_path.display()));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Spawn a background download job for multiple instruments.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn spawn_background_download_all(
|
||||
instruments: &[&Instrument],
|
||||
start: NaiveDate,
|
||||
end: NaiveDate,
|
||||
output_dir: &PathBuf,
|
||||
format: Format,
|
||||
timeframe_str: Option<&str>,
|
||||
concurrency: usize,
|
||||
) -> Result<()> {
|
||||
// Make output directory absolute
|
||||
let output_dir = if output_dir.is_absolute() {
|
||||
output_dir.clone()
|
||||
} else {
|
||||
std::env::current_dir()
|
||||
.unwrap_or_else(|_| PathBuf::from("."))
|
||||
.join(output_dir)
|
||||
};
|
||||
|
||||
// Create output directory if needed
|
||||
std::fs::create_dir_all(&output_dir)?;
|
||||
|
||||
// Determine timeframe string (default to "tick")
|
||||
let timeframe = timeframe_str
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| "tick".to_string());
|
||||
|
||||
// Create tasks for each instrument
|
||||
let mut tasks = Vec::with_capacity(instruments.len());
|
||||
|
||||
for instrument in instruments {
|
||||
// Adjust start date based on instrument's available data
|
||||
let effective_start = instrument
|
||||
.start_tick_date()
|
||||
.map_or(start, |instrument_start| {
|
||||
let instrument_start_date = instrument_start.date_naive();
|
||||
if start < instrument_start_date {
|
||||
instrument_start_date
|
||||
} else {
|
||||
start
|
||||
}
|
||||
});
|
||||
|
||||
// Skip if the instrument has no data in the requested range
|
||||
if effective_start > end {
|
||||
continue;
|
||||
}
|
||||
|
||||
let range = DateRange::new(effective_start, end)?;
|
||||
let output_path = output_dir.join(format!("{}.{}", instrument.id(), format.extension()));
|
||||
|
||||
let task = InstrumentTask::new(
|
||||
instrument.id().to_string(),
|
||||
effective_start.format("%Y-%m-%d").to_string(),
|
||||
end.format("%Y-%m-%d").to_string(),
|
||||
output_path,
|
||||
format.to_string(),
|
||||
timeframe.clone(),
|
||||
range.total_hours() as u32,
|
||||
);
|
||||
|
||||
tasks.push(task);
|
||||
}
|
||||
|
||||
if tasks.is_empty() {
|
||||
anyhow::bail!("No instruments with data in the specified date range");
|
||||
}
|
||||
|
||||
let mut job = DownloadJob::new(tasks, concurrency);
|
||||
|
||||
let state_manager =
|
||||
StateManager::with_default_path().context("Failed to initialize state manager")?;
|
||||
let spawner = DaemonSpawner::new(state_manager).context("Failed to create daemon spawner")?;
|
||||
let job_id = spawner
|
||||
.spawn(&mut job)
|
||||
.context("Failed to spawn background job")?;
|
||||
|
||||
println!("Background download started.");
|
||||
println!("Job ID: {}", job_id);
|
||||
println!("Instruments: {}", job.tasks.len());
|
||||
println!("Check status with: paracas status {}", job_id);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
//! Info command implementation.
|
||||
//!
|
||||
//! This module handles displaying detailed information about a specific instrument,
|
||||
//! including size estimates for different time periods.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use paracas_estimate::Estimator;
|
||||
use paracas_lib::prelude::*;
|
||||
|
||||
/// Show detailed information about an instrument, including size estimates.
|
||||
pub(crate) fn show_info(instrument_id: &str) -> Result<()> {
|
||||
let registry = InstrumentRegistry::global();
|
||||
let instrument = registry
|
||||
.get(instrument_id)
|
||||
.with_context(|| format!("Unknown instrument: {instrument_id}"))?;
|
||||
|
||||
// Basic info
|
||||
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"));
|
||||
|
||||
// Calculate estimates for different time periods
|
||||
let today = chrono::Utc::now().date_naive();
|
||||
let estimator = Estimator::global();
|
||||
|
||||
println!("\nDownload Estimates:");
|
||||
println!(
|
||||
"{:<20} {:>12} {:>12} {:>12}",
|
||||
"PERIOD", "DOWNLOAD", "OUTPUT (CSV)", "EST. TIME"
|
||||
);
|
||||
println!("{}", "-".repeat(60));
|
||||
|
||||
// Last 1 day
|
||||
if let Ok(range) = DateRange::new(today - chrono::Duration::days(1), today) {
|
||||
let est = estimator.estimate_single(instrument, &range);
|
||||
println!(
|
||||
"{:<20} {:>12} {:>12} {:>12}",
|
||||
"Last 1 day",
|
||||
Estimator::format_bytes(est.estimated_compressed_bytes),
|
||||
Estimator::format_bytes(est.estimated_output_bytes),
|
||||
Estimator::format_duration(est.estimated_duration),
|
||||
);
|
||||
}
|
||||
|
||||
// Last 1 week
|
||||
if let Ok(range) = DateRange::new(today - chrono::Duration::days(7), today) {
|
||||
let est = estimator.estimate_single(instrument, &range);
|
||||
println!(
|
||||
"{:<20} {:>12} {:>12} {:>12}",
|
||||
"Last 1 week",
|
||||
Estimator::format_bytes(est.estimated_compressed_bytes),
|
||||
Estimator::format_bytes(est.estimated_output_bytes),
|
||||
Estimator::format_duration(est.estimated_duration),
|
||||
);
|
||||
}
|
||||
|
||||
// Last 1 month
|
||||
if let Ok(range) = DateRange::new(today - chrono::Duration::days(30), today) {
|
||||
let est = estimator.estimate_single(instrument, &range);
|
||||
println!(
|
||||
"{:<20} {:>12} {:>12} {:>12}",
|
||||
"Last 1 month",
|
||||
Estimator::format_bytes(est.estimated_compressed_bytes),
|
||||
Estimator::format_bytes(est.estimated_output_bytes),
|
||||
Estimator::format_duration(est.estimated_duration),
|
||||
);
|
||||
}
|
||||
|
||||
// Last 1 year
|
||||
if let Ok(range) = DateRange::new(today - chrono::Duration::days(365), today) {
|
||||
let est = estimator.estimate_single(instrument, &range);
|
||||
println!(
|
||||
"{:<20} {:>12} {:>12} {:>12}",
|
||||
"Last 1 year",
|
||||
Estimator::format_bytes(est.estimated_compressed_bytes),
|
||||
Estimator::format_bytes(est.estimated_output_bytes),
|
||||
Estimator::format_duration(est.estimated_duration),
|
||||
);
|
||||
}
|
||||
|
||||
// Full history (from start to today)
|
||||
let start_date = start.date_naive();
|
||||
if let Ok(range) = DateRange::new(start_date, today) {
|
||||
let est = estimator.estimate_single(instrument, &range);
|
||||
let years = (today - start_date).num_days() as f64 / 365.25;
|
||||
println!(
|
||||
"{:<20} {:>12} {:>12} {:>12}",
|
||||
format!("Full history ({:.1}y)", years),
|
||||
Estimator::format_bytes(est.estimated_compressed_bytes),
|
||||
Estimator::format_bytes(est.estimated_output_bytes),
|
||||
Estimator::format_duration(est.estimated_duration),
|
||||
);
|
||||
}
|
||||
|
||||
println!("\nNote: Estimates are based on historical averages and may vary.");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
//! List command implementation.
|
||||
//!
|
||||
//! This module handles listing available instruments with optional filtering.
|
||||
|
||||
use crate::display::parse_category;
|
||||
use anyhow::Result;
|
||||
use paracas_lib::prelude::*;
|
||||
|
||||
/// List available instruments with optional category filter or search pattern.
|
||||
pub(crate) 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(())
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
//! CLI command implementations.
|
||||
|
||||
pub(crate) mod daemon_run;
|
||||
pub(crate) mod download;
|
||||
pub(crate) mod download_all;
|
||||
pub(crate) mod info;
|
||||
pub(crate) mod list;
|
||||
pub(crate) mod status;
|
||||
@@ -0,0 +1,192 @@
|
||||
//! Background job status command.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use paracas_daemon::{DownloadJob, JobStatus, StateManager};
|
||||
|
||||
/// Execute the status command.
|
||||
pub(crate) fn status(
|
||||
job_id: Option<&str>,
|
||||
running_only: bool,
|
||||
show_all: bool,
|
||||
follow: Option<u64>,
|
||||
cancel_id: Option<&str>,
|
||||
) -> Result<()> {
|
||||
let state_manager =
|
||||
StateManager::with_default_path().context("Failed to initialize state manager")?;
|
||||
|
||||
// Handle cancellation request
|
||||
if let Some(id) = cancel_id {
|
||||
return cancel_job(&state_manager, id);
|
||||
}
|
||||
|
||||
// Handle follow/watch mode
|
||||
if let Some(interval) = follow {
|
||||
return watch_jobs(&state_manager, job_id, interval);
|
||||
}
|
||||
|
||||
// Show specific job or list jobs
|
||||
#[allow(clippy::option_if_let_else)]
|
||||
match job_id {
|
||||
Some(id) => show_job_detail(&state_manager, id),
|
||||
None => list_jobs(&state_manager, running_only, show_all),
|
||||
}
|
||||
}
|
||||
|
||||
fn show_job_detail(state: &StateManager, job_id: &str) -> Result<()> {
|
||||
let id = job_id.parse().context("Invalid job ID format")?;
|
||||
|
||||
let job = state.load_job(id).context("Job not found")?;
|
||||
|
||||
println!("Job: {}", job.id);
|
||||
println!("Status: {:?}", job.status);
|
||||
println!("Created: {}", job.created_at.format("%Y-%m-%d %H:%M:%S"));
|
||||
|
||||
if let Some(started) = job.started_at {
|
||||
println!("Started: {}", started.format("%Y-%m-%d %H:%M:%S"));
|
||||
}
|
||||
if let Some(completed) = job.completed_at {
|
||||
println!("Completed: {}", completed.format("%Y-%m-%d %H:%M:%S"));
|
||||
}
|
||||
|
||||
println!("Progress: {:.1}%", job.progress_percent());
|
||||
println!(
|
||||
"PID: {}",
|
||||
job.pid
|
||||
.map(|p| p.to_string())
|
||||
.unwrap_or_else(|| "N/A".into())
|
||||
);
|
||||
println!(
|
||||
"Log: {}",
|
||||
job.log_file
|
||||
.as_ref()
|
||||
.map(|p| p.display().to_string())
|
||||
.unwrap_or_else(|| "N/A".into())
|
||||
);
|
||||
|
||||
println!("\nTasks:");
|
||||
for (i, task) in job.tasks.iter().enumerate() {
|
||||
let progress = if task.hours_total > 0 {
|
||||
(task.hours_completed as f64 / task.hours_total as f64) * 100.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
println!(
|
||||
" {}. {} [{:?}] {:.1}% ({}/{} hours)",
|
||||
i + 1,
|
||||
task.instrument_id,
|
||||
task.status,
|
||||
progress,
|
||||
task.hours_completed,
|
||||
task.hours_total,
|
||||
);
|
||||
if let Some(ref err) = task.error_message {
|
||||
println!(" Error: {}", err);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn list_jobs(state: &StateManager, running_only: bool, show_all: bool) -> Result<()> {
|
||||
let jobs = state.list_jobs()?;
|
||||
|
||||
let filtered: Vec<_> = jobs
|
||||
.into_iter()
|
||||
.filter(|job| {
|
||||
if running_only {
|
||||
matches!(job.status, JobStatus::Running | JobStatus::Pending)
|
||||
} else if show_all {
|
||||
true
|
||||
} else {
|
||||
// Default: show recent (last 24h) or active
|
||||
let is_recent = job.created_at > chrono::Utc::now() - chrono::Duration::hours(24);
|
||||
is_recent || matches!(job.status, JobStatus::Running | JobStatus::Pending)
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
if filtered.is_empty() {
|
||||
println!("No jobs found.");
|
||||
if !show_all {
|
||||
println!("Use --all to show all historical jobs.");
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
println!(
|
||||
"{:<36} {:<12} {:<10} {:<20}",
|
||||
"JOB ID", "STATUS", "PROGRESS", "CREATED"
|
||||
);
|
||||
println!("{}", "-".repeat(80));
|
||||
|
||||
for job in &filtered {
|
||||
println!(
|
||||
"{:<36} {:<12} {:>8.1}% {:<20}",
|
||||
job.id,
|
||||
format!("{:?}", job.status),
|
||||
job.progress_percent(),
|
||||
job.created_at.format("%Y-%m-%d %H:%M"),
|
||||
);
|
||||
}
|
||||
|
||||
println!("\nTotal: {} jobs", filtered.len());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cancel_job(state: &StateManager, job_id: &str) -> Result<()> {
|
||||
let id = job_id.parse().context("Invalid job ID format")?;
|
||||
|
||||
let mut job: DownloadJob = state.load_job(id).context("Job not found")?;
|
||||
|
||||
if !matches!(job.status, JobStatus::Running | JobStatus::Pending) {
|
||||
anyhow::bail!("Job is not running (status: {:?})", job.status);
|
||||
}
|
||||
|
||||
// Send SIGTERM to the process if running
|
||||
if let Some(pid) = job.pid {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::process::Command;
|
||||
let _ = Command::new("kill")
|
||||
.args(["-TERM", &pid.to_string()])
|
||||
.status();
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::process::Command;
|
||||
let _ = Command::new("taskkill")
|
||||
.args(["/PID", &pid.to_string()])
|
||||
.status();
|
||||
}
|
||||
}
|
||||
|
||||
job.status = JobStatus::Cancelled;
|
||||
state.save_job(&job)?;
|
||||
|
||||
println!("Job {} cancelled.", id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn watch_jobs(state: &StateManager, job_id: Option<&str>, interval_secs: u64) -> Result<()> {
|
||||
use std::io::Write;
|
||||
|
||||
let interval = std::time::Duration::from_secs(interval_secs);
|
||||
|
||||
loop {
|
||||
// Clear screen
|
||||
print!("\x1B[2J\x1B[1;1H");
|
||||
std::io::stdout().flush()?;
|
||||
|
||||
println!(
|
||||
"Watching jobs (refresh every {}s, Ctrl+C to exit)\n",
|
||||
interval_secs
|
||||
);
|
||||
|
||||
match job_id {
|
||||
Some(id) => show_job_detail(state, id)?,
|
||||
None => list_jobs(state, true, false)?,
|
||||
}
|
||||
|
||||
std::thread::sleep(interval);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
//! Display utilities and output formatting for the paracas CLI.
|
||||
|
||||
use anyhow::{Result, bail};
|
||||
use clap::ValueEnum;
|
||||
use paracas_lib::prelude::*;
|
||||
use std::fs::File;
|
||||
use std::io::BufWriter;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Output format for downloaded data.
|
||||
#[derive(Clone, Copy, ValueEnum)]
|
||||
pub(crate) enum Format {
|
||||
Csv,
|
||||
Json,
|
||||
Ndjson,
|
||||
Parquet,
|
||||
}
|
||||
|
||||
impl Format {
|
||||
/// Returns the file extension for this format.
|
||||
pub(crate) const fn extension(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Csv => "csv",
|
||||
Self::Json => "json",
|
||||
Self::Ndjson => "ndjson",
|
||||
Self::Parquet => "parquet",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Format {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.extension())
|
||||
}
|
||||
}
|
||||
|
||||
/// Aggregate ticks into OHLCV bars using the given timeframe.
|
||||
pub(crate) 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
|
||||
}
|
||||
|
||||
/// Write ticks to a file in the specified format.
|
||||
pub(crate) 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(())
|
||||
}
|
||||
|
||||
/// Write OHLCV bars to a file in the specified format.
|
||||
pub(crate) 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(())
|
||||
}
|
||||
|
||||
/// Parse a category string into a Category enum.
|
||||
pub(crate) 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
|
||||
),
|
||||
}
|
||||
}
|
||||
+135
-290
@@ -1,22 +1,21 @@
|
||||
//! 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 anyhow::{Context, Result};
|
||||
use clap::{Parser, Subcommand};
|
||||
use std::path::PathBuf;
|
||||
|
||||
mod commands;
|
||||
mod display;
|
||||
|
||||
use display::Format;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "paracas")]
|
||||
#[command(about = "High-performance Dukascopy tick data downloader", long_about = None)]
|
||||
#[command(version)]
|
||||
struct Cli {
|
||||
#[command(subcommand)]
|
||||
command: Commands,
|
||||
command: Option<Commands>,
|
||||
|
||||
/// Verbosity level (-v, -vv, -vvv)
|
||||
#[arg(short, long, action = clap::ArgAction::Count, global = true)]
|
||||
@@ -25,6 +24,10 @@ struct Cli {
|
||||
/// Quiet mode (suppress progress output)
|
||||
#[arg(short, long, global = true)]
|
||||
quiet: bool,
|
||||
|
||||
/// Hidden: Run as daemon with job ID (internal use only)
|
||||
#[arg(long, hide = true)]
|
||||
daemon_run: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
@@ -57,6 +60,14 @@ enum Commands {
|
||||
/// Maximum concurrent downloads
|
||||
#[arg(long, default_value = "32")]
|
||||
concurrency: usize,
|
||||
|
||||
/// Run in background as daemon
|
||||
#[arg(long)]
|
||||
background: bool,
|
||||
|
||||
/// Skip confirmation prompt (for background mode)
|
||||
#[arg(long)]
|
||||
yes: bool,
|
||||
},
|
||||
|
||||
/// List available instruments
|
||||
@@ -75,21 +86,88 @@ enum Commands {
|
||||
/// Instrument identifier
|
||||
instrument: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, ValueEnum)]
|
||||
enum Format {
|
||||
Csv,
|
||||
Json,
|
||||
Ndjson,
|
||||
Parquet,
|
||||
/// Check background job status
|
||||
Status {
|
||||
/// Specific job ID to check
|
||||
job_id: Option<String>,
|
||||
|
||||
/// Show only running jobs
|
||||
#[arg(long)]
|
||||
running: bool,
|
||||
|
||||
/// Show all jobs (including completed)
|
||||
#[arg(long)]
|
||||
all: bool,
|
||||
|
||||
/// Follow/watch mode (refresh every N seconds)
|
||||
#[arg(short, long)]
|
||||
follow: Option<u64>,
|
||||
|
||||
/// Cancel a running job
|
||||
#[arg(long)]
|
||||
cancel: Option<String>,
|
||||
},
|
||||
|
||||
/// Download all instruments (or filter by category)
|
||||
DownloadAll {
|
||||
/// Filter by category (forex, crypto, index, commodity)
|
||||
#[arg(short, long)]
|
||||
category: Option<String>,
|
||||
|
||||
/// Start date (YYYY-MM-DD). Defaults to each instrument's earliest data.
|
||||
#[arg(short, long)]
|
||||
start: Option<String>,
|
||||
|
||||
/// End date (YYYY-MM-DD). Defaults to today.
|
||||
#[arg(short, long)]
|
||||
end: Option<String>,
|
||||
|
||||
/// Output directory. Files named <instrument>.<format>
|
||||
#[arg(short, long, default_value = ".")]
|
||||
output_dir: 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 instruments to download
|
||||
#[arg(long, default_value = "4")]
|
||||
parallel_instruments: usize,
|
||||
|
||||
/// Maximum concurrent HTTP requests per instrument
|
||||
#[arg(long, default_value = "32")]
|
||||
concurrency: usize,
|
||||
|
||||
/// Run in background as daemon
|
||||
#[arg(long)]
|
||||
background: bool,
|
||||
|
||||
/// Skip confirmation prompt
|
||||
#[arg(long)]
|
||||
yes: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
let cli = Cli::parse();
|
||||
|
||||
match cli.command {
|
||||
// Check for daemon mode first (internal use)
|
||||
if let Some(job_id) = cli.daemon_run {
|
||||
return commands::daemon_run::daemon_run(&job_id).await;
|
||||
}
|
||||
|
||||
// Require a command otherwise
|
||||
let command = cli
|
||||
.command
|
||||
.context("No command provided. Use --help for usage.")?;
|
||||
|
||||
match command {
|
||||
Commands::Download {
|
||||
instrument,
|
||||
start,
|
||||
@@ -98,8 +176,10 @@ async fn main() -> Result<()> {
|
||||
format,
|
||||
timeframe,
|
||||
concurrency,
|
||||
background,
|
||||
yes,
|
||||
} => {
|
||||
download(
|
||||
commands::download::download(
|
||||
&instrument,
|
||||
start.as_deref(),
|
||||
end.as_deref(),
|
||||
@@ -107,284 +187,49 @@ async fn main() -> Result<()> {
|
||||
format,
|
||||
timeframe.as_deref(),
|
||||
concurrency,
|
||||
background,
|
||||
yes,
|
||||
cli.quiet,
|
||||
)
|
||||
.await
|
||||
}
|
||||
Commands::List { category, search } => {
|
||||
list_instruments(category.as_deref(), search.as_deref())
|
||||
commands::list::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;
|
||||
Commands::Info { instrument } => commands::info::show_info(&instrument),
|
||||
Commands::Status {
|
||||
job_id,
|
||||
running,
|
||||
all,
|
||||
follow,
|
||||
cancel,
|
||||
} => commands::status::status(job_id.as_deref(), running, all, follow, cancel.as_deref()),
|
||||
Commands::DownloadAll {
|
||||
category,
|
||||
start,
|
||||
end,
|
||||
output_dir,
|
||||
format,
|
||||
timeframe,
|
||||
parallel_instruments,
|
||||
concurrency,
|
||||
background,
|
||||
yes,
|
||||
} => {
|
||||
commands::download_all::download_all(
|
||||
category.as_deref(),
|
||||
start.as_deref(),
|
||||
end.as_deref(),
|
||||
output_dir,
|
||||
format,
|
||||
timeframe.as_deref(),
|
||||
parallel_instruments,
|
||||
concurrency,
|
||||
background,
|
||||
yes,
|
||||
cli.quiet,
|
||||
)
|
||||
.await
|
||||
}
|
||||
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
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user