use crate::analytics::optimization::{Constraints, ObjectiveFunction};
use crate::analytics::performance::{
compute_performance, optimize_portfolio, prepare_portfolio_data, PortfolioData,
PortfolioOptimizationResult, PortfolioPerformanceStats,
};
use crate::analytics::statistics::RebalanceConfig;
use crate::prelude::{Interval, Tickers, KLINE};
use std::error::Error;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ScheduleFrequency {
Monthly,
Quarterly,
SemiAnnually,
Annually,
}
impl std::fmt::Display for ScheduleFrequency {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ScheduleFrequency::Monthly => write!(f, "Monthly"),
ScheduleFrequency::Quarterly => write!(f, "Quarterly"),
ScheduleFrequency::SemiAnnually => write!(f, "Semi-Annually"),
ScheduleFrequency::Annually => write!(f, "Annually"),
}
}
}
#[derive(Debug, Clone)]
pub enum RebalanceStrategy {
Calendar(ScheduleFrequency),
Threshold(f64),
CalendarOrThreshold(ScheduleFrequency, f64),
}
impl std::fmt::Display for RebalanceStrategy {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
RebalanceStrategy::Calendar(freq) => write!(f, "Calendar ({freq})"),
RebalanceStrategy::Threshold(t) => write!(f, "Threshold ({:.1}%)", t * 100.0),
RebalanceStrategy::CalendarOrThreshold(freq, t) => {
write!(f, "Calendar ({freq}) or Threshold ({:.1}%)", t * 100.0)
}
}
}
}
#[derive(Debug, Clone)]
pub enum CashFlowAllocation {
ProRata,
Rebalance,
Custom(Vec<f64>),
}
#[derive(Debug, Clone)]
pub struct ScheduledCashFlow {
pub amount: f64,
pub frequency: ScheduleFrequency,
pub start_date: Option<String>,
pub end_date: Option<String>,
pub allocation: CashFlowAllocation,
}
#[derive(Debug, Clone)]
pub struct Transaction {
pub date: String,
pub ticker: String,
pub amount: f64,
}
pub fn is_period_boundary(prev_date: &str, curr_date: &str, freq: ScheduleFrequency) -> bool {
let prev_year: u32 = prev_date[..4].parse().unwrap_or(0);
let curr_year: u32 = curr_date[..4].parse().unwrap_or(0);
let prev_month: u32 = prev_date[5..7].parse().unwrap_or(0);
let curr_month: u32 = curr_date[5..7].parse().unwrap_or(0);
match freq {
ScheduleFrequency::Monthly => curr_month != prev_month || curr_year != prev_year,
ScheduleFrequency::Quarterly => {
let prev_q = (prev_month.saturating_sub(1)) / 3;
let curr_q = (curr_month.saturating_sub(1)) / 3;
curr_q != prev_q || curr_year != prev_year
}
ScheduleFrequency::SemiAnnually => {
let prev_h = (prev_month.saturating_sub(1)) / 6;
let curr_h = (curr_month.saturating_sub(1)) / 6;
curr_h != prev_h || curr_year != prev_year
}
ScheduleFrequency::Annually => curr_year != prev_year,
}
}
fn date_in_range(date: &str, start: &Option<String>, end: &Option<String>) -> bool {
let d = &date[..date.len().min(10)];
if let Some(ref s) = start {
let s = &s[..s.len().min(10)];
if d < s {
return false;
}
}
if let Some(ref e) = end {
let e = &e[..e.len().min(10)];
if d > e {
return false;
}
}
true
}
pub struct ExpandedCashFlows {
pub transactions: Vec<Transaction>,
pub rebalance_cash_flows: Vec<f64>,
}
pub fn expand_scheduled_cash_flows(
schedules: &[ScheduledCashFlow],
dates: &[String],
tickers: &[String],
target_weights: &[f64],
) -> ExpandedCashFlows {
let n = dates.len();
let mut transactions: Vec<Transaction> = Vec::new();
let mut rebalance_flows = vec![0.0_f64; n];
for sched in schedules {
for row in 1..n {
if !is_period_boundary(&dates[row - 1], &dates[row], sched.frequency) {
continue;
}
if !date_in_range(&dates[row], &sched.start_date, &sched.end_date) {
continue;
}
match &sched.allocation {
CashFlowAllocation::ProRata => {
for (i, ticker) in tickers.iter().enumerate() {
let amt = sched.amount * target_weights[i];
if amt.abs() > 1e-10 {
transactions.push(Transaction {
date: dates[row].clone(),
ticker: ticker.clone(),
amount: amt,
});
}
}
}
CashFlowAllocation::Custom(weights) => {
for (i, ticker) in tickers.iter().enumerate() {
let w = weights.get(i).copied().unwrap_or(0.0);
let amt = sched.amount * w;
if amt.abs() > 1e-10 {
transactions.push(Transaction {
date: dates[row].clone(),
ticker: ticker.clone(),
amount: amt,
});
}
}
}
CashFlowAllocation::Rebalance => {
rebalance_flows[row] += sched.amount;
}
}
}
}
ExpandedCashFlows {
transactions,
rebalance_cash_flows: rebalance_flows,
}
}
pub struct PortfolioBuilder {
pub ticker_symbols: Vec<String>,
pub benchmark_symbol: Option<String>,
pub start_date: String,
pub end_date: String,
pub interval: Interval,
pub confidence_level: f64,
pub risk_free_rate: f64,
pub tickers_data: Option<Vec<KLINE>>,
pub benchmark_data: Option<KLINE>,
pub objective_function: ObjectiveFunction,
pub constraints: Option<Constraints>,
pub weights: Option<Vec<f64>>,
pub transactions: Option<Vec<Transaction>>,
pub rebalance_strategy: Option<RebalanceStrategy>,
pub scheduled_cash_flows: Option<Vec<ScheduledCashFlow>>,
}
impl Default for PortfolioBuilder {
fn default() -> Self {
Self::new()
}
}
impl PortfolioBuilder {
pub fn new() -> PortfolioBuilder {
PortfolioBuilder {
ticker_symbols: Vec::new(),
benchmark_symbol: None,
start_date: String::new(),
end_date: String::new(),
interval: Interval::OneDay,
confidence_level: 0.95,
risk_free_rate: 0.0,
tickers_data: None,
benchmark_data: None,
objective_function: ObjectiveFunction::MaxSharpe,
constraints: None,
weights: None,
transactions: None,
rebalance_strategy: None,
scheduled_cash_flows: None,
}
}
pub fn ticker_symbols(mut self, ticker_symbols: Vec<&str>) -> PortfolioBuilder {
self.ticker_symbols = ticker_symbols.iter().map(|x| x.to_string()).collect();
self
}
pub fn benchmark_symbol(mut self, benchmark_symbol: &str) -> PortfolioBuilder {
self.benchmark_symbol = Some(benchmark_symbol.to_string());
self
}
pub fn start_date(mut self, start_date: &str) -> PortfolioBuilder {
self.start_date = start_date.to_string();
self
}
pub fn end_date(mut self, end_date: &str) -> PortfolioBuilder {
self.end_date = end_date.to_string();
self
}
pub fn interval(mut self, interval: Interval) -> PortfolioBuilder {
self.interval = interval;
self
}
pub fn confidence_level(mut self, confidence_level: f64) -> PortfolioBuilder {
self.confidence_level = confidence_level;
self
}
pub fn risk_free_rate(mut self, risk_free_rate: f64) -> PortfolioBuilder {
self.risk_free_rate = risk_free_rate;
self
}
pub fn tickers_data(mut self, tickers_data: Option<Vec<KLINE>>) -> PortfolioBuilder {
self.tickers_data = tickers_data;
self
}
pub fn benchmark_data(mut self, benchmark_data: Option<KLINE>) -> PortfolioBuilder {
self.benchmark_data = benchmark_data;
self
}
pub fn objective_function(mut self, objective_function: ObjectiveFunction) -> PortfolioBuilder {
self.objective_function = objective_function;
self
}
pub fn constraints(mut self, constraints: Option<Constraints>) -> PortfolioBuilder {
self.constraints = constraints;
self
}
pub fn weights(mut self, weights: Vec<f64>) -> PortfolioBuilder {
self.weights = Some(weights);
self
}
pub fn transactions(mut self, transactions: Vec<Transaction>) -> PortfolioBuilder {
self.transactions = Some(transactions);
self
}
pub fn rebalance_strategy(mut self, strategy: Option<RebalanceStrategy>) -> PortfolioBuilder {
self.rebalance_strategy = strategy;
self
}
pub fn scheduled_cash_flows(
mut self,
flows: Option<Vec<ScheduledCashFlow>>,
) -> PortfolioBuilder {
self.scheduled_cash_flows = flows;
self
}
pub async fn build(self) -> Result<Portfolio, Box<dyn Error>> {
let tickers = if self.tickers_data.is_some() {
let mut builder = Tickers::builder()
.tickers_data(self.tickers_data)
.benchmark_data(self.benchmark_data)
.confidence_level(self.confidence_level)
.risk_free_rate(self.risk_free_rate);
if let Some(ref sym) = self.benchmark_symbol {
builder = builder.benchmark_symbol(sym);
}
builder.build()
} else {
let mut builder = Tickers::builder()
.tickers(self.ticker_symbols.iter().map(|x| x.as_str()).collect())
.start_date(&self.start_date)
.end_date(&self.end_date)
.interval(self.interval)
.confidence_level(self.confidence_level)
.risk_free_rate(self.risk_free_rate);
if let Some(ref sym) = self.benchmark_symbol {
builder = builder.benchmark_symbol(sym);
}
builder.build()
};
let data = prepare_portfolio_data(&tickers, tickers.benchmark_ticker.as_ref()).await?;
let rebalance_config = self.rebalance_strategy.as_ref().map(|strategy| {
let target_weights = if let Some(ref alloc) = self.weights {
let total: f64 = alloc.iter().sum();
if total > 0.0 {
alloc.iter().map(|v| v / total).collect()
} else {
vec![
1.0 / data.portfolio_returns.width() as f64;
data.portfolio_returns.width()
]
}
} else {
vec![1.0 / data.portfolio_returns.width() as f64; data.portfolio_returns.width()]
};
RebalanceConfig {
target_weights,
strategy: strategy.clone(),
}
});
let performance_stats = if let Some(ref alloc) = self.weights {
let num_assets = data.portfolio_returns.width();
let total: f64 = alloc.iter().sum();
if total > 0.0 && alloc.len() == num_assets {
let fractional: Vec<f64> = alloc.iter().map(|v| v / total).collect();
let transactions = self.transactions.clone();
let initial_values = Some(alloc.clone());
compute_performance(
&data,
&fractional,
transactions,
initial_values,
rebalance_config.as_ref(),
self.scheduled_cash_flows.as_deref(),
)
.ok()
} else {
None
}
} else {
None
};
Ok(Portfolio {
tickers,
data,
objective_function: self.objective_function,
constraints: self.constraints,
weights: self.weights,
transactions: self.transactions,
rebalance_strategy: self.rebalance_strategy,
scheduled_cash_flows: self.scheduled_cash_flows,
optimization_result: None,
performance_stats,
})
}
}
#[derive(Debug, Clone)]
pub struct Portfolio {
pub tickers: Tickers,
pub data: PortfolioData,
pub objective_function: ObjectiveFunction,
pub constraints: Option<Constraints>,
pub weights: Option<Vec<f64>>,
pub transactions: Option<Vec<Transaction>>,
pub rebalance_strategy: Option<RebalanceStrategy>,
pub scheduled_cash_flows: Option<Vec<ScheduledCashFlow>>,
pub optimization_result: Option<PortfolioOptimizationResult>,
pub performance_stats: Option<PortfolioPerformanceStats>,
}
impl Portfolio {
pub fn builder() -> PortfolioBuilder {
PortfolioBuilder::new()
}
pub(crate) fn new_raw(
tickers: Tickers,
data: PortfolioData,
objective_function: ObjectiveFunction,
constraints: Option<Constraints>,
weights: Option<Vec<f64>>,
transactions: Option<Vec<Transaction>>,
) -> Portfolio {
Portfolio {
tickers,
data,
objective_function,
constraints,
weights,
transactions,
rebalance_strategy: None,
scheduled_cash_flows: None,
optimization_result: None,
performance_stats: None,
}
}
pub fn optimize(&mut self) -> Result<&PortfolioOptimizationResult, Box<dyn Error>> {
let result = optimize_portfolio(
&self.data,
self.objective_function,
self.constraints.clone(),
)?;
self.optimization_result = Some(result);
let weights = &self
.optimization_result
.as_ref()
.ok_or("BUG: optimization_result was just set but is None")?
.optimal_weights;
let rebalance_config = self
.rebalance_strategy
.as_ref()
.map(|strategy| RebalanceConfig {
target_weights: weights.to_vec(),
strategy: strategy.clone(),
});
let txns = self.transactions.clone();
let initial_values = self.weights.clone();
let perf = compute_performance(
&self.data,
weights,
txns,
initial_values,
rebalance_config.as_ref(),
self.scheduled_cash_flows.as_deref(),
)?;
self.performance_stats = Some(perf);
Ok(self
.optimization_result
.as_ref()
.ok_or("BUG: optimization_result was just set but is None")?)
}
pub async fn update_dates(
&mut self,
start_date: &str,
end_date: &str,
) -> Result<(), Box<dyn Error>> {
if self.tickers.tickers_data.is_some() {
return Err(
"update_dates() is not supported for portfolios built from custom data. \
Use update_data() to supply new KLINE data for the evaluation period."
.into(),
);
}
let same_dates = self.data.start_date == start_date && self.data.end_date == end_date;
if !same_dates {
let symbols: Vec<&str> = self
.tickers
.tickers
.iter()
.map(|t| t.ticker.as_str())
.collect();
let mut builder = Tickers::builder()
.tickers(symbols)
.start_date(start_date)
.end_date(end_date)
.interval(self.tickers.interval)
.confidence_level(self.tickers.confidence_level)
.risk_free_rate(self.tickers.risk_free_rate);
if let Some(ref sym) = self.tickers.benchmark_symbol {
builder = builder.benchmark_symbol(sym);
}
let new_tickers = builder.build();
let new_data =
prepare_portfolio_data(&new_tickers, new_tickers.benchmark_ticker.as_ref()).await?;
self.tickers = new_tickers;
self.data = new_data;
}
self.performance_stats = None;
Ok(())
}
pub async fn update_data(
&mut self,
tickers_data: Vec<KLINE>,
benchmark_data: Option<KLINE>,
) -> Result<(), Box<dyn Error>> {
let mut builder = Tickers::builder()
.tickers_data(Some(tickers_data))
.confidence_level(self.tickers.confidence_level)
.risk_free_rate(self.tickers.risk_free_rate);
if let Some(bd) = benchmark_data {
builder = builder.benchmark_data(Some(bd));
}
let new_tickers = builder.build();
let new_data =
prepare_portfolio_data(&new_tickers, new_tickers.benchmark_ticker.as_ref()).await?;
self.tickers = new_tickers;
self.data = new_data;
self.performance_stats = None;
Ok(())
}
pub fn performance_stats(&mut self) -> Result<&PortfolioPerformanceStats, Box<dyn Error>> {
let num_assets = self.data.portfolio_returns.width();
let resolved_weights = if let Some(ref opt) = self.optimization_result {
opt.optimal_weights.clone()
} else if let Some(ref w) = self.weights {
let total: f64 = w.iter().sum();
if total <= 0.0 {
return Err("Total weights must be greater than zero".into());
}
if w.len() != num_assets {
return Err(format!(
"Weights length ({}) must match the number of assets ({})",
w.len(),
num_assets
)
.into());
}
w.iter().map(|v| v / total).collect()
} else {
return Err("No weights or optimization result available. Either call \
.weights() on the builder, or call optimize() first."
.into());
};
let transactions = self.transactions.clone();
if resolved_weights.len() != num_assets {
return Err(format!(
"Weights length ({}) must match the number of assets ({})",
resolved_weights.len(),
num_assets
)
.into());
}
let rebalance_config = self
.rebalance_strategy
.as_ref()
.map(|strategy| RebalanceConfig {
target_weights: resolved_weights.clone(),
strategy: strategy.clone(),
});
let initial_values = self.weights.clone();
let result = compute_performance(
&self.data,
&resolved_weights,
transactions,
initial_values,
rebalance_config.as_ref(),
self.scheduled_cash_flows.as_deref(),
)?;
self.performance_stats = Some(result);
Ok(self.performance_stats.as_ref().unwrap())
}
}