feat: complete Rust migration with modular architecture

Major changes:
- Migrate Python/shell scripts to Rust modules:
  - analytics/extract.py → src/analytics/extract.rs (ReportExtractor)
  - analytics/analyze.py → src/analytics/analyze.rs (DealAnalyzer)
  - scripts/mqlcompile.sh → src/compile/mql_compiler.rs (MqlCompiler)
  - scripts/backtest_pipeline.sh → src/pipeline/backtest.rs (BacktestPipeline)

- New modular structure:
  - src/models/ - Config, Deal, Metrics, Report structs
  - src/analytics/ - Report parsing and deal analysis
  - src/compile/ - MQL5 compilation via Wine
  - src/pipeline/ - 5-stage backtest orchestration
  - src/tools/ - 27 MCP tool definitions and handlers

- Remove PyInstaller setup (now pure Rust)
- Remove migrated shell scripts (backtest_pipeline.sh, mqlcompile.sh)
- Add GitHub Actions CI/CD for macOS & Linux releases
- Update all documentation for Rust architecture

Binary size: 4.3MB (no Python dependencies)
Tools: 27 MCP tools fully functional
This commit is contained in:
Devid HW
2026-04-18 15:07:08 +07:00
parent 41792b1292
commit a3b046c68f
37 changed files with 3809 additions and 1587 deletions
+491
View File
@@ -0,0 +1,491 @@
use chrono::{DateTime, Datelike, NaiveDateTime};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use crate::models::deals::{Deal, DrawdownEvent, LossSequence, MonthlyPnl, PositionPair};
use crate::models::metrics::Metrics;
pub struct DealAnalyzer;
impl DealAnalyzer {
pub fn new() -> Self {
Self
}
pub fn analyze(&self, deals: &[Deal], metrics: &Metrics) -> AnalysisResult {
let monthly = self.monthly_pnl(deals);
let dd_events = self.reconstruct_dd_events(deals, metrics);
let top_losses = self.top_losses(deals, 10);
let loss_sequences = self.loss_sequences(deals);
let pairs = self.position_pairs(deals);
let bias = self.direction_bias(deals);
let streak = self.streak_analysis(deals);
let concurrent = self.concurrent_peak(deals);
AnalysisResult {
monthly,
dd_events,
top_losses,
loss_sequences,
position_pairs: pairs,
direction_bias: bias,
streak_analysis: streak,
concurrent_peak: concurrent,
}
}
fn monthly_pnl(&self, deals: &[Deal]) -> Vec<MonthlyPnl> {
let mut monthly: HashMap<String, (f64, i32)> = HashMap::new();
for deal in deals {
let time_str = &deal.time;
let profit = deal.profit;
let entry = deal.entry.to_lowercase();
if !entry.contains("out") && !entry.is_empty() {
continue;
}
if time_str.is_empty() || profit == 0.0 {
continue;
}
if let Some(dt) = Self::parse_datetime(time_str) {
let month = dt.format("%Y-%m").to_string();
let entry = monthly.entry(month).or_insert((0.0, 0));
entry.0 += profit;
entry.1 += 1;
}
}
let mut result: Vec<MonthlyPnl> = monthly
.into_iter()
.map(|(m, (pnl, trades))| MonthlyPnl {
month: m,
pnl: (pnl * 100.0).round() / 100.0,
trades,
green: pnl >= 0.0,
})
.collect();
result.sort_by(|a, b| a.month.cmp(&b.month));
result
}
fn reconstruct_dd_events(&self, deals: &[Deal], _metrics: &Metrics) -> Vec<DrawdownEvent> {
let mut balance_curve = Vec::new();
let mut peak_balance: f64 = 0.0;
let mut initial_balance: Option<f64> = None;
for deal in deals {
let balance = deal.balance;
if balance > 0.0 {
if initial_balance.is_none() {
initial_balance = Some(balance);
}
peak_balance = peak_balance.max(balance);
let dd_pct = if peak_balance > 0.0 {
(peak_balance - balance) / peak_balance * 100.0
} else {
0.0
};
balance_curve.push((deal.time.clone(), balance, dd_pct, deal.profit, deal.comment.clone()));
}
}
if balance_curve.is_empty() {
return Vec::new();
}
let mut events = Vec::new();
let mut in_dd = false;
let mut dd_start_idx = 0_usize;
let threshold = 1.0;
for (i, (_, _, dd_pct, _, _)) in balance_curve.iter().enumerate() {
if !in_dd && *dd_pct > threshold {
in_dd = true;
dd_start_idx = i;
} else if in_dd && *dd_pct < threshold {
let peak_idx = (dd_start_idx..=i)
.max_by(|a, b| {
let (_, _, dd_pct_a, _, _) = balance_curve[*a];
let (_, _, dd_pct_b, _, _) = balance_curve[*b];
dd_pct_a.partial_cmp(&dd_pct_b).unwrap()
})
.unwrap_or(dd_start_idx);
let event = self.build_dd_event(&balance_curve, dd_start_idx, peak_idx, Some(i));
if event.peak_dd_pct > 1.0 {
events.push(event);
}
in_dd = false;
}
}
if in_dd {
let peak_idx = (dd_start_idx..balance_curve.len())
.max_by(|a, b| {
let (_, _, dd_pct_a, _, _) = balance_curve[*a];
let (_, _, dd_pct_b, _, _) = balance_curve[*b];
dd_pct_a.partial_cmp(&dd_pct_b).unwrap()
})
.unwrap_or(dd_start_idx);
let event = self.build_dd_event(&balance_curve, dd_start_idx, peak_idx, None);
if event.peak_dd_pct > 1.0 {
events.push(event);
}
}
events.sort_by(|a, b| b.peak_dd_pct.partial_cmp(&a.peak_dd_pct).unwrap());
events.truncate(10);
events
}
fn build_dd_event(&self, curve: &[(String, f64, f64, f64, String)], start_idx: usize, peak_idx: usize, recovery_idx: Option<usize>) -> DrawdownEvent {
let (start_time, _, _, _, _) = &curve[start_idx];
let (peak_time, _, peak_dd, _, _) = &curve[peak_idx];
let mut event = DrawdownEvent {
peak_dd_pct: (*peak_dd * 100.0).round() / 100.0,
start_date: Self::extract_date(start_time),
end_date: Self::extract_date(peak_time),
recovery_date: None,
recovery_days: None,
duration_days: 0,
cause: "unknown".to_string(),
};
if let Some(rec_idx) = recovery_idx {
let (rec_time, _, _, _, _) = &curve[rec_idx];
event.recovery_date = Some(Self::extract_date(rec_time));
if let (Ok(start_dt), Ok(rec_dt)) = (
chrono::NaiveDate::parse_from_str(&event.start_date, "%Y-%m-%d"),
chrono::NaiveDate::parse_from_str(event.recovery_date.as_ref().unwrap(), "%Y-%m-%d")
) {
event.recovery_days = Some((rec_dt - start_dt).num_days() as i32);
}
}
if let (Ok(start_dt), Ok(end_dt)) = (
chrono::NaiveDate::parse_from_str(&event.start_date, "%Y-%m-%d"),
chrono::NaiveDate::parse_from_str(&event.end_date, "%Y-%m-%d")
) {
event.duration_days = (end_dt - start_dt).num_days() as i32;
}
event
}
fn top_losses(&self, deals: &[Deal], n: usize) -> Vec<LossEntry> {
let mut losses: Vec<LossEntry> = deals
.iter()
.filter(|d| d.profit < 0.0)
.map(|d| LossEntry {
date: Self::extract_date(&d.time),
loss_usd: (d.profit * 100.0).round() / 100.0,
comment: d.comment.clone(),
grid_depth_at_close: self.extract_layer(&d.comment),
volume: d.volume,
})
.collect();
losses.sort_by(|a, b| a.loss_usd.partial_cmp(&b.loss_usd).unwrap());
losses.truncate(n);
losses
}
fn loss_sequences(&self, deals: &[Deal]) -> Vec<LossSequence> {
let closed: Vec<&Deal> = deals
.iter()
.filter(|d| d.entry.to_lowercase().contains("out") && d.profit != 0.0)
.collect();
if closed.is_empty() {
return Vec::new();
}
let mut sequences = Vec::new();
let mut current_seq: Vec<&Deal> = Vec::new();
for deal in closed {
if deal.profit < 0.0 {
current_seq.push(deal);
} else {
if current_seq.len() >= 2 {
let total: f64 = current_seq.iter().map(|d| d.profit).sum();
sequences.push(LossSequence {
length: current_seq.len() as i32,
total_loss: (total * 100.0).round() / 100.0,
start: Self::extract_date(&current_seq[0].time),
end: Self::extract_date(&current_seq[current_seq.len() - 1].time),
});
}
current_seq.clear();
}
}
if current_seq.len() >= 2 {
let total: f64 = current_seq.iter().map(|d| d.profit).sum();
sequences.push(LossSequence {
length: current_seq.len() as i32,
total_loss: (total * 100.0).round() / 100.0,
start: Self::extract_date(&current_seq[0].time),
end: Self::extract_date(&current_seq[current_seq.len() - 1].time),
});
}
sequences.sort_by(|a, b| a.total_loss.partial_cmp(&b.total_loss).unwrap());
sequences.truncate(5);
sequences
}
fn position_pairs(&self, deals: &[Deal]) -> Vec<PositionPair> {
let mut open_pos: HashMap<String, &Deal> = HashMap::new();
let mut pairs = Vec::new();
for deal in deals {
let order = &deal.order;
let entry = deal.entry.to_lowercase();
if entry.contains("in") && !entry.contains("out") {
open_pos.insert(order.clone(), deal);
} else if entry.contains("out") && deal.profit != 0.0 {
if let Some(in_deal) = open_pos.remove(order) {
if let (Some(dt_out), Some(dt_in)) = (
Self::parse_datetime(&deal.time),
Self::parse_datetime(&in_deal.time)
) {
let hold_minutes = (dt_out - dt_in).num_seconds() as f64 / 60.0;
pairs.push(PositionPair {
time: deal.time.clone(),
deal_type: deal.deal_type.clone(),
profit: deal.profit,
volume: deal.volume,
layer: self.extract_layer(&deal.comment),
hold_minutes: Some((hold_minutes * 10.0).round() / 10.0),
comment: deal.comment.clone(),
magic: deal.magic.clone().unwrap_or_default(),
order: order.clone(),
});
}
}
}
}
pairs
}
fn direction_bias(&self, deals: &[Deal]) -> HashMap<String, DirectionStats> {
let mut stats: HashMap<String, (i32, i32, f64)> = HashMap::new();
stats.insert("buy".to_string(), (0, 0, 0.0));
stats.insert("sell".to_string(), (0, 0, 0.0));
for deal in deals {
let entry = deal.entry.to_lowercase();
if !entry.contains("out") || deal.profit == 0.0 {
continue;
}
let d = deal.deal_type.to_lowercase();
if let Some((trades, wins, total_pnl)) = stats.get_mut(&d) {
*trades += 1;
*total_pnl += deal.profit;
if deal.profit > 0.0 {
*wins += 1;
}
}
}
stats
.into_iter()
.filter(|(_, (trades, _, _))| *trades > 0)
.map(|(d, (trades, wins, total_pnl))| {
let avg_pnl = if trades > 0 { total_pnl / trades as f64 } else { 0.0 };
(d, DirectionStats {
trades,
win_rate: ((wins as f64 / trades as f64) * 1000.0).round() / 10.0,
total_pnl: (total_pnl * 100.0).round() / 100.0,
avg_pnl: (avg_pnl * 100.0).round() / 100.0,
})
})
.collect()
}
fn streak_analysis(&self, deals: &[Deal]) -> StreakAnalysis {
let closed: Vec<&Deal> = deals
.iter()
.filter(|d| d.entry.to_lowercase().contains("out") && d.profit != 0.0)
.collect();
if closed.is_empty() {
return StreakAnalysis::default();
}
let (mut max_win_streak, mut max_loss_streak) = (0, 0);
let (mut cur_win, mut cur_loss) = (0, 0);
let (mut max_win_start, mut max_win_end) = (String::new(), String::new());
let (mut max_loss_start, mut max_loss_end) = (String::new(), String::new());
let (mut win_run_start, mut loss_run_start) = (String::new(), String::new());
for deal in closed.iter() {
let profit = deal.profit;
let t = Self::extract_date(&deal.time);
if profit > 0.0 {
if cur_win == 0 {
win_run_start = t.clone();
}
cur_win += 1;
cur_loss = 0;
if cur_win > max_win_streak {
max_win_streak = cur_win;
max_win_start = win_run_start.clone();
max_win_end = t.clone();
}
} else {
if cur_loss == 0 {
loss_run_start = t.clone();
}
cur_loss += 1;
cur_win = 0;
if cur_loss > max_loss_streak {
max_loss_streak = cur_loss;
max_loss_start = loss_run_start.clone();
max_loss_end = t.clone();
}
}
}
let last = closed.last().unwrap();
StreakAnalysis {
max_win_streak,
max_win_start,
max_win_end,
max_loss_streak,
max_loss_start,
max_loss_end,
current_streak: if last.profit > 0.0 { cur_win } else { cur_loss },
current_streak_type: if last.profit > 0.0 { "win".to_string() } else { "loss".to_string() },
}
}
fn concurrent_peak(&self, deals: &[Deal]) -> ConcurrentPeak {
let mut events: Vec<(DateTime<chrono::Utc>, i32, &Deal)> = Vec::new();
for deal in deals {
let entry = deal.entry.to_lowercase();
if let Some(dt) = Self::parse_datetime(&deal.time) {
if entry.contains("in") && !entry.contains("out") {
events.push((dt, 1, deal));
} else if entry.contains("out") {
events.push((dt, -1, deal));
}
}
}
events.sort_by(|a, b| a.0.cmp(&b.0));
let mut count = 0;
let mut peak = 0;
let mut peak_time = String::new();
for (dt, delta, deal) in events {
count = (count + delta).max(0);
if count > peak {
peak = count;
peak_time = deal.time.clone();
}
}
ConcurrentPeak { peak_open: peak, peak_time }
}
fn parse_datetime(time_str: &str) -> Option<DateTime<chrono::Utc>> {
let s = time_str.trim();
let formats = [
"%Y.%m.%d %H:%M:%S",
"%Y-%m-%d %H:%M:%S",
"%Y.%m.%d",
"%Y-%m-%d",
];
for fmt in &formats {
if let Ok(dt) = NaiveDateTime::parse_from_str(&s[..s.len().min(19)], fmt) {
return Some(DateTime::from_naive_utc_and_offset(dt, chrono::Utc));
}
}
None
}
fn extract_date(time_str: &str) -> String {
if time_str.len() >= 10 {
time_str[..10].replace('.', "-")
} else {
time_str.to_string()
}
}
fn extract_layer(&self, comment: &str) -> i32 {
let re = regex::Regex::new(r"[Ll]ayer\s*#?(\d+)").ok();
if let Some(re) = re {
re.captures(comment)
.and_then(|cap| cap.get(1))
.and_then(|m| m.as_str().parse().ok())
.unwrap_or(0)
} else {
0
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AnalysisResult {
pub monthly: Vec<MonthlyPnl>,
pub dd_events: Vec<DrawdownEvent>,
pub top_losses: Vec<LossEntry>,
pub loss_sequences: Vec<LossSequence>,
pub position_pairs: Vec<PositionPair>,
pub direction_bias: HashMap<String, DirectionStats>,
pub streak_analysis: StreakAnalysis,
pub concurrent_peak: ConcurrentPeak,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LossEntry {
pub date: String,
pub loss_usd: f64,
pub comment: String,
pub grid_depth_at_close: i32,
pub volume: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DirectionStats {
pub trades: i32,
pub win_rate: f64,
pub total_pnl: f64,
pub avg_pnl: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct StreakAnalysis {
pub max_win_streak: i32,
pub max_win_start: String,
pub max_win_end: String,
pub max_loss_streak: i32,
pub max_loss_start: String,
pub max_loss_end: String,
pub current_streak: i32,
pub current_streak_type: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConcurrentPeak {
pub peak_open: i32,
pub peak_time: String,
}
+289
View File
@@ -0,0 +1,289 @@
use anyhow::{anyhow, Result};
use std::collections::HashMap;
use std::fs::{self, File};
use std::io::Write;
use std::path::{Path, PathBuf};
use crate::models::{Deal, Metrics};
pub struct ReportExtractor;
impl ReportExtractor {
pub fn new() -> Self {
Self
}
pub fn extract(&self, report_path: &str, output_dir: &str) -> Result<ExtractionResult> {
let format = Self::detect_format(report_path);
let (metrics, deals) = match format {
ReportFormat::Xml => self.parse_xml(report_path)?,
ReportFormat::Html => self.parse_html(report_path)?,
};
fs::create_dir_all(output_dir)?;
let metrics_path = Path::new(output_dir).join("metrics.json");
let deals_csv_path = Path::new(output_dir).join("deals.csv");
let deals_json_path = Path::new(output_dir).join("deals.json");
self.write_metrics(&metrics, &metrics_path)?;
self.write_deals_json(&deals, &deals_json_path)?;
self.write_deals_csv(&deals, &deals_csv_path)?;
Ok(ExtractionResult {
metrics,
deals,
metrics_path,
deals_csv_path,
deals_json_path,
})
}
fn detect_format(path: &str) -> ReportFormat {
if path.ends_with(".xml") || path.ends_with(".htm.xml") {
return ReportFormat::Xml;
}
if let Ok(file) = fs::read(path) {
let header = &file[..file.len().min(512)];
if header.windows(5).any(|w| w == b"<?xml") || header.windows(8).any(|w| w == b"Workbook") {
return ReportFormat::Xml;
}
}
ReportFormat::Html
}
fn parse_html(&self, path: &str) -> Result<(Metrics, Vec<Deal>)> {
let text = Self::read_text(path)?;
let metrics = Metrics::from_html(&text)
.ok_or_else(|| anyhow!("No metrics found in HTML report"))?;
let deals = self.parse_deals_html(&text)?;
Ok((metrics, deals))
}
fn parse_xml(&self, path: &str) -> Result<(Metrics, Vec<Deal>)> {
let text = Self::read_text(path)?;
let metrics = Metrics::from_html(&text)
.unwrap_or_default();
let deals = self.parse_deals_xml(&text)?;
Ok((metrics, deals))
}
fn parse_deals_html(&self, text: &str) -> Result<Vec<Deal>> {
let mut deals = Vec::new();
let re = regex::Regex::new(r"<tr[^>]*>.*?Deal.*?Time.*?Type.*?Direction.*?</tr>(.*)")
.map_err(|e| anyhow!("Regex error: {}", e))?;
if let Some(captures) = re.captures(text) {
let section = captures.get(1).map(|m| m.as_str()).unwrap_or("");
let row_re = regex::Regex::new(r"<tr[^>]*>(.*?)</tr>")
.map_err(|e| anyhow!("Regex error: {}", e))?;
for row_caps in row_re.captures_iter(section) {
let row = row_caps.get(1).map(|m| m.as_str()).unwrap_or("");
let cell_re = regex::Regex::new(r"<td[^>]*>(.*?)</td>")
.map_err(|e| anyhow!("Regex error: {}", e))?;
let cells: Vec<String> = cell_re.captures_iter(row)
.filter_map(|cap| cap.get(1))
.map(|m| Self::strip_tags(m.as_str()))
.map(|s| s.replace(',', ""))
.collect();
if cells.len() < 3 || cells[0].is_empty() {
continue;
}
if cells.iter().take(5).any(|c| {
let c_lower = c.trim().to_lowercase();
c_lower == "balance" || c_lower == "credit"
}) {
continue;
}
let deal = Deal {
time: cells.get(0).cloned().unwrap_or_default(),
deal: cells.get(1).cloned().unwrap_or_default(),
symbol: cells.get(2).cloned().unwrap_or_default(),
deal_type: cells.get(3).cloned().unwrap_or_default(),
entry: cells.get(4).cloned().unwrap_or_default(),
volume: cells.get(5).and_then(|s| s.parse().ok()).unwrap_or(0.0),
price: cells.get(6).and_then(|s| s.parse().ok()).unwrap_or(0.0),
order: cells.get(7).cloned().unwrap_or_default(),
commission: cells.get(8).and_then(|s| s.parse().ok()).unwrap_or(0.0),
swap: cells.get(9).and_then(|s| s.parse().ok()).unwrap_or(0.0),
profit: cells.get(10).and_then(|s| s.parse().ok()).unwrap_or(0.0),
balance: cells.get(11).and_then(|s| s.parse().ok()).unwrap_or(0.0),
comment: cells.get(12).cloned().unwrap_or_default(),
magic: cells.get(13).cloned(),
};
deals.push(deal);
}
}
Ok(deals)
}
fn parse_deals_xml(&self, text: &str) -> Result<Vec<Deal>> {
let mut deals = Vec::new();
let mut header_found = false;
let mut col_map: HashMap<usize, String> = HashMap::new();
let row_re = regex::Regex::new(r"<Row[^>]*>(.*?)</Row>")
.map_err(|e| anyhow!("Regex error: {}", e))?;
let cell_re = regex::Regex::new(r"<Cell[^>]*>.*?<Data[^>]*>(.*?)</Data>.*?</Cell>")
.map_err(|e| anyhow!("Regex error: {}", e))?;
for row_caps in row_re.captures_iter(text) {
let row = row_caps.get(1).map(|m| m.as_str()).unwrap_or("");
let cells: Vec<String> = cell_re.captures_iter(row)
.filter_map(|cap| cap.get(1))
.map(|m| Self::strip_tags(m.as_str()).replace(',', ""))
.collect();
if !header_found {
let row_str = cells.join("").to_lowercase();
if row_str.contains("time") || row_str.contains("type") || row_str.contains("volume") {
header_found = true;
for (i, h) in cells.iter().enumerate() {
let h_lower = h.to_lowercase().trim().to_string();
let deal_columns = ["time", "deal", "symbol", "type", "entry", "volume", "price", "order", "commission", "swap", "profit", "balance", "comment"];
for col in &deal_columns {
if h_lower.contains(col) || col.contains(&h_lower) {
col_map.insert(i, col.to_string());
break;
}
}
}
continue;
}
}
if cells.is_empty() || cells[0].is_empty() {
continue;
}
let mut deal_map: HashMap<String, String> = HashMap::new();
for (i, val) in cells.iter().enumerate() {
if let Some(col) = col_map.get(&i) {
deal_map.insert(col.clone(), val.clone());
}
}
if !deal_map.is_empty() {
let deal = Deal {
time: deal_map.get("time").cloned().unwrap_or_default(),
deal: deal_map.get("deal").cloned().unwrap_or_default(),
symbol: deal_map.get("symbol").cloned().unwrap_or_default(),
deal_type: deal_map.get("type").cloned().unwrap_or_default(),
entry: deal_map.get("entry").cloned().unwrap_or_default(),
volume: deal_map.get("volume").and_then(|s| s.parse().ok()).unwrap_or(0.0),
price: deal_map.get("price").and_then(|s| s.parse().ok()).unwrap_or(0.0),
order: deal_map.get("order").cloned().unwrap_or_default(),
commission: deal_map.get("commission").and_then(|s| s.parse().ok()).unwrap_or(0.0),
swap: deal_map.get("swap").and_then(|s| s.parse().ok()).unwrap_or(0.0),
profit: deal_map.get("profit").and_then(|s| s.parse().ok()).unwrap_or(0.0),
balance: deal_map.get("balance").and_then(|s| s.parse().ok()).unwrap_or(0.0),
comment: deal_map.get("comment").cloned().unwrap_or_default(),
magic: deal_map.get("magic").cloned(),
};
deals.push(deal);
}
}
Ok(deals)
}
fn write_metrics(&self, metrics: &Metrics, path: &Path) -> Result<()> {
let json = serde_json::to_string_pretty(metrics)?;
let mut file = File::create(path)?;
file.write_all(json.as_bytes())?;
Ok(())
}
fn write_deals_json(&self, deals: &[Deal], path: &Path) -> Result<()> {
let json = serde_json::to_string_pretty(deals)?;
let mut file = File::create(path)?;
file.write_all(json.as_bytes())?;
Ok(())
}
fn write_deals_csv(&self, deals: &[Deal], path: &Path) -> Result<()> {
let mut file = File::create(path)?;
writeln!(file, "time,deal,symbol,type,entry,volume,price,order,commission,swap,profit,balance,comment")?;
for deal in deals {
writeln!(file, "{},{},{},{},{},{},{},{},{},{},{},{},\"{}\"",
deal.time,
deal.deal,
deal.symbol,
deal.deal_type,
deal.entry,
deal.volume,
deal.price,
deal.order,
deal.commission,
deal.swap,
deal.profit,
deal.balance,
deal.comment.replace('"', "\"\"")
)?;
}
Ok(())
}
fn read_text(path: &str) -> Result<String> {
let raw = fs::read(path)?;
if raw.starts_with(&[0xFF, 0xFE]) || raw.starts_with(&[0xFE, 0xFF]) {
// UTF-16 BOM
let text = String::from_utf16_lossy(
raw.chunks_exact(2)
.map(|c| u16::from_le_bytes([c[0], c[1]]))
.collect::<Vec<_>>()
.as_slice()
);
return Ok(text);
}
if let Ok(text) = String::from_utf8(raw.clone()) {
return Ok(text);
}
Ok(String::from_utf8_lossy(&raw).to_string())
}
fn strip_tags(html: &str) -> String {
let re = regex::Regex::new(r"<[^>]+>").unwrap();
re.replace_all(html, "").trim().to_string()
}
}
pub struct ExtractionResult {
pub metrics: Metrics,
pub deals: Vec<Deal>,
pub metrics_path: PathBuf,
pub deals_csv_path: PathBuf,
pub deals_json_path: PathBuf,
}
#[derive(Debug, Clone, Copy)]
enum ReportFormat {
Html,
Xml,
}
+5
View File
@@ -0,0 +1,5 @@
pub mod extract;
pub mod analyze;
pub use extract::ReportExtractor;
pub use analyze::DealAnalyzer;