use_portfolio_weight/
lib.rs1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::fmt;
5use std::{collections::BTreeMap, collections::btree_map, error::Error};
6
7pub mod prelude {
9 pub use crate::{AssetWeight, PortfolioWeight, PortfolioWeightError, WeightSet};
10}
11
12#[derive(Clone, Copy, Debug, PartialEq, PartialOrd)]
14pub struct PortfolioWeight {
15 value: f64,
16}
17
18impl PortfolioWeight {
19 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 #[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#[derive(Clone, Debug, PartialEq)]
49pub struct AssetWeight {
50 asset_id: String,
51 weight: PortfolioWeight,
52}
53
54impl AssetWeight {
55 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 #[must_use]
77 pub fn asset_id(&self) -> &str {
78 &self.asset_id
79 }
80
81 #[must_use]
83 pub const fn weight(&self) -> PortfolioWeight {
84 self.weight
85 }
86}
87
88#[derive(Clone, Debug, Default, PartialEq)]
90pub struct WeightSet {
91 weights: BTreeMap<String, PortfolioWeight>,
92}
93
94impl WeightSet {
95 #[must_use]
97 pub const fn new() -> Self {
98 Self {
99 weights: BTreeMap::new(),
100 }
101 }
102
103 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 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 #[must_use]
137 pub fn sum(&self) -> f64 {
138 self.weights.values().map(|weight| weight.value()).sum()
139 }
140
141 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 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#[derive(Clone, Debug, Eq, PartialEq)]
179pub enum PortfolioWeightError {
180 NonFiniteWeight,
182 EmptyAssetId,
184 DuplicateAssetId(String),
186 NonFiniteTolerance,
188 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}