590 lines
19 KiB
Rust
590 lines
19 KiB
Rust
//! 历史跟踪系统
|
||
//!
|
||
//! 提供估值历史记录、趋势分析、数据可视化和导出功能
|
||
|
||
use rust_decimal::Decimal;
|
||
use serde::{Deserialize, Serialize};
|
||
use anyhow::{Result, Context};
|
||
use std::collections::VecDeque;
|
||
use chrono::{DateTime, Utc};
|
||
use std::fs::File;
|
||
use std::io::Write;
|
||
use std::path::Path;
|
||
|
||
use crate::{FinalValuationResult, Jurisdiction, InternationalAgreement};
|
||
|
||
/// 历史记录条目
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct ValuationHistoryEntry {
|
||
/// 资产ID
|
||
pub asset_id: String,
|
||
/// 辖区
|
||
pub jurisdiction: Jurisdiction,
|
||
/// 国际协定
|
||
pub agreement: InternationalAgreement,
|
||
/// 估值结果
|
||
pub result: FinalValuationResult,
|
||
/// 记录时间
|
||
pub timestamp: DateTime<Utc>,
|
||
/// 用户ID(可选)
|
||
pub user_id: Option<String>,
|
||
/// 备注(可选)
|
||
pub notes: Option<String>,
|
||
}
|
||
|
||
impl ValuationHistoryEntry {
|
||
/// 创建新的历史记录
|
||
pub fn new(
|
||
asset_id: String,
|
||
jurisdiction: Jurisdiction,
|
||
agreement: InternationalAgreement,
|
||
result: FinalValuationResult,
|
||
) -> Self {
|
||
Self {
|
||
asset_id,
|
||
jurisdiction,
|
||
agreement,
|
||
result,
|
||
timestamp: Utc::now(),
|
||
user_id: None,
|
||
notes: None,
|
||
}
|
||
}
|
||
|
||
/// 设置用户ID
|
||
pub fn with_user_id(mut self, user_id: String) -> Self {
|
||
self.user_id = Some(user_id);
|
||
self
|
||
}
|
||
|
||
/// 设置备注
|
||
pub fn with_notes(mut self, notes: String) -> Self {
|
||
self.notes = Some(notes);
|
||
self
|
||
}
|
||
}
|
||
|
||
/// 历史记录存储
|
||
pub struct ValuationHistory {
|
||
/// 内存存储(最近的记录)
|
||
memory_storage: VecDeque<ValuationHistoryEntry>,
|
||
/// 最大内存记录数
|
||
max_memory_entries: usize,
|
||
/// 持久化文件路径
|
||
persistence_path: Option<String>,
|
||
}
|
||
|
||
impl ValuationHistory {
|
||
/// 创建新的历史记录存储
|
||
pub fn new(max_memory_entries: usize) -> Self {
|
||
Self {
|
||
memory_storage: VecDeque::new(),
|
||
max_memory_entries,
|
||
persistence_path: None,
|
||
}
|
||
}
|
||
|
||
/// 设置持久化路径
|
||
pub fn with_persistence(mut self, path: String) -> Self {
|
||
self.persistence_path = Some(path);
|
||
self
|
||
}
|
||
|
||
/// 添加历史记录
|
||
pub fn add(&mut self, entry: ValuationHistoryEntry) -> Result<()> {
|
||
// 添加到内存存储
|
||
self.memory_storage.push_back(entry.clone());
|
||
|
||
// 如果超过最大数量,移除最旧的记录
|
||
if self.memory_storage.len() > self.max_memory_entries {
|
||
self.memory_storage.pop_front();
|
||
}
|
||
|
||
// 持久化到文件
|
||
if let Some(ref path) = self.persistence_path {
|
||
self.persist_entry(&entry, path)?;
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
/// 持久化单条记录到文件
|
||
fn persist_entry(&self, entry: &ValuationHistoryEntry, path: &str) -> Result<()> {
|
||
let mut file = std::fs::OpenOptions::new()
|
||
.create(true)
|
||
.append(true)
|
||
.open(path)
|
||
.context("打开持久化文件失败")?;
|
||
|
||
let json = serde_json::to_string(entry)?;
|
||
writeln!(file, "{}", json)?;
|
||
|
||
Ok(())
|
||
}
|
||
|
||
/// 获取指定资产的所有历史记录
|
||
pub fn get_by_asset(&self, asset_id: &str) -> Vec<ValuationHistoryEntry> {
|
||
self.memory_storage
|
||
.iter()
|
||
.filter(|entry| entry.asset_id == asset_id)
|
||
.cloned()
|
||
.collect()
|
||
}
|
||
|
||
/// 获取指定时间范围内的历史记录
|
||
pub fn get_by_time_range(
|
||
&self,
|
||
start: DateTime<Utc>,
|
||
end: DateTime<Utc>,
|
||
) -> Vec<ValuationHistoryEntry> {
|
||
self.memory_storage
|
||
.iter()
|
||
.filter(|entry| entry.timestamp >= start && entry.timestamp <= end)
|
||
.cloned()
|
||
.collect()
|
||
}
|
||
|
||
/// 获取最近N条记录
|
||
pub fn get_recent(&self, count: usize) -> Vec<ValuationHistoryEntry> {
|
||
self.memory_storage
|
||
.iter()
|
||
.rev()
|
||
.take(count)
|
||
.cloned()
|
||
.collect()
|
||
}
|
||
|
||
/// 获取所有记录
|
||
pub fn get_all(&self) -> Vec<ValuationHistoryEntry> {
|
||
self.memory_storage.iter().cloned().collect()
|
||
}
|
||
|
||
/// 清空内存存储
|
||
pub fn clear(&mut self) {
|
||
self.memory_storage.clear();
|
||
}
|
||
|
||
/// 从持久化文件加载历史记录
|
||
pub fn load_from_file(&mut self, path: &str) -> Result<usize> {
|
||
let content = std::fs::read_to_string(path)
|
||
.context("读取持久化文件失败")?;
|
||
|
||
let mut count = 0;
|
||
for line in content.lines() {
|
||
if line.trim().is_empty() {
|
||
continue;
|
||
}
|
||
|
||
match serde_json::from_str::<ValuationHistoryEntry>(line) {
|
||
Ok(entry) => {
|
||
self.memory_storage.push_back(entry);
|
||
count += 1;
|
||
|
||
// 保持最大数量限制
|
||
if self.memory_storage.len() > self.max_memory_entries {
|
||
self.memory_storage.pop_front();
|
||
}
|
||
}
|
||
Err(e) => {
|
||
log::warn!("解析历史记录失败: {}", e);
|
||
}
|
||
}
|
||
}
|
||
|
||
Ok(count)
|
||
}
|
||
}
|
||
|
||
/// 趋势分析结果
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct TrendAnalysis {
|
||
/// 资产ID
|
||
pub asset_id: String,
|
||
/// 分析时间范围
|
||
pub time_range: (DateTime<Utc>, DateTime<Utc>),
|
||
/// 数据点数量
|
||
pub data_points: usize,
|
||
/// 平均估值
|
||
pub average_valuation: Decimal,
|
||
/// 最小估值
|
||
pub min_valuation: Decimal,
|
||
/// 最大估值
|
||
pub max_valuation: Decimal,
|
||
/// 标准差
|
||
pub std_deviation: f64,
|
||
/// 变化率(%)
|
||
pub change_rate: f64,
|
||
/// 趋势方向
|
||
pub trend_direction: TrendDirection,
|
||
/// 波动性
|
||
pub volatility: f64,
|
||
/// 置信度平均值
|
||
pub avg_confidence: f64,
|
||
}
|
||
|
||
/// 趋势方向
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub enum TrendDirection {
|
||
/// 上升
|
||
Upward,
|
||
/// 下降
|
||
Downward,
|
||
/// 稳定
|
||
Stable,
|
||
/// 波动
|
||
Volatile,
|
||
}
|
||
|
||
/// 趋势分析器
|
||
pub struct TrendAnalyzer;
|
||
|
||
impl TrendAnalyzer {
|
||
/// 分析资产估值趋势
|
||
pub fn analyze(entries: &[ValuationHistoryEntry]) -> Result<TrendAnalysis> {
|
||
if entries.is_empty() {
|
||
anyhow::bail!("没有历史数据可供分析");
|
||
}
|
||
|
||
let asset_id = entries[0].asset_id.clone();
|
||
|
||
// 按时间排序
|
||
let mut sorted_entries = entries.to_vec();
|
||
sorted_entries.sort_by_key(|e| e.timestamp);
|
||
|
||
let valuations: Vec<Decimal> = sorted_entries
|
||
.iter()
|
||
.map(|e| e.result.valuation_xtzh)
|
||
.collect();
|
||
|
||
let confidences: Vec<f64> = sorted_entries
|
||
.iter()
|
||
.map(|e| e.result.confidence)
|
||
.collect();
|
||
|
||
// 计算统计指标
|
||
let average_valuation = Self::calculate_average(&valuations);
|
||
let min_valuation = valuations.iter().min().expect("mainnet: handle error").clone();
|
||
let max_valuation = valuations.iter().max().expect("mainnet: handle error").clone();
|
||
let std_deviation = Self::calculate_std_deviation(&valuations, average_valuation);
|
||
|
||
// 计算变化率
|
||
let first_val = valuations.first().expect("mainnet: handle error");
|
||
let last_val = valuations.last().expect("mainnet: handle error");
|
||
let change_rate = if *first_val > Decimal::ZERO {
|
||
((*last_val - *first_val) / *first_val * Decimal::new(100, 0))
|
||
.to_string()
|
||
.parse::<f64>()
|
||
.unwrap_or(0.0)
|
||
} else {
|
||
0.0
|
||
};
|
||
|
||
// 判断趋势方向
|
||
let trend_direction = Self::determine_trend_direction(change_rate, std_deviation);
|
||
|
||
// 计算波动性
|
||
let volatility = Self::calculate_volatility(&valuations);
|
||
|
||
// 计算平均置信度
|
||
let avg_confidence = confidences.iter().sum::<f64>() / confidences.len() as f64;
|
||
|
||
let time_range = (
|
||
sorted_entries.first().expect("mainnet: handle error").timestamp,
|
||
sorted_entries.last().expect("mainnet: handle error").timestamp,
|
||
);
|
||
|
||
Ok(TrendAnalysis {
|
||
asset_id,
|
||
time_range,
|
||
data_points: entries.len(),
|
||
average_valuation,
|
||
min_valuation,
|
||
max_valuation,
|
||
std_deviation,
|
||
change_rate,
|
||
trend_direction,
|
||
volatility,
|
||
avg_confidence,
|
||
})
|
||
}
|
||
|
||
/// 计算平均值
|
||
fn calculate_average(values: &[Decimal]) -> Decimal {
|
||
let sum: Decimal = values.iter().sum();
|
||
sum / Decimal::new(values.len() as i64, 0)
|
||
}
|
||
|
||
/// 计算标准差
|
||
fn calculate_std_deviation(values: &[Decimal], mean: Decimal) -> f64 {
|
||
let variance: f64 = values
|
||
.iter()
|
||
.map(|v| {
|
||
let diff = (*v - mean).to_string().parse::<f64>().unwrap_or(0.0);
|
||
diff * diff
|
||
})
|
||
.sum::<f64>() / values.len() as f64;
|
||
|
||
variance.sqrt()
|
||
}
|
||
|
||
/// 判断趋势方向
|
||
fn determine_trend_direction(change_rate: f64, std_deviation: f64) -> TrendDirection {
|
||
if std_deviation > 1000.0 {
|
||
TrendDirection::Volatile
|
||
} else if change_rate > 5.0 {
|
||
TrendDirection::Upward
|
||
} else if change_rate < -5.0 {
|
||
TrendDirection::Downward
|
||
} else {
|
||
TrendDirection::Stable
|
||
}
|
||
}
|
||
|
||
/// 计算波动性
|
||
fn calculate_volatility(values: &[Decimal]) -> f64 {
|
||
if values.len() < 2 {
|
||
return 0.0;
|
||
}
|
||
|
||
let returns: Vec<f64> = values
|
||
.windows(2)
|
||
.map(|w| {
|
||
let prev = w[0].to_string().parse::<f64>().unwrap_or(1.0);
|
||
let curr = w[1].to_string().parse::<f64>().unwrap_or(1.0);
|
||
(curr - prev) / prev
|
||
})
|
||
.collect();
|
||
|
||
let mean_return = returns.iter().sum::<f64>() / returns.len() as f64;
|
||
let variance = returns
|
||
.iter()
|
||
.map(|r| (r - mean_return).powi(2))
|
||
.sum::<f64>() / returns.len() as f64;
|
||
|
||
variance.sqrt()
|
||
}
|
||
}
|
||
|
||
/// 数据导出器
|
||
pub struct DataExporter;
|
||
|
||
impl DataExporter {
|
||
/// 导出为JSON格式
|
||
pub fn export_json(entries: &[ValuationHistoryEntry], path: &Path) -> Result<()> {
|
||
let json = serde_json::to_string_pretty(entries)?;
|
||
let mut file = File::create(path)?;
|
||
file.write_all(json.as_bytes())?;
|
||
Ok(())
|
||
}
|
||
|
||
/// 导出为CSV格式
|
||
pub fn export_csv(entries: &[ValuationHistoryEntry], path: &Path) -> Result<()> {
|
||
let mut file = File::create(path)?;
|
||
|
||
// 写入CSV头
|
||
writeln!(
|
||
file,
|
||
"Timestamp,Asset ID,Jurisdiction,Agreement,Valuation (XTZH),Confidence,Requires Review,Notes"
|
||
)?;
|
||
|
||
// 写入数据行
|
||
for entry in entries {
|
||
writeln!(
|
||
file,
|
||
"{},{},{:?},{:?},{},{},{},{}",
|
||
entry.timestamp.to_rfc3339(),
|
||
entry.asset_id,
|
||
entry.jurisdiction,
|
||
entry.agreement,
|
||
entry.result.valuation_xtzh,
|
||
entry.result.confidence,
|
||
entry.result.requires_human_review,
|
||
entry.notes.as_deref().unwrap_or(""),
|
||
)?;
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
/// 导出为Markdown报告
|
||
pub fn export_markdown(
|
||
entries: &[ValuationHistoryEntry],
|
||
trend: &TrendAnalysis,
|
||
path: &Path,
|
||
) -> Result<()> {
|
||
let mut file = File::create(path)?;
|
||
|
||
writeln!(file, "# NAC资产估值历史报告\n")?;
|
||
writeln!(file, "## 趋势分析\n")?;
|
||
writeln!(file, "- **资产ID**: {}", trend.asset_id)?;
|
||
writeln!(file, "- **数据点数量**: {}", trend.data_points)?;
|
||
writeln!(file, "- **时间范围**: {} 至 {}",
|
||
trend.time_range.0.format("%Y-%m-%d %H:%M:%S"),
|
||
trend.time_range.1.format("%Y-%m-%d %H:%M:%S")
|
||
)?;
|
||
writeln!(file, "- **平均估值**: {} XTZH", trend.average_valuation)?;
|
||
writeln!(file, "- **估值范围**: {} - {} XTZH", trend.min_valuation, trend.max_valuation)?;
|
||
writeln!(file, "- **标准差**: {:.2}", trend.std_deviation)?;
|
||
writeln!(file, "- **变化率**: {:.2}%", trend.change_rate)?;
|
||
writeln!(file, "- **趋势方向**: {:?}", trend.trend_direction)?;
|
||
writeln!(file, "- **波动性**: {:.4}", trend.volatility)?;
|
||
writeln!(file, "- **平均置信度**: {:.1}%\n", trend.avg_confidence * 100.0)?;
|
||
|
||
writeln!(file, "## 历史记录\n")?;
|
||
writeln!(file, "| 时间 | 估值 (XTZH) | 置信度 | 需要审核 |")?;
|
||
writeln!(file, "|------|-------------|--------|----------|")?;
|
||
|
||
for entry in entries {
|
||
writeln!(
|
||
file,
|
||
"| {} | {} | {:.1}% | {} |",
|
||
entry.timestamp.format("%Y-%m-%d %H:%M"),
|
||
entry.result.valuation_xtzh,
|
||
entry.result.confidence * 100.0,
|
||
if entry.result.requires_human_review { "是" } else { "否" }
|
||
)?;
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
/// 导出为HTML可视化报告
|
||
pub fn export_html(
|
||
entries: &[ValuationHistoryEntry],
|
||
trend: &TrendAnalysis,
|
||
path: &Path,
|
||
) -> Result<()> {
|
||
let mut file = File::create(path)?;
|
||
|
||
writeln!(file, "<!DOCTYPE html>")?;
|
||
writeln!(file, "<html lang='zh-CN'>")?;
|
||
writeln!(file, "<head>")?;
|
||
writeln!(file, " <meta charset='UTF-8'>")?;
|
||
writeln!(file, " <meta name='viewport' content='width=device-width, initial-scale=1.0'>")?;
|
||
writeln!(file, " <title>NAC资产估值历史报告</title>")?;
|
||
writeln!(file, " <script src='https://cdn.jsdelivr.net/npm/chart.js'></script>")?;
|
||
writeln!(file, " <style>")?;
|
||
writeln!(file, " body {{ font-family: Arial, sans-serif; margin: 20px; }}")?;
|
||
writeln!(file, " h1 {{ color: #333; }}")?;
|
||
writeln!(file, " .stats {{ display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px; margin: 20px 0; }}")?;
|
||
writeln!(file, " .stat-card {{ background: #f5f5f5; padding: 15px; border-radius: 5px; }}")?;
|
||
writeln!(file, " .stat-label {{ font-size: 14px; color: #666; }}")?;
|
||
writeln!(file, " .stat-value {{ font-size: 24px; font-weight: bold; color: #333; }}")?;
|
||
writeln!(file, " canvas {{ max-width: 100%; height: 400px; }}")?;
|
||
writeln!(file, " </style>")?;
|
||
writeln!(file, "</head>")?;
|
||
writeln!(file, "<body>")?;
|
||
writeln!(file, " <h1>NAC资产估值历史报告</h1>")?;
|
||
|
||
writeln!(file, " <div class='stats'>")?;
|
||
writeln!(file, " <div class='stat-card'>")?;
|
||
writeln!(file, " <div class='stat-label'>平均估值</div>")?;
|
||
writeln!(file, " <div class='stat-value'>{} XTZH</div>", trend.average_valuation)?;
|
||
writeln!(file, " </div>")?;
|
||
writeln!(file, " <div class='stat-card'>")?;
|
||
writeln!(file, " <div class='stat-label'>变化率</div>")?;
|
||
writeln!(file, " <div class='stat-value'>{:.2}%</div>", trend.change_rate)?;
|
||
writeln!(file, " </div>")?;
|
||
writeln!(file, " <div class='stat-card'>")?;
|
||
writeln!(file, " <div class='stat-label'>平均置信度</div>")?;
|
||
writeln!(file, " <div class='stat-value'>{:.1}%</div>", trend.avg_confidence * 100.0)?;
|
||
writeln!(file, " </div>")?;
|
||
writeln!(file, " </div>")?;
|
||
|
||
writeln!(file, " <canvas id='valuationChart'></canvas>")?;
|
||
|
||
writeln!(file, " <script>")?;
|
||
writeln!(file, " const ctx = document.getElementById('valuationChart').getContext('2d');")?;
|
||
writeln!(file, " const chart = new Chart(ctx, {{")?;
|
||
writeln!(file, " type: 'line',")?;
|
||
writeln!(file, " data: {{")?;
|
||
writeln!(file, " labels: [")?;
|
||
for entry in entries {
|
||
writeln!(file, " '{}',", entry.timestamp.format("%m-%d %H:%M"))?;
|
||
}
|
||
writeln!(file, " ],")?;
|
||
writeln!(file, " datasets: [{{")?;
|
||
writeln!(file, " label: '估值 (XTZH)',")?;
|
||
writeln!(file, " data: [")?;
|
||
for entry in entries {
|
||
writeln!(file, " {},", entry.result.valuation_xtzh)?;
|
||
}
|
||
writeln!(file, " ],")?;
|
||
writeln!(file, " borderColor: 'rgb(75, 192, 192)',")?;
|
||
writeln!(file, " tension: 0.1")?;
|
||
writeln!(file, " }}]")?;
|
||
writeln!(file, " }},")?;
|
||
writeln!(file, " options: {{")?;
|
||
writeln!(file, " responsive: true,")?;
|
||
writeln!(file, " plugins: {{")?;
|
||
writeln!(file, " title: {{ display: true, text: '估值趋势图' }}")?;
|
||
writeln!(file, " }}")?;
|
||
writeln!(file, " }}")?;
|
||
writeln!(file, " }});")?;
|
||
writeln!(file, " </script>")?;
|
||
writeln!(file, "</body>")?;
|
||
writeln!(file, "</html>")?;
|
||
|
||
Ok(())
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use std::collections::HashMap;
|
||
|
||
fn create_test_entry(valuation: i64) -> ValuationHistoryEntry {
|
||
ValuationHistoryEntry::new(
|
||
"test_asset".to_string(),
|
||
Jurisdiction::US,
|
||
InternationalAgreement::WTO,
|
||
FinalValuationResult {
|
||
valuation_xtzh: Decimal::new(valuation, 0),
|
||
confidence: 0.85,
|
||
model_results: vec![],
|
||
weights: HashMap::new(),
|
||
is_anomaly: false,
|
||
anomaly_report: None,
|
||
divergence_report: "Test".to_string(),
|
||
requires_human_review: false,
|
||
},
|
||
)
|
||
}
|
||
|
||
#[test]
|
||
fn test_history_storage() {
|
||
let mut history = ValuationHistory::new(10);
|
||
|
||
let entry = create_test_entry(1000000);
|
||
history.add(entry.clone()).expect("mainnet: handle error");
|
||
|
||
let entries = history.get_by_asset("test_asset");
|
||
assert_eq!(entries.len(), 1);
|
||
assert_eq!(entries[0].result.valuation_xtzh, Decimal::new(1000000, 0));
|
||
}
|
||
|
||
#[test]
|
||
fn test_trend_analysis() {
|
||
let entries = vec![
|
||
create_test_entry(1000000),
|
||
create_test_entry(1050000),
|
||
create_test_entry(1100000),
|
||
create_test_entry(1150000),
|
||
];
|
||
|
||
let trend = TrendAnalyzer::analyze(&entries).expect("mainnet: handle error");
|
||
assert_eq!(trend.data_points, 4);
|
||
assert!(trend.change_rate > 0.0); // 上升趋势
|
||
}
|
||
|
||
#[test]
|
||
fn test_data_export_json() {
|
||
let entries = vec![create_test_entry(1000000)];
|
||
let path = Path::new("/tmp/test_export.json");
|
||
|
||
let result = DataExporter::export_json(&entries, path);
|
||
assert!(result.is_ok());
|
||
}
|
||
}
|