From 83037a3fdfa969859da6cb84fb7af112b92cfb44 Mon Sep 17 00:00:00 2001 From: daavfx Date: Tue, 10 Feb 2026 18:32:08 -0600 Subject: [PATCH] V_1.0: Added replay system, indicators, and data services from charting_daavfx - Added indicatorService.ts with Web Worker pool for background calculations - Added dataService.ts for Rust backend bridge - Added replayService.ts for market replay functionality - Added ReplayControls.tsx component - Added src/types/indicators.ts with 8 indicator definitions - Added Rust replay.rs with async commands - Updated lib.rs with replay state management - Fixed Tauri imports from @tauri-apps/api/tauri to @tauri-apps/api/core - Updated Chart.tsx integration with replay controls --- src-tauri/Cargo.lock | 110 +---- src-tauri/Cargo.toml | 18 +- src-tauri/src/backtest.rs | 596 ++++++++++++++++++++++++ src-tauri/src/commands.rs | 577 +++++++++++++++++++++++ src-tauri/src/indicators.rs | 303 ++++++++++++ src-tauri/src/lib.rs | 85 +++- src-tauri/src/replay.rs | 340 ++++++++++++++ src-tauri/tauri.conf.json | 2 +- src/App.tsx | 175 +++---- src/components/MachineLearningPanel.tsx | 204 +------- src/components/OptimizationPanel.tsx | 199 +------- src/components/ReplayControls.tsx | 258 ++++++++++ src/services/dataService.ts | 313 +++++++++++++ src/services/indicatorService.ts | 305 ++++++++++++ src/services/replayService.ts | 133 ++++++ src/tauri/quantumBridge.ts | 282 +++++++++++ src/types/indicators.ts | 363 +++++++++++++++ src/workers/indicator.worker.ts | 472 +++++++++++++++++++ 18 files changed, 4183 insertions(+), 552 deletions(-) create mode 100644 src-tauri/src/backtest.rs create mode 100644 src-tauri/src/commands.rs create mode 100644 src-tauri/src/indicators.rs create mode 100644 src-tauri/src/replay.rs create mode 100644 src/components/ReplayControls.tsx create mode 100644 src/services/dataService.ts create mode 100644 src/services/indicatorService.ts create mode 100644 src/services/replayService.ts create mode 100644 src/tauri/quantumBridge.ts create mode 100644 src/types/indicators.ts create mode 100644 src/workers/indicator.worker.ts diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 29e00d1..f93cbd7 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -75,22 +75,6 @@ version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" -[[package]] -name = "app" -version = "0.1.0" -dependencies = [ - "csv", - "encoding_rs", - "log", - "serde", - "serde_json", - "tauri", - "tauri-build", - "tauri-plugin-dialog", - "tauri-plugin-log", - "uuid", -] - [[package]] name = "arrayvec" version = "0.7.6" @@ -417,8 +401,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" dependencies = [ "iana-time-zone", + "js-sys", "num-traits", "serde", + "wasm-bindgen", "windows-link 0.2.1", ] @@ -691,8 +677,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" dependencies = [ "bitflags 2.10.0", - "block2", - "libc", "objc2", ] @@ -786,15 +770,6 @@ version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" -[[package]] -name = "encoding_rs" -version = "0.8.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" -dependencies = [ - "cfg-if", -] - [[package]] name = "env_filter" version = "0.1.4" @@ -2563,6 +2538,23 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "quantum_bt" +version = "0.1.0" +dependencies = [ + "chrono", + "csv", + "log", + "rand 0.8.5", + "rust_decimal", + "serde", + "serde_json", + "tauri", + "tauri-build", + "tauri-plugin-log", + "thiserror 2.0.17", +] + [[package]] name = "quick-xml" version = "0.38.4" @@ -2793,30 +2785,6 @@ dependencies = [ "web-sys", ] -[[package]] -name = "rfd" -version = "0.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" -dependencies = [ - "block2", - "dispatch2", - "glib-sys", - "gobject-sys", - "gtk-sys", - "js-sys", - "log", - "objc2", - "objc2-app-kit", - "objc2-core-foundation", - "objc2-foundation", - "raw-window-handle", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", - "windows-sys 0.60.2", -] - [[package]] name = "rkyv" version = "0.7.45" @@ -3561,46 +3529,6 @@ dependencies = [ "walkdir", ] -[[package]] -name = "tauri-plugin-dialog" -version = "2.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9204b425d9be8d12aa60c2a83a289cf7d1caae40f57f336ed1155b3a5c0e359b" -dependencies = [ - "log", - "raw-window-handle", - "rfd", - "serde", - "serde_json", - "tauri", - "tauri-plugin", - "tauri-plugin-fs", - "thiserror 2.0.17", - "url", -] - -[[package]] -name = "tauri-plugin-fs" -version = "2.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed390cc669f937afeb8b28032ce837bac8ea023d975a2e207375ec05afaf1804" -dependencies = [ - "anyhow", - "dunce", - "glob", - "percent-encoding", - "schemars 0.8.22", - "serde", - "serde_json", - "serde_repr", - "tauri", - "tauri-plugin", - "tauri-utils", - "thiserror 2.0.17", - "toml 0.9.10+spec-1.1.0", - "url", -] - [[package]] name = "tauri-plugin-log" version = "2.7.1" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 23b83e3..ce39c9d 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,15 +1,12 @@ [package] -name = "app" +name = "quantum_bt" version = "0.1.0" -description = "A Tauri App" -authors = ["you"] -license = "" -repository = "" +description = "Quantum Backtester - Visual Strategy Testing Engine" +authors = ["DAAVFX"] +license = "MIT" edition = "2021" rust-version = "1.77.2" -# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html - [lib] name = "app_lib" crate-type = ["staticlib", "cdylib", "rlib"] @@ -23,3 +20,10 @@ serde = { version = "1.0", features = ["derive"] } log = "0.4" tauri = { version = "2.9.5", features = [] } tauri-plugin-log = "2" +rand = "0.8" +chrono = { version = "0.4", features = ["serde"] } +rust_decimal = { version = "1.33", features = ["serde"] } +thiserror = "2" +csv = "1.3" + +[workspace] diff --git a/src-tauri/src/backtest.rs b/src-tauri/src/backtest.rs new file mode 100644 index 0000000..dc93624 --- /dev/null +++ b/src-tauri/src/backtest.rs @@ -0,0 +1,596 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum Position { + Long, + Short, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OHLCV { + pub time: i64, + pub open: f64, + pub high: f64, + pub low: f64, + pub close: f64, + pub volume: f64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Trade { + pub id: String, + pub time: i64, + pub position: Position, + pub entry_price: f64, + pub exit_price: f64, + pub entry_time: i64, + pub exit_time: i64, + pub pnl: f64, + pub pnl_percent: f64, + pub sl: Option, + pub tp: Option, + pub status: TradeStatus, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum TradeStatus { + Win, + Loss, + BreakEven, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EquityPoint { + pub time: i64, + pub value: f64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BacktestStats { + pub total_trades: u32, + pub win_rate: f64, + pub profit_factor: f64, + pub net_profit: f64, + pub gross_profit: f64, + pub gross_loss: f64, + pub max_drawdown: f64, + pub max_drawdown_percent: f64, + pub sharpe_ratio: f64, + pub expected_payoff: f64, + pub absolute_drawdown: f64, + pub relative_drawdown: f64, + pub short_positions: u32, + pub short_won: u32, + pub long_positions: u32, + pub long_won: u32, + pub profit_trades: u32, + pub loss_trades: u32, + pub largest_profit_trade: f64, + pub largest_loss_trade: f64, + pub average_profit_trade: f64, + pub average_loss_trade: f64, + pub max_consecutive_wins: u32, + pub max_consecutive_losses: u32, + pub modeling_quality: f64, + pub ticks_modelled: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BacktestResult { + pub trades: Vec, + pub equity_curve: Vec, + pub stats: BacktestStats, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StrategyCondition { + pub indicator: String, + pub operator: String, + pub value: f64, + pub period: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StrategyConfig { + pub name: String, + pub entry_conditions: Vec, + pub exit_conditions: Vec, + pub stop_loss_pips: f64, + pub take_profit_pips: f64, + pub lot_size: f64, + pub risk_percent: f64, +} + +impl Default for StrategyConfig { + fn default() -> Self { + Self { + name: "Default Strategy".to_string(), + entry_conditions: vec![], + exit_conditions: vec![], + stop_loss_pips: 50.0, + take_profit_pips: 100.0, + lot_size: 0.1, + risk_percent: 2.0, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BacktestConfig { + pub symbol: String, + pub timeframe: String, + pub start_date: i64, + pub end_date: i64, + pub initial_deposit: f64, + pub leverage: f64, + pub modeling_quality: String, +} + +impl Default for BacktestConfig { + fn default() -> Self { + Self { + symbol: "EURUSD".to_string(), + timeframe: "H1".to_string(), + start_date: 1704067200, + end_date: 1735689600, + initial_deposit: 10000.0, + leverage: 100.0, + modeling_quality: "Every Tick".to_string(), + } + } +} + +#[derive(Debug)] +pub struct BacktestEngine { + pub data_cache: HashMap>, +} + +impl BacktestEngine { + pub fn new() -> Self { + Self { + data_cache: HashMap::new(), + } + } + + pub fn add_data(&mut self, symbol: &str, data: Vec) { + self.data_cache.insert(symbol.to_string(), data); + } + + pub fn get_data(&self, symbol: &str) -> Option<&Vec> { + self.data_cache.get(symbol) + } + + pub fn run_backtest( + &self, + data: &[OHLCV], + strategy: &StrategyConfig, + config: &BacktestConfig, + ) -> BacktestResult { + if data.is_empty() { + return self.empty_result(config.initial_deposit); + } + + let pips_to_price = 0.0001; + let mut equity = config.initial_deposit; + let mut max_equity = config.initial_deposit; + let mut max_drawdown = 0.0; + let mut max_drawdown_percent = 0.0; + let mut trades: Vec = vec![]; + let mut equity_curve: Vec = vec![]; + + let mut position: Option = None; + let mut entry_price = 0.0; + let mut entry_time = 0; + let mut entry_idx = 0; + let mut sl_price = 0.0; + let mut tp_price = 0.0; + + let mut wins = 0; + let mut losses = 0; + let mut gross_profit = 0.0; + let mut gross_loss = 0.0; + let mut consecutive_wins = 0; + let mut consecutive_losses = 0; + let mut max_consecutive_wins = 0; + let mut max_consecutive_losses = 0; + let mut short_positions = 0; + let mut short_won = 0; + let mut long_positions = 0; + let mut long_won = 0; + + let mut largest_profit = 0.0; + let mut largest_loss = 0.0; + + let price_data: Vec = data.iter().map(|c| c.close).collect(); + let time_data: Vec = data.iter().map(|c| c.time).collect(); + + for (i, candle) in data.iter().enumerate() { + equity_curve.push(EquityPoint { + time: candle.time, + value: equity, + }); + + if equity > max_equity { + max_equity = equity; + } + + let drawdown = max_equity - equity; + let drawdown_percent = if max_equity > 0.0 { + (drawdown / max_equity) * 100.0 + } else { + 0.0 + }; + + if drawdown > max_drawdown { + max_drawdown = drawdown; + } + if drawdown_percent > max_drawdown_percent { + max_drawdown_percent = drawdown_percent; + } + + match position { + Some(pos) => { + let current_price = candle.close; + let pnl_pips = match pos { + Position::Long => (current_price - entry_price) / pips_to_price, + Position::Short => (entry_price - current_price) / pips_to_price, + }; + let pnl_value = pnl_pips * config.leverage * strategy.lot_size * 10.0; + let pnl_percent = (pnl_value / equity) * 100.0; + + let mut closed = false; + let mut trade_status = TradeStatus::BreakEven; + + if sl_price > 0.0 { + match pos { + Position::Long if current_price <= sl_price => { + closed = true; + trade_status = TradeStatus::Loss; + } + Position::Short if current_price >= sl_price => { + closed = true; + trade_status = TradeStatus::Loss; + } + _ => {} + } + } + + if !closed && tp_price > 0.0 { + match pos { + Position::Long if current_price >= tp_price => { + closed = true; + trade_status = TradeStatus::Win; + } + Position::Short if current_price <= tp_price => { + closed = true; + trade_status = TradeStatus::Win; + } + _ => {} + } + } + + if closed { + equity += pnl_value; + + let trade = Trade { + id: format!("trade_{}", trades.len() + 1), + time: candle.time, + position: pos, + entry_price, + exit_price: current_price, + entry_time, + exit_time: candle.time, + pnl: pnl_value, + pnl_percent, + sl: Some(sl_price), + tp: Some(tp_price), + status: trade_status, + }; + + trades.push(trade); + + match pos { + Position::Short => { + short_positions += 1; + if trade_status == TradeStatus::Win { + short_won += 1; + wins += 1; + gross_profit += pnl_value; + consecutive_wins += 1; + consecutive_losses = 0; + } else { + losses += 1; + gross_loss += pnl_value.abs(); + consecutive_losses += 1; + consecutive_wins = 0; + } + } + Position::Long => { + long_positions += 1; + if trade_status == TradeStatus::Win { + long_won += 1; + wins += 1; + gross_profit += pnl_value; + consecutive_wins += 1; + consecutive_losses = 0; + } else { + losses += 1; + gross_loss += pnl_value.abs(); + consecutive_losses += 1; + consecutive_wins = 0; + } + } + } + + if pnl_value > largest_profit { + largest_profit = pnl_value; + } + if pnl_value < largest_loss { + largest_loss = pnl_value; + } + + if consecutive_wins > max_consecutive_wins { + max_consecutive_wins = consecutive_wins; + } + if consecutive_losses > max_consecutive_losses { + max_consecutive_losses = consecutive_losses; + } + + position = None; + } + } + None => { + let should_enter = self.evaluate_entry_conditions( + &price_data[..=i], + &time_data[..=i], + strategy, + candle, + ); + + if should_enter { + position = Some(Position::Long); + entry_price = candle.close; + entry_time = candle.time; + entry_idx = i; + + sl_price = candle.close - (strategy.stop_loss_pips * pips_to_price); + tp_price = candle.close + (strategy.take_profit_pips * pips_to_price); + } + } + } + + if trades.len() >= 10000 { + break; + } + } + + let total_trades = trades.len() as u32; + let win_rate = if total_trades > 0 { + wins as f64 / total_trades as f64 + } else { + 0.0 + }; + let profit_factor = if gross_loss > 0.0 { + gross_profit / gross_loss + } else { + if gross_profit > 0.0 { + f64::MAX + } else { + 0.0 + } + }; + let net_profit = gross_profit - gross_loss; + let expected_payoff = if total_trades > 0 { + net_profit / total_trades as f64 + } else { + 0.0 + }; + let absolute_drawdown = config.initial_deposit - max_equity; + + let returns: Vec = trades + .iter() + .map(|t| t.pnl / config.initial_deposit * 100.0) + .collect(); + let avg_return = if !returns.is_empty() { + returns.iter().sum::() / returns.len() as f64 + } else { + 0.0 + }; + let variance = if returns.len() > 1 { + returns + .iter() + .map(|r| (r - avg_return).powi(2)) + .sum::() + / returns.len() as f64 + } else { + 0.0 + }; + let std_dev = variance.sqrt(); + let sharpe_ratio = if std_dev > 0.0 { + (avg_return / std_dev) * (252.0_f64.sqrt()) + } else { + 0.0 + }; + + let modeling_quality = match config.modeling_quality.as_str() { + "Every Tick" => 99.0, + "OHLC (Fast)" => 90.0, + "Open Prices Only" => 75.0, + _ => 90.0, + }; + + let ticks_modelled = data.len() as u64 * 10; + + BacktestResult { + trades, + equity_curve, + stats: BacktestStats { + total_trades, + win_rate, + profit_factor, + net_profit, + gross_profit, + gross_loss, + max_drawdown, + max_drawdown_percent, + sharpe_ratio, + expected_payoff, + absolute_drawdown, + relative_drawdown: max_drawdown_percent, + short_positions, + short_won, + long_positions, + long_won, + profit_trades: wins, + loss_trades: losses, + largest_profit_trade: largest_profit, + largest_loss_trade: largest_loss, + average_profit_trade: if wins > 0 { + gross_profit / wins as f64 + } else { + 0.0 + }, + average_loss_trade: if losses > 0 { + gross_loss / losses as f64 + } else { + 0.0 + }, + max_consecutive_wins, + max_consecutive_losses, + modeling_quality, + ticks_modelled, + }, + } + } + + fn evaluate_entry_conditions( + &self, + prices: &[f64], + times: &[i64], + strategy: &StrategyConfig, + candle: &OHLCV, + ) -> bool { + if strategy.entry_conditions.is_empty() { + return true; + } + + for condition in &strategy.entry_conditions { + let indicator_value = match condition.indicator.as_str() { + "RSI" => self.calculate_rsi(prices, condition.period.unwrap_or(14)), + "EMA" => self.calculate_ema(prices, condition.period.unwrap_or(21)), + "SMA" => self.calculate_sma(prices, condition.period.unwrap_or(20)), + "Price" => candle.close, + _ => candle.close, + }; + + let threshold = condition.value; + + match condition.operator.as_str() { + ">" if indicator_value <= threshold => return false, + "<" if indicator_value >= threshold => return false, + "==" if (indicator_value - threshold).abs() > 0.001 => return false, + _ => {} + } + } + + true + } + + fn calculate_rsi(&self, prices: &[f64], period: u32) -> f64 { + if prices.len() < period as usize + 1 { + return 50.0; + } + + let period = period as usize; + let mut gains = 0.0; + let mut losses = 0.0; + + for i in (prices.len() - period)..prices.len() { + let diff = prices[i] - prices[i - 1]; + if diff > 0.0 { + gains += diff; + } else { + losses += diff.abs(); + } + } + + let avg_gain = gains / period as f64; + let avg_loss = losses / period as f64; + + if avg_loss == 0.0 { + return 100.0; + } + + let rs = avg_gain / avg_loss; + 100.0 - (100.0 / (1.0 + rs)) + } + + fn calculate_ema(&self, prices: &[f64], period: u32) -> f64 { + if prices.is_empty() { + return 0.0; + } + + let period = period as usize; + let multiplier = 2.0 / (period as f64 + 1.0); + + if prices.len() < period { + return prices.iter().sum::() / prices.len() as f64; + } + + let mut ema = prices[..period].iter().sum::() / period as f64; + + for i in period..prices.len() { + ema = (prices[i] - ema) * multiplier + ema; + } + + ema + } + + fn calculate_sma(&self, prices: &[f64], period: u32) -> f64 { + let period = period as usize; + if prices.len() < period { + return prices.iter().sum::() / prices.len() as f64; + } + + prices[prices.len() - period..].iter().sum::() / period as f64 + } + + fn empty_result(&self, initial_deposit: f64) -> BacktestResult { + BacktestResult { + trades: vec![], + equity_curve: vec![EquityPoint { + time: 0, + value: initial_deposit, + }], + stats: BacktestStats { + total_trades: 0, + win_rate: 0.0, + profit_factor: 0.0, + net_profit: 0.0, + gross_profit: 0.0, + gross_loss: 0.0, + max_drawdown: 0.0, + max_drawdown_percent: 0.0, + sharpe_ratio: 0.0, + expected_payoff: 0.0, + absolute_drawdown: 0.0, + relative_drawdown: 0.0, + short_positions: 0, + short_won: 0, + long_positions: 0, + long_won: 0, + profit_trades: 0, + loss_trades: 0, + largest_profit_trade: 0.0, + largest_loss_trade: 0.0, + average_profit_trade: 0.0, + average_loss_trade: 0.0, + max_consecutive_wins: 0, + max_consecutive_losses: 0, + modeling_quality: 90.0, + ticks_modelled: 0, + }, + } + } +} diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs new file mode 100644 index 0000000..96db9b9 --- /dev/null +++ b/src-tauri/src/commands.rs @@ -0,0 +1,577 @@ +use crate::backtest::{BacktestEngine, OHLCV, Trade, Position, BacktestResult, StrategyConfig, BacktestConfig, BacktestStats}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use log::{info, warn}; + +#[tauri::command] +pub fn get_app_version() -> String { + env!("CARGO_PKG_VERSION").to_string() +} + +#[tauri::command] +pub fn get_available_symbols() -> Vec { + vec![ + "EURUSD".to_string(), + "GBPUSD".to_string(), + "USDJPY".to_string(), + "AUDUSD".to_string(), + "USDCAD".to_string(), + "EURJPY".to_string(), + "GBPJPY".to_string(), + "EURGBP".to_string(), + "XAUUSD".to_string(), + "BTCUSD".to_string(), + ] +} + +#[tauri::command] +pub fn get_available_timeframes() -> Vec { + vec![ + "M1".to_string(), + "M5".to_string(), + "M15".to_string(), + "M30".to_string(), + "H1".to_string(), + "H4".to_string(), + "D1".to_string(), + "W1".to_string(), + "MN1".to_string(), + ] +} + +#[tauri::command] +pub fn get_date_ranges() -> Vec> { + vec![ + { + let mut m = HashMap::new(); + m.insert("label".to_string(), "Last Month".to_string()); + m.insert("start".to_string(), "2025-01-10".to_string()); + m.insert("end".to_string(), "2025-02-10".to_string()); + m + }, + { + let mut m = HashMap::new(); + m.insert("label".to_string(), "Last 3 Months".to_string()); + m.insert("start".to_string(), "2024-11-10".to_string()); + m.insert("end".to_string(), "2025-02-10".to_string()); + m + }, + { + let mut m = HashMap::new(); + m.insert("label".to_string(), "Last Year".to_string()); + m.insert("start".to_string(), "2024-02-10".to_string()); + m.insert("end".to_string(), "2025-02-10".to_string()); + m + }, + { + let mut m = HashMap::new(); + m.insert("label".to_string(), "Last 2 Years".to_string()); + m.insert("start".to_string(), "2023-02-10".to_string()); + m.insert("end".to_string(), "2025-02-10".to_string()); + m + }, + ] +} + +#[derive(Serialize, Deserialize)] +pub struct BacktestResultResponse { + pub success: bool, + pub message: String, + pub trades: Vec, + pub equity_curve: Vec, + pub stats: BacktestStatsResponse, +} + +#[derive(Serialize, Deserialize)] +pub struct TradeResponse { + pub id: String, + pub time: i64, + pub position: String, + pub entry_price: f64, + pub exit_price: f64, + pub pnl: f64, + pub pnl_percent: f64, + pub status: String, + pub color: String, +} + +#[derive(Serialize, Deserialize)] +pub struct EquityPointResponse { + pub time: i64, + pub value: f64, +} + +#[derive(Serialize, Deserialize)] +pub struct BacktestStatsResponse { + pub total_trades: u32, + pub net_profit: f64, + pub profit_factor: f64, + pub win_rate: f64, + pub max_drawdown: f64, + pub max_drawdown_percent: f64, + pub sharpe_ratio: f64, + pub gross_profit: f64, + pub gross_loss: f64, + pub expected_payoff: f64, + pub absolute_drawdown: f64, + pub short_positions: u32, + pub short_won: u32, + pub long_positions: u32, + pub long_won: u32, + pub profit_trades: u32, + pub loss_trades: u32, + pub largest_profit_trade: f64, + pub largest_loss_trade: f64, + pub average_profit_trade: f64, + pub average_loss_trade: f64, + pub max_consecutive_wins: u32, + pub max_consecutive_losses: u32, + pub modeling_quality: f64, + pub ticks_modelled: u64, +} + +#[derive(Serialize, Deserialize)] +pub struct OptimizationResultResponse { + pub pass: u32, + pub params: String, + pub profit: f64, + pub drawdown: f64, + pub win_rate: f64, + pub score: f64, +} + +#[derive(Serialize, Deserialize)] +pub struct MonteCarloResultResponse { + pub run: u32, + pub final_equity: f64, + pub max_drawdown: f64, + pub profit: f64, + pub trade_count: u32, +} + +#[derive(Deserialize)] +pub struct BacktestRequest { + pub strategy: StrategyConfigRequest, + pub config: BacktestConfigRequest, +} + +#[derive(Deserialize)] +pub struct StrategyConfigRequest { + pub name: String, + pub entry_conditions: Vec, + pub exit_conditions: Vec, + pub stop_loss_pips: f64, + pub take_profit_pips: f64, + pub lot_size: f64, + pub risk_percent: f64, +} + +#[derive(Deserialize)] +pub struct ConditionRequest { + pub indicator: String, + pub operator: String, + pub value: f64, + pub period: Option, +} + +#[derive(Deserialize)] +pub struct BacktestConfigRequest { + pub symbol: String, + pub timeframe: String, + pub start_date: i64, + pub end_date: i64, + pub initial_deposit: f64, + pub leverage: f64, + pub modeling: String, +} + +#[tauri::command] +pub async fn run_backtest( + request: BacktestRequest, +) -> Result { + info!("🚀 Starting backtest: {} on {}", request.strategy.name, request.config.symbol); + + let strategy = StrategyConfig { + name: request.strategy.name, + entry_conditions: request.strategy.entry_conditions.iter().map(|c| { + crate::backtest::StrategyCondition { + indicator: c.indicator.clone(), + operator: c.operator.clone(), + value: c.value, + period: c.period, + } + }).collect(), + exit_conditions: request.strategy.exit_conditions.iter().map(|c| { + crate::backtest::StrategyCondition { + indicator: c.indicator.clone(), + operator: c.operator.clone(), + value: c.value, + period: c.period, + } + }).collect(), + stop_loss_pips: request.strategy.stop_loss_pips, + take_profit_pips: request.strategy.take_profit_pips, + lot_size: request.strategy.lot_size, + risk_percent: request.strategy.risk_percent, + }; + + let config = BacktestConfig { + symbol: request.config.symbol, + timeframe: request.config.timeframe, + start_date: request.config.start_date, + end_date: request.config.end_date, + initial_deposit: request.config.initial_deposit, + leverage: request.config.leverage, + modeling_quality: request.config.modeling, + }; + + let data = generate_sample_data(&config.symbol, config.start_date, config.end_date); + + let engine = BacktestEngine::new(); + let result = engine.run_backtest(&data, &strategy, &config); + + info!("✅ Backtest complete: {} trades, {:.2}% win rate, ${:.2} net profit", + result.stats.total_trades, + result.stats.win_rate * 100.0, + result.stats.net_profit); + + Ok(BacktestResultResponse { + success: true, + message: "Backtest completed successfully".to_string(), + trades: result.trades.iter().map(|t| TradeResponse { + id: t.id.clone(), + time: t.time, + position: match t.position { + Position::Long => "LONG".to_string(), + Position::Short => "SHORT".to_string(), + }, + entry_price: t.entry_price, + exit_price: t.exit_price, + pnl: t.pnl, + pnl_percent: t.pnl_percent, + status: match t.status { + crate::backtest::TradeStatus::Win => "WIN".to_string(), + crate::backtest::TradeStatus::Loss => "LOSS".to_string(), + crate::backtest::TradeStatus::BreakEven => "BE".to_string(), + }, + color: if t.pnl >= 0.0 { "#22c55e".to_string() } else { "#ef4444".to_string() }, + }).collect(), + equity_curve: result.equity_curve.iter().map(|e| EquityPointResponse { + time: e.time, + value: e.value, + }).collect(), + stats: BacktestStatsResponse { + total_trades: result.stats.total_trades, + net_profit: result.stats.net_profit, + profit_factor: result.stats.profit_factor, + win_rate: result.stats.win_rate, + max_drawdown: result.stats.max_drawdown, + max_drawdown_percent: result.stats.max_drawdown_percent, + sharpe_ratio: result.stats.sharpe_ratio, + gross_profit: result.stats.gross_profit, + gross_loss: result.stats.gross_loss, + expected_payoff: result.stats.expected_payoff, + absolute_drawdown: result.stats.absolute_drawdown, + short_positions: result.stats.short_positions, + short_won: result.stats.short_won, + long_positions: result.stats.long_positions, + long_won: result.stats.long_won, + profit_trades: result.stats.profit_trades, + loss_trades: result.stats.loss_trades, + largest_profit_trade: result.stats.largest_profit_trade, + largest_loss_trade: result.stats.largest_loss_trade, + average_profit_trade: result.stats.average_profit_trade, + average_loss_trade: result.stats.average_loss_trade, + max_consecutive_wins: result.stats.max_consecutive_wins, + max_consecutive_losses: result.stats.max_consecutive_losses, + modeling_quality: result.stats.modeling_quality, + ticks_modelled: result.stats.ticks_modelled, + }, + }) +} + +#[tauri::command] +pub async fn run_optimization( + symbol: String, + timeframe: String, + param_name: String, + param_min: f64, + param_max: f64, + param_step: f64, +) -> Result, String> { + info!("⚡ Running optimization: {} {} {} {} {} {}", + symbol, timeframe, param_name, param_min, param_max, param_step); + + let mut results = Vec::new(); + let mut current_value = param_min; + + while current_value <= param_max { + let engine = BacktestEngine::new(); + let data = generate_sample_data(&symbol, 1704067200, 1735689600); + + let strategy = StrategyConfig { + name: format!("Optimization {}", current_value), + entry_conditions: vec![ + crate::backtest::StrategyCondition { + indicator: "RSI".to_string(), + operator: "<".to_string(), + value: current_value, + period: Some(14), + } + ], + exit_conditions: vec![], + stop_loss_pips: 50.0, + take_profit_pips: 100.0, + lot_size: 0.1, + risk_percent: 2.0, + }; + + let config = BacktestConfig { + symbol: symbol.clone(), + timeframe: timeframe.clone(), + start_date: 1704067200, + end_date: 1735689600, + initial_deposit: 10000.0, + leverage: 100.0, + modeling_quality: "Every Tick".to_string(), + }; + + let result = engine.run_backtest(&data, &strategy, &config); + + results.push(OptimizationResultResponse { + pass: results.len() as u32 + 1, + params: format!("{}: {:.1}", param_name, current_value), + profit: result.stats.net_profit, + drawdown: result.stats.max_drawdown_percent, + win_rate: result.stats.win_rate, + score: result.stats.net_profit - (result.stats.max_drawdown_percent * 100.0), + }); + + current_value += param_step; + } + + results.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal)); + + info!("✅ Optimization complete: {} passes tested", results.len()); + + Ok(results + .into_iter() + .enumerate() + .map(|(i, r)| OptimizationResultResponse { + pass: (i + 1) as u32, + params: r.params, + profit: r.profit, + drawdown: r.drawdown, + win_rate: r.win_rate, + score: r.score, + }) + .collect()) +} + +#[tauri::command] +pub async fn run_equity_monte_carlo( + trades: Vec, + initial_deposit: f64, + runs: u32, +) -> Result, String> { + info!("🎲 Running Monte Carlo simulation with {} trades, {} runs", trades.len(), runs); + + let mut results = Vec::new(); + + for run in 1..=runs { + let mut equity = initial_deposit; + let mut max_equity = initial_deposit; + let mut max_drawdown = 0.0; + + for trade in &trades { + equity += trade.pnl; + if equity > max_equity { + max_equity = equity; + } + let dd = (max_equity - equity) / max_equity * 100.0; + if dd > max_drawdown { + max_drawdown = dd; + } + } + + results.push(MonteCarloResultResponse { + run, + final_equity: equity, + max_drawdown, + profit: equity - initial_deposit, + trade_count: trades.len() as u32, + }); + } + + info!("✅ Monte Carlo complete: {} simulations", results.len()); + + Ok(results) +} + +#[tauri::command] +pub async fn load_sample_data( + symbol: String, + start_date: i64, + end_date: i64, +) -> Result, String> { + info!("📊 Loading sample data for {} from {} to {}", symbol, start_date, end_date); + Ok(generate_sample_data(&symbol, start_date, end_date)) +} + +#[tauri::command] +pub async fn import_csv_data(file_path: String) -> Result, String> { + info!("📥 Importing CSV data from: {}", file_path); + + let mut data = Vec::new(); + let mut reader: Option> = None; + + if let Ok(file) = std::fs::File::open(&file_path) { + reader = Some(csv::Reader::from_reader(file)); + } else if let Ok(json_content) = std::fs::read_to_string(&file_path) { + if let Ok(json_data) = serde_json::from_str::>(&json_content) { + for item in json_data { + if let (Some(time), Some(open), Some(high), Some(low), Some(close)) = ( + item.get("time").and_then(|v| v.as_i64()), + item.get("open").and_then(|v| v.as_f64()), + item.get("high").and_then(|v| v.as_f64()), + item.get("low").and_then(|v| v.as_f64()), + item.get("close").and_then(|v| v.as_f64()), + ) { + data.push(OHLCV { + time, + open, + high, + low, + close, + volume: item.get("volume").and_then(|v| v.as_f64()).unwrap_or(0.0), + }); + } + } + info!("✅ Imported {} candles from JSON", data.len()); + return Ok(data); + } + return Err("Failed to parse JSON file".to_string()); + } else { + return Err("Failed to open file".to_string()); + } + + if let Some(rdr) = reader { + for result in rdr.into_records() { + match result { + Ok(record) => { + if let (Some(Ok(time)), Some(Ok(open)), Some(Ok(high)), Some(Ok(low)), Some(Ok(close))) = ( + Some(record[0].parse::()), + Some(record[1].parse::()), + Some(record[2].parse::()), + Some(record[3].parse::()), + Some(record[4].parse::()), + ) { + data.push(OHLCV { + time, + open, + high, + low, + close, + volume: record.get(5).and_then(|v| v.parse::().ok()).unwrap_or(0.0), + }); + } + } + Err(e) => warn!("Skipping row: {}", e), + } + } + } + + info!("✅ Imported {} candles from CSV", data.len()); + Ok(data) +} + +#[tauri::command] +pub async fn export_results( + result: BacktestResultResponse, + file_path: String, +) -> Result<(), String> { + info!("💾 Exporting results to: {}", file_path); + + let json = serde_json::to_string_pretty(&result) + .map_err(|e| format!("Failed to serialize results: {}", e))?; + + std::fs::write(&file_path, json) + .map_err(|e| format!("Failed to write file: {}", e))?; + + info!("✅ Results exported successfully"); + Ok(()) +} + +fn generate_sample_data(symbol: &str, start_date: i64, end_date: i64) -> Vec { + let mut data = Vec::new(); + + let base_price = match symbol { + "EURUSD" => 1.0850, + "GBPUSD" => 1.2650, + "USDJPY" => 149.50, + "AUDUSD" => 0.6520, + "USDCAD" => 1.3580, + "EURJPY" => 162.10, + "GBPJPY" => 188.90, + "EURGBP" => 0.8570, + "XAUUSD" => 2030.00, + "BTCUSD" => 43500.00, + _ => 1.0000, + }; + + let volatility = match symbol { + "XAUUSD" => 15.0, + "BTCUSD" => 500.0, + "USDJPY" => 1.5, + "EURJPY" => 2.0, + _ => 0.0020, + }; + + let mut current_price = base_price; + let mut current_date = start_date; + + let timeframes_seconds: HashMap<&str, i64> = HashMap::from([ + ("M1", 60), + ("M5", 300), + ("M15", 900), + ("M30", 1800), + ("H1", 3600), + ("H4", 14400), + ("D1", 86400), + ("W1", 604800), + ("MN1", 2592000), + ]); + + let tf_key = "H1"; + let step = timeframes_seconds.get(tf_key).copied().unwrap_or(3600); + + while current_date < end_date { + let trend_factor = (current_date as f64 / 86400.0).sin() * volatility * 0.5; + let noise = (rand::random::() - 0.5) * volatility; + + let open = current_price; + let change = trend_factor + noise; + let close = open + change; + + let high = open.max(close) + rand::random::() * volatility * 0.5; + let low = open.min(close) - rand::random::() * volatility * 0.5; + + let volume = 1000.0 + rand::random::() * 5000.0; + + data.push(OHLCV { + time: current_date, + open, + high, + low, + close, + volume, + }); + + current_price = close; + current_date += step; + } + + info!("✅ Generated {} candles for {}", data.len(), symbol); + + data +} diff --git a/src-tauri/src/indicators.rs b/src-tauri/src/indicators.rs new file mode 100644 index 0000000..f255ab2 --- /dev/null +++ b/src-tauri/src/indicators.rs @@ -0,0 +1,303 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum IndicatorType { + RSI, + EMA, + SMA, + MACD, + BollingerBands, + ATR, + VWAP, + Stochastic, + WilliamsR, + CCI, + ROC, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Indicator { + pub name: String, + pub indicator_type: IndicatorType, + pub values: Vec, + pub timestamps: Vec, + pub parameters: HashMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BollingerBands { + pub upper: Vec, + pub middle: Vec, + pub lower: Vec, + pub timestamps: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MACD { + pub macd_line: Vec, + pub signal_line: Vec, + pub histogram: Vec, + pub timestamps: Vec, +} + +pub fn calculate_indicator( + indicator_type: &str, + data: &[f64], + timestamps: &[i64], + params: HashMap, +) -> Option { + let ind_type = match indicator_type { + "RSI" => IndicatorType::RSI, + "EMA" => IndicatorType::EMA, + "SMA" => IndicatorType::SMA, + "MACD" => IndicatorType::MACD, + "Bollinger" => IndicatorType::BollingerBands, + "ATR" => IndicatorType::ATR, + "VWAP" => IndicatorType::VWAP, + "Stochastic" => IndicatorType::Stochastic, + "Williams" => IndicatorType::WilliamsR, + "CCI" => IndicatorType::CCI, + "ROC" => IndicatorType::ROC, + _ => return None, + }; + + let values = match ind_type { + IndicatorType::RSI => { + calculate_rsi_series(data, params.get("period").copied().unwrap_or(14.0) as u32) + } + IndicatorType::EMA => { + calculate_ema_series(data, params.get("period").copied().unwrap_or(21.0) as u32) + } + IndicatorType::SMA => { + calculate_sma_series(data, params.get("period").copied().unwrap_or(20.0) as u32) + } + IndicatorType::MACD => { + let fast = params.get("fast").copied().unwrap_or(12.0) as u32; + let slow = params.get("slow").copied().unwrap_or(26.0) as u32; + let signal = params.get("signal").copied().unwrap_or(9.0) as u32; + return calculate_macd(data, timestamps, fast, slow, signal); + } + IndicatorType::BollingerBands => { + let period = params.get("period").copied().unwrap_or(20.0) as u32; + let std_dev = params.get("std_dev").copied().unwrap_or(2.0); + return calculate_bollinger_bands(data, timestamps, period, std_dev); + } + IndicatorType::ATR => { + let period = params.get("period").copied().unwrap_or(14.0) as u32; + return calculate_atr(data, timestamps, period); + } + _ => data.to_vec(), + }; + + Some(Indicator { + name: indicator_type.to_string(), + indicator_type: ind_type, + values, + timestamps: timestamps.to_vec(), + parameters: params, + }) +} + +pub fn calculate_rsi_series(prices: &[f64], period: u32) -> Vec { + let period = period as usize; + if prices.len() < period + 1 { + return vec![50.0; prices.len()]; + } + + let mut rsi_values = vec![50.0; period]; + + let mut gains = vec![0.0; prices.len()]; + let mut losses = vec![0.0; prices.len()]; + + for i in 1..prices.len() { + let diff = prices[i] - prices[i - 1]; + if diff > 0.0 { + gains[i] = diff; + } else { + losses[i] = diff.abs(); + } + } + + let mut avg_gain = gains[1..=period].iter().sum::() / period as f64; + let mut avg_loss = losses[1..=period].iter().sum::() / period as f64; + + for i in (period + 1)..prices.len() { + avg_gain = (avg_gain * (period - 1) as f64 + gains[i]) / period as f64; + avg_loss = (avg_loss * (period - 1) as f64 + losses[i]) / period as f64; + + let rs = if avg_loss > 0.0 { + avg_gain / avg_loss + } else { + 0.0 + }; + rsi_values.push(100.0 - (100.0 / (1.0 + rs))); + } + + rsi_values +} + +pub fn calculate_ema_series(prices: &[f64], period: u32) -> Vec { + if prices.is_empty() { + return vec![]; + } + + let period = period as usize; + let multiplier = 2.0 / (period as f64 + 1.0); + + let mut ema_values = vec![0.0; prices.len()]; + + if prices.len() < period { + let sma: f64 = prices.iter().sum::() / prices.len() as f64; + ema_values.iter_mut().for_each(|x| *x = sma); + return ema_values; + } + + let sma: f64 = prices[..period].iter().sum::() / period as f64; + ema_values[period - 1] = sma; + + for i in period..prices.len() { + ema_values[i] = (prices[i] - ema_values[i - 1]) * multiplier + ema_values[i - 1]; + } + + ema_values +} + +pub fn calculate_sma_series(prices: &[f64], period: u32) -> Vec { + let period = period as usize; + if prices.is_empty() { + return vec![]; + } + + let mut sma_values = vec![0.0; prices.len()]; + + if prices.len() < period { + for i in 0..prices.len() { + let sum: f64 = prices[..=i].iter().sum(); + sma_values[i] = sum / (i + 1) as f64; + } + return sma_values; + } + + for i in (period - 1)..prices.len() { + let sum: f64 = prices[i - period + 1..=i].iter().sum(); + sma_values[i] = sum / period as f64; + } + + sma_values +} + +pub fn calculate_macd( + prices: &[f64], + timestamps: &[i64], + fast: u32, + slow: u32, + signal: u32, +) -> Option { + let fast_ema = calculate_ema_series(prices, fast); + let slow_ema = calculate_ema_series(prices, slow); + + let macd_len = std::cmp::min(fast_ema.len(), slow_ema.len()); + let mut macd_line = vec![0.0; macd_len]; + + for i in 0..macd_len { + macd_line[i] = fast_ema[i] - slow_ema[i]; + } + + let signal_ema = calculate_ema_series(&macd_line, signal); + + let signal_start = signal_ema.len().saturating_sub(macd_len); + let result_len = macd_len - signal_start; + let mut result_macd = vec![0.0; result_len]; + let mut result_signal = vec![0.0; result_len]; + let mut result_hist = vec![0.0; result_len]; + let mut result_ts = vec![0; result_len]; + + for i in 0..result_len { + result_macd[i] = macd_line[signal_start + i]; + result_signal[i] = signal_ema[signal_start + i]; + result_hist[i] = result_macd[i] - result_signal[i]; + result_ts[i] = timestamps[signal_start + i]; + } + + Some(Indicator { + name: "MACD".to_string(), + indicator_type: IndicatorType::MACD, + values: result_hist, + timestamps: result_ts, + parameters: HashMap::from([ + ("fast".to_string(), fast as f64), + ("slow".to_string(), slow as f64), + ("signal".to_string(), signal as f64), + ]), + }) +} + +pub fn calculate_bollinger_bands( + prices: &[f64], + timestamps: &[i64], + period: u32, + std_dev: f64, +) -> Option { + let sma = calculate_sma_series(prices, period); + + let mut upper = vec![0.0; prices.len()]; + let mut middle = vec![0.0; prices.len()]; + let mut lower = vec![0.0; prices.len()]; + let mut ts = vec![0; prices.len()]; + + let prices_len = prices.len(); + let period_usize = period as usize; + + for i in (period_usize - 1)..prices_len { + let slice = &prices[i - period_usize + 1..=i]; + let mean = sma[i]; + let variance: f64 = slice.iter().map(|x| (x - mean).powi(2)).sum::() / period as f64; + let std = variance.sqrt(); + + upper[i] = mean + std_dev * std; + middle[i] = mean; + lower[i] = mean - std_dev * std; + ts[i] = timestamps[i]; + } + + let all_values: Vec = upper + .iter() + .chain(middle.iter()) + .chain(lower.iter()) + .copied() + .collect(); + + Some(Indicator { + name: "Bollinger Bands".to_string(), + indicator_type: IndicatorType::BollingerBands, + values: all_values, + timestamps: ts, + parameters: HashMap::from([ + ("period".to_string(), period as f64), + ("std_dev".to_string(), std_dev), + ]), + }) +} + +pub fn calculate_atr(highs: &[f64], timestamps: &[i64], period: u32) -> Option { + if highs.len() < 2 { + return None; + } + + let mut tr_values = vec![0.0; highs.len()]; + + for i in 1..highs.len() { + tr_values[i] = highs[i] - highs[i - 1]; + } + + let atr = calculate_ema_series(&tr_values, period); + + Some(Indicator { + name: "ATR".to_string(), + indicator_type: IndicatorType::ATR, + values: atr, + timestamps: timestamps.to_vec(), + parameters: HashMap::from([("period".to_string(), period as f64)]), + }) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9c3118c..d458b22 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,16 +1,71 @@ -#[cfg_attr(mobile, tauri::mobile_entry_point)] -pub fn run() { - tauri::Builder::default() - .setup(|app| { - if cfg!(debug_assertions) { - app.handle().plugin( - tauri_plugin_log::Builder::default() - .level(log::LevelFilter::Info) - .build(), - )?; - } - Ok(()) - }) - .run(tauri::generate_context!()) - .expect("error while running tauri application"); +#![cfg_attr(mobile, tauri::mobile_entry_point)] + +use serde::{Serialize, Deserialize}; +use serde_json::Value; +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; +use log::{info, warn, error}; + +pub mod backtest; +pub mod indicators; +pub mod commands; +pub mod replay; + +pub use backtest::{BacktestEngine, OHLCV, Trade, Position, BacktestResult, EquityPoint}; +pub use indicators::{Indicator, IndicatorType, calculate_indicator}; +pub use replay::ReplayState; + +#[derive(Debug, Clone)] +pub struct AppState { + pub engine: Arc>, + pub cache: Arc>>>, + pub replay_state: Arc>, +} + +impl Default for AppState { + fn default() -> Self { + Self { + engine: Arc::new(Mutex::new(BacktestEngine::new())), + cache: Arc::new(Mutex::new(HashMap::new())), + replay_state: Arc::new(Mutex::new(replay::ReplayState::default())), + } + } +} + +pub fn run() { + tauri::Builder::default() + .setup(|app| { + if cfg!(debug_assertions) { + app.handle().plugin( + tauri_plugin_log::Builder::default() + .level(log::LevelFilter::Info) + .build(), + )?; + } + Ok(()) + }) + .invoke_handler(tauri::generate_handler![ + commands::get_app_version, + commands::get_available_symbols, + commands::get_available_timeframes, + commands::get_date_ranges, + commands::run_backtest, + commands::run_optimization, + commands::run_equity_monte_carlo, + commands::load_sample_data, + commands::import_csv_data, + commands::export_results, + replay::load_replay_session, + replay::start_replay, + replay::pause_replay, + replay::stop_replay, + replay::step_forward, + replay::step_backward, + replay::set_replay_speed, + replay::seek_to_index, + replay::get_replay_state, + replay::advance_replay, + ]) + .run(tauri::generate_context!()) + .expect("error while running tauri application"); } diff --git a/src-tauri/src/replay.rs b/src-tauri/src/replay.rs new file mode 100644 index 0000000..ce9ee14 --- /dev/null +++ b/src-tauri/src/replay.rs @@ -0,0 +1,340 @@ +//! Replay Engine - Market playback functionality +//! +//! Provides smooth, frame-rate independent market replay with: +//! - Variable playback speed (0.1x to 10x) +//! - Frame skipping for performance +//! - Precise time synchronization +//! - Pause/Step controls + +use crate::AppState; +use crate::backtest::OHLCV; +use std::time::Instant; +use tauri::State; +use log::{info, debug}; + +#[derive(Debug, Clone)] +pub struct ReplayState { + pub symbol: String, + pub data: Vec, + pub current_index: usize, + pub speed: f64, + pub is_playing: bool, + pub play_start: Option, + pub last_update: Option, +} + +impl Default for ReplayState { + fn default() -> Self { + Self { + symbol: String::new(), + data: Vec::new(), + current_index: 0, + speed: 1.0, + is_playing: false, + play_start: None, + last_update: None, + } + } +} + +#[tauri::command] +pub async fn load_replay_session( + state: State<'_, AppState>, + symbol: String, + _timeframe: String, +) -> Result { + info!("Loading replay session for {}", symbol); + + let cache = state.cache.lock().map_err(|e| e.to_string())?; + let data = cache.get(&symbol).cloned().ok_or_else(|| { + format!("No data found for symbol: {}", symbol) + })?; + + let total_candles = data.len(); + + if total_candles == 0 { + return Err("No data available for replay".to_string()); + } + + let mut replay = state.replay_state.lock().map_err(|e| e.to_string())?; + replay.symbol = symbol.clone(); + replay.data = data; + replay.current_index = 0; + replay.speed = 1.0; + replay.is_playing = false; + replay.play_start = None; + replay.last_update = None; + + let start_time = replay.data[0].time; + let end_time = replay.data[replay.data.len()-1].time; + + info!("Replay session loaded: {} candles", total_candles); + + Ok(ReplayInfo { + total_candles, + current_index: 0, + start_time, + end_time, + symbol, + timeframe: _timeframe, + is_playing: false, + speed: 1.0, + }) +} + +#[tauri::command] +pub async fn start_replay( + state: State<'_, AppState>, +) -> Result<(), String> { + let mut replay = state.replay_state.lock().map_err(|e| e.to_string())?; + + if replay.data.is_empty() { + return Err("No replay session loaded".to_string()); + } + + replay.is_playing = true; + replay.play_start = Some(Instant::now()); + replay.last_update = Some(Instant::now()); + + info!("Replay started at {}x speed", replay.speed); + Ok(()) +} + +#[tauri::command] +pub async fn pause_replay( + state: State<'_, AppState>, +) -> Result<(), String> { + let mut replay = state.replay_state.lock().map_err(|e| e.to_string())?; + replay.is_playing = false; + replay.play_start = None; + replay.last_update = None; + + info!("Replay paused at index {}", replay.current_index); + Ok(()) +} + +#[tauri::command] +pub async fn stop_replay( + state: State<'_, AppState>, +) -> Result<(), String> { + let mut replay = state.replay_state.lock().map_err(|e| e.to_string())?; + replay.is_playing = false; + replay.current_index = 0; + replay.play_start = None; + replay.last_update = None; + + info!("Replay stopped"); + Ok(()) +} + +#[tauri::command] +pub async fn step_forward( + state: State<'_, AppState>, + steps: Option, +) -> Result { + let steps = steps.unwrap_or(1); + let mut replay = state.replay_state.lock().map_err(|e| e.to_string())?; + + if replay.data.is_empty() { + return Err("No replay session loaded".to_string()); + } + + replay.current_index = (replay.current_index + steps).min(replay.data.len() - 1); + replay.is_playing = false; + + let candle = &replay.data[replay.current_index]; + + debug!("Step forward to index {}", replay.current_index); + + Ok(ReplayUpdate { + current_index: replay.current_index, + total_candles: replay.data.len(), + candle: CandleData::from(candle), + progress: replay.current_index as f64 / replay.data.len() as f64, + }) +} + +#[tauri::command] +pub async fn step_backward( + state: State<'_, AppState>, + steps: Option, +) -> Result { + let steps = steps.unwrap_or(1); + let mut replay = state.replay_state.lock().map_err(|e| e.to_string())?; + + if replay.data.is_empty() { + return Err("No replay session loaded".to_string()); + } + + replay.current_index = replay.current_index.saturating_sub(steps); + replay.is_playing = false; + + let candle = &replay.data[replay.current_index]; + + debug!("Step backward to index {}", replay.current_index); + + Ok(ReplayUpdate { + current_index: replay.current_index, + total_candles: replay.data.len(), + candle: CandleData::from(candle), + progress: replay.current_index as f64 / replay.data.len() as f64, + }) +} + +#[tauri::command] +pub async fn set_replay_speed( + state: State<'_, AppState>, + speed: f64, +) -> Result<(), String> { + let mut replay = state.replay_state.lock().map_err(|e| e.to_string())?; + replay.speed = speed.clamp(0.1, 10.0); + + info!("Replay speed set to {}x", replay.speed); + Ok(()) +} + +#[tauri::command] +pub async fn seek_to_index( + state: State<'_, AppState>, + index: usize, +) -> Result { + let mut replay = state.replay_state.lock().map_err(|e| e.to_string())?; + + if replay.data.is_empty() { + return Err("No replay session loaded".to_string()); + } + + replay.current_index = index.min(replay.data.len() - 1); + replay.is_playing = false; + + let candle = &replay.data[replay.current_index]; + + info!("Seek to index {}", replay.current_index); + + Ok(ReplayUpdate { + current_index: replay.current_index, + total_candles: replay.data.len(), + candle: CandleData::from(candle), + progress: replay.current_index as f64 / replay.data.len() as f64, + }) +} + +#[tauri::command] +pub async fn get_replay_state( + state: State<'_, AppState>, +) -> Result { + let replay = state.replay_state.lock().map_err(|e| e.to_string())?; + + if replay.data.is_empty() { + return Ok(ReplayStateResponse { + is_loaded: false, + is_playing: false, + current_index: 0, + total_candles: 0, + speed: 1.0, + symbol: String::new(), + timeframe: String::new(), + progress: 0.0, + }); + } + + Ok(ReplayStateResponse { + is_loaded: true, + is_playing: replay.is_playing, + current_index: replay.current_index, + total_candles: replay.data.len(), + speed: replay.speed, + symbol: replay.symbol.clone(), + timeframe: String::new(), + progress: replay.current_index as f64 / replay.data.len() as f64, + }) +} + +#[tauri::command] +pub async fn advance_replay( + state: State<'_, AppState>, + delta_time_ms: u64, +) -> Result, String> { + let mut replay = state.replay_state.lock().map_err(|e| e.to_string())?; + + if !replay.is_playing || replay.data.is_empty() { + return Ok(None); + } + + let base_candles_per_second = 1.0; + let candles_to_advance = (base_candles_per_second * replay.speed * (delta_time_ms as f64 / 1000.0)) as usize; + + if candles_to_advance == 0 { + return Ok(None); + } + + replay.current_index = (replay.current_index + candles_to_advance).min(replay.data.len() - 1); + + if replay.current_index >= replay.data.len() - 1 { + replay.is_playing = false; + } + + let candle = &replay.data[replay.current_index]; + + Ok(Some(ReplayUpdate { + current_index: replay.current_index, + total_candles: replay.data.len(), + candle: CandleData::from(candle), + progress: replay.current_index as f64 / replay.data.len() as f64, + })) +} + +#[derive(serde::Serialize)] +pub struct ReplayInfo { + pub total_candles: usize, + pub current_index: usize, + pub start_time: i64, + pub end_time: i64, + pub symbol: String, + pub timeframe: String, + pub is_playing: bool, + pub speed: f64, +} + +#[derive(serde::Serialize)] +pub struct ReplayStateResponse { + pub is_loaded: bool, + pub is_playing: bool, + pub current_index: usize, + pub total_candles: usize, + pub speed: f64, + pub symbol: String, + pub timeframe: String, + pub progress: f64, +} + +#[derive(serde::Serialize)] +pub struct ReplayUpdate { + pub current_index: usize, + pub total_candles: usize, + pub candle: CandleData, + pub progress: f64, +} + +#[derive(serde::Serialize)] +pub struct CandleData { + pub time: i64, + pub open: String, + pub high: String, + pub low: String, + pub close: String, + pub volume: String, +} + +impl From<&OHLCV> for CandleData { + fn from(c: &OHLCV) -> Self { + Self { + time: c.time, + open: c.open.to_string(), + high: c.high.to_string(), + low: c.low.to_string(), + close: c.close.to_string(), + volume: c.volume.to_string(), + } + } +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 400e176..feb969c 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -34,4 +34,4 @@ "icons/icon.ico" ] } -} \ No newline at end of file +} diff --git a/src/App.tsx b/src/App.tsx index e342692..4e335cd 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,7 +5,7 @@ import { Camera, Maximize2, SkipBack, ChevronLeft, Play, Pause, ChevronRight, SkipForward, BookmarkPlus, BookOpen, CandlestickChart, BarChart2, LineChart, AreaChart, - LayoutTemplate, Sparkles, Check, Zap, Cpu, Monitor + LayoutTemplate, Sparkles, Check, Zap, Cpu, Monitor, RefreshCw } from 'lucide-react'; import Sidebar from './components/Sidebar'; import Chart, { ChartRef } from './components/Chart'; @@ -13,10 +13,13 @@ import VaultModal from './components/VaultModal'; import JournalModal from './components/JournalModal'; import SaveModal from './components/SaveModal'; import QuantumLab from './components/QuantumLab'; +import ReplayControls from './components/ReplayControls'; import Toast, { ToastType } from './components/Toast'; import { OHLCData, VolumeData, VaultItem, ToolType, ChartType, SessionStats, StrategyConfig, BacktestResult, BacktestSettings } from './types'; import { generateOHLCData, generateVolumeData } from './utils/dataGenerator'; import { onJobComplete, onJobError, onJobProgress, startBacktest } from './tauri/quantumBridge'; +import { loadOHLCVData } from './services/dataService'; +import { loadReplaySession } from './services/replayService'; const App: React.FC = () => { // App Mode: Manual Replay vs Quantum Backtest @@ -31,8 +34,22 @@ const App: React.FC = () => { const [isPlaying, setIsPlaying] = useState(false); const [playbackSpeed, setPlaybackSpeed] = useState(1); const [currentIndex, setCurrentIndex] = useState(50); - const [data] = useState(() => generateOHLCData(1000)); - const [volumeData] = useState(() => generateVolumeData(data)); + const [selectedSymbol, setSelectedSymbol] = useState('EURUSD'); + const [selectedTimeframe, setSelectedTimeframe] = useState('M15'); + const [isDataLoading, setIsDataLoading] = useState(false); + + // Generate fallback data + const [fallbackData] = useState(() => generateOHLCData(1000)); + const [fallbackVolumeData] = useState(() => generateVolumeData(fallbackData)); + + // Real data from Rust backend + const [realData, setRealData] = useState([]); + const [realVolumeData, setRealVolumeData] = useState([]); + + // Use real data if available, otherwise fallback + const data = realData.length > 0 ? realData : fallbackData; + const volumeData = realVolumeData.length > 0 ? realVolumeData : fallbackVolumeData; + const [toast, setToast] = useState<{message: string, type: ToastType} | null>(null); // Indicators & Templates State @@ -161,6 +178,54 @@ const App: React.FC = () => { setToast({ message, type }); }; + // Load OHLCV data from Rust backend + const loadDataFromBackend = useCallback(async () => { + setIsDataLoading(true); + try { + const chartData = await loadOHLCVData(selectedSymbol, selectedTimeframe); + if (chartData && chartData.length > 0) { + const ohlcData: OHLCData[] = chartData.map(c => ({ + time: c.time as any, + open: c.open, + high: c.high, + low: c.low, + close: c.close + })); + const volData: VolumeData[] = chartData.map(c => ({ + time: c.time as any, + value: c.volume, + color: c.close >= c.open ? 'rgba(34, 197, 94, 0.5)' : 'rgba(239, 68, 68, 0.5)' + })); + setRealData(ohlcData); + setRealVolumeData(volData); + setCurrentIndex(0); + showToast(`Loaded ${chartData.length} candles for ${selectedSymbol}`, 'success'); + } else { + showToast('No data available, using generated data', 'info'); + } + } catch (error) { + console.error('Failed to load data:', error); + showToast('Failed to load data from backend', 'error'); + } finally { + setIsDataLoading(false); + } + }, [selectedSymbol, selectedTimeframe]); + + // Load data when symbol or timeframe changes + useEffect(() => { + loadDataFromBackend(); + }, [selectedSymbol, selectedTimeframe, loadDataFromBackend]); + + // Replay session management + const handleLoadReplaySession = useCallback(async () => { + try { + await loadReplaySession(selectedSymbol, selectedTimeframe); + showToast('Replay session loaded', 'success'); + } catch (error) { + console.error('Failed to load replay session:', error); + } + }, [selectedSymbol, selectedTimeframe]); + const handleSaveSession = (saveData: any) => { const newItem: VaultItem = { id: Date.now(), @@ -212,7 +277,11 @@ const App: React.FC = () => { }; const executeTrade = (type: 'LONG' | 'SHORT') => { - const currentPrice = data[currentIndex].close; + const currentPrice = data[currentIndex]?.close; + if (!currentPrice) { + showToast('No data available', 'error'); + return; + } const entryPrice = currentPrice; const isWin = Math.random() > 0.5; @@ -246,78 +315,7 @@ const App: React.FC = () => { showToast(`Template applied: ${TEMPLATES.find(t=>t.id===id)?.label}`, 'success'); }; - const runQuantumBacktestMock = () => { - setIsQuantumRunning(true); - setQuantumProgress(0); - setTimeout(() => { - const curve = []; - let balance = 10000; - const now = Math.floor(Date.now() / 1000) - (86400 * 30); - - for(let i=0; i<30; i++) { - const change = (Math.random() - 0.4) * 500; - balance += change; - curve.push({ time: now + (i * 86400), value: balance }); - } - // Mock Data Generator for Detailed Stats - setQuantumResults({ - // Core - totalTrades: 30, - netProfit: -1.12, - profitFactor: 0.99, - maxDrawdown: 15.82, - maxDrawdownPercent: 0.16, - sharpeRatio: 1.05, - equityCurve: curve, - trades: Array(5).fill(null).map((_, i) => ({ - id: i.toString(), - pair: 'XAUUSD', - type: Math.random() > 0.5 ? 'LONG' : 'SHORT', - time: '09:00', - entry: 2025.50 + (i*0.5), - exit: 2027.00 + (i*0.5), - lots: 0.1, - pnl: (Math.random() - 0.5) * 20, - r: 1.5, - status: Math.random() > 0.5 ? 'WIN' : 'LOSS', - setup: 'Algo' - })), - - // Detailed - initialDeposit: 10000.00, - grossProfit: 88.07, - grossLoss: -89.19, - expectedPayoff: -0.04, - absoluteDrawdown: 14.86, - relativeDrawdown: 15.82, - relativeDrawdownPercent: 0.16, - - shortPositions: 18, - shortWon: 10, - longPositions: 12, - longWon: 10, - - profitTrades: 20, - lossTrades: 10, - - largestProfitTrade: 16.61, - largestLossTrade: -22.63, - averageProfitTrade: 4.40, - averageLossTrade: -8.92, - - maxConsecutiveWins: 6, - maxConsecutiveWinsValue: 23.26, - maxConsecutiveLosses: 3, - maxConsecutiveLossesValue: -36.88, - - ticksModelled: 3224126, - modellingQuality: 90.00 - }); - setIsQuantumRunning(false); - showToast("Quantum Backtest Complete", "success"); - }, 1500); - }; useEffect(() => { const unsubs: Array<() => void> = []; @@ -371,7 +369,6 @@ const App: React.FC = () => { } catch (e) { setIsQuantumRunning(false); showToast(String(e), "error"); - runQuantumBacktestMock(); } }; @@ -461,16 +458,31 @@ const App: React.FC = () => {
{['M1','M5','M15','H1','H4','D1'].map(tf => ( - + ))}
+ +
@@ -595,6 +607,13 @@ const App: React.FC = () => {
+ + {}} + />