use serde::{Deserialize, Serialize};
#[derive(Debug, thiserror::Error)]
pub enum PortfolioError {
#[error("No assets configured")]
NoAssets,
#[error("No scenarios configured")]
NoScenarios,
#[error("Invalid budget: {0}")]
InvalidBudget(f64),
#[error("Renewable fraction constraint infeasible")]
InfeasibleConstraint,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum PortfolioMethod {
MeanVariance,
CVaR,
MinimaxRegret,
StochasticProgramming,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PortfolioOptConfig {
pub n_assets: usize,
pub n_scenarios: usize,
pub n_hours: usize,
pub risk_aversion: f64,
pub target_renewable_pct: f64,
pub max_curtailment_pct: f64,
pub method: PortfolioMethod,
}
impl Default for PortfolioOptConfig {
fn default() -> Self {
Self {
n_assets: 3,
n_scenarios: 10,
n_hours: 24,
risk_aversion: 0.5,
target_renewable_pct: 0.6,
max_curtailment_pct: 0.1,
method: PortfolioMethod::MeanVariance,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RenewableAsset {
pub id: usize,
pub technology: String,
pub capacity_mw: f64,
pub capex_m_usd_per_mw: f64,
pub opex_m_usd_per_mw_year: f64,
pub capacity_factor_scenarios: Vec<Vec<f64>>,
pub correlation_group: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PortfolioScenario {
pub id: usize,
pub probability: f64,
pub load_mw: Vec<f64>,
pub electricity_price: Vec<f64>,
pub carbon_price_usd_per_t: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AssetAllocation {
pub asset_id: usize,
pub allocated_mw: f64,
pub investment_m_usd: f64,
pub expected_annual_energy_gwh: f64,
pub contribution_pct: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PortfolioResult {
pub allocations: Vec<AssetAllocation>,
pub total_investment_m_usd: f64,
pub expected_annual_revenue_m_usd: f64,
pub revenue_std_m_usd: f64,
pub cvar_95_m_usd: f64,
pub sharpe_ratio: f64,
pub renewable_fraction_pct: f64,
pub curtailment_pct: f64,
pub pareto_optimal: bool,
}
pub struct RenewablePortfolioOptimizer {
config: PortfolioOptConfig,
assets: Vec<RenewableAsset>,
scenarios: Vec<PortfolioScenario>,
investment_budget_m_usd: f64,
risk_free_rate: f64,
}
impl RenewablePortfolioOptimizer {
pub fn new(config: PortfolioOptConfig) -> Self {
Self {
config,
assets: Vec::new(),
scenarios: Vec::new(),
investment_budget_m_usd: 1000.0,
risk_free_rate: 0.03,
}
}
pub fn add_asset(&mut self, asset: RenewableAsset) {
self.assets.push(asset);
}
pub fn add_scenario(&mut self, scenario: PortfolioScenario) {
self.scenarios.push(scenario);
}
pub fn set_budget(&mut self, budget_m_usd: f64) {
self.investment_budget_m_usd = budget_m_usd;
}
pub fn set_risk_free_rate(&mut self, rate: f64) {
self.risk_free_rate = rate;
}
pub fn optimize(&self) -> Result<PortfolioResult, PortfolioError> {
if self.assets.is_empty() {
return Err(PortfolioError::NoAssets);
}
if self.scenarios.is_empty() {
return Err(PortfolioError::NoScenarios);
}
if self.investment_budget_m_usd <= 0.0 {
return Err(PortfolioError::InvalidBudget(self.investment_budget_m_usd));
}
match self.config.method {
PortfolioMethod::MeanVariance => self.optimize_mean_variance(),
PortfolioMethod::CVaR => self.optimize_cvar(),
PortfolioMethod::MinimaxRegret => self.optimize_minimax_regret(),
PortfolioMethod::StochasticProgramming => self.optimize_two_stage(),
}
}
fn optimize_mean_variance(&self) -> Result<PortfolioResult, PortfolioError> {
let na = self.assets.len();
let _budget = self.investment_budget_m_usd;
let (means, variances) = self.compute_asset_stats();
let scores: Vec<f64> = (0..na)
.map(|i| (means[i] - self.config.risk_aversion * variances[i]).max(0.0))
.collect();
let total_score: f64 = scores.iter().sum();
let weights: Vec<f64> = if total_score < 1e-12 {
vec![1.0 / na as f64; na]
} else {
scores.iter().map(|s| s / total_score).collect()
};
self.build_result(weights, true)
}
fn optimize_cvar(&self) -> Result<PortfolioResult, PortfolioError> {
let na = self.assets.len();
let (means, _) = self.compute_asset_stats();
let scenario_returns = self.per_scenario_returns_equal_weight();
let cvar_val = self.compute_cvar(&scenario_returns, 0.05);
let mean_sum: f64 = means.iter().sum::<f64>();
let weights: Vec<f64> = if mean_sum < 1e-12 {
vec![1.0 / na as f64; na]
} else {
means
.iter()
.map(|m| m.max(0.0) / mean_sum.max(1e-12))
.collect()
};
let _ = cvar_val;
self.build_result(weights, true)
}
fn optimize_minimax_regret(&self) -> Result<PortfolioResult, PortfolioError> {
let na = self.assets.len();
let ns = self.scenarios.len();
if ns == 0 {
return Err(PortfolioError::NoScenarios);
}
let asset_scenario_returns = self.compute_per_asset_scenario_returns();
let best_per_scenario: Vec<f64> = (0..ns)
.map(|s| {
asset_scenario_returns
.iter()
.map(|r| *r.get(s).unwrap_or(&0.0))
.fold(f64::NEG_INFINITY, f64::max)
})
.collect();
let max_regret: Vec<f64> = (0..na)
.map(|a| {
(0..ns)
.map(|s| {
let ret = asset_scenario_returns[a].get(s).copied().unwrap_or(0.0);
(best_per_scenario[s] - ret).max(0.0)
})
.fold(f64::NEG_INFINITY, f64::max)
})
.collect();
let inv_regret: Vec<f64> = max_regret.iter().map(|r| 1.0 / (r + 1.0)).collect();
let total: f64 = inv_regret.iter().sum();
let weights: Vec<f64> = inv_regret.iter().map(|r| r / total).collect();
self.build_result(weights, false)
}
fn optimize_two_stage(&self) -> Result<PortfolioResult, PortfolioError> {
let na = self.assets.len();
let (means, vars) = self.compute_asset_stats();
let scores: Vec<f64> = (0..na)
.map(|i| (means[i] - 0.5 * self.config.risk_aversion * vars[i]).max(1e-6))
.collect();
let total: f64 = scores.iter().sum();
let weights: Vec<f64> = scores.iter().map(|s| s / total).collect();
self.build_result(weights, true)
}
pub fn efficient_frontier(
&self,
n_points: usize,
) -> Result<Vec<PortfolioResult>, PortfolioError> {
if self.assets.is_empty() {
return Err(PortfolioError::NoAssets);
}
let mut results = Vec::with_capacity(n_points);
for i in 0..n_points {
let ra = i as f64 / (n_points - 1).max(1) as f64;
let mut opt = RenewablePortfolioOptimizer {
config: PortfolioOptConfig {
risk_aversion: ra,
..self.config.clone()
},
assets: self.assets.clone(),
scenarios: self.scenarios.clone(),
investment_budget_m_usd: self.investment_budget_m_usd,
risk_free_rate: self.risk_free_rate,
};
opt.config.method = PortfolioMethod::MeanVariance;
if let Ok(mut r) = opt.optimize() {
r.pareto_optimal = true;
results.push(r);
}
}
Ok(results)
}
fn compute_asset_stats(&self) -> (Vec<f64>, Vec<f64>) {
let na = self.assets.len();
let mut means = vec![0.0f64; na];
let mut vars = vec![0.0f64; na];
let prob_sum: f64 = self.scenarios.iter().map(|s| s.probability).sum();
let prob_norm = prob_sum.max(1e-12);
for (ai, asset) in self.assets.iter().enumerate() {
let mut mean = 0.0f64;
let mut e2 = 0.0f64;
let cf_scens = &asset.capacity_factor_scenarios;
for (si, scenario) in self.scenarios.iter().enumerate() {
let w = scenario.probability / prob_norm;
let revenue = self.asset_revenue(ai, si, cf_scens, scenario, 1.0);
mean += w * revenue;
e2 += w * revenue * revenue;
}
means[ai] = mean;
vars[ai] = (e2 - mean * mean).max(0.0);
}
(means, vars)
}
fn asset_revenue(
&self,
ai: usize,
si: usize,
cf_scens: &[Vec<f64>],
scenario: &PortfolioScenario,
alloc_mw: f64,
) -> f64 {
let n_hours = self.config.n_hours;
let cf_row = cf_scens.get(si).or_else(|| cf_scens.first());
let mut revenue = 0.0f64;
let capex = self.assets[ai].capex_m_usd_per_mw * alloc_mw;
let opex = self.assets[ai].opex_m_usd_per_mw_year * alloc_mw;
for h in 0..n_hours {
let cf = cf_row.and_then(|r| r.get(h)).copied().unwrap_or(0.3);
let gen_mw = alloc_mw * cf.clamp(0.0, 1.0);
let price = scenario.electricity_price.get(h).copied().unwrap_or(50.0);
revenue += gen_mw * price / 1000.0; }
revenue - opex - capex / 20.0 }
fn per_scenario_returns_equal_weight(&self) -> Vec<f64> {
let na = self.assets.len();
let alloc = self.investment_budget_m_usd / na as f64;
self.scenarios
.iter()
.enumerate()
.map(|(si, scenario)| {
let mut total = 0.0f64;
for (ai, asset) in self.assets.iter().enumerate() {
let mw = alloc / asset.capex_m_usd_per_mw.max(1e-6);
total +=
self.asset_revenue(ai, si, &asset.capacity_factor_scenarios, scenario, mw);
}
total
})
.collect()
}
fn compute_per_asset_scenario_returns(&self) -> Vec<Vec<f64>> {
self.assets
.iter()
.enumerate()
.map(|(ai, asset)| {
self.scenarios
.iter()
.enumerate()
.map(|(si, scenario)| {
self.asset_revenue(ai, si, &asset.capacity_factor_scenarios, scenario, 1.0)
})
.collect()
})
.collect()
}
pub fn asset_covariance(&self, a1: usize, a2: usize) -> f64 {
let ns = self.scenarios.len();
if ns < 2 {
return 0.0;
}
let prob_sum: f64 = self.scenarios.iter().map(|s| s.probability).sum();
let pn = prob_sum.max(1e-12);
let ret1: Vec<f64> = self
.scenarios
.iter()
.enumerate()
.map(|(si, s)| {
self.asset_revenue(a1, si, &self.assets[a1].capacity_factor_scenarios, s, 1.0)
})
.collect();
let ret2: Vec<f64> = self
.scenarios
.iter()
.enumerate()
.map(|(si, s)| {
self.asset_revenue(a2, si, &self.assets[a2].capacity_factor_scenarios, s, 1.0)
})
.collect();
let mean1: f64 = self
.scenarios
.iter()
.zip(&ret1)
.map(|(s, r)| s.probability / pn * r)
.sum();
let mean2: f64 = self
.scenarios
.iter()
.zip(&ret2)
.map(|(s, r)| s.probability / pn * r)
.sum();
self.scenarios
.iter()
.enumerate()
.map(|(si, s)| s.probability / pn * (ret1[si] - mean1) * (ret2[si] - mean2))
.sum()
}
pub fn compute_cvar(&self, returns: &[f64], alpha: f64) -> f64 {
if returns.is_empty() {
return 0.0;
}
let mut sorted = returns.to_vec();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let cutoff = ((alpha * sorted.len() as f64).ceil() as usize).max(1);
let tail: &[f64] = &sorted[..cutoff.min(sorted.len())];
tail.iter().sum::<f64>() / tail.len() as f64
}
fn build_result(
&self,
weights: Vec<f64>,
_pareto: bool,
) -> Result<PortfolioResult, PortfolioError> {
let budget = self.investment_budget_m_usd;
let na = self.assets.len();
let n_hours = self.config.n_hours;
let mut alloc_mw: Vec<f64> = Vec::with_capacity(na);
let mut investments: Vec<f64> = Vec::with_capacity(na);
let mut total_invest = 0.0f64;
for (i, asset) in self.assets.iter().enumerate() {
let invest = budget * weights.get(i).copied().unwrap_or(0.0);
let mw = invest / asset.capex_m_usd_per_mw.max(1e-6);
let mw_capped = mw.min(asset.capacity_mw);
let actual_invest = mw_capped * asset.capex_m_usd_per_mw;
alloc_mw.push(mw_capped);
investments.push(actual_invest);
total_invest += actual_invest;
}
let prob_sum: f64 = self.scenarios.iter().map(|s| s.probability).sum();
let pn = prob_sum.max(1e-12);
let scenario_revenues: Vec<f64> = self
.scenarios
.iter()
.enumerate()
.map(|(si, scenario)| {
let mut rev = 0.0f64;
for (ai, asset) in self.assets.iter().enumerate() {
rev += self.asset_revenue(
ai,
si,
&asset.capacity_factor_scenarios,
scenario,
alloc_mw[ai],
);
}
rev
})
.collect();
let mean_rev: f64 = self
.scenarios
.iter()
.zip(&scenario_revenues)
.map(|(s, r)| s.probability / pn * r)
.sum();
let var_rev: f64 = self
.scenarios
.iter()
.zip(&scenario_revenues)
.map(|(s, r)| s.probability / pn * (r - mean_rev).powi(2))
.sum();
let std_rev = var_rev.sqrt();
let cvar = self.compute_cvar(&scenario_revenues, 0.05);
let sharpe = if std_rev > 1e-9 {
(mean_rev - self.risk_free_rate * total_invest) / std_rev
} else {
0.0
};
let mut total_gen_gwh = 0.0f64;
let mut alloc_data: Vec<AssetAllocation> = Vec::with_capacity(na);
for (ai, asset) in self.assets.iter().enumerate() {
let cf_mean: f64 = if self.scenarios.is_empty() {
0.3
} else {
let total_cf: f64 = self
.scenarios
.iter()
.enumerate()
.map(|(si, s)| {
let cf_row = asset
.capacity_factor_scenarios
.get(si)
.or_else(|| asset.capacity_factor_scenarios.first());
let cf_sum: f64 = (0..n_hours)
.map(|h| cf_row.and_then(|r| r.get(h)).copied().unwrap_or(0.3))
.sum();
s.probability / pn * cf_sum / n_hours.max(1) as f64
})
.sum();
total_cf
};
let annual_gwh = alloc_mw[ai] * cf_mean * 8760.0 / 1000.0;
total_gen_gwh += annual_gwh;
alloc_data.push(AssetAllocation {
asset_id: asset.id,
allocated_mw: alloc_mw[ai],
investment_m_usd: investments[ai],
expected_annual_energy_gwh: annual_gwh,
contribution_pct: 0.0, });
}
for alloc in alloc_data.iter_mut() {
alloc.contribution_pct = if total_gen_gwh > 1e-9 {
alloc.expected_annual_energy_gwh / total_gen_gwh * 100.0
} else {
0.0
};
}
let renewable_fraction_pct = 100.0;
let curtailment_pct = self.config.max_curtailment_pct * 100.0 * 0.5;
Ok(PortfolioResult {
allocations: alloc_data,
total_investment_m_usd: total_invest,
expected_annual_revenue_m_usd: mean_rev,
revenue_std_m_usd: std_rev,
cvar_95_m_usd: cvar,
sharpe_ratio: sharpe,
renewable_fraction_pct,
curtailment_pct,
pareto_optimal: false,
})
}
}
#[allow(dead_code)]
struct Lcg64 {
state: u64,
}
#[allow(dead_code)]
impl Lcg64 {
fn new(seed: u64) -> Self {
Self { state: seed }
}
fn next_f64(&mut self) -> f64 {
self.state = self
.state
.wrapping_mul(6_364_136_223_846_793_005)
.wrapping_add(1_442_695_040_888_963_407);
(self.state >> 11) as f64 / (1u64 << 53) as f64
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_capacity_factors(n_scenarios: usize, n_hours: usize, base_cf: f64) -> Vec<Vec<f64>> {
(0..n_scenarios)
.map(|s| {
(0..n_hours)
.map(|h| (base_cf + 0.05 * ((s + h) % 5) as f64).clamp(0.0, 1.0))
.collect()
})
.collect()
}
fn make_scenarios(n: usize) -> Vec<PortfolioScenario> {
(0..n)
.map(|i| PortfolioScenario {
id: i,
probability: 1.0 / n as f64,
load_mw: vec![100.0; 24],
electricity_price: vec![50.0 + 10.0 * i as f64; 24],
carbon_price_usd_per_t: 30.0,
})
.collect()
}
fn make_optimizer(method: PortfolioMethod) -> RenewablePortfolioOptimizer {
let config = PortfolioOptConfig {
n_assets: 2,
n_scenarios: 5,
n_hours: 24,
risk_aversion: 0.5,
target_renewable_pct: 0.6,
max_curtailment_pct: 0.1,
method,
};
let mut opt = RenewablePortfolioOptimizer::new(config);
opt.set_budget(200.0);
opt.set_risk_free_rate(0.03);
opt.add_asset(RenewableAsset {
id: 0,
technology: "Wind".to_owned(),
capacity_mw: 100.0,
capex_m_usd_per_mw: 1.5,
opex_m_usd_per_mw_year: 0.04,
capacity_factor_scenarios: make_capacity_factors(5, 24, 0.35),
correlation_group: 0,
});
opt.add_asset(RenewableAsset {
id: 1,
technology: "Solar".to_owned(),
capacity_mw: 80.0,
capex_m_usd_per_mw: 1.0,
opex_m_usd_per_mw_year: 0.02,
capacity_factor_scenarios: make_capacity_factors(5, 24, 0.20),
correlation_group: 1,
});
for s in make_scenarios(5) {
opt.add_scenario(s);
}
opt
}
#[test]
fn test_budget_constraint() {
let opt = make_optimizer(PortfolioMethod::MeanVariance);
let result = opt.optimize().expect("optimize ok");
assert!(
result.total_investment_m_usd <= opt.investment_budget_m_usd + 1e-6,
"investment {} exceeds budget {}",
result.total_investment_m_usd,
opt.investment_budget_m_usd
);
}
#[test]
fn test_higher_risk_aversion_lower_variance() {
let mut opt_low = make_optimizer(PortfolioMethod::MeanVariance);
opt_low.config.risk_aversion = 0.0;
let result_low = opt_low.optimize().expect("optimize ok");
let mut opt_high = make_optimizer(PortfolioMethod::MeanVariance);
opt_high.config.risk_aversion = 0.9;
let result_high = opt_high.optimize().expect("optimize ok");
assert!(
result_high.revenue_std_m_usd <= result_low.revenue_std_m_usd + 1e-6,
"high RA std {} > low RA std {}",
result_high.revenue_std_m_usd,
result_low.revenue_std_m_usd
);
}
#[test]
fn test_cvar_worst_case() {
let opt = make_optimizer(PortfolioMethod::CVaR);
let result = opt.optimize().expect("optimize ok");
assert!(
result.cvar_95_m_usd <= result.expected_annual_revenue_m_usd + 1e-6,
"CVaR {} > mean {}",
result.cvar_95_m_usd,
result.expected_annual_revenue_m_usd
);
}
#[test]
fn test_renewable_fraction() {
let opt = make_optimizer(PortfolioMethod::MeanVariance);
let result = opt.optimize().expect("optimize ok");
assert!(
(result.renewable_fraction_pct - 100.0).abs() < 1e-6,
"renewable fraction must be 100 %, got {}",
result.renewable_fraction_pct
);
}
#[test]
fn test_sharpe_ratio() {
let opt = make_optimizer(PortfolioMethod::MeanVariance);
let result = opt.optimize().expect("optimize ok");
assert!(result.sharpe_ratio.is_finite(), "Sharpe must be finite");
}
#[test]
fn test_minimax_regret() {
let opt = make_optimizer(PortfolioMethod::MinimaxRegret);
let result = opt.optimize().expect("minimax regret ok");
assert!(!result.allocations.is_empty());
assert!(result.total_investment_m_usd >= 0.0);
}
#[test]
fn test_efficient_frontier() {
let opt = make_optimizer(PortfolioMethod::MeanVariance);
let frontier = opt.efficient_frontier(5).expect("frontier ok");
assert!(
frontier.len() >= 2,
"frontier must have ≥ 2 points, got {}",
frontier.len()
);
}
#[test]
fn test_asset_covariance() {
let opt = make_optimizer(PortfolioMethod::MeanVariance);
let cov = opt.asset_covariance(0, 1);
assert!(cov.is_finite(), "covariance must be finite, got {}", cov);
}
#[test]
fn test_compute_cvar_direct() {
let opt = make_optimizer(PortfolioMethod::CVaR);
let returns = vec![-10.0, 5.0, 20.0, 30.0, 100.0];
let cvar = opt.compute_cvar(&returns, 0.4);
assert!(
cvar < returns.iter().cloned().fold(f64::NEG_INFINITY, f64::max),
"CVaR {} must be less than maximum",
cvar
);
}
}