-
-
Save rust-play/72b3dee8fc6e88b048a1945cc43aa721 to your computer and use it in GitHub Desktop.
Code shared from the Rust Playground
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| use chrono::NaiveDate; | |
| use serde::Deserialize; | |
| use std::f64::consts::PI; | |
| use std::io::Cursor; | |
| const CSV_URL: &str = "https://raw.githubusercontent.com/coinmetrics/data/master/csv/btc.csv"; | |
| const GENESIS_DATE: &str = "2009-01-03"; | |
| /// Raw CSV Row matching Coin Metrics structure | |
| #[derive(Debug, Deserialize)] | |
| struct RawMetricsRow { | |
| time: String, | |
| #[serde(rename = "PriceUSD")] | |
| price_usd: Option<f64>, | |
| #[serde(rename = "CapMrktCurUSD")] | |
| cap_mrkt_cur_usd: Option<f64>, | |
| #[serde(rename = "CapMVRVCur")] | |
| cap_mvrv_cur: Option<f64>, | |
| #[serde(rename = "SplyCur")] | |
| sply_cur: Option<f64>, | |
| } | |
| /// Cleaned & Processed Data Point | |
| #[derive(Debug, Clone)] | |
| struct BtcDataPoint { | |
| time: NaiveDate, | |
| day: f64, | |
| price_usd: f64, | |
| cap_mvrv_cur: f64, | |
| rp: f64, // Realised price = aggregate cost basis | |
| centre: f64, | |
| } | |
| fn main() { | |
| println!("Loading Coin Metrics BTC data..."); | |
| let df = match load_data() { | |
| Ok(data) if !data.is_empty() => data, | |
| Err(e) => { | |
| eprintln!("Network or parsing notice: {}", e); | |
| eprintln!("Falling back to embedded baseline dataset for demonstration..."); | |
| load_demo_data() | |
| } | |
| _ => load_demo_data(), | |
| }; | |
| if df.is_empty() { | |
| eprintln!("No valid data found."); | |
| return; | |
| } | |
| // Prepare arrays for Layer 1 fit | |
| let x_vals: Vec<f64> = df.iter().map(|d| d.day.log10()).collect(); | |
| let y_vals: Vec<f64> = df.iter().map(|d| d.price_usd.log10()).collect(); | |
| // ========================================================================= | |
| // LAYER 1: Power-Law Trend (log10 P = a + b * log10(day)) | |
| // ========================================================================= | |
| let (b, a) = polyfit_deg1(&x_vals, &y_vals); | |
| let mut residuals = Vec::with_capacity(df.len()); | |
| let mut df_fitted = df.clone(); | |
| let y_mean = y_vals.iter().sum::<f64>() / y_vals.len() as f64; | |
| let mut ss_res = 0.0; | |
| let mut ss_tot = 0.0; | |
| for (i, row) in df_fitted.iter_mut().enumerate() { | |
| let fit_y = a + b * x_vals[i]; | |
| let resid = y_vals[i] - fit_y; | |
| residuals.push(resid); | |
| row.centre = 10.0_f64.powf(fit_y); | |
| ss_res += resid * resid; | |
| ss_tot += (y_vals[i] - y_mean) * (y_vals[i] - y_mean); | |
| } | |
| let r2 = 1.0 - (ss_res / ss_tot); | |
| let resid_std = (ss_res / residuals.len() as f64).sqrt(); | |
| println!("============================================================"); | |
| println!("LAYER 1 power-law trend"); | |
| println!(" log10 P = {:.4} + {:.4} * log10(day since genesis)", a, b); | |
| println!( | |
| " R^2 = {:.4} residual sd = {:.3} (1 sigma = x{:.2})", | |
| r2, | |
| resid_std, | |
| 10.0_f64.powf(resid_std) | |
| ); | |
| // ========================================================================= | |
| // LAYER 2: Damped Halving Cycle (Fitted on Residuals >= 2012-01-01) | |
| // ========================================================================= | |
| let cutoff_date = NaiveDate::from_ymd_opt(2012, 1, 1).unwrap(); | |
| let sub_data: Vec<&BtcDataPoint> = df_fitted.iter().filter(|d| d.time >= cutoff_date).collect(); | |
| let t_sub: Vec<f64> = sub_data.iter().map(|d| d.day).collect(); | |
| let r_sub: Vec<f64> = sub_data | |
| .iter() | |
| .map(|d| d.price_usd.log10() - d.centre.log10()) | |
| .collect(); | |
| // Fit non-linear curve: f(t) = c + A0 * exp(-lam * t / 365.25) * sin(2*pi*(t - phi)/T) | |
| let p_opt = fit_damped_cycle(&t_sub, &r_sub); | |
| let (a0, lam, t_period, phi, _c_offset) = (p_opt[0], p_opt[1], p_opt[2], p_opt[3], p_opt[4]); | |
| println!("\nLAYER 2 damped halving cycle (fitted on residuals)"); | |
| println!( | |
| " amplitude A0 = {:.3} decay = {:.3}/yr period = {:.0}d ({:.2}yr) phase = {:.0}d", | |
| a0, | |
| lam, | |
| t_period, | |
| t_period / 365.25, | |
| phi | |
| ); | |
| // ========================================================================= | |
| // LAYER 3: Cost-Basis Rails (MVRV at Cycle Tops and Bottoms) | |
| // ========================================================================= | |
| let tops = [ | |
| ("2013", "2013-11-30"), | |
| ("2017", "2017-12-17"), | |
| ("2021", "2021-11-10"), | |
| ("2025", "2025-10-06"), | |
| ]; | |
| let bots = [ | |
| ("2015", "2015-01-14"), | |
| ("2018", "2018-12-15"), | |
| ("2022", "2022-11-21"), | |
| ]; | |
| println!("\nLAYER 3 cost-basis rails"); | |
| println!(" cycle tops (MVRV = price / aggregate cost basis):"); | |
| let mut td_tops = Vec::new(); | |
| let mut tm_tops = Vec::new(); | |
| for (year, date_str) in tops.iter() { | |
| if let Some(row) = find_nearest_date(&df_fitted, date_str) { | |
| let mult = row.price_usd / row.centre; | |
| println!( | |
| " {}: MVRV {:.2} price/trend {:.2}", | |
| year, row.cap_mvrv_cur, mult | |
| ); | |
| td_tops.push(row.day); | |
| tm_tops.push(mult); | |
| } | |
| } | |
| println!(" cycle bottoms:"); | |
| for (year, date_str) in bots.iter() { | |
| if let Some(row) = find_nearest_date(&df_fitted, date_str) { | |
| println!( | |
| " {}: MVRV {:.2} price/trend {:.2}", | |
| year, | |
| row.cap_mvrv_cur, | |
| row.price_usd / row.centre | |
| ); | |
| } | |
| } | |
| // Declining ceiling envelope fitted to price/trend at tops: mult = 1 + exp(p + q*day) | |
| let y_ceiling: Vec<f64> = tm_tops.iter().map(|&tm| (tm - 1.0).max(1e-4).ln()).collect(); | |
| let (q, p) = polyfit_deg1(&td_tops, &y_ceiling); | |
| println!( | |
| "\n ceiling multiple = 1 + exp({:.4} {:+.6}*day) (decays toward 1: peaks merge into trend)", | |
| p, q | |
| ); | |
| println!(" floor multiple ~ 0.45 x trend (price at aggregate cost basis, where capitulation exhausts supply)"); | |
| // ========================================================================= | |
| // LATEST READING | |
| // ========================================================================= | |
| if let Some(now) = df_fitted.last() { | |
| println!( | |
| "\nLATEST ({}): price ${} fair ${} cost basis ${} MVRV {:.2}", | |
| now.time.format("%Y-%m-%d"), | |
| format_usd(now.price_usd), | |
| format_usd(now.centre), | |
| format_usd(now.rp), | |
| now.cap_mvrv_cur | |
| ); | |
| } | |
| println!("============================================================"); | |
| } | |
| // ============================================================================= | |
| // Helper Math & Data Processing Functions | |
| // ============================================================================= | |
| /// Polynomial fit of degree 1 (Simple Linear Regression: y = intercept + slope * x) | |
| /// Returns (slope, intercept) | |
| fn polyfit_deg1(x: &[f64], y: &[f64]) -> (f64, f64) { | |
| let n = x.len() as f64; | |
| let sum_x: f64 = x.iter().sum(); | |
| let sum_y: f64 = y.iter().sum(); | |
| let sum_xy: f64 = x.iter().zip(y.iter()).map(|(&xi, &yi)| xi * yi).sum(); | |
| let sum_xx: f64 = x.iter().map(|&xi| xi * xi).sum(); | |
| let slope = (n * sum_xy - sum_x * sum_y) / (n * sum_xx - sum_x * sum_x); | |
| let intercept = (sum_y - slope * sum_x) / n; | |
| (slope, intercept) | |
| } | |
| /// Finds row closest to target YYYY-MM-DD date string | |
| fn find_nearest_date<'a>(df: &'a [BtcDataPoint], target_date_str: &str) -> Option<&'a BtcDataPoint> { | |
| let target = NaiveDate::parse_from_str(target_date_str, "%Y-%m-%d").ok()?; | |
| df.iter() | |
| .min_by_key(|row| (row.time - target).num_days().abs()) | |
| } | |
| /// Format USD values with comma separators | |
| fn format_usd(val: f64) -> String { | |
| let rounded = val.round() as i64; | |
| let s = rounded.to_string(); | |
| let mut result = String::new(); | |
| let mut count = 0; | |
| for ch in s.chars().rev() { | |
| if count > 0 && count % 3 == 0 && ch != '-' { | |
| result.push(','); | |
| } | |
| result.push(ch); | |
| count += 1; | |
| } | |
| result.chars().rev().collect() | |
| } | |
| /// Pure-Rust Nelder-Mead Optimization for Layer 2 Non-Linear Least Squares | |
| fn fit_damped_cycle(t: &[f64], r: &[f64]) -> [f64; 5] { | |
| let p0 = [0.6, 0.15, 1430.0, 1000.0, 0.0]; | |
| let bounds_min = [0.1, 0.0, 1200.0, -2000.0, -0.3]; | |
| let bounds_max = [1.5, 0.6, 1700.0, 4000.0, 0.3]; | |
| let loss = |p: &[f64; 5]| -> f64 { | |
| let mut penalty = 0.0; | |
| for i in 0..5 { | |
| if p[i] < bounds_min[i] { | |
| let diff = bounds_min[i] - p[i]; | |
| penalty += 1e6 * diff * diff; | |
| } else if p[i] > bounds_max[i] { | |
| let diff = p[i] - bounds_max[i]; | |
| penalty += 1e6 * diff * diff; | |
| } | |
| } | |
| let (a0, lam, period, phi, c) = (p[0], p[1], p[2], p[3], p[4]); | |
| let mut sq_err = 0.0; | |
| for (&ti, &ri) in t.iter().zip(r.iter()) { | |
| let pred = c + a0 * (-lam * ti / 365.25).exp() * ((2.0 * PI * (ti - phi)) / period).sin(); | |
| let err = ri - pred; | |
| sq_err += err * err; | |
| } | |
| sq_err + penalty | |
| }; | |
| let dim = 5; | |
| let num_v = dim + 1; | |
| let mut v = vec![p0; num_v]; | |
| let steps = [0.1, 0.02, 50.0, 100.0, 0.05]; | |
| for i in 0..dim { | |
| v[i + 1][i] += steps[i]; | |
| } | |
| let mut f_vals: Vec<f64> = v.iter().map(|pt| loss(pt)).collect(); | |
| for _ in 0..25_000 { | |
| let mut order: Vec<usize> = (0..num_v).collect(); | |
| order.sort_by(|&a, &b| f_vals[a].partial_cmp(&f_vals[b]).unwrap()); | |
| let mut v_sorted = vec![[0.0; 5]; num_v]; | |
| let mut f_sorted = vec![0.0; num_v]; | |
| for i in 0..num_v { | |
| v_sorted[i] = v[order[i]]; | |
| f_sorted[i] = f_vals[order[i]]; | |
| } | |
| v = v_sorted; | |
| f_vals = f_sorted; | |
| let mut centroid = [0.0; 5]; | |
| for i in 0..dim { | |
| for d in 0..5 { | |
| centroid[d] += v[i][d]; | |
| } | |
| } | |
| for d in 0..5 { | |
| centroid[d] /= dim as f64; | |
| } | |
| let mut xr = [0.0; 5]; | |
| for d in 0..5 { | |
| xr[d] = centroid[d] + 1.0 * (centroid[d] - v[dim][d]); | |
| } | |
| let fxr = loss(&xr); | |
| if fxr < f_vals[0] { | |
| let mut xe = [0.0; 5]; | |
| for d in 0..5 { | |
| xe[d] = centroid[d] + 2.0 * (xr[d] - centroid[d]); | |
| } | |
| let fxe = loss(&xe); | |
| if fxe < fxr { | |
| v[dim] = xe; | |
| f_vals[dim] = fxe; | |
| } else { | |
| v[dim] = xr; | |
| f_vals[dim] = fxr; | |
| } | |
| } else if fxr < f_vals[dim - 1] { | |
| v[dim] = xr; | |
| f_vals[dim] = fxr; | |
| } else { | |
| let mut xc = [0.0; 5]; | |
| let alpha = if fxr < f_vals[dim] { 0.5 } else { -0.5 }; | |
| for d in 0..5 { | |
| xc[d] = centroid[d] + alpha * (xr[d] - centroid[d]); | |
| } | |
| let fxc = loss(&xc); | |
| if fxc < f_vals[dim] { | |
| v[dim] = xc; | |
| f_vals[dim] = fxc; | |
| } else { | |
| for i in 1..num_v { | |
| for d in 0..5 { | |
| v[i][d] = v[0][d] + 0.5 * (v[i][d] - v[0][d]); | |
| } | |
| f_vals[i] = loss(&v[i]); | |
| } | |
| } | |
| } | |
| } | |
| v[0] | |
| } | |
| /// Fetch real CSV via HTTP or return error | |
| fn fetch_csv() -> Result<String, Box<dyn std::error::Error>> { | |
| let client = reqwest::blocking::Client::builder() | |
| .timeout(std::time::Duration::from_secs(15)) | |
| .build()?; | |
| let text = client.get(CSV_URL).send()?.text()?; | |
| Ok(text) | |
| } | |
| /// Parse CSV raw string into structured Bitcoin data | |
| fn load_data() -> Result<Vec<BtcDataPoint>, Box<dyn std::error::Error>> { | |
| let raw_csv = fetch_csv()?; | |
| parse_csv_content(&raw_csv) | |
| } | |
| fn parse_csv_content(raw_csv: &str) -> Result<Vec<BtcDataPoint>, Box<dyn std::error::Error>> { | |
| let genesis = NaiveDate::parse_from_str(GENESIS_DATE, "%Y-%m-%d")?; | |
| let mut rdr = csv::ReaderBuilder::new().from_reader(Cursor::new(raw_csv)); | |
| let mut data = Vec::new(); | |
| for result in rdr.deserialize() { | |
| let row: RawMetricsRow = match result { | |
| Ok(r) => r, | |
| Err(_) => continue, | |
| }; | |
| if let (Some(price), Some(cap_mrkt), Some(mvrv), Some(sply)) = ( | |
| row.price_usd, | |
| row.cap_mrkt_cur_usd, | |
| row.cap_mvrv_cur, | |
| row.sply_cur, | |
| ) { | |
| if price <= 0.0 || mvrv <= 0.0 || sply <= 0.0 { | |
| continue; | |
| } | |
| let date_str = if row.time.len() >= 10 { | |
| &row.time[..10] | |
| } else { | |
| &row.time | |
| }; | |
| if let Ok(time) = NaiveDate::parse_from_str(date_str, "%Y-%m-%d") { | |
| let day = (time - genesis).num_days() as f64; | |
| if day > 0.0 { | |
| let rp = cap_mrkt / mvrv / sply; | |
| data.push(BtcDataPoint { | |
| time, | |
| day, | |
| price_usd: price, | |
| cap_mvrv_cur: mvrv, | |
| rp, | |
| centre: 0.0, | |
| }); | |
| } | |
| } | |
| } | |
| } | |
| Ok(data) | |
| } | |
| /// Embedded fallback data generator for offline/playground environments | |
| fn load_demo_data() -> Vec<BtcDataPoint> { | |
| let genesis = NaiveDate::parse_from_str(GENESIS_DATE, "%Y-%m-%d").unwrap(); | |
| let dates = [ | |
| ("2012-01-01", 5.25, 1.12, 7200000.0, 37000000.0), | |
| ("2013-11-30", 1125.0, 5.60, 13500000000.0, 12000000.0), | |
| ("2015-01-14", 172.0, 0.72, 2350000000.0, 13700000.0), | |
| ("2017-12-17", 19783.0, 4.80, 330000000000.0, 16700000.0), | |
| ("2018-12-15", 3200.0, 0.70, 55000000000.0, 17400000.0), | |
| ("2021-11-10", 69000.0, 4.00, 1280000000000.0, 18800000.0), | |
| ("2022-11-21", 15800.0, 0.82, 300000000000.0, 19200000.0), | |
| ("2025-10-06", 125000.0, 2.80, 2400000000000.0, 19700000.0), | |
| ]; | |
| dates | |
| .iter() | |
| .map(|(d_str, p, mvrv, cap, sply)| { | |
| let time = NaiveDate::parse_from_str(d_str, "%Y-%m-%d").unwrap(); | |
| let day = (time - genesis).num_days() as f64; | |
| BtcDataPoint { | |
| time, | |
| day, | |
| price_usd: *p, | |
| cap_mvrv_cur: *mvrv, | |
| rp: cap / mvrv / sply, | |
| centre: 0.0, | |
| } | |
| }) | |
| .collect() | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| version = "1" |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment