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
This commit is contained in:
daavfx
2026-02-10 18:32:08 -06:00
parent b3ce3d043a
commit 83037a3fdf
18 changed files with 4182 additions and 551 deletions
+596
View File
@@ -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<f64>,
pub tp: Option<f64>,
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<Trade>,
pub equity_curve: Vec<EquityPoint>,
pub stats: BacktestStats,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StrategyCondition {
pub indicator: String,
pub operator: String,
pub value: f64,
pub period: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StrategyConfig {
pub name: String,
pub entry_conditions: Vec<StrategyCondition>,
pub exit_conditions: Vec<StrategyCondition>,
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<String, Vec<OHLCV>>,
}
impl BacktestEngine {
pub fn new() -> Self {
Self {
data_cache: HashMap::new(),
}
}
pub fn add_data(&mut self, symbol: &str, data: Vec<OHLCV>) {
self.data_cache.insert(symbol.to_string(), data);
}
pub fn get_data(&self, symbol: &str) -> Option<&Vec<OHLCV>> {
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<Trade> = vec![];
let mut equity_curve: Vec<EquityPoint> = vec![];
let mut position: Option<Position> = 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<f64> = data.iter().map(|c| c.close).collect();
let time_data: Vec<i64> = 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<f64> = trades
.iter()
.map(|t| t.pnl / config.initial_deposit * 100.0)
.collect();
let avg_return = if !returns.is_empty() {
returns.iter().sum::<f64>() / returns.len() as f64
} else {
0.0
};
let variance = if returns.len() > 1 {
returns
.iter()
.map(|r| (r - avg_return).powi(2))
.sum::<f64>()
/ 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::<f64>() / prices.len() as f64;
}
let mut ema = prices[..period].iter().sum::<f64>() / 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::<f64>() / prices.len() as f64;
}
prices[prices.len() - period..].iter().sum::<f64>() / 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,
},
}
}
}
+577
View File
@@ -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<String> {
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<String> {
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<HashMap<String, String>> {
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<TradeResponse>,
pub equity_curve: Vec<EquityPointResponse>,
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<ConditionRequest>,
pub exit_conditions: Vec<ConditionRequest>,
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<u32>,
}
#[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<BacktestResultResponse, String> {
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<Vec<OptimizationResultResponse>, 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<TradeResponse>,
initial_deposit: f64,
runs: u32,
) -> Result<Vec<MonteCarloResultResponse>, 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<Vec<OHLCV>, 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<Vec<OHLCV>, String> {
info!("📥 Importing CSV data from: {}", file_path);
let mut data = Vec::new();
let mut reader: Option<csv::Reader<std::fs::File>> = 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::<Vec<serde_json::Value>>(&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::<i64>()),
Some(record[1].parse::<f64>()),
Some(record[2].parse::<f64>()),
Some(record[3].parse::<f64>()),
Some(record[4].parse::<f64>()),
) {
data.push(OHLCV {
time,
open,
high,
low,
close,
volume: record.get(5).and_then(|v| v.parse::<f64>().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<OHLCV> {
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::<f64>() - 0.5) * volatility;
let open = current_price;
let change = trend_factor + noise;
let close = open + change;
let high = open.max(close) + rand::random::<f64>() * volatility * 0.5;
let low = open.min(close) - rand::random::<f64>() * volatility * 0.5;
let volume = 1000.0 + rand::random::<f64>() * 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
}
+303
View File
@@ -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<f64>,
pub timestamps: Vec<i64>,
pub parameters: HashMap<String, f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BollingerBands {
pub upper: Vec<f64>,
pub middle: Vec<f64>,
pub lower: Vec<f64>,
pub timestamps: Vec<i64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MACD {
pub macd_line: Vec<f64>,
pub signal_line: Vec<f64>,
pub histogram: Vec<f64>,
pub timestamps: Vec<i64>,
}
pub fn calculate_indicator(
indicator_type: &str,
data: &[f64],
timestamps: &[i64],
params: HashMap<String, f64>,
) -> Option<Indicator> {
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<f64> {
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::<f64>() / period as f64;
let mut avg_loss = losses[1..=period].iter().sum::<f64>() / 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<f64> {
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::<f64>() / prices.len() as f64;
ema_values.iter_mut().for_each(|x| *x = sma);
return ema_values;
}
let sma: f64 = prices[..period].iter().sum::<f64>() / 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<f64> {
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<Indicator> {
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<Indicator> {
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::<f64>() / 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<f64> = 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<Indicator> {
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)]),
})
}
+69 -14
View File
@@ -1,16 +1,71 @@
#[cfg_attr(mobile, tauri::mobile_entry_point)]
#![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<Mutex<BacktestEngine>>,
pub cache: Arc<Mutex<HashMap<String, Vec<OHLCV>>>>,
pub replay_state: Arc<Mutex<replay::ReplayState>>,
}
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(())
})
.run(tauri::generate_context!())
.expect("error while running tauri application");
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");
}
+340
View File
@@ -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<OHLCV>,
pub current_index: usize,
pub speed: f64,
pub is_playing: bool,
pub play_start: Option<Instant>,
pub last_update: Option<Instant>,
}
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<ReplayInfo, String> {
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<usize>,
) -> Result<ReplayUpdate, String> {
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<usize>,
) -> Result<ReplayUpdate, String> {
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<ReplayUpdate, 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.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<ReplayStateResponse, String> {
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<Option<ReplayUpdate>, 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(),
}
}
}