use crate::analytics::performance::TickerPerformance;
use crate::analytics::statistics::{
correlation_matrix, cumulative_returns_list, resample_returns_pct, PerformancePeriod,
ReturnsFrequency,
};
use crate::charts::set_layout;
use crate::prelude::{DataTableDisplay, DataTableFormat, Tickers, TickersData};
use crate::reports::table::{build_frequency_toggle, build_period_toggle, DataTable};
use plotly::common::{ColorScalePalette, Mode, Title};
use plotly::layout::Axis;
use plotly::{HeatMap, Layout, Plot, Scatter};
use polars::prelude::{Column, DataFrame};
use std::error::Error;
pub trait TickersCharts {
fn ohlcv_table(&self) -> impl std::future::Future<Output = Result<DataTable, Box<dyn Error>>>;
fn summary_stats_table(
&self,
) -> impl std::future::Future<Output = Result<DataTable, Box<dyn Error>>>;
fn performance_stats_table(
&self,
) -> impl std::future::Future<Output = Result<DataTable, Box<dyn Error>>>;
fn returns_table(&self)
-> impl std::future::Future<Output = Result<DataTable, Box<dyn Error>>>;
fn returns_chart(
&self,
height: Option<usize>,
width: Option<usize>,
) -> impl std::future::Future<Output = Result<Plot, Box<dyn Error>>>;
fn returns_matrix(
&self,
height: Option<usize>,
width: Option<usize>,
) -> impl std::future::Future<Output = Result<Plot, Box<dyn Error>>>;
}
impl TickersCharts for Tickers {
async fn ohlcv_table(&self) -> Result<DataTable, Box<dyn Error>> {
let data = self.get_chart().await?;
let table = data.to_datatable("ohlcv", true, DataTableFormat::Number);
Ok(table)
}
async fn summary_stats_table(&self) -> Result<DataTable, Box<dyn Error>> {
let df = self.get_ticker_stats().await?;
let table = df.to_datatable("summary_stats", true, DataTableFormat::Number);
Ok(table)
}
async fn performance_stats_table(&self) -> Result<DataTable, Box<dyn Error>> {
let periods: Vec<PerformancePeriod> = if self.tickers.is_empty() {
vec![PerformancePeriod::Full]
} else {
match self.tickers[0].performance_stats().await {
Ok(stats) => stats.periodic_stats.iter().map(|(p, _)| *p).collect(),
Err(_) => vec![PerformancePeriod::Full],
}
};
let mut all_stats: Vec<(
String,
crate::analytics::performance::TickerPerformanceStats,
)> = Vec::new();
for ticker in &self.tickers {
match ticker.performance_stats().await {
Ok(s) => all_stats.push((ticker.ticker.clone(), s)),
Err(e) => eprintln!("Skipping {}: {e}", ticker.ticker),
}
}
const STAT_NAMES: [&str; 16] = [
"Daily Return",
"Daily Volatility",
"Cumulative Return",
"Annualized Return",
"Annualized Volatility",
"Alpha",
"Beta",
"Sharpe Ratio",
"Sortino Ratio",
"Active Return",
"Active Risk",
"Information Ratio",
"Calmar Ratio",
"Maximum Drawdown",
"Value at Risk",
"Expected Shortfall",
];
let mut period_entries: Vec<(String, String)> = Vec::with_capacity(periods.len());
let mut primary_df: Option<DataFrame> = None;
for period in &periods {
let mut ticker_symbols: Vec<String> = Vec::new();
let mut numeric_fields: Vec<Vec<f64>> = vec![vec![]; 16];
for (symbol, stats) in &all_stats {
let s = stats
.periodic_stats
.iter()
.find(|(p, _)| *p == *period)
.map(|(_, s)| s)
.unwrap_or(&stats.performance_stats);
ticker_symbols.push(symbol.clone());
numeric_fields[0].push(s.daily_return);
numeric_fields[1].push(s.daily_volatility);
numeric_fields[2].push(s.cumulative_return);
numeric_fields[3].push(s.annualized_return);
numeric_fields[4].push(s.annualized_volatility);
numeric_fields[5].push(s.alpha.unwrap_or(f64::NAN));
numeric_fields[6].push(s.beta.unwrap_or(f64::NAN));
numeric_fields[7].push(s.sharpe_ratio);
numeric_fields[8].push(s.sortino_ratio);
numeric_fields[9].push(s.active_return.unwrap_or(f64::NAN));
numeric_fields[10].push(s.active_risk.unwrap_or(f64::NAN));
numeric_fields[11].push(s.information_ratio.unwrap_or(f64::NAN));
numeric_fields[12].push(s.calmar_ratio);
numeric_fields[13].push(s.maximum_drawdown);
numeric_fields[14].push(s.value_at_risk);
numeric_fields[15].push(s.expected_shortfall);
}
let mut columns: Vec<Column> = vec![Column::new("Symbol".into(), ticker_symbols)];
for (ci, name) in STAT_NAMES.iter().enumerate() {
columns.push(Column::new(
(*name).into(),
numeric_fields[ci]
.iter()
.map(|x| x.to_string())
.collect::<Vec<String>>(),
));
}
let df = DataFrame::new(columns)?;
let id = format!(
"perf_stats_{}",
period.to_string().to_lowercase().replace(' ', "_")
);
let html = df
.to_datatable(
&id,
true,
DataTableFormat::Performance("tickers".to_string()),
)
.to_html()?;
period_entries.push((period.to_string(), html));
if *period == PerformancePeriod::Full && primary_df.is_none() {
primary_df = Some(df);
}
}
if period_entries.is_empty() {
return Err("No period stats available".into());
}
let primary = primary_df.unwrap_or_else(|| DataFrame::default());
let toggle_html = build_period_toggle(&period_entries, "tickers_perf_stats");
Ok(DataTable::new_composite(
primary,
"performance_stats".to_string(),
toggle_html,
))
}
async fn returns_table(&self) -> Result<DataTable, Box<dyn Error>> {
let native = ReturnsFrequency::from_interval_enum(self.interval);
let freqs = native.available_frequencies();
let mut raw_returns = self.returns().await?;
let dates: Vec<String> = raw_returns
.column("timestamp")?
.str()?
.into_no_null_iter()
.map(|s| s.to_string())
.collect();
let _ = raw_returns.drop_in_place("timestamp")?;
let mut freq_entries: Vec<(String, String)> = Vec::with_capacity(freqs.len());
let mut primary_df: Option<DataFrame> = None;
for freq in &freqs {
let pct_df = if *freq == native {
let col_names: Vec<String> = raw_returns
.get_column_names()
.iter()
.map(|n| n.to_string())
.collect();
let mut cols: Vec<Column> = vec![Column::new("Timestamp".into(), dates.clone())];
for name in &col_names {
let series = raw_returns.column(name.as_str())?.f64()?;
let vals: Vec<f64> = series
.into_no_null_iter()
.map(|v| (v * 100.0 * 100.0).round() / 100.0)
.collect();
cols.push(Column::new(name.as_str().into(), vals));
}
DataFrame::new(cols)?
} else {
let (labels, resampled_df) = resample_returns_pct(&dates, &raw_returns, *freq)?;
let col_names: Vec<String> = resampled_df
.get_column_names()
.iter()
.map(|n| n.to_string())
.collect();
let mut cols: Vec<Column> = vec![Column::new("Timestamp".into(), labels.clone())];
for name in &col_names {
let series = resampled_df.column(name.as_str())?.f64()?;
let vals: Vec<f64> = series
.into_no_null_iter()
.map(|v| (v * 100.0 * 100.0).round() / 100.0)
.collect();
cols.push(Column::new(name.as_str().into(), vals));
}
DataFrame::new(cols)?
};
let id = format!("returns_{}", freq.to_string().to_lowercase());
let html = pct_df
.to_datatable(&id, true, DataTableFormat::Number)
.to_html()?;
freq_entries.push((freq.to_string(), html));
if *freq == native && primary_df.is_none() {
primary_df = Some(pct_df);
}
}
if freq_entries.is_empty() {
return Err("No returns data available".into());
}
let primary = primary_df.ok_or("Native frequency data not found")?;
let toggle_html = build_frequency_toggle(&freq_entries, "tickers_returns");
Ok(DataTable::new_composite(
primary,
"returns_table".to_string(),
toggle_html,
))
}
async fn returns_chart(
&self,
height: Option<usize>,
width: Option<usize>,
) -> Result<Plot, Box<dyn Error>> {
let symbols = self
.tickers
.iter()
.map(|x| x.ticker.clone())
.collect::<Vec<String>>();
let asset_returns = self.returns().await?;
let dates = asset_returns
.column("timestamp")?
.str()?
.into_no_null_iter()
.map(|x| x.to_string())
.collect::<Vec<String>>();
let mut plot = Plot::new();
for symbol in symbols {
match asset_returns.column(&symbol) {
Ok(returns_series) => {
let returns = returns_series
.f64()
.unwrap_or_else(|_| panic!("returns column '{}' is not Float64", symbol))
.to_vec()
.iter()
.map(|x| x.unwrap_or_default())
.collect::<Vec<f64>>();
let cum_returns = cumulative_returns_list(returns.clone());
let cum_returns_trace = Scatter::new(dates.clone(), cum_returns.clone())
.name(symbol)
.mode(Mode::Lines);
plot.add_trace(cum_returns_trace);
}
Err(e) => {
eprintln!("Unable to fetch returns for {symbol}: {e}");
}
}
}
let layout = Layout::new()
.title(Title::from("<span style=\"font-weight:bold; color:darkgreen;\">Tickers Cumulative Returns</span>"))
.y_axis(
Axis::new()
.title(Title::from("Cumulative Returns"))
.tick_format(".0%")
);
let plot = set_layout(plot, layout, height, width);
Ok(plot)
}
async fn returns_matrix(
&self,
height: Option<usize>,
width: Option<usize>,
) -> Result<Plot, Box<dyn Error>> {
let mut returns = self.returns().await?;
let _ = returns.drop_in_place("timestamp");
let labels = returns
.get_column_names()
.iter()
.map(|x| x.to_string())
.collect::<Vec<String>>();
let corr_matrix = correlation_matrix(&returns)?;
let corr_matrix = corr_matrix.outer_iter().map(|row| row.to_vec()).collect();
let heatmap = HeatMap::new(labels.to_vec(), labels.to_vec(), corr_matrix)
.zmin(-1.0)
.zmax(1.0)
.color_scale(ColorScalePalette::Jet.into());
let mut plot = Plot::new();
plot.add_trace(heatmap);
let layout = Layout::new().title(Title::from(
"<span style=\"font-weight:bold; color:darkgreen;\">Returns Correlation Matrix</span>",
));
let plot = set_layout(plot, layout, height, width);
Ok(plot)
}
}