Skip to content

Commit ef18eb7

Browse files
authored
Merge pull request #524 from abdulrcrtw/feat/sandbox-scaffold-fixtures-env
feat(devkit/sandbox): scaffold sandbox module, normal network fixture, in-memory env, and analytics docs
2 parents a718afb + 47a21e7 commit ef18eb7

6 files changed

Lines changed: 360 additions & 0 deletions

File tree

packages/devkit/README.md

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,3 +240,107 @@ Benchmarks compile and run on every PR touching `packages/devkit/` via the [Devk
240240
[dev-dependencies]
241241
stellar-devkit = { path = "../devkit" }
242242
```
243+
244+
## Fee Analytics
245+
246+
The `analytics` module provides composable, zero-dependency fee analysis functions for trend detection, volatility measurement, correlation, forecasting, and regime change detection.
247+
248+
### Modules
249+
250+
| Sub-module | Key exports | Description |
251+
|---|---|---|
252+
| `analytics::trend` | `analyze_trend`, `fee_velocity`, `trend_strength_score` | Linear regression trend detection and rate-of-change measurement |
253+
| `analytics::volatility` | `compute_volatility`, `bollinger_bands`, `coefficient_of_variation` | Standard deviation, CV, and Bollinger Bands (SMA ± 2σ) |
254+
| `analytics::correlation` | `pearson_correlation`, `autocorrelation`, `cross_correlation` | Pearson correlation, lag-based autocorrelation, fee-capacity correlation |
255+
| `analytics::forecaster` | `forecast`, `forecast_linear`, `forecast_holt`, `confidence_intervals` | Linear extrapolation, Holt double-exponential smoothing, and CI bands |
256+
| `analytics::regime` | `detect_regime_change`, `ks_statistic` | KS-statistic regime shift detector (flags when KS > 0.3) |
257+
258+
### Trend Detection
259+
260+
```rust
261+
use stellar_devkit::analytics::trend::{analyze_trend, fee_velocity, trend_strength_score, TrendDirection};
262+
263+
let fees: Vec<f64> = (0..100).map(|i| 100.0 + i as f64 * 5.0).collect();
264+
let trend = analyze_trend(&fees);
265+
assert_eq!(trend.direction, TrendDirection::Upward);
266+
println!("R²: {:.3}", trend.r_squared); // ~1.0 for perfect line
267+
println!("Strength: {:.3}", trend_strength_score(&fees)); // same as r_squared
268+
269+
// Rate of change in stroops/sec
270+
let timestamped: Vec<(u64, u64)> = (0..10)
271+
.map(|i| (i as u64 * 1_000, 100 + i as u64 * 50))
272+
.collect();
273+
let velocity = fee_velocity(&timestamped, 30);
274+
println!("Velocity: {:.1} stroops/sec", velocity);
275+
```
276+
277+
### Volatility Measures
278+
279+
```rust
280+
use stellar_devkit::analytics::volatility::{compute_volatility, bollinger_bands, coefficient_of_variation};
281+
282+
let fees: Vec<f64> = (0..200).map(|i| 200.0 + (i as f64 * 0.3).sin() * 50.0).collect();
283+
284+
let v = compute_volatility(&fees);
285+
println!("Std dev: {:.2}", v.standard_deviation);
286+
println!("CV: {:.4}", v.coefficient_of_variation); // scale-invariant
287+
288+
// Standalone CV function
289+
let cv = coefficient_of_variation(&fees);
290+
291+
// Bollinger Bands with window=20
292+
let bands = bollinger_bands(&fees, 20);
293+
for b in &bands {
294+
assert!(b.upper_band >= b.sma && b.sma >= b.lower_band);
295+
}
296+
```
297+
298+
### Forecasting
299+
300+
```rust
301+
use stellar_devkit::analytics::forecaster::{forecast_linear, forecast_holt, confidence_intervals};
302+
303+
let fees: Vec<f64> = (0..50).map(|i| 100.0 + i as f64 * 3.0).collect();
304+
305+
// Linear extrapolation
306+
let linear = forecast_linear(&fees, 10);
307+
308+
// Holt's double exponential smoothing (alpha=0.3, beta=0.1)
309+
let holt = forecast_holt(&fees, 10, 0.3, 0.1);
310+
311+
// 80% and 95% confidence intervals around the linear forecast
312+
let cis = confidence_intervals(&linear, /* residual_variance= */ 25.0);
313+
println!("Next step: {:.1} ± {:.1} (95%)", cis[0].predicted, cis[0].upper_95 - cis[0].predicted);
314+
```
315+
316+
### Regime Change Detection
317+
318+
```rust
319+
use stellar_devkit::analytics::regime::detect_regime_change;
320+
321+
// Rolling 1-hour window vs 24-hour baseline
322+
let fees_1h: Vec<f64> = vec![50_000.0; 100]; // sustained spike
323+
let fees_24h: Vec<f64> = (0..1000).map(|_| 200.0).collect(); // normal baseline
324+
325+
if detect_regime_change(&fees_1h, &fees_24h) {
326+
println!("Regime change detected — fee distribution has fundamentally shifted");
327+
}
328+
```
329+
330+
### Correlation
331+
332+
```rust
333+
use stellar_devkit::analytics::correlation::{pearson_correlation, autocorrelation, cross_correlation};
334+
335+
let fees: Vec<f64> = (0..100).map(|i| 100.0 + (i as f64 * 0.2).sin() * 50.0).collect();
336+
let capacity: Vec<f64> = (0..100).map(|i| 0.5 + (i as f64 * 0.2).sin() * 0.3).collect();
337+
338+
// Fee-capacity cross-correlation
339+
let r = cross_correlation(&fees, &capacity);
340+
println!("Fee-capacity Pearson r: {:.3}", r.pearson_r);
341+
342+
// Autocorrelation at lag 5
343+
let ac = autocorrelation(&fees, 5);
344+
println!("Autocorrelation at lag 5: {:.3}", ac);
345+
assert_eq!(autocorrelation(&fees, 0), 1.0); // lag=0 is always 1.0
346+
```

packages/devkit/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ pub mod data_quality;
55
pub mod error;
66
pub mod harness;
77
pub mod monitoring;
8+
pub mod sandbox;
89
pub mod simulation;
910
pub mod test_helpers;
1011
pub mod types;
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
//! In-memory fee database for the sandbox environment.
2+
//!
3+
//! [`SandboxEnv`] holds 10,000 synthetic fee records spanning a 24-hour window
4+
//! covering Normal, Rising, Congested, and Spike network scenarios.
5+
//! No external database dependencies are required.
6+
7+
use crate::sandbox::fixtures;
8+
9+
/// Network regime represented by a block of fee records.
10+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11+
pub enum Regime {
12+
/// Quiet network — fees 100–500 stroops.
13+
Normal,
14+
/// Fees gradually climbing above the normal band.
15+
Rising,
16+
/// Sustained high demand; fees significantly elevated.
17+
Congested,
18+
/// Short-lived burst; fees spike then recover.
19+
Spike,
20+
}
21+
22+
/// A single fee observation stored in the sandbox database.
23+
#[derive(Debug, Clone, PartialEq, Eq)]
24+
pub struct FeeRecord {
25+
/// Milliseconds since the Unix epoch.
26+
pub timestamp_ms: u64,
27+
/// Fee amount in stroops.
28+
pub fee_stroops: u64,
29+
/// Network regime this record belongs to.
30+
pub regime: Regime,
31+
}
32+
33+
/// In-memory fee database pre-seeded with 10,000 [`FeeRecord`]s spanning 24 hours.
34+
///
35+
/// Records are partitioned evenly across four [`Regime`] scenarios so tests can
36+
/// exercise the full range of network behaviour without hitting live infrastructure.
37+
///
38+
/// # Example
39+
/// ```rust
40+
/// use stellar_devkit::sandbox::environment::SandboxEnv;
41+
///
42+
/// let mut env = SandboxEnv::new();
43+
/// assert_eq!(env.records().len(), 10_000);
44+
/// env.reset();
45+
/// assert_eq!(env.records().len(), 10_000);
46+
/// ```
47+
pub struct SandboxEnv {
48+
records: Vec<FeeRecord>,
49+
}
50+
51+
impl SandboxEnv {
52+
/// Creates a new [`SandboxEnv`] and seeds it with 10,000 records.
53+
pub fn new() -> Self {
54+
let mut env = Self { records: Vec::with_capacity(10_000) };
55+
env.seed();
56+
env
57+
}
58+
59+
/// Seeds the database with 10,000 fee records spanning 24 hours.
60+
pub fn seed(&mut self) {
61+
self.records.clear();
62+
const ANCHOR_MS: u64 = 1_753_315_200_000;
63+
const DAY_MS: u64 = 86_400_000;
64+
let start_ms = ANCHOR_MS.saturating_sub(DAY_MS);
65+
let per_regime: u64 = 2_500;
66+
let regime_duration_ms = DAY_MS / 4;
67+
68+
let scenarios: &[(Regime, u64)] = &[
69+
(Regime::Normal, 0),
70+
(Regime::Rising, 1),
71+
(Regime::Congested, 2),
72+
(Regime::Spike, 3),
73+
];
74+
75+
for &(regime, quarter) in scenarios {
76+
let seg_start = start_ms + quarter * regime_duration_ms;
77+
let interval_ms = regime_duration_ms / per_regime;
78+
for i in 0..per_regime {
79+
let timestamp_ms = seg_start + i * interval_ms;
80+
let fee_stroops = match regime {
81+
Regime::Normal => 100 + (i * 400 / per_regime),
82+
Regime::Rising => 400 + (i * 1_100 / per_regime),
83+
Regime::Congested => 1_000 + (i * 4_000 / per_regime),
84+
Regime::Spike => if i % 50 == 0 { 50_000 } else { 100 + (i % 50) * 4 },
85+
};
86+
self.records.push(FeeRecord { timestamp_ms, fee_stroops, regime });
87+
}
88+
}
89+
}
90+
91+
/// Discards all records and re-seeds the database from scratch.
92+
pub fn reset(&mut self) {
93+
self.seed();
94+
}
95+
96+
/// Returns a read-only slice of all fee records.
97+
pub fn records(&self) -> &[FeeRecord] {
98+
&self.records
99+
}
100+
101+
/// Returns a mutable reference to the underlying record list.
102+
pub fn records_mut(&mut self) -> &mut Vec<FeeRecord> {
103+
&mut self.records
104+
}
105+
106+
/// Returns only the records belonging to the given [`Regime`].
107+
pub fn records_for_regime(&self, regime: Regime) -> Vec<&FeeRecord> {
108+
self.records.iter().filter(|r| r.regime == regime).collect()
109+
}
110+
111+
/// Total number of fee records currently in the database.
112+
pub fn len(&self) -> usize {
113+
self.records.len()
114+
}
115+
116+
/// Returns `true` if the database contains no records.
117+
pub fn is_empty(&self) -> bool {
118+
self.records.is_empty()
119+
}
120+
121+
/// Creates a [`SandboxEnv`] seeded from the normal-network fixture.
122+
pub fn from_normal_fixture() -> Self {
123+
let raw = fixtures::normal_network();
124+
let records = raw
125+
.into_iter()
126+
.map(|(timestamp_ms, fee_stroops)| FeeRecord {
127+
timestamp_ms,
128+
fee_stroops,
129+
regime: Regime::Normal,
130+
})
131+
.collect();
132+
Self { records }
133+
}
134+
}
135+
136+
impl Default for SandboxEnv {
137+
fn default() -> Self {
138+
Self::new()
139+
}
140+
}
141+
142+
#[cfg(test)]
143+
mod tests {
144+
use super::*;
145+
146+
#[test]
147+
fn seed_produces_10k_records() {
148+
let env = SandboxEnv::new();
149+
assert_eq!(env.len(), 10_000);
150+
}
151+
152+
#[test]
153+
fn each_regime_has_2500_records() {
154+
let env = SandboxEnv::new();
155+
for regime in [Regime::Normal, Regime::Rising, Regime::Congested, Regime::Spike] {
156+
assert_eq!(env.records_for_regime(regime).len(), 2_500);
157+
}
158+
}
159+
160+
#[test]
161+
fn reset_restores_10k_records() {
162+
let mut env = SandboxEnv::new();
163+
env.records_mut().clear();
164+
env.reset();
165+
assert_eq!(env.len(), 10_000);
166+
}
167+
168+
#[test]
169+
fn from_normal_fixture_has_10k_records() {
170+
let env = SandboxEnv::from_normal_fixture();
171+
assert_eq!(env.len(), 10_000);
172+
}
173+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
//! Fixture generators for the sandbox.
2+
//!
3+
//! Each function returns a [`Vec`] of `(timestamp_ms, fee_stroops)` tuples
4+
//! representing a particular network condition. All fixtures are fully
5+
//! deterministic — no RNG seed is required.
6+
7+
use std::f64::consts::PI;
8+
9+
/// Generates 10,000 fee records representing a quiet Stellar testnet session.
10+
///
11+
/// Fees are clustered in the **100–500 stroop** range with gentle sinusoidal
12+
/// variation. No spike events are included. Timestamps are spaced ~8.64 seconds
13+
/// apart spanning exactly 24 hours.
14+
pub fn normal_network() -> Vec<(u64, u64)> {
15+
const ANCHOR_MS: u64 = 1_753_315_200_000;
16+
const COUNT: usize = 10_000;
17+
const DAY_MS: u64 = 86_400_000;
18+
let interval_ms = DAY_MS / COUNT as u64;
19+
20+
(0..COUNT)
21+
.map(|i| {
22+
let timestamp_ms = ANCHOR_MS + i as u64 * interval_ms;
23+
let fi = i as f64;
24+
let wave1 = 120.0 * (2.0 * PI * fi / 137.0).sin();
25+
let wave2 = 80.0 * (2.0 * PI * fi / 89.0).sin();
26+
let raw = 200.0 + wave1 + wave2;
27+
let fee = (raw.round() as i64).clamp(100, 500) as u64;
28+
(timestamp_ms, fee)
29+
})
30+
.collect()
31+
}
32+
33+
#[cfg(test)]
34+
mod tests {
35+
use super::*;
36+
37+
#[test]
38+
fn normal_network_count() {
39+
assert_eq!(normal_network().len(), 10_000);
40+
}
41+
42+
#[test]
43+
fn normal_network_fees_in_range() {
44+
for (_, fee) in normal_network() {
45+
assert!(fee >= 100 && fee <= 500, "fee out of range: {fee}");
46+
}
47+
}
48+
49+
#[test]
50+
fn normal_network_timestamps_ascending() {
51+
let records = normal_network();
52+
for w in records.windows(2) {
53+
assert!(w[0].0 < w[1].0);
54+
}
55+
}
56+
}

packages/devkit/src/sandbox/mod.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
pub mod environment;
2+
pub mod fixtures;
3+
pub mod runner;
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
//! Synchronous test runner for the sandbox environment.
2+
3+
use crate::sandbox::environment::SandboxEnv;
4+
5+
/// Runs `f` with a freshly-seeded [`SandboxEnv`] and returns its result.
6+
pub fn run<F, T>(f: F) -> T
7+
where
8+
F: FnOnce(&SandboxEnv) -> T,
9+
{
10+
let env = SandboxEnv::new();
11+
f(&env)
12+
}
13+
14+
#[cfg(test)]
15+
mod tests {
16+
use super::*;
17+
18+
#[test]
19+
fn run_receives_seeded_env() {
20+
let count = run(|env| env.len());
21+
assert_eq!(count, 10_000);
22+
}
23+
}

0 commit comments

Comments
 (0)