Files
foxhunt/bin/fxt/src/commands/data.rs
jgrusewski 0fdcc496be fix: type-safe proto enums, MCP protocol compliance, TUI teardown
- Replace hardcoded i32 enum matches with ProtoEnum::try_from() in
  8 command files (cluster, config, data, model, risk, service, train, tune)
- Replace hardcoded i32 literals with enum variants (EmergencyStopType,
  ExportFormat, TrainingMode)
- Add JSON-RPC 2.0 PARSE_ERROR (-32700) for malformed JSON input
- Validate jsonrpc:"2.0" version on incoming MCP requests
- Change unreachable execute_tool catch-all from Ok to Err
- Fix TUI teardown to not mask original event_loop error
- Rename config_cmd module to config (commands::config)
- 77 tests pass, 0 clippy warnings

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 22:29:13 +01:00

259 lines
8.1 KiB
Rust

//! `fxt data` -- data pipeline management.
use anyhow::Result;
use clap::{Parser, Subcommand};
use colored::Colorize;
use serde::Serialize;
use crate::grpc::FoxhuntClient;
use crate::output::{self, HumanReadable, OutputFormat};
#[derive(Parser, Debug)]
pub struct DataCommand {
#[command(subcommand)]
action: DataAction,
}
#[derive(Subcommand, Debug)]
enum DataAction {
/// Download market data
Download {
/// Symbol to download
#[arg(long)]
symbol: String,
/// Start date (YYYY-MM-DD)
#[arg(long)]
from: String,
/// End date (YYYY-MM-DD)
#[arg(long)]
to: String,
/// Schema (ohlcv-1m, ohlcv-1s, trades, mbp-1)
#[arg(long, default_value = "ohlcv-1m")]
schema: String,
},
/// Show data pipeline status
Status,
/// Show data cache inventory
Cache,
/// Show live data feed status
Feeds,
}
// ── Result types ──────────────────────────────────────────────────────
#[derive(Serialize)]
struct DownloadResult {
job_id: String,
status: String,
estimated_cost_usd: f64,
message: String,
}
impl HumanReadable for DownloadResult {
fn print_human(&self) {
println!("{}", "Download Scheduled".green().bold());
println!(" Job ID: {}", self.job_id);
println!(" Status: {}", self.status);
println!(
" Est. Cost: ${:.2}",
self.estimated_cost_usd
);
println!(" Message: {}", self.message);
}
}
#[derive(Serialize)]
struct DownloadJobInfo {
job_id: String,
status: String,
dataset: String,
symbols: Vec<String>,
start_date: String,
end_date: String,
progress_pct: f32,
bytes_downloaded: u64,
total_bytes: u64,
records_count: u64,
data_quality_score: f64,
error_message: String,
}
#[derive(Serialize)]
struct StatusResult {
jobs: Vec<DownloadJobInfo>,
total_count: u32,
}
impl HumanReadable for StatusResult {
fn print_human(&self) {
println!(
"{} ({} total)",
"Download Jobs".cyan().bold(),
self.total_count,
);
if self.jobs.is_empty() {
println!(" No download jobs found.");
return;
}
for job in &self.jobs {
let status_colored = match job.status.as_str() {
"COMPLETED" => job.status.green().to_string(),
"DOWNLOADING" | "VALIDATING" | "UPLOADING" => job.status.yellow().to_string(),
"FAILED" => job.status.red().to_string(),
"CANCELLED" => job.status.dimmed().to_string(),
_ => job.status.clone(),
};
println!(
" {} [{}] {:.0}% {} {} -> {}",
job.job_id.dimmed(),
status_colored,
job.progress_pct,
job.symbols.join(","),
job.start_date,
job.end_date,
);
if !job.error_message.is_empty() {
println!(" Error: {}", job.error_message.red());
}
}
}
}
#[derive(Serialize)]
struct CacheResult {
message: String,
}
impl HumanReadable for CacheResult {
fn print_human(&self) {
println!("{}", "Data Cache".cyan().bold());
println!(" {}", self.message);
}
}
#[derive(Serialize)]
struct FeedsResult {
message: String,
}
impl HumanReadable for FeedsResult {
fn print_human(&self) {
println!("{}", "Live Data Feeds".cyan().bold());
println!(" {}", self.message);
}
}
// ── Helpers ───────────────────────────────────────────────────────────
fn download_status_name(value: i32) -> &'static str {
use crate::proto::data_acquisition::DownloadStatus;
match DownloadStatus::try_from(value) {
Ok(DownloadStatus::Pending) => "PENDING",
Ok(DownloadStatus::Downloading) => "DOWNLOADING",
Ok(DownloadStatus::Validating) => "VALIDATING",
Ok(DownloadStatus::Uploading) => "UPLOADING",
Ok(DownloadStatus::Completed) => "COMPLETED",
Ok(DownloadStatus::Failed) => "FAILED",
Ok(DownloadStatus::Cancelled) => "CANCELLED",
_ => "UNKNOWN",
}
}
// ── Execute ───────────────────────────────────────────────────────────
impl DataCommand {
pub async fn execute(&self, client: &FoxhuntClient, format: OutputFormat) -> Result<()> {
match &self.action {
DataAction::Download {
symbol,
from,
to,
schema,
} => {
let response = client
.data_acquisition()
.schedule_download(crate::proto::data_acquisition::ScheduleDownloadRequest {
dataset: "GLBX.MDP3".to_owned(),
symbols: vec![symbol.clone()],
start_date: from.clone(),
end_date: to.clone(),
schema: schema.clone(),
description: String::new(),
tags: Default::default(),
priority: 3,
})
.await?
.into_inner();
let result = DownloadResult {
job_id: response.job_id,
status: download_status_name(response.status).to_owned(),
estimated_cost_usd: response.estimated_cost_usd,
message: response.message,
};
output::render(&result, format)?;
}
DataAction::Status => {
let response = client
.data_acquisition()
.list_download_jobs(
crate::proto::data_acquisition::ListDownloadJobsRequest {
page: 0,
page_size: 50,
status_filter: 0,
start_time: 0,
end_time: 0,
},
)
.await?
.into_inner();
let jobs: Vec<DownloadJobInfo> = response
.jobs
.into_iter()
.map(|j| DownloadJobInfo {
job_id: j.job_id,
status: download_status_name(j.status).to_owned(),
dataset: j.dataset,
symbols: j.symbols,
start_date: j.start_date,
end_date: j.end_date,
progress_pct: j.progress_percentage,
bytes_downloaded: 0,
total_bytes: 0,
records_count: 0,
data_quality_score: 0.0,
error_message: String::new(),
})
.collect();
let result = StatusResult {
total_count: response.total_count,
jobs,
};
output::render(&result, format)?;
}
DataAction::Cache => {
let result = CacheResult {
message:
"Data cache managed by MinIO. Use `fxt data download` to fetch new data."
.to_owned(),
};
output::render(&result, format)?;
}
DataAction::Feeds => {
let result = FeedsResult {
message:
"Live feed status requires a running data acquisition service. Use `fxt service status data-acquisition` for details."
.to_owned(),
};
output::render(&result, format)?;
}
}
Ok(())
}
}