131 lines
3.6 KiB
Rust
131 lines
3.6 KiB
Rust
use cxai_sdk::models::energy::*;
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
/// Market analysis utilities for ERCOT data.
|
|
pub struct MarketAnalyzer;
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct SpreadAnalysis {
|
|
pub avg_dam: f64,
|
|
pub avg_rt: f64,
|
|
pub spread: f64,
|
|
pub max_spread: f64,
|
|
pub arbitrage_opportunities: usize,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct RenewableMix {
|
|
pub wind_mw: f64,
|
|
pub solar_mw: f64,
|
|
pub total_renewable_mw: f64,
|
|
pub demand_mw: f64,
|
|
pub renewable_percentage: f64,
|
|
}
|
|
|
|
impl MarketAnalyzer {
|
|
/// Calculate DAM/RT price spread.
|
|
pub fn spread(dam_prices: &[DamPrice], rt_prices: &[RtPrice]) -> SpreadAnalysis {
|
|
let avg_dam = if dam_prices.is_empty() {
|
|
0.0
|
|
} else {
|
|
dam_prices.iter().map(|p| p.price).sum::<f64>() / dam_prices.len() as f64
|
|
};
|
|
|
|
let avg_rt = if rt_prices.is_empty() {
|
|
0.0
|
|
} else {
|
|
rt_prices.iter().map(|p| p.price).sum::<f64>() / rt_prices.len() as f64
|
|
};
|
|
|
|
let spreads: Vec<f64> = dam_prices
|
|
.iter()
|
|
.zip(rt_prices.iter())
|
|
.map(|(d, r)| (d.price - r.price).abs())
|
|
.collect();
|
|
|
|
let max_spread = spreads.iter().cloned().fold(0.0_f64, f64::max);
|
|
let arbitrage_opportunities = spreads.iter().filter(|&&s| s > 10.0).count();
|
|
|
|
SpreadAnalysis {
|
|
avg_dam,
|
|
avg_rt,
|
|
spread: avg_dam - avg_rt,
|
|
max_spread,
|
|
arbitrage_opportunities,
|
|
}
|
|
}
|
|
|
|
/// Calculate renewable energy mix.
|
|
pub fn renewable_mix(
|
|
wind: &WindGeneration,
|
|
solar: &SolarGeneration,
|
|
demand: &SystemDemand,
|
|
) -> RenewableMix {
|
|
let total = wind.generation_mw + solar.generation_mw;
|
|
let pct = if demand.demand_mw > 0.0 {
|
|
(total / demand.demand_mw) * 100.0
|
|
} else {
|
|
0.0
|
|
};
|
|
|
|
RenewableMix {
|
|
wind_mw: wind.generation_mw,
|
|
solar_mw: solar.generation_mw,
|
|
total_renewable_mw: total,
|
|
demand_mw: demand.demand_mw,
|
|
renewable_percentage: pct,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use chrono::{NaiveDate, Utc};
|
|
|
|
#[test]
|
|
fn spread_calculation() {
|
|
let dam = vec![DamPrice {
|
|
settlement_point: "HB_HOUSTON".into(),
|
|
delivery_date: NaiveDate::from_ymd_opt(2026, 4, 20).unwrap(),
|
|
hour_ending: 14,
|
|
price: 45.0,
|
|
timestamp: None,
|
|
}];
|
|
let rt = vec![RtPrice {
|
|
settlement_point: "HB_HOUSTON".into(),
|
|
price: 52.0,
|
|
interval: "15min".into(),
|
|
timestamp: Utc::now(),
|
|
}];
|
|
|
|
let analysis = MarketAnalyzer::spread(&dam, &rt);
|
|
assert_eq!(analysis.avg_dam, 45.0);
|
|
assert_eq!(analysis.avg_rt, 52.0);
|
|
assert!((analysis.spread - (-7.0)).abs() < f64::EPSILON);
|
|
}
|
|
|
|
#[test]
|
|
fn renewable_mix_calculation() {
|
|
let wind = WindGeneration {
|
|
generation_mw: 15000.0,
|
|
capacity_mw: 40000.0,
|
|
timestamp: Utc::now(),
|
|
};
|
|
let solar = SolarGeneration {
|
|
generation_mw: 8000.0,
|
|
capacity_mw: 20000.0,
|
|
timestamp: Utc::now(),
|
|
};
|
|
let demand = SystemDemand {
|
|
demand_mw: 60000.0,
|
|
timestamp: Utc::now(),
|
|
forecast_mw: None,
|
|
};
|
|
|
|
let mix = MarketAnalyzer::renewable_mix(&wind, &solar, &demand);
|
|
assert_eq!(mix.total_renewable_mw, 23000.0);
|
|
assert!((mix.renewable_percentage - 38.333333333333336).abs() < 0.001);
|
|
}
|
|
}
|