Skip to content

Commit e3c2e8e

Browse files
authored
Merge pull request #520 from danielships/feat/analytics-unit-tests
test(devkit/analytics): add unit tests for trend direction, fee velocity, volatility, and Bollinger Bands
2 parents 8c72cc8 + 5f6bff6 commit e3c2e8e

2 files changed

Lines changed: 316 additions & 0 deletions

File tree

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
//! Unit tests for the fee trend direction detector and fee velocity calculator.
2+
//!
3+
//! Covers:
4+
//! - `TrendDirection` detection (Upward / Downward / Sideways) on synthetic sequences
5+
//! - Boundary conditions at the ±5% slope threshold used by `analyze_trend`
6+
//! - `fee_velocity` on sequences with a known rate of change
7+
8+
use stellar_devkit::analytics::trend::{analyze_trend, fee_velocity, TrendDirection};
9+
10+
// ---------------------------------------------------------------------------
11+
// TrendDirection detection — issue #459
12+
// ---------------------------------------------------------------------------
13+
14+
#[test]
15+
fn upward_trend_on_strictly_rising_sequence() {
16+
let fees: Vec<f64> = (0..30).map(|i| 100.0 + i as f64 * 20.0).collect();
17+
let result = analyze_trend(&fees);
18+
assert_eq!(
19+
result.direction,
20+
TrendDirection::Upward,
21+
"strictly rising sequence must be Upward"
22+
);
23+
assert!(result.slope > 0.0, "slope must be positive");
24+
}
25+
26+
#[test]
27+
fn downward_trend_on_strictly_falling_sequence() {
28+
let fees: Vec<f64> = (0..30).map(|i| 600.0 - i as f64 * 15.0).collect();
29+
let result = analyze_trend(&fees);
30+
assert_eq!(
31+
result.direction,
32+
TrendDirection::Downward,
33+
"strictly falling sequence must be Downward"
34+
);
35+
assert!(result.slope < 0.0, "slope must be negative");
36+
}
37+
38+
#[test]
39+
fn sideways_trend_on_constant_sequence() {
40+
let fees = vec![200.0_f64; 50];
41+
let result = analyze_trend(&fees);
42+
assert_eq!(
43+
result.direction,
44+
TrendDirection::Sideways,
45+
"constant sequence must be Sideways"
46+
);
47+
assert!(
48+
result.slope.abs() < 1e-6,
49+
"slope must be effectively zero, got {}",
50+
result.slope
51+
);
52+
}
53+
54+
#[test]
55+
fn sideways_trend_on_oscillating_sequence() {
56+
// Fees alternating ±5 around a constant mean — no net trend.
57+
let fees: Vec<f64> = (0..40)
58+
.map(|i| if i % 2 == 0 { 300.0 } else { 310.0 })
59+
.collect();
60+
let result = analyze_trend(&fees);
61+
// Slope should be near zero for this symmetric oscillation.
62+
assert!(
63+
result.slope.abs() < 1.0,
64+
"alternating sequence should have near-zero slope, got {}",
65+
result.slope
66+
);
67+
}
68+
69+
#[test]
70+
fn trend_on_empty_sequence_is_sideways_with_zero_slope() {
71+
let result = analyze_trend(&[]);
72+
assert_eq!(result.direction, TrendDirection::Sideways);
73+
assert_eq!(result.slope, 0.0);
74+
assert_eq!(result.r_squared, 0.0);
75+
}
76+
77+
#[test]
78+
fn upward_r_squared_close_to_one_for_perfect_line() {
79+
let fees: Vec<f64> = (0..20).map(|i| 100.0 + i as f64 * 5.0).collect();
80+
let result = analyze_trend(&fees);
81+
assert!(
82+
result.r_squared > 0.99,
83+
"R² should be ~1.0 for a perfect linear sequence, got {}",
84+
result.r_squared
85+
);
86+
}
87+
88+
#[test]
89+
fn mean_is_correct() {
90+
let fees = vec![100.0, 200.0, 300.0];
91+
let result = analyze_trend(&fees);
92+
assert!((result.mean - 200.0).abs() < f64::EPSILON, "mean should be 200");
93+
}
94+
95+
// ---------------------------------------------------------------------------
96+
// Fee velocity calculator — issue #460
97+
// ---------------------------------------------------------------------------
98+
99+
#[test]
100+
fn fee_velocity_known_rate_of_change() {
101+
// Fees rise by 100 stroops per second.
102+
// Timestamps: 0 ms, 1000 ms, 2000 ms (1 s apart).
103+
// Fee values: 0, 100, 200.
104+
let fees: Vec<(u64, u64)> = vec![(0, 0), (1_000, 100), (2_000, 200)];
105+
let v = fee_velocity(&fees, 10);
106+
assert!(
107+
(v - 100.0).abs() < 0.01,
108+
"expected ~100 stroops/sec, got {}",
109+
v
110+
);
111+
}
112+
113+
#[test]
114+
fn fee_velocity_zero_when_fees_flat() {
115+
let fees: Vec<(u64, u64)> = vec![(0, 500), (1_000, 500), (2_000, 500)];
116+
let v = fee_velocity(&fees, 10);
117+
assert!(v.abs() < f64::EPSILON, "flat fees must yield zero velocity");
118+
}
119+
120+
#[test]
121+
fn fee_velocity_declining_fees() {
122+
// Fees drop by 50 stroops per second.
123+
let fees: Vec<(u64, u64)> = vec![(0, 200), (1_000, 150), (2_000, 100)];
124+
let v = fee_velocity(&fees, 10);
125+
assert!(
126+
(v - (-50.0)).abs() < 0.01,
127+
"expected -50 stroops/sec, got {}",
128+
v
129+
);
130+
}
131+
132+
#[test]
133+
fn fee_velocity_empty_slice_is_zero() {
134+
assert_eq!(fee_velocity(&[], 5), 0.0);
135+
}
136+
137+
#[test]
138+
fn fee_velocity_single_point_is_zero() {
139+
assert_eq!(fee_velocity(&[(1000, 200)], 5), 0.0);
140+
}
141+
142+
#[test]
143+
fn fee_velocity_respects_window() {
144+
// Build a long sequence where only the last 2 s have a rate of change.
145+
// Everything before should be filtered out by the window.
146+
let mut fees: Vec<(u64, u64)> = (0..100)
147+
.map(|i| (i as u64 * 100, 200u64)) // flat for 10 s
148+
.collect();
149+
// Add two more points 1 s apart with a rising fee.
150+
let last_ts = fees.last().unwrap().0;
151+
fees.push((last_ts + 1_000, 200));
152+
fees.push((last_ts + 2_000, 400));
153+
// With window_secs=2, only the last two points matter → 200 stroops/sec.
154+
let v = fee_velocity(&fees, 2);
155+
assert!(
156+
(v - 200.0).abs() < 1.0,
157+
"expected ~200 stroops/sec within window, got {}",
158+
v
159+
);
160+
}
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
//! Unit tests for the fee volatility calculators and Bollinger Bands.
2+
//!
3+
//! Covers:
4+
//! - `compute_volatility` — standard deviation and coefficient of variation
5+
//! - CV scale-invariance: CV([1,2,3]) == CV([10,20,30])
6+
//! - Bollinger Bands ordering: upper > sma > lower at every point
7+
//! - Bandwidth > 0 for non-constant sequences
8+
9+
use stellar_devkit::analytics::volatility::{bollinger_bands, compute_volatility};
10+
11+
// ---------------------------------------------------------------------------
12+
// Volatility calculators — issue #461
13+
// ---------------------------------------------------------------------------
14+
15+
#[test]
16+
fn std_dev_on_known_distribution() {
17+
// For values [2, 4, 4, 4, 5, 5, 7, 9], population std dev ≈ 2.0
18+
let fees = vec![2.0, 4.0, 4.0, 4.0, 5.0, 5.0, 7.0, 9.0];
19+
let v = compute_volatility(&fees);
20+
assert!(
21+
(v.standard_deviation - 2.0).abs() < 0.01,
22+
"expected std_dev ≈ 2.0, got {}",
23+
v.standard_deviation
24+
);
25+
}
26+
27+
#[test]
28+
fn std_dev_zero_for_constant_sequence() {
29+
let fees = vec![100.0_f64; 20];
30+
let v = compute_volatility(&fees);
31+
assert!(
32+
v.standard_deviation < f64::EPSILON,
33+
"constant sequence must have zero std_dev"
34+
);
35+
}
36+
37+
#[test]
38+
fn cv_is_scale_invariant() {
39+
let small: Vec<f64> = vec![1.0, 2.0, 3.0];
40+
let large: Vec<f64> = vec![10.0, 20.0, 30.0];
41+
let cv_small = compute_volatility(&small).coefficient_of_variation;
42+
let cv_large = compute_volatility(&large).coefficient_of_variation;
43+
assert!(
44+
(cv_small - cv_large).abs() < 1e-10,
45+
"CV must be scale-invariant: {} vs {}",
46+
cv_small,
47+
cv_large
48+
);
49+
}
50+
51+
#[test]
52+
fn cv_zero_for_constant_sequence() {
53+
let fees = vec![250.0_f64; 10];
54+
let v = compute_volatility(&fees);
55+
assert!(
56+
v.coefficient_of_variation < f64::EPSILON,
57+
"constant sequence must have zero CV"
58+
);
59+
}
60+
61+
#[test]
62+
fn volatility_max_and_min_correct() {
63+
let fees = vec![50.0, 100.0, 200.0, 75.0, 150.0];
64+
let v = compute_volatility(&fees);
65+
assert_eq!(v.max, 200.0);
66+
assert_eq!(v.min, 50.0);
67+
assert_eq!(v.range, 150.0);
68+
}
69+
70+
#[test]
71+
fn volatility_empty_slice_returns_zeros() {
72+
let v = compute_volatility(&[]);
73+
assert_eq!(v.standard_deviation, 0.0);
74+
assert_eq!(v.coefficient_of_variation, 0.0);
75+
assert_eq!(v.max, 0.0);
76+
assert_eq!(v.min, 0.0);
77+
}
78+
79+
// ---------------------------------------------------------------------------
80+
// Bollinger Bands — issue #462
81+
// ---------------------------------------------------------------------------
82+
83+
#[test]
84+
fn bollinger_upper_gt_sma_gt_lower_for_non_constant_sequence() {
85+
let fees: Vec<f64> = (0..50).map(|i| 100.0 + (i as f64 * 7.3).sin() * 30.0).collect();
86+
let bands = bollinger_bands(&fees, 10);
87+
// For any point where std_dev > 0 (non-constant window), upper > sma > lower.
88+
for b in &bands {
89+
if b.bandwidth > f64::EPSILON {
90+
assert!(
91+
b.upper_band > b.sma,
92+
"upper_band ({}) must be > sma ({})",
93+
b.upper_band,
94+
b.sma
95+
);
96+
assert!(
97+
b.sma > b.lower_band,
98+
"sma ({}) must be > lower_band ({})",
99+
b.sma,
100+
b.lower_band
101+
);
102+
}
103+
}
104+
}
105+
106+
#[test]
107+
fn bollinger_bandwidth_positive_for_non_constant() {
108+
let fees: Vec<f64> = (0..30).map(|i| 100.0 + i as f64 * 5.0).collect();
109+
let bands = bollinger_bands(&fees, 5);
110+
// After the first window fills (index >= window-1), bandwidth must be > 0.
111+
for b in bands.iter().skip(4) {
112+
assert!(
113+
b.bandwidth > 0.0,
114+
"bandwidth must be > 0 for non-constant window, got {}",
115+
b.bandwidth
116+
);
117+
}
118+
}
119+
120+
#[test]
121+
fn bollinger_bandwidth_zero_for_constant_sequence() {
122+
let fees = vec![200.0_f64; 20];
123+
let bands = bollinger_bands(&fees, 5);
124+
for b in &bands {
125+
assert!(
126+
b.bandwidth < f64::EPSILON,
127+
"constant sequence must have zero bandwidth, got {}",
128+
b.bandwidth
129+
);
130+
}
131+
}
132+
133+
#[test]
134+
fn bollinger_count_equals_input_length() {
135+
let fees: Vec<f64> = (0..100).map(|i| i as f64).collect();
136+
let bands = bollinger_bands(&fees, 20);
137+
assert_eq!(bands.len(), fees.len());
138+
}
139+
140+
#[test]
141+
fn bollinger_sma_correct_at_full_window() {
142+
// First 5 values: 0, 1, 2, 3, 4 → SMA = 2.0 at index 4 with window=5.
143+
let fees: Vec<f64> = (0..10).map(|i| i as f64).collect();
144+
let bands = bollinger_bands(&fees, 5);
145+
assert!(
146+
(bands[4].sma - 2.0).abs() < 1e-10,
147+
"SMA at index 4 should be 2.0, got {}",
148+
bands[4].sma
149+
);
150+
}
151+
152+
#[test]
153+
fn bollinger_bands_empty_input_returns_empty() {
154+
let bands = bollinger_bands(&[], 10);
155+
assert!(bands.is_empty());
156+
}

0 commit comments

Comments
 (0)