Skip to main content

use_portfolio_weight/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::fmt;
5use std::{collections::BTreeMap, collections::btree_map, error::Error};
6
7/// Common portfolio weight primitives.
8pub mod prelude {
9    pub use crate::{AssetWeight, PortfolioWeight, PortfolioWeightError, WeightSet};
10}
11
12/// A finite portfolio weight value.
13#[derive(Clone, Copy, Debug, PartialEq, PartialOrd)]
14pub struct PortfolioWeight {
15    value: f64,
16}
17
18impl PortfolioWeight {
19    /// Creates a portfolio weight from a finite `f64`.
20    ///
21    /// Negative values are accepted for short-exposure vocabulary.
22    ///
23    /// # Errors
24    ///
25    /// Returns [`PortfolioWeightError::NonFiniteWeight`] when `value` is not finite.
26    pub const fn new(value: f64) -> Result<Self, PortfolioWeightError> {
27        if value.is_finite() {
28            Ok(Self { value })
29        } else {
30            Err(PortfolioWeightError::NonFiniteWeight)
31        }
32    }
33
34    /// Returns the weight value.
35    #[must_use]
36    pub const fn value(self) -> f64 {
37        self.value
38    }
39}
40
41impl fmt::Display for PortfolioWeight {
42    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
43        self.value.fmt(formatter)
44    }
45}
46
47/// A non-empty asset identifier paired with a portfolio weight.
48#[derive(Clone, Debug, PartialEq)]
49pub struct AssetWeight {
50    asset_id: String,
51    weight: PortfolioWeight,
52}
53
54impl AssetWeight {
55    /// Creates an asset weight.
56    ///
57    /// # Errors
58    ///
59    /// Returns [`PortfolioWeightError::EmptyAssetId`] when the trimmed identifier is empty.
60    pub fn new(
61        asset_id: impl AsRef<str>,
62        weight: PortfolioWeight,
63    ) -> Result<Self, PortfolioWeightError> {
64        let trimmed = asset_id.as_ref().trim();
65        if trimmed.is_empty() {
66            return Err(PortfolioWeightError::EmptyAssetId);
67        }
68
69        Ok(Self {
70            asset_id: trimmed.to_string(),
71            weight,
72        })
73    }
74
75    /// Returns the asset identifier.
76    #[must_use]
77    pub fn asset_id(&self) -> &str {
78        &self.asset_id
79    }
80
81    /// Returns the asset weight.
82    #[must_use]
83    pub const fn weight(&self) -> PortfolioWeight {
84        self.weight
85    }
86}
87
88/// A deterministic set of asset weights keyed by asset identifier.
89#[derive(Clone, Debug, Default, PartialEq)]
90pub struct WeightSet {
91    weights: BTreeMap<String, PortfolioWeight>,
92}
93
94impl WeightSet {
95    /// Creates an empty weight set.
96    #[must_use]
97    pub const fn new() -> Self {
98        Self {
99            weights: BTreeMap::new(),
100        }
101    }
102
103    /// Creates a weight set from asset weights.
104    ///
105    /// # Errors
106    ///
107    /// Returns [`PortfolioWeightError::DuplicateAssetId`] when an asset appears more than once.
108    pub fn from_asset_weights(
109        weights: impl IntoIterator<Item = AssetWeight>,
110    ) -> Result<Self, PortfolioWeightError> {
111        let mut set = Self::new();
112        for weight in weights {
113            set.insert(weight)?;
114        }
115        Ok(set)
116    }
117
118    /// Inserts an asset weight.
119    ///
120    /// # Errors
121    ///
122    /// Returns [`PortfolioWeightError::DuplicateAssetId`] when the asset already exists.
123    pub fn insert(&mut self, asset_weight: AssetWeight) -> Result<(), PortfolioWeightError> {
124        if self.weights.contains_key(asset_weight.asset_id()) {
125            return Err(PortfolioWeightError::DuplicateAssetId(
126                asset_weight.asset_id().to_string(),
127            ));
128        }
129
130        self.weights
131            .insert(asset_weight.asset_id, asset_weight.weight);
132        Ok(())
133    }
134
135    /// Returns the sum of all weights.
136    #[must_use]
137    pub fn sum(&self) -> f64 {
138        self.weights.values().map(|weight| weight.value()).sum()
139    }
140
141    /// Checks whether weights sum approximately to `1.0` within `tolerance`.
142    ///
143    /// # Errors
144    ///
145    /// Returns [`PortfolioWeightError::NonFiniteTolerance`] or
146    /// [`PortfolioWeightError::NegativeTolerance`] when tolerance is invalid.
147    pub fn is_approximately_fully_invested(
148        &self,
149        tolerance: f64,
150    ) -> Result<bool, PortfolioWeightError> {
151        if !tolerance.is_finite() {
152            return Err(PortfolioWeightError::NonFiniteTolerance);
153        }
154
155        if tolerance < 0.0 {
156            return Err(PortfolioWeightError::NegativeTolerance);
157        }
158
159        Ok((self.sum() - 1.0).abs() <= tolerance)
160    }
161
162    /// Iterates over weights in deterministic asset-id order.
163    pub fn iter(&self) -> btree_map::Iter<'_, String, PortfolioWeight> {
164        self.weights.iter()
165    }
166}
167
168impl<'a> IntoIterator for &'a WeightSet {
169    type Item = (&'a String, &'a PortfolioWeight);
170    type IntoIter = btree_map::Iter<'a, String, PortfolioWeight>;
171
172    fn into_iter(self) -> Self::IntoIter {
173        self.iter()
174    }
175}
176
177/// Errors returned by portfolio weight helpers.
178#[derive(Clone, Debug, Eq, PartialEq)]
179pub enum PortfolioWeightError {
180    /// Weight values must be finite.
181    NonFiniteWeight,
182    /// Asset identifiers must be non-empty after trimming whitespace.
183    EmptyAssetId,
184    /// Asset identifiers must be unique in a weight set.
185    DuplicateAssetId(String),
186    /// Tolerances must be finite.
187    NonFiniteTolerance,
188    /// Tolerances must not be negative.
189    NegativeTolerance,
190}
191
192impl fmt::Display for PortfolioWeightError {
193    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
194        match self {
195            Self::NonFiniteWeight => formatter.write_str("portfolio weight must be finite"),
196            Self::EmptyAssetId => formatter.write_str("asset identifier cannot be empty"),
197            Self::DuplicateAssetId(asset_id) => {
198                write!(formatter, "duplicate asset identifier: {asset_id}")
199            },
200            Self::NonFiniteTolerance => formatter.write_str("weight tolerance must be finite"),
201            Self::NegativeTolerance => formatter.write_str("weight tolerance cannot be negative"),
202        }
203    }
204}
205
206impl Error for PortfolioWeightError {}
207
208#[cfg(test)]
209mod tests {
210    use super::{AssetWeight, PortfolioWeight, WeightSet};
211
212    #[test]
213    fn accepts_valid_weight() {
214        let weight = PortfolioWeight::new(0.25).expect("weight should be valid");
215
216        assert!((weight.value() - 0.25).abs() < f64::EPSILON);
217    }
218
219    #[test]
220    fn constructs_weight_set() {
221        let weights = WeightSet::from_asset_weights([
222            AssetWeight::new(
223                "XYZ",
224                PortfolioWeight::new(0.40).expect("weight should be valid"),
225            )
226            .expect("asset should be valid"),
227            AssetWeight::new(
228                "ABC",
229                PortfolioWeight::new(0.60).expect("weight should be valid"),
230            )
231            .expect("asset should be valid"),
232        ])
233        .expect("set should be valid");
234
235        let ids: Vec<&str> = (&weights)
236            .into_iter()
237            .map(|(asset_id, _)| asset_id.as_str())
238            .collect();
239        assert_eq!(ids, vec!["ABC", "XYZ"]);
240    }
241
242    #[test]
243    fn sums_weights() {
244        let weights = WeightSet::from_asset_weights([
245            AssetWeight::new(
246                "ABC",
247                PortfolioWeight::new(0.60).expect("weight should be valid"),
248            )
249            .expect("asset should be valid"),
250            AssetWeight::new(
251                "XYZ",
252                PortfolioWeight::new(0.40).expect("weight should be valid"),
253            )
254            .expect("asset should be valid"),
255        ])
256        .expect("set should be valid");
257
258        assert!((weights.sum() - 1.0).abs() < f64::EPSILON);
259    }
260
261    #[test]
262    fn checks_approximate_fully_invested() {
263        let weights = WeightSet::from_asset_weights([
264            AssetWeight::new(
265                "ABC",
266                PortfolioWeight::new(0.60).expect("weight should be valid"),
267            )
268            .expect("asset should be valid"),
269            AssetWeight::new(
270                "XYZ",
271                PortfolioWeight::new(0.400_000_000_1).expect("weight should be valid"),
272            )
273            .expect("asset should be valid"),
274        ])
275        .expect("set should be valid");
276
277        assert!(
278            weights
279                .is_approximately_fully_invested(1.0e-9)
280                .expect("check should succeed")
281        );
282    }
283
284    #[test]
285    fn documents_negative_weight_behavior() {
286        let weight = PortfolioWeight::new(-0.10).expect("negative weight should be valid");
287
288        assert!((weight.value() + 0.10).abs() < f64::EPSILON);
289    }
290}