1#[derive(Debug, Clone)]
9pub struct AssetReturns {
10 pub symbol: String,
12 pub returns: Vec<f64>,
14}
15
16#[derive(Debug, Clone)]
18pub struct ClusterAssignment {
19 pub symbol: String,
21 pub cluster_id: usize,
23 pub distance_to_centroid: f64,
25}
26
27#[derive(Debug, Clone)]
29pub struct KMeansResult {
30 pub assignments: Vec<ClusterAssignment>,
32 pub centroids: Vec<Vec<f64>>,
34 pub inertia: f64,
36 pub iterations: u32,
38}
39
40pub struct KMeans;
42
43impl KMeans {
44 pub fn fit(data: &[Vec<f64>], k: usize, max_iter: u32, seed: u64) -> KMeansResult {
52 assert!(!data.is_empty(), "data must be non-empty");
53 assert!(k > 0, "k must be > 0");
54 assert!(k <= data.len(), "k must not exceed number of data points");
55
56 let n = data.len();
57 let d = data[0].len();
58
59 let mut lcg = seed;
61 let mut centroid_indices: Vec<usize> = Vec::with_capacity(k);
62 while centroid_indices.len() < k {
63 lcg = lcg.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1_442_695_040_888_963_407);
64 let idx = (lcg >> 33) as usize % n;
65 if !centroid_indices.contains(&idx) {
66 centroid_indices.push(idx);
67 }
68 }
69 let mut centroids: Vec<Vec<f64>> = centroid_indices.iter().map(|&i| data[i].clone()).collect();
70
71 let mut assignments = vec![0usize; n];
72 let mut iterations = 0u32;
73
74 for _iter in 0..max_iter {
75 iterations += 1;
76 let mut changed = false;
77
78 for (i, point) in data.iter().enumerate() {
80 let best = (0..k)
81 .min_by(|&a, &b| {
82 euclidean_sq(point, ¢roids[a])
83 .partial_cmp(&euclidean_sq(point, ¢roids[b]))
84 .unwrap_or(std::cmp::Ordering::Equal)
85 })
86 .unwrap_or(0);
87 if assignments[i] != best {
88 assignments[i] = best;
89 changed = true;
90 }
91 }
92
93 let mut sums = vec![vec![0.0f64; d]; k];
95 let mut counts = vec![0usize; k];
96 for (i, point) in data.iter().enumerate() {
97 let c = assignments[i];
98 counts[c] += 1;
99 for (j, &v) in point.iter().enumerate() {
100 sums[c][j] += v;
101 }
102 }
103 for c in 0..k {
104 if counts[c] > 0 {
105 for j in 0..d {
106 centroids[c][j] = sums[c][j] / counts[c] as f64;
107 }
108 }
109 }
110
111 if !changed {
112 break;
113 }
114 }
115
116 let inertia: f64 = data
118 .iter()
119 .zip(assignments.iter())
120 .map(|(p, &c)| euclidean_sq(p, ¢roids[c]))
121 .sum();
122
123 let cluster_assignments: Vec<ClusterAssignment> = data
125 .iter()
126 .zip(assignments.iter())
127 .enumerate()
128 .map(|(i, (p, &c))| ClusterAssignment {
129 symbol: i.to_string(),
130 cluster_id: c,
131 distance_to_centroid: euclidean_sq(p, ¢roids[c]).sqrt(),
132 })
133 .collect();
134
135 KMeansResult {
136 assignments: cluster_assignments,
137 centroids,
138 inertia,
139 iterations,
140 }
141 }
142}
143
144pub struct CorrelationClusterer {
154 symbols: Vec<String>,
155 corr_matrix: Vec<Vec<f64>>,
157 last_result: Option<KMeansResult>,
158}
159
160impl CorrelationClusterer {
161 pub fn from_returns(assets: &[AssetReturns]) -> Self {
166 let n = assets.len();
167 let mut corr_matrix = vec![vec![0.0f64; n]; n];
168
169 for i in 0..n {
170 corr_matrix[i][i] = 1.0;
171 for j in (i + 1)..n {
172 let c = pearson_correlation(&assets[i].returns, &assets[j].returns);
173 corr_matrix[i][j] = c;
174 corr_matrix[j][i] = c;
175 }
176 }
177
178 let symbols = assets.iter().map(|a| a.symbol.clone()).collect();
179 Self { symbols, corr_matrix, last_result: None }
180 }
181
182 pub fn cluster(&mut self, k: usize, seed: u64) -> KMeansResult {
184 let mut result = KMeans::fit(&self.corr_matrix, k, 300, seed);
185
186 for (assign, sym) in result.assignments.iter_mut().zip(self.symbols.iter()) {
188 assign.symbol = sym.clone();
189 }
190
191 self.last_result = Some(result.clone());
192 result
193 }
194
195 pub fn cluster_labels(&self) -> Vec<(String, usize)> {
199 match &self.last_result {
200 Some(r) => r
201 .assignments
202 .iter()
203 .map(|a| (a.symbol.clone(), a.cluster_id))
204 .collect(),
205 None => Vec::new(),
206 }
207 }
208
209 pub fn silhouette_score(&self, result: &KMeansResult) -> f64 {
214 let n = result.assignments.len();
215 if n < 2 {
216 return 0.0;
217 }
218 let k = result.centroids.len();
219 if k == 1 {
220 return 0.0;
221 }
222
223 let mut sil_sum = 0.0f64;
225 let mut count = 0usize;
226
227 for (i, assign) in result.assignments.iter().enumerate() {
228 let row_idx = match self.symbols.iter().position(|s| s == &assign.symbol) {
230 Some(idx) => idx,
231 None => continue,
232 };
233 let feat = &self.corr_matrix[row_idx];
234 let my_cluster = assign.cluster_id;
235
236 let same_cluster_pts: Vec<usize> = result
238 .assignments
239 .iter()
240 .enumerate()
241 .filter(|(j, a)| *j != i && a.cluster_id == my_cluster)
242 .map(|(j, _)| j)
243 .collect();
244
245 let a = if same_cluster_pts.is_empty() {
246 0.0
247 } else {
248 let sum: f64 = same_cluster_pts
249 .iter()
250 .map(|&j| {
251 let other_sym = &result.assignments[j].symbol;
252 let other_idx = self
253 .symbols
254 .iter()
255 .position(|s| s == other_sym)
256 .unwrap_or(0);
257 euclidean_sq(feat, &self.corr_matrix[other_idx]).sqrt()
258 })
259 .sum();
260 sum / same_cluster_pts.len() as f64
261 };
262
263 let mut b = f64::INFINITY;
265 for c in 0..k {
266 if c == my_cluster {
267 continue;
268 }
269 let other_pts: Vec<usize> = result
270 .assignments
271 .iter()
272 .enumerate()
273 .filter(|(_, a)| a.cluster_id == c)
274 .map(|(j, _)| j)
275 .collect();
276 if other_pts.is_empty() {
277 continue;
278 }
279 let sum: f64 = other_pts
280 .iter()
281 .map(|&j| {
282 let other_sym = &result.assignments[j].symbol;
283 let other_idx = self
284 .symbols
285 .iter()
286 .position(|s| s == other_sym)
287 .unwrap_or(0);
288 euclidean_sq(feat, &self.corr_matrix[other_idx]).sqrt()
289 })
290 .sum();
291 let mean_dist = sum / other_pts.len() as f64;
292 if mean_dist < b {
293 b = mean_dist;
294 }
295 }
296
297 let denom = a.max(b);
301 let s_i = if same_cluster_pts.is_empty() || !b.is_finite() || denom == 0.0 {
302 0.0
303 } else {
304 (b - a) / denom
305 };
306 sil_sum += s_i;
307 count += 1;
308 }
309
310 if count == 0 { 0.0 } else { sil_sum / count as f64 }
311 }
312}
313
314fn euclidean_sq(a: &[f64], b: &[f64]) -> f64 {
319 a.iter().zip(b.iter()).map(|(&x, &y)| (x - y).powi(2)).sum()
320}
321
322fn pearson_correlation(a: &[f64], b: &[f64]) -> f64 {
323 let n = a.len().min(b.len());
324 if n < 2 {
325 return 0.0;
326 }
327 let mean_a = a[..n].iter().sum::<f64>() / n as f64;
328 let mean_b = b[..n].iter().sum::<f64>() / n as f64;
329 let mut cov = 0.0f64;
330 let mut var_a = 0.0f64;
331 let mut var_b = 0.0f64;
332 for i in 0..n {
333 let da = a[i] - mean_a;
334 let db = b[i] - mean_b;
335 cov += da * db;
336 var_a += da * da;
337 var_b += db * db;
338 }
339 let denom = (var_a * var_b).sqrt();
340 if denom == 0.0 { 0.0 } else { cov / denom }
341}
342
343#[cfg(test)]
348mod tests {
349 use super::*;
350
351 fn make_asset(symbol: &str, returns: Vec<f64>) -> AssetReturns {
352 AssetReturns { symbol: symbol.to_string(), returns }
353 }
354
355 #[test]
358 fn test_kmeans_two_clear_clusters() {
359 let data: Vec<Vec<f64>> = vec![
361 vec![0.1], vec![0.2], vec![0.0],
362 vec![10.0], vec![10.1], vec![9.9],
363 ];
364 let result = KMeans::fit(&data, 2, 100, 42);
365 assert_eq!(result.centroids.len(), 2);
366 assert_eq!(result.assignments.len(), 6);
367 let c0 = result.assignments[0].cluster_id;
369 let c3 = result.assignments[3].cluster_id;
370 assert_ne!(c0, c3, "near-0 and near-10 must be in different clusters");
371 }
372
373 #[test]
374 fn test_kmeans_k_equals_n() {
375 let data: Vec<Vec<f64>> = vec![vec![1.0], vec![2.0], vec![3.0]];
376 let result = KMeans::fit(&data, 3, 10, 7);
377 assert_eq!(result.centroids.len(), 3);
378 assert_eq!(result.assignments.len(), 3);
379 }
380
381 #[test]
382 fn test_kmeans_inertia_non_negative() {
383 let data: Vec<Vec<f64>> = vec![vec![1.0, 2.0], vec![3.0, 4.0], vec![5.0, 6.0]];
384 let result = KMeans::fit(&data, 2, 50, 1);
385 assert!(result.inertia >= 0.0);
386 }
387
388 #[test]
389 fn test_kmeans_iterations_bounded() {
390 let data: Vec<Vec<f64>> = (0..20).map(|i| vec![i as f64]).collect();
391 let result = KMeans::fit(&data, 3, 5, 99);
392 assert!(result.iterations <= 5);
393 }
394
395 #[test]
398 fn test_pearson_perfect_positive() {
399 let a = vec![1.0, 2.0, 3.0, 4.0];
400 let b = vec![2.0, 4.0, 6.0, 8.0];
401 let c = pearson_correlation(&a, &b);
402 assert!((c - 1.0).abs() < 1e-10);
403 }
404
405 #[test]
406 fn test_pearson_perfect_negative() {
407 let a = vec![1.0, 2.0, 3.0, 4.0];
408 let b = vec![4.0, 3.0, 2.0, 1.0];
409 let c = pearson_correlation(&a, &b);
410 assert!((c + 1.0).abs() < 1e-10);
411 }
412
413 #[test]
414 fn test_pearson_too_short() {
415 assert_eq!(pearson_correlation(&[1.0], &[1.0]), 0.0);
416 assert_eq!(pearson_correlation(&[], &[]), 0.0);
417 }
418
419 #[test]
422 fn test_correlation_clusterer_diagonal_ones() {
423 let assets = vec![
424 make_asset("A", vec![0.01, -0.01, 0.02, -0.02]),
425 make_asset("B", vec![0.02, -0.02, 0.04, -0.04]),
426 make_asset("C", vec![-0.01, 0.01, -0.02, 0.02]),
427 ];
428 let clusterer = CorrelationClusterer::from_returns(&assets);
429 for i in 0..3 {
431 assert!((clusterer.corr_matrix[i][i] - 1.0).abs() < 1e-10);
432 }
433 assert!((clusterer.corr_matrix[0][1] - 1.0).abs() < 1e-6);
435 assert!((clusterer.corr_matrix[0][2] + 1.0).abs() < 1e-6);
437 }
438
439 #[test]
440 fn test_correlation_clusterer_cluster_labels() {
441 let assets = vec![
442 make_asset("A", vec![0.01, 0.02, 0.03]),
443 make_asset("B", vec![0.01, 0.02, 0.03]),
444 make_asset("C", vec![-0.01, -0.02, -0.03]),
445 ];
446 let mut clusterer = CorrelationClusterer::from_returns(&assets);
447 let _result = clusterer.cluster(2, 42);
448 let labels = clusterer.cluster_labels();
449 assert_eq!(labels.len(), 3);
450 let symbols: Vec<&str> = labels.iter().map(|(s, _)| s.as_str()).collect();
451 assert!(symbols.contains(&"A"));
452 assert!(symbols.contains(&"B"));
453 assert!(symbols.contains(&"C"));
454 }
455
456 #[test]
457 fn test_cluster_labels_before_cluster_call() {
458 let assets = vec![make_asset("X", vec![0.01, 0.02])];
459 let clusterer = CorrelationClusterer::from_returns(&assets);
460 assert!(clusterer.cluster_labels().is_empty());
461 }
462
463 #[test]
464 fn test_silhouette_score_range() {
465 let assets = vec![
466 make_asset("A", vec![0.01, 0.02, 0.03, 0.04]),
467 make_asset("B", vec![0.01, 0.02, 0.03, 0.04]),
468 make_asset("C", vec![-0.03, -0.02, -0.01, 0.00]),
469 make_asset("D", vec![-0.03, -0.02, -0.01, 0.00]),
470 ];
471 let mut clusterer = CorrelationClusterer::from_returns(&assets);
472 let result = clusterer.cluster(2, 5);
473 let score = clusterer.silhouette_score(&result);
474 assert!(score >= -1.0 && score <= 1.0, "Silhouette must be in [-1, 1]");
475 }
476
477 #[test]
478 fn test_silhouette_single_cluster() {
479 let assets = vec![
480 make_asset("A", vec![0.01, 0.02]),
481 make_asset("B", vec![0.01, 0.02]),
482 ];
483 let mut clusterer = CorrelationClusterer::from_returns(&assets);
484 let result = clusterer.cluster(1, 1);
485 assert_eq!(clusterer.silhouette_score(&result), 0.0);
486 }
487}