use serde::{Deserialize, Serialize};
use super::triggers::Trigger;
use super::validate::push_issue;
use super::{Account, Issue};
const SHARE_TOLERANCE: f64 = 1e-6;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum AssetClass {
Stocks,
Bonds,
Cash,
}
impl AssetClass {
pub const ALL: &'static [Self] = &[Self::Stocks, Self::Bonds, Self::Cash];
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Stocks => "stocks",
Self::Bonds => "bonds",
Self::Cash => "cash",
}
}
#[must_use]
pub const fn index(self) -> usize {
self as usize
}
}
pub type ClassReturns = [f64; 3];
#[expect(
clippy::trivially_copy_pass_by_ref,
reason = "serde's skip_serializing_if passes a reference"
)]
fn is_zero(share: &f64) -> bool {
*share == 0.0
}
#[derive(Debug, Clone, Copy, PartialEq, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Mix {
#[serde(default, skip_serializing_if = "is_zero")]
pub stocks: f64,
#[serde(default, skip_serializing_if = "is_zero")]
pub bonds: f64,
#[serde(default, skip_serializing_if = "is_zero")]
pub cash: f64,
}
impl Mix {
#[must_use]
pub fn share(&self, class: AssetClass) -> f64 {
match class {
AssetClass::Stocks => self.stocks,
AssetClass::Bonds => self.bonds,
AssetClass::Cash => self.cash,
}
}
#[must_use]
pub fn blend(&self, returns: &ClassReturns) -> f64 {
AssetClass::ALL
.iter()
.map(|&class| self.share(class) * returns[class.index()])
.sum()
}
fn check(&self, path: &str, issues: &mut Vec<Issue>) {
let shares = AssetClass::ALL.iter().map(|&class| self.share(class));
if shares
.clone()
.any(|share| !share.is_finite() || !(0.0..=1.0).contains(&share))
{
push_issue(issues, path, "each share must be between 0 and 1");
return;
}
if (shares.sum::<f64>() - 1.0).abs() > SHARE_TOLERANCE {
push_issue(issues, path, "the shares must add up to 1");
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct MixPhase {
pub from: Trigger,
#[serde(default, skip_serializing_if = "is_zero")]
pub stocks: f64,
#[serde(default, skip_serializing_if = "is_zero")]
pub bonds: f64,
#[serde(default, skip_serializing_if = "is_zero")]
pub cash: f64,
}
impl MixPhase {
#[must_use]
pub fn mix(&self) -> Mix {
Mix {
stocks: self.stocks,
bonds: self.bonds,
cash: self.cash,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum Allocation {
GlidePath(Vec<MixPhase>),
Mix(Mix),
}
pub(super) fn check_returns(path: &str, account: &Account, issues: &mut Vec<Issue>) {
let rate_path = format!("{path}.expected_return");
if let Some(rate) = account.expected_return
&& (!rate.is_finite() || !(-1.0..=1.0).contains(&rate))
{
push_issue(issues, &rate_path, "must be a rate between -1.0 and 1.0");
}
let Some(allocation) = &account.allocation else {
return;
};
if account.expected_return.is_some() {
push_issue(
issues,
&rate_path,
"an account with an allocation earns its mix; remove the expected return",
);
}
let path = format!("{path}.allocation");
match allocation {
Allocation::Mix(mix) => mix.check(&path, issues),
Allocation::GlidePath(phases) if phases.is_empty() => {
push_issue(issues, &path, "a glide path needs at least one step");
}
Allocation::GlidePath(phases) => {
for (i, phase) in phases.iter().enumerate() {
phase.mix().check(&format!("{path}[{i}]"), issues);
}
}
}
}